10d150ac2b
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>
181 lines
10 KiB
TypeScript
181 lines
10 KiB
TypeScript
/**
|
||
* 藏書地圖 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), ["稍後重試"]);
|
||
}
|
||
},
|
||
);
|
||
}
|