Files
Arcrun/mcp/src/lib/library-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

158 lines
7.3 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.
/**
* 藏書地圖(library-map)共用邏輯 — SDD M4system-dev/docs/3-specs/library-map/design.md §4
*
* 兩個 consumer 共用本檔:
* 1. kbdb_get_map MCP tooltools/kbdb_map.ts)— 薄殼呼 GET /map/map/:library
* 2. MCP server instructions 注入(mcp-handler.ts)— 連線時把全館地圖渲染成緊湊文字嵌入
*
* 走既有 KBDB service bindingkbdbFetch),不新增 binding、不碰 D1(薄殼鐵律)。
*
* 鐵律(design §4):注入失敗絕不能讓 MCP 連線失敗——buildLibraryMapInstructions 任何錯誤
* (超時/HTTP 錯/空庫/JSON 壞)一律回 nullcaller 靜默略過,instructions 沒地圖照常可用。
*
* 快取抉擇(design §4 給了「快取+TTL 或每次現拉」兩個選項,這裡選 isolate 內 TTL 快取):
* 本 MCP 用 stateless StreamableHTTPsessionIdGenerator: 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";
import { portalFetch, type KnowledgeIdentity } from "./portal-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 值容錯 parsekbdb 正常已回 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 總量控制在數百 tokenR3)。 */
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;
/**
* 快取以「身分」分格(2026-08-12)。
*
* 為什麼不能共用一格:地圖本身就是情報(哪些庫存在、各有多少關聯、核心 entity 是誰)。
* 以帳密連線時只該看到自己有權限的庫;若跟服務級連線共用同一格快取,先連上的那個人
* 會把自己的視野留給下一個人——那是跨帳號外洩,不是效能問題。
*/
const instructionsCache = new Map<string, { text: string | null; expiresAt: number }>();
/** 測試用:清掉 isolate 內快取(prod 不呼叫)。 */
export function __resetLibraryMapInstructionsCacheForTests(): void {
instructionsCache.clear();
}
/**
* 組 MCP server instructions 的藏書地圖段(design §4 / §6「session 啟動 → instructions 已含
* 全館地圖(push 零查詢)」)。任何失敗(超時/HTTP 錯/空庫/壞 JSON)→ nullcaller 靜默略過)。
*
* 以帳密連線(identity.kind === 'portal')時走 cypher `/portal/data/map`——只拿得到這個
* 帳號有權限的庫;服務級憑據維持既有 KBDB `/map` 直連。舊 tokenstale)不給地圖。
*/
export async function buildLibraryMapInstructions(
env: Env,
identity: KnowledgeIdentity,
): Promise<string | null> {
if (identity.kind === "stale") return null;
// 快取 keyportal 用 session(=這個人這次登入),service 用固定字串。
// session token 只當 Map 的 key 活在 isolate 記憶體內,不落地、不寫 log。
const cacheKey = identity.kind === "portal" ? `portal:${identity.portal.session}` : "service";
const now = Date.now();
const hit = instructionsCache.get(cacheKey);
if (hit && hit.expiresAt > now) return hit.text;
let text: string | null = null;
try {
const res = await Promise.race([
identity.kind === "portal"
? portalFetch(env, identity.portal.session, "/portal/data/map")
: 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.set(cacheKey, {
text,
expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS),
});
// isolate 內的快取,不做失效協議;但別讓不同帳號的格子無上限長大(isolate 可活很久)。
if (instructionsCache.size > 64) {
for (const [k, v] of instructionsCache) if (v.expiresAt <= now) instructionsCache.delete(k);
}
return text;
}