Files
Arcrun/mcp/tests/unit/tools/kbdb-map.test.ts
T
Claude 343572bf5d feat(mcp): library-map M4 — kbdb_get_map tool + instructions 注入全館地圖
library-map SDD M4(design §4,源頭 #39)。兩件:

1. kbdb_get_map(tools/kbdb_map.ts,與 #68 kbdb_graph_neighbors 同族薄殼):
   - 無參數=全館地圖(每庫一行:library+narrative+top 3 entities+triplet_count)
   - 帶 library=該庫詳圖;top_entities/relation_profile/bridges 若為 JSON 字串形
     容錯 parse 成物件(parse 失敗當空陣列,不 crash)
   - 404/空庫誠實回報+POST /map/recompute backfill 指引
   - 走既有 KBDB service binding(kbdbFetch),不碰 D1、不新增 binding

2. instructions 注入(lib/library-map.ts+mcp-handler.ts):
   - 連線時拉 GET /map,渲染成緊湊文字({library}:{narrative}|核心:{top3}|{n} triplets)
     嵌 server instructions(design §6:session 啟動 push 零查詢)
   - 快取選型:isolate 內 TTL 快取(成功 5min/失敗 1min)——stateless StreamableHTTP
     每個 HTTP request 重建 McpServer,「每次現拉」實際是每個 tool call 都多打一次 /map
   - timeout 1.5s;任何失敗(超時/HTTP錯/空庫/壞JSON)回 null 靜默略過,絕不擋 MCP 連線(鐵律)

測試:假 KBDB binding(比照 kbdb-graph.test.ts)17 新測,pnpm vitest run 76/76 過、tsc --noEmit 乾淨。
merge 後需 gated redeploy arcrun-mcp(leo 閘)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUmjwkHLVBHM3ydhT1WSW3
2026-07-19 09:11:46 +00:00

298 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫(比照 kbdb-graph.test.ts)──────
type ToolHandler = (args: Record<string, unknown>) => Promise<{
content: { type: string; text: string }[];
isError?: boolean;
}>;
function makeServer() {
const tools = new Map<string, { description: string; handler: ToolHandler }>();
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<string, unknown>;
}
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);
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);
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);
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);
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);
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");
});
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);
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: "kbleo 的知識庫主庫。核心: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);
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);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(res.isError).toBeUndefined();
const map = (parseResult(res).data as { map: Record<string, unknown> }).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);
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");
});
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);
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);
expect(text).not.toBeNull();
// design §4 格式:{library}{narrative}|核心:{top3}{triplet_count} triplets
expect(text!).toContain("kbleo 的知識庫主庫|核心:00-INDEX、kb/00-INDEX、Gitea111 triplets");
expect(text!).toContain("notes:手機隨手筆記|核心:Gitea108 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)).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)).resolves.toBeNull();
});
it("empty libraries → null(沒地圖就不注入,不塞空段落)", async () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
});
it("caches within TTLsame isolate 第二次不再打 /map", async () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const first = await buildLibraryMapInstructions(env);
const second = await buildLibraryMapInstructions(env);
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-INDEX5 triplets");
});
it("empty input → null", () => {
expect(renderLibraryMapLines([])).toBeNull();
});
});