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
This commit is contained in:
Claude
2026-07-19 09:11:46 +00:00
parent bdfc2e3e0b
commit 343572bf5d
7 changed files with 592 additions and 2 deletions
+297
View File
@@ -0,0 +1,297 @@
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();
});
});