/** * 藏書地圖 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"; import { portalFetch, portalError, staleIdentityError, type KnowledgeIdentity, } from "../lib/portal-client.js"; /** * 空庫/404 時的指引(誠實回報+給下一步,鐵律:不假綠)。 * * 2026-08-08 修正:舊版寫「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過(總管實測 * grep 全 repo 查無任何呼叫點),是假話。真正的機制:kbdb `GET /map`/`GET /map/:library` * 每次查詢都會自動核對即時三元組數,落差就地重算(`kbdb/src/actions/library-map.ts` * `ensureFreshLibraryMaps`)——資料進來後下一次查詢就會反映,不需要任何人記得呼叫任何端點。 * `POST /map/recompute` 仍然存在,但只在極少數情況才需要手動打:舊三元組沒有 `library` slot * 值、只能靠 `source_uri` 前綴回填時(`source_prefix` 參數)。 */ const RECOMPUTE_HINTS = [ "地圖每次查詢都會自動核對即時三元組數並重算過期的庫,不必手動處理", "少見情況(舊三元組沒有 library 標記)才需要手動:POST /map/recompute?library=<庫名>(可帶 body {narrative, source_prefix})", ]; /** 註冊全部藏書地圖工具(library-map M4)。 */ export function registerAllKbdbMapTools(server: McpServer, env: Env, identity: KnowledgeIdentity) { registerGetMap(server, env, identity); } /** 單庫詳圖回傳形狀(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, identity: KnowledgeIdentity) { 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( "限定某資料歸屬範圍(選填;登入身分下不生效,看得到哪些庫由你的帳號權限決定)", ), }, async ({ library, owner_id }) => { if (identity.kind === "stale") return staleIdentityError(); try { // 登入身分:走 cypher 的 portal 資料面 —— 只會回這個帳號有權限的庫 //(KBDB 的 /map 對權限無知,會回全館;過濾在 cypher 那邊 server 側做)。 const isPortal = identity.kind === "portal"; const qs = !isPortal && owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : ""; const mapFetch = (path: string) => identity.kind === "portal" ? portalFetch(env, identity.portal.session, `/portal/data${path}`) : kbdbFetch(env, path); if (!library) { // 全館地圖:每庫一行(library+narrative+top 3 entities+triplet_count)。 const res = await mapFetch(`/map${qs}`); if (!res.ok && isPortal) return portalError(res, "取全館地圖"); 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; note?: string }; 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) { // 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS // 註解),所以「地圖是空的」現在真的等於「這個租戶目前沒有任何三元組資料」, // 不再是「沒人跑過 recompute」那種曖昧狀態。 // 登入身分下還有第二種可能:這個帳號一個庫都沒被授權——「沒權限看」與「沒有資料」 // 不可以長得一樣,所以分開講(cypher 端會附 note 說明)。 return successResponse({ libraries: [], count: 0 }, [ isPortal ? "看不到任何庫:可能是這個知識庫真的還沒有三元組資料,也可能是你的帳號還沒被授權任何庫——請向管理員確認你的可用知識庫" : "全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)", ...(data.note ? [data.note] : []), ...RECOMPUTE_HINTS, ]); } return successResponse({ libraries, count: libraries.length }, [ "要看某庫細節:kbdb_get_map(library='庫名')", "進庫查內容:kbdb_search(關鍵字/語義);查關係:kbdb_graph_neighbors", ]); } // 單庫詳圖:完整 slots(slot 陣列 parse 成物件再回)。 const res = await mapFetch(`/map/${encodeURIComponent(library)}${qs}`); if (res.status === 404) { // 地圖是讀時即時核對重算的:只要這個庫「已知」(有三元組、entries 蓋過章、或登記過), // 上一步就會自動把它補成一筆 triplet_count:0 的地圖,走不到這個分支。真的落到 404, // 代表這個名字在這個租戶的資料裡從沒出現過——不是「這庫是空的」,是根本沒有這個庫 // (可能打錯字,或這個庫在別的租戶/別的 owner_id 底下)。 return errorResponse( "map_not_found", isPortal ? `查無庫「${library}」——這個名字不存在,或不在你被授權的知識庫範圍內(兩者刻意同一句話,不洩漏某個庫存不存在)` : `查無庫「${library}」——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute)`, ["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名/確認你有權限的庫)", ...RECOMPUTE_HINTS], ); } if (!res.ok && isPortal) return portalError(res, `取庫「${library}」詳圖`); 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 同時出現在哪些其他庫(只有兩側三元組都標了 library 值才抓得到,舊資料若沒標會偏稀疏,是誠實現況不是 bug)", "沿核心 entity 挖關係:kbdb_graph_neighbors(subject=entity 名)", ]); } catch (e) { return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]); } }, ); }