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:
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 藏書地圖(library-map)共用邏輯 — SDD M4(system-dev/docs/3-specs/library-map/design.md §4)
|
||||
*
|
||||
* 兩個 consumer 共用本檔:
|
||||
* 1. kbdb_get_map MCP tool(tools/kbdb_map.ts)— 薄殼呼 GET /map//map/:library
|
||||
* 2. MCP server instructions 注入(mcp-handler.ts)— 連線時把全館地圖渲染成緊湊文字嵌入
|
||||
*
|
||||
* 走既有 KBDB service binding(kbdbFetch),不新增 binding、不碰 D1(薄殼鐵律)。
|
||||
*
|
||||
* 鐵律(design §4):注入失敗絕不能讓 MCP 連線失敗——buildLibraryMapInstructions 任何錯誤
|
||||
* (超時/HTTP 錯/空庫/JSON 壞)一律回 null,caller 靜默略過,instructions 沒地圖照常可用。
|
||||
*
|
||||
* 快取抉擇(design §4 給了「快取+TTL 或每次現拉」兩個選項,這裡選 isolate 內 TTL 快取):
|
||||
* 本 MCP 用 stateless StreamableHTTP(sessionIdGenerator: undefined)——每個 HTTP request 都
|
||||
* 重建 McpServer,也就是說「每次現拉」實際上是每個 tool call 都多打一次 /map,不是每條連線一次。
|
||||
* 地圖只在 ingest recompute 後才變,所以用 module 層(isolate 內)TTL 快取:成功 5 分鐘、
|
||||
* 失敗 1 分鐘(避免 kbdb 掛掉時每個 request 都白等 timeout)。isolate 回收即自然失效,無需失效協議。
|
||||
*/
|
||||
|
||||
import type { Env } from "../types.js";
|
||||
import { kbdbFetch } from "./kbdb-client.js";
|
||||
|
||||
/** 全館視圖一行(kbdb GET /map 的 libraries[] 元素;top_entities 已是 top-3 名字)。 */
|
||||
export interface LibraryMapRow {
|
||||
library: string;
|
||||
narrative: string | null;
|
||||
top_entities: unknown; // 正常是 string[];防禦:舊部署可能回 JSON 字串形
|
||||
triplet_count: number | string;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* slot 值容錯 parse:kbdb 正常已回 parsed 陣列,但 slot 底層存的是 JSON 字串
|
||||
* (live 曾觀測到字串形直出)——遇字串就 JSON.parse,parse 失敗/非陣列一律回空陣列(不 crash)。
|
||||
*/
|
||||
export function parseSlotArray<T>(raw: unknown): T[] {
|
||||
if (Array.isArray(raw)) return raw as T[];
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
return Array.isArray(v) ? (v as T[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** top_entities 統一成名字清單(元素可能是 "name" 字串或 {name, degree} 物件)。 */
|
||||
export function entityNames(raw: unknown, limit: number): string[] {
|
||||
return parseSlotArray<unknown>(raw)
|
||||
.map((e) => {
|
||||
if (typeof e === "string") return e;
|
||||
if (e && typeof e === "object" && typeof (e as { name?: unknown }).name === "string") {
|
||||
return (e as { name: string }).name;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((n): n is string => !!n)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/** 全館地圖渲染上限:庫行數與 narrative 截斷長度(instructions 總量控制在數百 token,R3)。 */
|
||||
const MAX_LIBRARY_LINES = 30;
|
||||
const MAX_NARRATIVE_CHARS = 60;
|
||||
|
||||
/** 把 GET /map 的 libraries[] 渲染成緊湊文字(每庫一行,design §4 指定格式)。空清單回 null。 */
|
||||
export function renderLibraryMapLines(libraries: LibraryMapRow[]): string | null {
|
||||
const rows = libraries.filter((l) => l && typeof l.library === "string" && l.library);
|
||||
if (rows.length === 0) return null;
|
||||
const lines = rows.slice(0, MAX_LIBRARY_LINES).map((l) => {
|
||||
const narrative = (l.narrative ?? "").trim() || "(narrative 待補)";
|
||||
const clipped =
|
||||
narrative.length > MAX_NARRATIVE_CHARS ? `${narrative.slice(0, MAX_NARRATIVE_CHARS)}…` : narrative;
|
||||
const core = entityNames(l.top_entities, 3);
|
||||
const count = Number(l.triplet_count ?? 0) || 0;
|
||||
return `- ${l.library}:${clipped}|核心:${core.length ? core.join("、") : "(尚無)"}|${count} triplets`;
|
||||
});
|
||||
const omitted = rows.length > MAX_LIBRARY_LINES ? `\n(其餘 ${rows.length - MAX_LIBRARY_LINES} 庫略,kbdb_get_map 可看全部)` : "";
|
||||
return lines.join("\n") + omitted;
|
||||
}
|
||||
|
||||
/** 拉 /map 的逾時上限(ms):instructions 是加分不是依賴,不值得讓連線多等。 */
|
||||
const MAP_FETCH_TIMEOUT_MS = 1500;
|
||||
/** 快取 TTL:成功 5 分鐘(地圖只在 ingest recompute 後變)、失敗/空 1 分鐘(別每 request 白等)。 */
|
||||
const CACHE_TTL_OK_MS = 5 * 60 * 1000;
|
||||
const CACHE_TTL_FAIL_MS = 60 * 1000;
|
||||
|
||||
let instructionsCache: { text: string | null; expiresAt: number } | null = null;
|
||||
|
||||
/** 測試用:清掉 isolate 內快取(prod 不呼叫)。 */
|
||||
export function __resetLibraryMapInstructionsCacheForTests(): void {
|
||||
instructionsCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 組 MCP server instructions 的藏書地圖段(design §4 / §6「session 啟動 → instructions 已含
|
||||
* 全館地圖(push 零查詢)」)。任何失敗(超時/HTTP 錯/空庫/壞 JSON)→ null(caller 靜默略過)。
|
||||
*/
|
||||
export async function buildLibraryMapInstructions(env: Env): Promise<string | null> {
|
||||
const now = Date.now();
|
||||
if (instructionsCache && instructionsCache.expiresAt > now) return instructionsCache.text;
|
||||
|
||||
let text: string | null = null;
|
||||
try {
|
||||
const res = await Promise.race([
|
||||
kbdbFetch(env, "/map"),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("library map fetch timeout")), MAP_FETCH_TIMEOUT_MS),
|
||||
),
|
||||
]);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { libraries?: LibraryMapRow[] };
|
||||
const body = renderLibraryMapLines(Array.isArray(data.libraries) ? data.libraries : []);
|
||||
if (body) {
|
||||
text =
|
||||
"【藏書地圖】KBDB 全館現況(每庫一行)。查資料前先看這裡定位該進哪個庫;" +
|
||||
"需要某庫細節呼叫 kbdb_get_map(library),不確定該查什麼時先呼叫 kbdb_get_map。\n" +
|
||||
body;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 鐵律:地圖是加分不是依賴——任何錯誤都不往外拋,instructions 沒地圖照常可用。
|
||||
text = null;
|
||||
}
|
||||
|
||||
instructionsCache = { text, expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS) };
|
||||
return text;
|
||||
}
|
||||
+10
-1
@@ -1,6 +1,7 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
import { registerAllTools } from "./tools/registry.js";
|
||||
import { buildLibraryMapInstructions } from "./lib/library-map.js";
|
||||
import { Env } from "./types.js";
|
||||
|
||||
export async function handleMcpRequest(
|
||||
@@ -9,8 +10,16 @@ export async function handleMcpRequest(
|
||||
orgNamespace: string,
|
||||
partnerToken: string,
|
||||
): Promise<Response> {
|
||||
// library-map SDD M4(design §4/§6):連線時把全館藏書地圖嵌進 server instructions,
|
||||
// session 一開就知道館裡有哪些庫(push 零查詢)。builder 內建 timeout+isolate TTL 快取
|
||||
//(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 靜默略過,絕不擋 MCP 連線(鐵律)。
|
||||
const mapInstructions = await buildLibraryMapInstructions(env);
|
||||
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
const server = new McpServer({ name: "arcrun-mcp-server", version: "1.0.0" });
|
||||
const server = new McpServer(
|
||||
{ name: "arcrun-mcp-server", version: "1.0.0" },
|
||||
mapInstructions ? { instructions: mapInstructions } : undefined,
|
||||
);
|
||||
|
||||
registerAllTools(server, env, orgNamespace, partnerToken);
|
||||
await server.connect(transport);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 藏書地圖 MCP 薄殼(library-map SDD M4;源頭 Arcrun#39)
|
||||
*
|
||||
* rule 07 §5(薄殼鐵律):地圖的聚合 SQL 全住 kbdb base(actions/library-map.ts,D6 裁定),
|
||||
* MCP 只做介面轉換——經既有 KBDB service binding(kbdbFetch)打 GET /map//map/:library,
|
||||
* 不碰 D1、不新增 binding。與 #68 kbdb_graph_neighbors 同族(D17 KBDB MCP 面,kbdb_* 前綴)。
|
||||
*
|
||||
* 端點契約(kbdb/src/routes/map.ts,M2 已 merge):
|
||||
* GET /map → { success, libraries:[{library, narrative, top_entities(名字 top3),
|
||||
* triplet_count, updated_at}], count }
|
||||
* GET /map/:library → { success, map:{record_id, library, narrative, content, top_entities,
|
||||
* relation_profile, bridges, triplet_count, commit_hash, status, updated_at} }
|
||||
* 404 → { success:false, error:'not found' }(該庫從未 recompute)
|
||||
*
|
||||
* slot 值防禦:top_entities/relation_profile/bridges 底層存 JSON 字串,正常 base 已 parse;
|
||||
* 但仍容錯「字串形直出」(live 曾觀測)——字串就 parse、失敗當空陣列,絕不 crash(鐵律)。
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import type { Env } from "../types.js";
|
||||
import { kbdbFetch } from "../lib/kbdb-client.js";
|
||||
import { errorResponse, successResponse } from "../lib/cypher-client.js";
|
||||
import { entityNames, parseSlotArray, type LibraryMapRow } from "../lib/library-map.js";
|
||||
|
||||
/** 空庫/404 時的 backfill 指引(誠實回報+給下一步,鐵律:不假綠)。 */
|
||||
const RECOMPUTE_HINTS = [
|
||||
"地圖由 ingest 尾端自動重算(M3);尚未接鏈的庫要手動 backfill:對 kbdb 呼 POST /map/recompute?library=<庫名>(可帶 body {narrative, source_prefix})",
|
||||
"backfill 過渡期(triplet 還沒有 library slot 值)用 source_prefix 以 source_uri 前綴歸庫,如 {\"source_prefix\":\"gitea:Leo/kb@\"}",
|
||||
];
|
||||
|
||||
/** 註冊全部藏書地圖工具(library-map M4)。 */
|
||||
export function registerAllKbdbMapTools(server: McpServer, env: Env) {
|
||||
registerGetMap(server, env);
|
||||
}
|
||||
|
||||
/** 單庫詳圖回傳形狀(GET /map/:library 的 map,slot 陣列已 parse 成物件)。 */
|
||||
interface LibraryMapDetail {
|
||||
record_id?: string;
|
||||
library?: string;
|
||||
narrative?: string | null;
|
||||
content?: string | null;
|
||||
top_entities?: unknown;
|
||||
relation_profile?: unknown;
|
||||
bridges?: unknown;
|
||||
triplet_count?: number | string;
|
||||
commit_hash?: string | null;
|
||||
status?: string;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* kbdb_get_map — 藏書地圖。無參數=全館(每庫一行);帶 library=該庫詳圖。
|
||||
* design §6 retrieval 流程的第一站:地圖 → get_map(library) 細節 → graph/search 進庫。
|
||||
*/
|
||||
export function registerGetMap(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"kbdb_get_map",
|
||||
"藏書地圖:KBDB 全館導覽。不帶參數=全館地圖(每庫一行:庫名+narrative+核心 top 3 entities+" +
|
||||
"triplet 數),帶 library 參數=該庫詳圖(完整 top_entities/relation_profile/跨庫 bridges)。" +
|
||||
"不確定該查什麼時,先呼叫此工具——先看地圖定位該進哪個庫,再用 kbdb_search(關鍵字/語義)或 " +
|
||||
"kbdb_graph_neighbors(關係遍歷)進庫查細節。",
|
||||
{
|
||||
library: z.string().min(1).optional().describe(
|
||||
"庫名(如 'kb'/'notes')。帶了回該庫詳圖;不帶回全館地圖(先看全館再挑庫)",
|
||||
),
|
||||
owner_id: z.string().optional().describe("限定某資料歸屬範圍(選填,與其他 kbdb_* 工具同義)"),
|
||||
},
|
||||
async ({ library, owner_id }) => {
|
||||
try {
|
||||
const qs = owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "";
|
||||
|
||||
if (!library) {
|
||||
// 全館地圖:每庫一行(library+narrative+top 3 entities+triplet_count)。
|
||||
const res = await kbdbFetch(env, `/map${qs}`);
|
||||
if (!res.ok) {
|
||||
return errorResponse(
|
||||
"map_fetch_failed",
|
||||
`取全館地圖失敗 HTTP ${res.status}`,
|
||||
["稍後重試", ...RECOMPUTE_HINTS],
|
||||
await res.text().catch(() => ""),
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as { libraries?: LibraryMapRow[]; count?: number };
|
||||
const libraries = (Array.isArray(data.libraries) ? data.libraries : []).map((l) => ({
|
||||
...l,
|
||||
// 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。
|
||||
top_entities: entityNames(l.top_entities, 3),
|
||||
triplet_count: Number(l.triplet_count ?? 0) || 0,
|
||||
}));
|
||||
if (libraries.length === 0) {
|
||||
// 空庫誠實回報:不是錯誤(端點正常、就是還沒有地圖),給 backfill 指引。
|
||||
return successResponse({ libraries: [], count: 0 }, [
|
||||
"全館地圖是空的:還沒有任何庫跑過 recompute",
|
||||
...RECOMPUTE_HINTS,
|
||||
]);
|
||||
}
|
||||
return successResponse({ libraries, count: libraries.length }, [
|
||||
"要看某庫細節:kbdb_get_map(library='庫名')",
|
||||
"進庫查內容:kbdb_search(關鍵字/語義);查關係:kbdb_graph_neighbors",
|
||||
]);
|
||||
}
|
||||
|
||||
// 單庫詳圖:完整 slots(slot 陣列 parse 成物件再回)。
|
||||
const res = await kbdbFetch(env, `/map/${encodeURIComponent(library)}${qs}`);
|
||||
if (res.status === 404) {
|
||||
return errorResponse(
|
||||
"map_not_found",
|
||||
`庫「${library}」還沒有地圖(從未 recompute,或庫名打錯)`,
|
||||
["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名)", ...RECOMPUTE_HINTS],
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
return errorResponse(
|
||||
"map_fetch_failed",
|
||||
`取庫「${library}」詳圖失敗 HTTP ${res.status}`,
|
||||
["稍後重試", ...RECOMPUTE_HINTS],
|
||||
await res.text().catch(() => ""),
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as { map?: LibraryMapDetail };
|
||||
const raw = data.map ?? {};
|
||||
const map = {
|
||||
...raw,
|
||||
// slot 字串容錯 parse(任務規格:JSON 字串形要 parse 成物件再回,失敗當空陣列)。
|
||||
top_entities: parseSlotArray<{ name: string; degree: number }>(raw.top_entities),
|
||||
relation_profile: parseSlotArray<{ predicate: string; count: number }>(raw.relation_profile),
|
||||
bridges: parseSlotArray<{ entity: string; libraries: string[] }>(raw.bridges),
|
||||
triplet_count: Number(raw.triplet_count ?? 0) || 0,
|
||||
};
|
||||
return successResponse({ map }, [
|
||||
"bridges=此庫 entity 同時出現在哪些其他庫(M3 backfill 前會偏稀疏,是誠實現況不是 bug)",
|
||||
"沿核心 entity 挖關係:kbdb_graph_neighbors(subject=entity 名)",
|
||||
]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { registerAllSkillExampleTools } from "./arcrun_skills_examples.js";
|
||||
import { registerAllRecipeTools } from "./arcrun_recipe.js";
|
||||
import { registerAllKbdbDataTools } from "./kbdb_data.js";
|
||||
import { registerAllKbdbGraphTools } from "./kbdb_graph.js";
|
||||
import { registerAllKbdbMapTools } from "./kbdb_map.js";
|
||||
import { registerWhoami } from "./arcrun_whoami.js";
|
||||
|
||||
export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
|
||||
@@ -53,6 +54,9 @@ export function registerAllTools(server: McpServer, env: Env, orgNamespace: stri
|
||||
// issue #68: KBDB graph 查詢薄殼(kbdb_graph_neighbors,調 /q/:ns/graph_neighbors 同步查詢端點)
|
||||
// 補齊 D17「KBDB MCP=RAG 套餐」第三模式:關鍵字/語義之外的圖(關係遍歷)
|
||||
registerAllKbdbGraphTools(server, env, orgNamespace);
|
||||
// library-map SDD M4(Arcrun#39): 藏書地圖薄殼(kbdb_get_map,調 kbdb GET /map//map/:library)
|
||||
// retrieval 第一站:先看地圖定位庫,再 search/graph 進庫(design §6)
|
||||
registerAllKbdbMapTools(server, env);
|
||||
// §7.8 P1 D2: whoami(與 CLI acr whoami 對齊,AI 不繞 CLI 自己 curl 猜帳號)
|
||||
registerWhoami(server, env, orgNamespace);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user