import { describe, it, expect, beforeEach } from "vitest"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { Env } from "../../../src/types.js"; import { registerGetMap } from "../../../src/tools/kbdb_map.js"; import { buildLibraryMapInstructions, renderLibraryMapLines, __resetLibraryMapInstructionsCacheForTests, } from "../../../src/lib/library-map.js"; import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js"; /** 服務級憑據(static token / partner key)——既有 KBDB 直連路徑,行為零變更。 */ const SERVICE: KnowledgeIdentity = { kind: "service" }; /** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面(只看得到自己有權限的庫)。 */ const PORTAL: KnowledgeIdentity = { kind: "portal", portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] }, }; /** 本次改版前簽發的舊 token(沒有身分)。 */ const STALE: KnowledgeIdentity = { kind: "stale" }; /** 假 CYPHER_EXECUTOR binding(portal 資料面用)。 */ function makePortalEnv(respond: (url: URL, init?: RequestInit) => Response) { const calls: { url: URL; init?: RequestInit }[] = []; const env = { CYPHER_EXECUTOR: { fetch: async (input: string, init?: RequestInit) => { const url = new URL(input); calls.push({ url, init }); return respond(url, init); }, }, } as unknown as Env; return { env, calls }; } // ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫(比照 kbdb-graph.test.ts)────── type ToolHandler = (args: Record) => Promise<{ content: { type: string; text: string }[]; isError?: boolean; }>; function makeServer() { const tools = new Map(); const server = { tool(name: string, description: string, _schema: unknown, handler: ToolHandler) { tools.set(name, { description, handler }); }, }; return { server: server as unknown as McpServer, tools }; } // ── 假 KBDB service binding:記錄請求、回預設 response ───────────────────────────── function makeEnv(respond: (url: URL, init?: RequestInit) => Response) { const calls: { url: URL; init?: RequestInit }[] = []; const env = { KBDB: { fetch: async (input: string, init?: RequestInit) => { const url = new URL(input); calls.push({ url, init }); return respond(url, init); }, }, KBDB_INTERNAL_TOKEN: "test-token", } as unknown as Env; return { env, calls }; } function parseResult(r: { content: { text: string }[] }) { return JSON.parse(r.content[0].text) as Record; } const KB_ROW = { library: "kb", narrative: "leo 的知識庫主庫", top_entities: ["00-INDEX", "kb/00-INDEX", "Gitea"], triplet_count: 111, updated_at: 1784451880, }; describe("kbdb_get_map: registration", () => { it("registers under kbdb_* prefix (D17) with the 'call this first' hint in description", () => { const { server, tools } = makeServer(); const { env } = makeEnv(() => new Response("{}")); registerGetMap(server, env, SERVICE); expect(tools.has("kbdb_get_map")).toBe(true); // 任務規格:description 必含「不確定該查什麼時,先呼叫此工具」 expect(tools.get("kbdb_get_map")!.description).toContain("不確定該查什麼時,先呼叫此工具"); }); }); describe("kbdb_get_map: 全館地圖(無參數)", () => { it("hits GET /map via KBDB binding and returns per-library rows", async () => { const { server, tools } = makeServer(); const { env, calls } = makeEnv( () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })), ); registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({}); expect(calls).toHaveLength(1); expect(calls[0].url.pathname).toBe("/map"); // kbdbFetch 慣例:KBDB_INTERNAL_TOKEN 注入 Authorization expect(new Headers(calls[0].init?.headers).get("Authorization")).toBe("Bearer test-token"); const body = parseResult(res); expect(body.ok).toBe(true); const data = body.data as { libraries: { library: string; top_entities: string[] }[]; count: number }; expect(data.count).toBe(1); expect(data.libraries[0].library).toBe("kb"); expect(data.libraries[0].top_entities).toEqual(["00-INDEX", "kb/00-INDEX", "Gitea"]); }); it("owner_id is forwarded as query param", async () => { const { server, tools } = makeServer(); const { env, calls } = makeEnv( () => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })), ); registerGetMap(server, env, SERVICE); await tools.get("kbdb_get_map")!.handler({ owner_id: "leo" }); expect(calls[0].url.searchParams.get("owner_id")).toBe("leo"); }); it("top_entities in JSON-string form is parsed defensively (live-observed slot shape)", async () => { const { server, tools } = makeServer(); const row = { ...KB_ROW, top_entities: '[{"name":"00-INDEX","degree":29},{"name":"Gitea","degree":7}]', triplet_count: "111", // slot 底層存字串 → 要轉數字 }; const { env } = makeEnv( () => new Response(JSON.stringify({ success: true, libraries: [row], count: 1 })), ); registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({}); const data = parseResult(res).data as { libraries: { top_entities: string[]; triplet_count: number }[]; }; expect(data.libraries[0].top_entities).toEqual(["00-INDEX", "Gitea"]); expect(data.libraries[0].triplet_count).toBe(111); }); it("空庫誠實回報+提示跑 POST /map/recompute backfill(鐵律:不假綠不 crash)", async () => { const { server, tools } = makeServer(); const { env } = makeEnv( () => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })), ); registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({}); const body = parseResult(res); expect(body.ok).toBe(true); expect((body.data as { count: number }).count).toBe(0); expect(JSON.stringify(body.hints)).toContain("POST /map/recompute"); }); // 2026-08-08:舊版說明文字宣稱「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過 // (總管實測 grep 全 repo 查無任何呼叫點),是假話。修法:GET /map 讀端本身自動核對 // 即時三元組數並補算,不靠任何外部呼叫者。這裡釘住那句謊言不會再出現在任何 hint 裡。 it("不再宣稱「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過,是假話(已修正措辭)", async () => { const { server, tools } = makeServer(); const { env } = makeEnv( () => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })), ); registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({}); const body = parseResult(res); const hintsText = JSON.stringify(body.hints); expect(hintsText).not.toContain("地圖由 ingest 尾端自動重算"); expect(hintsText).not.toContain("(M3)"); expect(hintsText).not.toContain("(M3)"); // 誠實的新措辭:空=這個租戶真的沒資料,不是「沒人跑過 recompute」 expect(hintsText).toContain("這個租戶目前沒有任何三元組資料"); }); it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => { const { server, tools } = makeServer(); const { env } = makeEnv(() => new Response("boom", { status: 500 })); registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({}); expect(res.isError).toBe(true); const body = parseResult(res); expect(body.error_code).toBe("map_fetch_failed"); expect(JSON.stringify(body.next_actions)).toContain("POST /map/recompute"); }); }); describe("kbdb_get_map: 單庫詳圖(library 參數)", () => { const DETAIL = { record_id: "e_123", library: "kb", narrative: "leo 的知識庫主庫", content: "kb:leo 的知識庫主庫。核心:00-INDEX", top_entities: [{ name: "00-INDEX", degree: 29 }], relation_profile: [{ predicate: "連結至", count: 48 }], bridges: [{ entity: "Gitea", libraries: ["notes"] }], triplet_count: 111, commit_hash: null, status: "active", updated_at: 1784451880, }; it("hits GET /map/:library and returns full detail", async () => { const { server, tools } = makeServer(); const { env, calls } = makeEnv( () => new Response(JSON.stringify({ success: true, map: DETAIL })), ); registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" }); expect(calls[0].url.pathname).toBe("/map/kb"); const map = (parseResult(res).data as { map: typeof DETAIL }).map; expect(map.record_id).toBe("e_123"); expect(map.relation_profile).toEqual([{ predicate: "連結至", count: 48 }]); expect(map.bridges).toEqual([{ entity: "Gitea", libraries: ["notes"] }]); }); it("JSON-string slot values are parsed into objects(任務規格); broken JSON → 空陣列", async () => { const { server, tools } = makeServer(); const raw = { ...DETAIL, top_entities: '[{"name":"00-INDEX","degree":29}]', relation_profile: '[{"predicate":"連結至","count":48}]', bridges: "not-json{{", // parse 失敗 → 當空陣列,不 crash triplet_count: "111", }; const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: raw }))); registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" }); expect(res.isError).toBeUndefined(); const map = (parseResult(res).data as { map: Record }).map; expect(map.top_entities).toEqual([{ name: "00-INDEX", degree: 29 }]); expect(map.relation_profile).toEqual([{ predicate: "連結至", count: 48 }]); expect(map.bridges).toEqual([]); expect(map.triplet_count).toBe(111); }); it("404 → map_not_found with recompute backfill hint(誠實回報)", async () => { const { server, tools } = makeServer(); const { env } = makeEnv( () => new Response(JSON.stringify({ success: false, error: "not found" }), { status: 404 }), ); registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({ library: "ghost" }); expect(res.isError).toBe(true); const body = parseResult(res); expect(body.error_code).toBe("map_not_found"); expect(JSON.stringify(body.next_actions)).toContain("POST /map/recompute"); // 2026-08-08:404 現在的語意是「查無此庫」(地圖是即時核對重算的,已知庫即使空也回 200), // 不再是舊版那句「從未 recompute」的曖昧說法。 expect(String(body.human_message)).toContain("查無庫"); expect(String(body.human_message)).toContain("從沒出現過"); }); it("binding throws → internal_error, not an unhandled crash", async () => { const { server, tools } = makeServer(); const env = { KBDB: { fetch: async () => { throw new Error("binding down"); }, }, } as unknown as Env; registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" }); expect(res.isError).toBe(true); expect(parseResult(res).error_code).toBe("internal_error"); }); }); // ── instructions 注入(design §4:拉不到=靜默略過,絕不擋連線)──────────────────────── describe("buildLibraryMapInstructions", () => { beforeEach(() => __resetLibraryMapInstructionsCacheForTests()); it("renders one compact line per library in the指定格式", async () => { const { env } = makeEnv( () => new Response( JSON.stringify({ success: true, libraries: [ KB_ROW, { library: "notes", narrative: "手機隨手筆記", top_entities: ["Gitea"], triplet_count: 108 }, ], count: 2, }), ), ); const text = await buildLibraryMapInstructions(env, SERVICE); expect(text).not.toBeNull(); // design §4 格式:{library}:{narrative}|核心:{top3}|{triplet_count} triplets expect(text!).toContain("kb:leo 的知識庫主庫|核心:00-INDEX、kb/00-INDEX、Gitea|111 triplets"); expect(text!).toContain("notes:手機隨手筆記|核心:Gitea|108 triplets"); // 導引句:把地圖跟 get_map 工具接起來(design §6 retrieval 流程) expect(text!).toContain("kbdb_get_map"); }); it("HTTP error → null(靜默略過,不 throw 不擋連線)", async () => { const { env } = makeEnv(() => new Response("boom", { status: 500 })); await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull(); }); it("binding throws → null(靜默略過)", async () => { const env = { KBDB: { fetch: async () => { throw new Error("kbdb down"); }, }, } as unknown as Env; await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull(); }); it("empty libraries → null(沒地圖就不注入,不塞空段落)", async () => { const { env } = makeEnv( () => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })), ); await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull(); }); it("caches within TTL:same isolate 第二次不再打 /map", async () => { const { env, calls } = makeEnv( () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })), ); const first = await buildLibraryMapInstructions(env, SERVICE); const second = await buildLibraryMapInstructions(env, SERVICE); expect(second).toBe(first); expect(calls).toHaveLength(1); }); }); describe("renderLibraryMapLines", () => { it("null narrative / string-form top_entities / string count 都容錯", () => { const text = renderLibraryMapLines([ { library: "kb", narrative: null, top_entities: '["00-INDEX"]', triplet_count: "5", }, ]); expect(text).toBe("- kb:(narrative 待補)|核心:00-INDEX|5 triplets"); }); it("empty input → null", () => { expect(renderLibraryMapLines([])).toBeNull(); }); }); // ── 2026-08-12:地圖也要跟著登入者的權限走 ──────────────────────────────────── // 地圖本身就是情報(有哪些庫、各有多少關聯、核心 entity 是誰)——不能整館推給 // 一個只有部分權限的帳號。 describe("藏書地圖:登入身分(portal 資料面)", () => { beforeEach(() => __resetLibraryMapInstructionsCacheForTests()); it("kbdb_get_map 打 /portal/data/map,帶登入者 session,不碰 KBDB 服務金鑰", async () => { const { server, tools } = makeServer(); const { env, calls } = makePortalEnv( () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })), ); registerGetMap(server, env, PORTAL); const res = await tools.get("kbdb_get_map")!.handler({}); expect(res.isError).toBeUndefined(); expect(calls).toHaveLength(1); expect(calls[0].url.pathname).toBe("/portal/data/map"); expect(new Headers(calls[0].init!.headers as HeadersInit).get("Authorization")).toBe("Bearer sess-abc"); }); it("呼叫端硬塞 owner_id 也不生效(查詢範圍由帳號權限決定,不由呼叫端指定)", async () => { const { server, tools } = makeServer(); const { env, calls } = makePortalEnv( () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })), ); registerGetMap(server, env, PORTAL); await tools.get("kbdb_get_map")!.handler({ owner_id: "someone-else" }); expect(calls[0].url.searchParams.get("owner_id")).toBeNull(); }); it("查沒權限的庫 → 與「不存在」同一句話(不洩存在性)", async () => { const { server, tools } = makeServer(); const { env } = makePortalEnv(() => new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 })); registerGetMap(server, env, PORTAL); const res = await tools.get("kbdb_get_map")!.handler({ library: "secret-lib" }); expect(res.isError).toBe(true); const body = parseResult(res); expect(body.error_code).toBe("map_not_found"); expect(String(body.human_message)).toContain("不在你被授權"); }); it("session 過期(401)→ session_expired,不說「地圖是空的」", async () => { const { server, tools } = makeServer(); const { env } = makePortalEnv( () => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }), ); registerGetMap(server, env, PORTAL); const res = await tools.get("kbdb_get_map")!.handler({}); expect(res.isError).toBe(true); expect(parseResult(res).error_code).toBe("session_expired"); }); it("舊 token(沒身分)→ identity_missing,且一個查詢都不發(fail-closed)", async () => { const { server, tools } = makeServer(); const { env, calls } = makePortalEnv(() => new Response("{}")); registerGetMap(server, env, STALE); const res = await tools.get("kbdb_get_map")!.handler({}); expect(res.isError).toBe(true); expect(parseResult(res).error_code).toBe("identity_missing"); expect(calls).toHaveLength(0); }); it("instructions 的地圖也走 portal 資料面(連線開場推的庫名不得超出權限)", async () => { const { env, calls } = makePortalEnv( () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })), ); const text = await buildLibraryMapInstructions(env, PORTAL); expect(text).toContain("kb"); expect(calls[0].url.pathname).toBe("/portal/data/map"); }); it("**快取不跨身分共用**:不同 session 各自打一次,不會拿到別人的視野", async () => { const { env, calls } = makePortalEnv( () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })), ); const other: KnowledgeIdentity = { kind: "portal", portal: { session: "sess-other", display_name: "小明", role: "user", libraries: ["notes"] }, }; await buildLibraryMapInstructions(env, PORTAL); await buildLibraryMapInstructions(env, other); expect(calls).toHaveLength(2); // 兩次真的各打一次 await buildLibraryMapInstructions(env, PORTAL); expect(calls).toHaveLength(2); // 同一 session 第二次才吃快取 }); it("舊 token → 不給地圖(instructions 不外洩任何庫名)", async () => { const { env, calls } = makePortalEnv(() => new Response("{}")); expect(await buildLibraryMapInstructions(env, STALE)).toBeNull(); expect(calls).toHaveLength(0); }); });