Files
Arcrun/mcp/src/tools/kbdb_map.ts
T
uncle6me-web 10d150ac2b fix(mcp): MCP 用登入者的身分查詢,不再去找一把服務內部金鑰
leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。

病根(不是金鑰沒同步,是身分沒接住):
  oauth/routes.ts 驗完 Portal 帳密只留下 `loginOk = res.ok` 一個布林值,身分當場丟棄,
  namespace 改從 `MCP_OWNER_NAMESPACE || "leo"` 拿。於是查詢時手上沒有身分可帶,
  只好用 KBDB_INTERNAL_TOKEN 直打 KBDB——那條路繞過 portal 所有庫過濾,
  而且不管誰登入都看到同一格、看到全部。CLI 也從不注入 MCP_OWNER_NAMESPACE,
  所以那個 "leo" 預設值是每台實例的實際行為,不是理論上的邊角。

修法(走既有那條路,不發明新的):
1. 接住身分:/authorize 解析 /portal/login 回應,把 portal session token +
   display_name/role/libraries 存進 authorization code → access token。
   /portal/login 補回 session_expires_in,access_token TTL 夾成
   min(自己的 TTL, portal session TTL)——不讓「MCP 還連著、底下 session 早死」。
   cypher 回 200 但沒給 session_token(舊版)→ 不發碼,不簽一張沒有身分的 token。
2. 攜帶身分:kbdb_* 全部改走 cypher `/portal/data/*`,Authorization 帶登入者的
   session。庫過濾/租戶注入/停用即時生效全在 server 側,與人類走 portal 網頁同一道閘。
   kbdb_graph_neighbors 因此不再需要 kbdb_base(server 自己知道查哪個庫)。
   藏書地圖(含連線時注入 instructions 的那份)同樣只回有權限的庫,快取改 per-session
   分格——地圖本身就是情報,不能讓先連上的人把視野留給下一個。
3. fail-closed:舊 token 沒有身分 → 誠實要求重新連線,不偷偷退回服務金鑰那條老路。
   服務級憑據(static token / partner key)維持既有 KBDB 直連,arcrun_* 零回歸。

新增 cypher portal 資料面端點(能力長在 API,MCP 只暴露;rule 07):
  GET  /portal/data/map、/portal/data/map/:library
  GET  /portal/data/templates、POST /portal/data/templates
  GET  /portal/data/records/by-template/:t、GET /portal/data/records/:id
  POST /portal/data/records
全部:呼叫端自帶 owner_id 一律不生效;越權與不存在同回 404;寫入 owner_id 由 server 定死。

KBDB base:`GET /records/:id` 與 by-template 補回 owner_id 欄位——原本不回,
呼叫端無從判斷「這筆是不是我的」,按 id 直讀等於沒有租戶邊界。

沒動:KBDB fail-closed 閘、任何金鑰、租戶字串仍不下發給呼叫端。

驗證:
  mcp        tsc 綠;vitest 113/113 綠(改前 48 綠 29 紅)
  cypher     vitest 400 綠 / 14 紅,14 紅與 base commit a24f291 逐條相同(既有)
  kbdb       vitest 208 綠 / 5 紅,5 紅同為既有(migrations/*.sql 被 gitignore)
  端到端     ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:33:12 +08:00

181 lines
10 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.
/**
* 藏書地圖 MCP 薄殼(library-map SDD M4;源頭 Arcrun#39
*
* rule 07 §5(薄殼鐵律):地圖的聚合 SQL 全住 kbdb baseactions/library-map.tsD6 裁定),
* MCP 只做介面轉換——經既有 KBDB service bindingkbdbFetch)打 GET /map/map/:library
* 不碰 D1、不新增 binding。與 #68 kbdb_graph_neighbors 同族(D17 KBDB MCP 面,kbdb_* 前綴)。
*
* 端點契約(kbdb/src/routes/map.tsM2 已 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 的 mapslot 陣列已 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) {
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_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",
]);
}
// 單庫詳圖:完整 slotsslot 陣列 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), ["稍後重試"]);
}
},
);
}