From 10d150ac2b4385af95a457f3c411430c4a146cf9 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Wed, 12 Aug 2026 19:33:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(mcp):=20MCP=20=E7=94=A8=E7=99=BB=E5=85=A5?= =?UTF-8?q?=E8=80=85=E7=9A=84=E8=BA=AB=E5=88=86=E6=9F=A5=E8=A9=A2=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=8E=BB=E6=89=BE=E4=B8=80=E6=8A=8A=E6=9C=8D?= =?UTF-8?q?=E5=8B=99=E5=85=A7=E9=83=A8=E9=87=91=E9=91=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cypher-executor/src/routes/portal-data.ts | 209 +++++++++++++++++ cypher-executor/src/routes/portal.ts | 5 + cypher-executor/tests/portal-data.test.ts | 208 +++++++++++++++++ kbdb/src/actions/record-crud.ts | 24 +- mcp/src/index.ts | 19 +- mcp/src/lib/library-map.ts | 40 +++- mcp/src/lib/portal-client.ts | 113 +++++++++ mcp/src/mcp-handler.ts | 10 +- mcp/src/middleware/partner-auth.ts | 30 ++- mcp/src/oauth/routes.ts | 62 ++++- mcp/src/oauth/store.ts | 31 +++ mcp/src/tools/arcrun_whoami.ts | 70 ++++-- mcp/src/tools/kbdb_data.ts | 207 ++++++++++++----- mcp/src/tools/kbdb_graph.ts | 67 +++++- mcp/src/tools/kbdb_map.ts | 47 +++- mcp/src/tools/registry.ts | 19 +- mcp/src/types.ts | 22 +- mcp/tests/unit/oauth.test.ts | 214 +++++++++++++++++- .../unit/tools/kbdb-data-identity.test.ts | 208 +++++++++++++++++ mcp/tests/unit/tools/kbdb-graph.test.ts | 99 +++++++- mcp/tests/unit/tools/kbdb-map.test.ts | 152 +++++++++++-- 21 files changed, 1695 insertions(+), 161 deletions(-) create mode 100644 mcp/src/lib/portal-client.ts create mode 100644 mcp/tests/unit/tools/kbdb-data-identity.test.ts diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts index 3ffc3bf..401f7ef 100644 --- a/cypher-executor/src/routes/portal-data.ts +++ b/cypher-executor/src/routes/portal-data.ts @@ -653,6 +653,215 @@ portalDataRouter.get('/portal/data/workflows', (c) => }), ); +// ═══════════════════════════════════════════════════════════════════════════ +// 授權的 AI(arcrun-mcp)走的資料面 — 與人類 portal 同一道閘、同一份權限 +// ═══════════════════════════════════════════════════════════════════════════ +// +// leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西; +// AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」 +// 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。 +// +// 之前的病:MCP 驗完帳密只留下一個布林值,身分當場丟掉(oauth/routes.ts 舊 `loginOk = res.ok`), +// 於是查詢時只好去找一把**服務內部金鑰**(KBDB_INTERNAL_TOKEN)直打 KBDB—— +// 那條路繞過了本檔上半部所有的庫過濾,等於「誰登入都看到同一格、而且是全部」。 +// +// 修法=MCP 改帶**登入者的 portal session token** 打本段端點。所以本段的每一支: +// ① 一律 requirePortalUser(session → 回讀 user record → 停用即時生效), +// ② owner_id / library 由 server 注入,**呼叫端傳什麼都不看**(與上半部同一條紅線: +// 呼叫端自己帶租戶字串=繞過庫過濾), +// ③ 越權與不存在同回 404(不洩存在性)。 +// +// 薄殼(rule 07):這裡沒有新能力——template/record/map 的真身都在 KBDB 基本盤, +// 本段只做「權限注入+轉發」,與上半部 search/entries 一模一樣的做法。 + +/** + * record 的庫歸屬。與 entry 不同:**沒有 `library` slot 的 record 不套庫過濾**。 + * + * 為什麼不比照 entry 用 'general' fallback:entry 是知識內容(庫是它的第一屬性,沒標就歸 + * general 是對的);record 是結構化資料列(contact / workflow_metadata / triplet…), + * 「庫」只對 triplet 這種有標 library slot 的才有意義。若照抄 general fallback, + * 一個庫權限是 ["kb"] 的帳號會連自己建的 contact 都讀不回——那是誤殺,不是隔離。 + * 租戶邊界仍然守著(owner_id 由 server 注入/逐筆比對),這裡只多守「有標庫的別越庫」。 + */ +function recordLibrary(values: Record | undefined): string | null { + const lib = values?.library; + return typeof lib === 'string' && lib.trim() ? lib.trim() : null; +} + +/** record 可讀?租戶要對;有標 library 的還要在用戶庫集合內。 */ +function canReadRecord( + rec: { values?: Record; owner_id?: string | null }, + tenant: string, + libraries: string[], +): boolean { + if ((rec.owner_id ?? '') !== tenant) return false; + const lib = recordLibrary(rec.values); + return lib === null || canReadLibrary(libraries, lib); +} + +// GET /portal/data/map — 藏書地圖全館視圖,**只回這個帳號有權限的庫**。 +// KBDB 的 /map 對權限無知(它回全館),過濾在這裡做——MCP 不得比 portal 同一個帳號看得更多。 +portalDataRouter.get('/portal/data/map', (c) => + run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const libraries = parseLibraries(auth.user.values.libraries); + if (libraries.length === 0) { + return c.json({ success: true, libraries: [], count: 0, note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' }); + } + const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`); + if (!res.ok) { + return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); + } + const body = (await res.json().catch(() => null)) as { libraries?: { library?: string }[] } | null; + if (!body || !Array.isArray(body.libraries)) { + return c.json({ error: '藏書地圖讀取失敗:KBDB 回應不是預期的 libraries 清單' }, 502); + } + const allowed = body.libraries.filter( + (l) => typeof l?.library === 'string' && canReadLibrary(libraries, l.library), + ); + return c.json({ success: true, libraries: allowed, count: allowed.length }); + }), +); + +// GET /portal/data/map/:library — 單庫詳圖。無權該庫 → 與不存在同回 404(不洩存在性)。 +portalDataRouter.get('/portal/data/map/:library', (c) => + run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const libraries = parseLibraries(auth.user.values.libraries); + const library = c.req.param('library'); + if (!canReadLibrary(libraries, library)) return notFound(c); + const res = await kbdbFetch( + c.env, + `/map/${encodeURIComponent(library)}?owner_id=${encodeURIComponent(portalTenant(c.env))}`, + ); + if (res.status === 404) return notFound(c); + if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502); + return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } }); + }), +); + +// GET /portal/data/templates — template 清單。 +// template=虛擬表定義(schema),**全域共享不分租戶**(kbdb-proxy 同一裁定,leo 2026-06-14): +// 它描述「資料長什麼形狀」,不含任何人的內容。內容的隔離在 records/entries 那層。 +portalDataRouter.get('/portal/data/templates', (c) => + run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const res = await kbdbFetch(c.env, '/templates'); + if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502); + return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } }); + }), +); + +// POST /portal/data/templates — 建 template(name + slots)。 +// 鐵律:這是「虛擬表定義」,不是建真的資料表;KBDB 不提供建表/SQL。 +// created_by 記租戶(溯源),template 本身全域可見可用。 +portalDataRouter.post('/portal/data/templates', (c) => + run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const body = (await c.req.json().catch(() => null)) as + | { name?: unknown; slots?: unknown; description?: unknown } + | null; + if (!body || typeof body.name !== 'string' || !body.name.trim() || !Array.isArray(body.slots)) { + return c.json({ error: 'name 與 slots[] 必填' }, 400); + } + const res = await kbdbFetch(c.env, '/templates', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: body.name, + slots: body.slots, + description: typeof body.description === 'string' ? body.description : undefined, + created_by: portalTenant(c.env), + }), + }); + return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); + }), +); + +// GET /portal/data/records/by-template/:template — 某 template 底下的 record。 +// server 注入 owner_id(呼叫端傳的一律忽略);有標 library 的再逐筆過濾。 +portalDataRouter.get('/portal/data/records/by-template/:template', (c) => + run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const libraries = parseLibraries(auth.user.values.libraries); + if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 }); + const tenant = portalTenant(c.env); + const res = await kbdbFetch( + c.env, + `/records/by-template/${encodeURIComponent(c.req.param('template'))}?owner_id=${encodeURIComponent(tenant)}`, + ); + if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502); + const body = (await res.json().catch(() => null)) as + | { records?: { values?: Record; owner_id?: string | null }[] } + | null; + if (!body || !Array.isArray(body.records)) { + return c.json({ error: 'record 讀取失敗:KBDB 回應不是預期的 records 清單' }, 502); + } + // KBDB 已按 owner_id 過濾;這裡再守一次庫(縱深防禦,且舊部署若回多了不會外洩)。 + const records = body.records.filter((r) => canReadRecord(r, tenant, libraries)); + return c.json({ success: true, records, count: records.length }); + }), +); + +// GET /portal/data/records/:recordId — 單筆 record。 +// 逐筆驗歸屬(owner_id 必須是本實例租戶)+ 驗庫;兩者不符與不存在同回 404。 +portalDataRouter.get('/portal/data/records/:recordId', (c) => + run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const libraries = parseLibraries(auth.user.values.libraries); + if (libraries.length === 0) return notFound(c); + const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param('recordId'))}`); + if (res.status === 404) return notFound(c); + if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502); + const body = (await res.json().catch(() => null)) as + | { record?: { values?: Record; owner_id?: string | null } } + | null; + const record = body?.record; + if (!record) return notFound(c); + if (!canReadRecord(record, portalTenant(c.env), libraries)) return notFound(c); + return c.json({ success: true, record }); + }), +); + +// POST /portal/data/records — 依 template 填一筆 record。 +// owner_id **一律由 server 定死成本實例租戶**(呼叫端傳的忽略)——寫入端若讓呼叫端挑歸屬, +// 等於開一扇「把資料寫進別人格子」的門。要寫進某個庫(values.library)必須有該庫權限。 +portalDataRouter.post('/portal/data/records', (c) => + run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const libraries = parseLibraries(auth.user.values.libraries); + if (libraries.length === 0) { + return c.json({ error: '此帳號尚未被授權任何知識庫,無法寫入' }, 403); + } + const body = (await c.req.json().catch(() => null)) as + | { template?: unknown; values?: unknown } + | null; + if (!body || typeof body.template !== 'string' || !body.template.trim() || !body.values || typeof body.values !== 'object') { + return c.json({ error: 'template 與 values 必填' }, 400); + } + const values = body.values as Record; + const targetLib = recordLibrary(values); + if (targetLib !== null && !canReadLibrary(libraries, targetLib)) { + // 寫入越庫是**明確拒絕**(403),不套讀取那條 404 不洩存在性的規則: + // 庫名是呼叫端自己指定的,這裡沒有「洩漏某庫存在」的問題,講清楚才可修正。 + return c.json({ error: `無「${targetLib}」庫的權限,不能寫入該庫` }, 403); + } + const res = await kbdbFetch(c.env, '/records', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ template: body.template, values, owner_id: portalTenant(c.env) }), + }); + return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); + }), +); + // GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令): // // 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」 diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 9a4edfe..973d202 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -677,6 +677,11 @@ portalRouter.post('/portal/login', (c) => display_name: rec.values.display_name ?? '', role: rec.values.role ?? 'user', libraries: parseLibraries(rec.values.libraries), + // session 還能活多久(秒)。**非機密**(是這台實例的 TTL 設定,不是任何人的憑據), + // 但呼叫端需要它才能把自己發的憑證對齊這個上限——arcrun-mcp 用它把 OAuth + // access_token 的 TTL 夾到 min(自己的 TTL, 這個值):否則 MCP token 活 30 天、 + // 底下的 portal session 7 天就死,使用者會在第 8 天遇到「連著卻查不到」的鬼打牆。 + session_expires_in: sessionTtl(c.env), // 絕不回租戶字串(design §3.3:portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*) }); }), diff --git a/cypher-executor/tests/portal-data.test.ts b/cypher-executor/tests/portal-data.test.ts index 2367df7..db68f13 100644 --- a/cypher-executor/tests/portal-data.test.ts +++ b/cypher-executor/tests/portal-data.test.ts @@ -232,6 +232,214 @@ describe('GET /portal/data/entries/:id(逐筆驗庫)', () => { }); }); +// ═══════════════ 3b. 授權的 AI(arcrun-mcp)走的資料面 ═══════════════ +// +// leo 2026-08-12:「AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」 +// ⇒ 這幾支端點與人類走的 search/entries 是同一道閘:同一個 session、同一份庫權限、 +// 同樣「呼叫端自帶 owner_id 一律不生效」、同樣「越權與不存在同一句 404」。 + +describe('藏書地圖 /portal/data/map(MCP 走的那條)', () => { + it('只回這個帳號有權限的庫;全館其他庫不出現在回應裡', async () => { + await seedSession('tok-m1', 'rec_1'); + mockGetRecord('rec_1', userValues({ libraries: '["finance"]' })); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' }) + .reply(200, { + success: true, + libraries: [ + { library: 'finance', narrative: '財務', top_entities: [], triplet_count: 3 }, + { library: 'hr', narrative: '人資', top_entities: [], triplet_count: 9 }, + ], + count: 2, + }); + const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m1' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { libraries: { library: string }[]; count: number }; + expect(data.libraries.map((l) => l.library)).toEqual(['finance']); + expect(data.count).toBe(1); + }); + + it('["*"] 全庫 → 全部庫都回', async () => { + await seedSession('tok-m2', 'rec_2'); + mockGetRecord('rec_2', userValues({ libraries: '["*"]' })); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' }) + .reply(200, { + success: true, + libraries: [ + { library: 'finance', narrative: '', top_entities: [], triplet_count: 3 }, + { library: 'hr', narrative: '', top_entities: [], triplet_count: 9 }, + ], + count: 2, + }); + const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m2' }); + const data = (await res.json()) as { libraries: { library: string }[] }; + expect(data.libraries.map((l) => l.library)).toEqual(['finance', 'hr']); + }); + + it('庫集合為空 → 誠實空結果+說明,不打 KBDB', async () => { + await seedSession('tok-m3', 'rec_3'); + mockGetRecord('rec_3', userValues({ libraries: '[]' })); + const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m3' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { count: number; note?: string }; + expect(data.count).toBe(0); + expect(data.note).toContain('尚未被授權'); + }); + + it('單庫詳圖:無權該庫 → 404 同一句(不打 KBDB,不洩該庫存不存在)', async () => { + await seedSession('tok-m4', 'rec_4'); + mockGetRecord('rec_4', userValues({ libraries: '["finance"]' })); + const res = await get('/portal/data/map/hr', { Authorization: 'Bearer tok-m4' }); + expect(res.status).toBe(404); + expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料'); + }); + + it('單庫詳圖:有權該庫 → 200 轉發', async () => { + await seedSession('tok-m5', 'rec_5'); + mockGetRecord('rec_5', userValues({ libraries: '["finance"]' })); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/map/finance'), method: 'GET' }) + .reply(200, { success: true, map: { library: 'finance', triplet_count: 3 } }); + const res = await get('/portal/data/map/finance', { Authorization: 'Bearer tok-m5' }); + expect(res.status).toBe(200); + }); + + it('未登入 → 401', async () => { + expect((await get('/portal/data/map')).status).toBe(401); + }); +}); + +describe('結構化資料 /portal/data/records、/portal/data/templates(MCP 走的那條)', () => { + it('by-template:server 注入 owner_id;caller 自帶的被靜默覆蓋(繞不過)', async () => { + await seedSession('tok-r1', 'rec_1'); + mockGetRecord('rec_1', userValues({ libraries: '["*"]' })); + let captured = ''; + fetchMock + .get(KBDB) + .intercept({ + path: (p: string) => { + if (!p.startsWith('/records/by-template/contact')) return false; + captured = p; + return true; + }, + method: 'GET', + }) + .reply(200, { success: true, records: [], count: 0 }); + const res = await get('/portal/data/records/by-template/contact?owner_id=someone-else', { + Authorization: 'Bearer tok-r1', + }); + expect(res.status).toBe(200); + expect(new URL(`http://x${captured}`).searchParams.get('owner_id')).toBe(TENANT); + }); + + it('by-template:有標 library 的 record 越庫的被濾掉;沒標 library 的照回', async () => { + await seedSession('tok-r2', 'rec_2'); + mockGetRecord('rec_2', userValues({ libraries: '["finance"]' })); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' }) + .reply(200, { + success: true, + records: [ + { record_id: 'r1', owner_id: TENANT, values: { library: 'finance', subject: 'A' } }, + { record_id: 'r2', owner_id: TENANT, values: { library: 'hr', subject: 'B' } }, + { record_id: 'r3', owner_id: TENANT, values: { subject: 'C' } }, // 沒標庫=結構化資料列 + ], + count: 3, + }); + const res = await get('/portal/data/records/by-template/triplet', { Authorization: 'Bearer tok-r2' }); + const data = (await res.json()) as { records: { record_id: string }[] }; + expect(data.records.map((r) => r.record_id)).toEqual(['r1', 'r3']); + }); + + it('單筆:別的租戶的 record → 404 同一句(就算全庫權限也擋)', async () => { + await seedSession('tok-r3', 'rec_3'); + mockGetRecord('rec_3', userValues({ libraries: '["*"]' })); + fetchMock + .get(KBDB) + .intercept({ path: '/records/r_other', method: 'GET' }) + .reply(200, { success: true, record: { record_id: 'r_other', owner_id: 'other-tenant', values: {} } }); + const res = await get('/portal/data/records/r_other', { Authorization: 'Bearer tok-r3' }); + expect(res.status).toBe(404); + expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料'); + }); + + it('單筆:越庫的 record → 404 同一句;有權的 → 200', async () => { + await seedSession('tok-r4', 'rec_4'); + mockGetRecord('rec_4', userValues({ libraries: '["finance"]' })); + fetchMock + .get(KBDB) + .intercept({ path: '/records/r_hr', method: 'GET' }) + .reply(200, { success: true, record: { record_id: 'r_hr', owner_id: TENANT, values: { library: 'hr' } } }); + expect((await get('/portal/data/records/r_hr', { Authorization: 'Bearer tok-r4' })).status).toBe(404); + + await seedSession('tok-r5', 'rec_5'); + mockGetRecord('rec_5', userValues({ libraries: '["finance"]' })); + fetchMock + .get(KBDB) + .intercept({ path: '/records/r_fin', method: 'GET' }) + .reply(200, { success: true, record: { record_id: 'r_fin', owner_id: TENANT, values: { library: 'finance' } } }); + expect((await get('/portal/data/records/r_fin', { Authorization: 'Bearer tok-r5' })).status).toBe(200); + }); + + it('寫入:owner_id 由 server 定死,呼叫端塞的不算', async () => { + await seedSession('tok-r6', 'rec_6'); + mockGetRecord('rec_6', userValues({ libraries: '["*"]' })); + let body: Record = {}; + fetchMock + .get(KBDB) + .intercept({ + path: '/records', + method: 'POST', + body: (b: string) => { + body = JSON.parse(b) as Record; + return true; + }, + }) + .reply(200, { success: true, record: { record_id: 'r_new' } }); + const res = await SELF.fetch('http://localhost/portal/data/records', { + method: 'POST', + headers: { Authorization: 'Bearer tok-r6', 'Content-Type': 'application/json' }, + body: JSON.stringify({ template: 'contact', values: { name: 'Leo' }, owner_id: 'someone-else' }), + }); + expect(res.status).toBe(200); + expect(body.owner_id).toBe(TENANT); + }); + + it('寫入越庫 → 403(明確拒絕,庫名是呼叫端自己指定的,沒有存在性可洩)', async () => { + await seedSession('tok-r7', 'rec_7'); + mockGetRecord('rec_7', userValues({ libraries: '["finance"]' })); + const res = await SELF.fetch('http://localhost/portal/data/records', { + method: 'POST', + headers: { Authorization: 'Bearer tok-r7', 'Content-Type': 'application/json' }, + body: JSON.stringify({ template: 'note', values: { library: 'hr', body: 'x' } }), + }); + expect(res.status).toBe(403); + }); + + it('templates 全域共享(schema 非內容):登入即可列', async () => { + await seedSession('tok-t1', 'rec_t1'); + mockGetRecord('rec_t1', userValues({ libraries: '["finance"]' })); + fetchMock + .get(KBDB) + .intercept({ path: '/templates', method: 'GET' }) + .reply(200, { success: true, templates: [{ id: 'tpl1', name: 'contact' }], count: 1 }); + const res = await get('/portal/data/templates', { Authorization: 'Bearer tok-t1' }); + expect(res.status).toBe(200); + expect(((await res.json()) as { count: number }).count).toBe(1); + }); + + it('未登入 → 401(records / templates 都是)', async () => { + expect((await get('/portal/data/templates')).status).toBe(401); + expect((await get('/portal/data/records/by-template/contact')).status).toBe(401); + expect((await get('/portal/data/records/r1')).status).toBe(401); + }); +}); + // ═══════════════ 4. graph D-4 粗閘 ═══════════════ describe('GET /portal/data/graph/neighbors/:name(D-4 粗閘)', () => { diff --git a/kbdb/src/actions/record-crud.ts b/kbdb/src/actions/record-crud.ts index 83e181c..86cdbef 100644 --- a/kbdb/src/actions/record-crud.ts +++ b/kbdb/src/actions/record-crud.ts @@ -65,6 +65,13 @@ export interface RecordResult { record_id: string; template_id: string; values: Record; + /** + * record 的歸屬(=其底層 slot entries 的 owner_id,createRecord 寫入時同一值)。 + * 2026-08-12 補:`GET /records/:id` 原本不回這欄,所以**呼叫端無從判斷這筆是不是自己的** + * ——按 id 直讀等於沒有租戶邊界。要讓 cypher 的 portal 資料面(授權的人/AI 走的那條) + * 能對單筆做「不是我的就回 404」,歸屬必須跟著資料一起回來。無歸屬的舊資料 → null。 + */ + owner_id: string | null; } export async function createRecord(db: D1Database, input: CreateRecordInput): Promise { @@ -85,7 +92,7 @@ export async function createRecord(db: D1Database, input: CreateRecordInput): Pr .bind(uid('ev'), recordId, tpl.id, slot, entry.id) .run(); } - return { record_id: recordId, template_id: tpl.id, values: input.values }; + return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null }; } // Update an existing record's slot values (mira-dissolve T2.1, issue #6). @@ -147,17 +154,19 @@ export async function updateRecord( export async function getRecord(db: D1Database, recordId: string): Promise { const res = await db .prepare( - `SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id + `SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id FROM entry_values ev JOIN entries e ON ev.entry_id = e.id WHERE ev.record_id = ?`, ) .bind(recordId) - .all<{ slot: string; content: string; template_id: string }>(); + .all<{ slot: string; content: string; template_id: string; owner_id: string | null }>(); const rows = res.results ?? []; if (rows.length === 0) return null; const values: Record = {}; for (const r of rows) values[r.slot] = r.content; - return { record_id: recordId, template_id: rows[0].template_id, values }; + // 歸屬取第一個非 null 的 slot entry owner(同一 record 的 slot entries 同歸屬) + const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null; + return { record_id: recordId, template_id: rows[0].template_id, values, owner_id }; } export async function searchByTemplate(db: D1Database, template: string, owner_id?: string, limit = 100): Promise { @@ -192,19 +201,20 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i const placeholders = chunk.map(() => '?').join(','); const evRes = await db .prepare( - `SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id + `SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id FROM entry_values ev JOIN entries e ON ev.entry_id = e.id WHERE ev.record_id IN (${placeholders})`, ) .bind(...chunk) - .all<{ record_id: string; slot: string; content: string; template_id: string }>(); + .all<{ record_id: string; slot: string; content: string; template_id: string; owner_id: string | null }>(); for (const r of evRes.results ?? []) { let rec = byId.get(r.record_id); if (!rec) { - rec = { record_id: r.record_id, template_id: r.template_id, values: {} }; + rec = { record_id: r.record_id, template_id: r.template_id, values: {}, owner_id: null }; byId.set(r.record_id, rec); } rec.values[r.slot] = r.content; + if (rec.owner_id == null && r.owner_id != null) rec.owner_id = r.owner_id; } } return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r); diff --git a/mcp/src/index.ts b/mcp/src/index.ts index c396868..f81b7fb 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -1,13 +1,25 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { Env } from "./types.js"; -import { partnerAuthMiddleware } from "./middleware/partner-auth.js"; +import { partnerAuthMiddleware, type AuthPath } from "./middleware/partner-auth.js"; import { handleMcpRequest } from "./mcp-handler.js"; +import { resolveKnowledgeIdentity } from "./lib/portal-client.js"; +import type { PortalIdentity } from "./oauth/store.js"; import { inspectorHtml } from "./pages/inspector.js"; import { kbdbFetch } from "./lib/kbdb-client.js"; import { registerOAuthRoutes } from "./oauth/routes.js"; -const _app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>(); +const _app = new Hono<{ + Bindings: Env; + Variables: { + org_namespace: string; + partner_token: string; + // 登入者身分(以帳密走 OAuth 連進來時才有)+ 這條連線是哪種憑據。 + // 知識面工具(kbdb_*)據此決定走 portal 資料面還是既有 KBDB 直連(見 lib/portal-client.ts)。 + portal?: PortalIdentity; + auth_path: AuthPath; + }; +}>(); // ── OAuth 2.1 server 路由(掛在 worker 根路徑,非 /mcp)────────────────────────── // well-known / authorize / token / register 必須在 origin 根,claude.ai 遠端 connector 才發現得到。 @@ -261,7 +273,8 @@ app.options("/mcp", (c) => { app.post("/", partnerAuthMiddleware, async (c) => { const orgNamespace = c.get("org_namespace"); const partnerToken = c.get("partner_token"); - return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken); + const identity = resolveKnowledgeIdentity(c.get("auth_path"), c.get("portal")); + return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken, identity); }); // 輸出根 app(_app):與 basePath('/mcp') 的 app 共享同一份 router,故 OAuth 根路由與 diff --git a/mcp/src/lib/library-map.ts b/mcp/src/lib/library-map.ts index 113192c..6003f7a 100644 --- a/mcp/src/lib/library-map.ts +++ b/mcp/src/lib/library-map.ts @@ -19,6 +19,7 @@ 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 { @@ -86,25 +87,45 @@ const MAP_FETCH_TIMEOUT_MS = 1500; const CACHE_TTL_OK_MS = 5 * 60 * 1000; const CACHE_TTL_FAIL_MS = 60 * 1000; -let instructionsCache: { text: string | null; expiresAt: number } | null = null; +/** + * 快取以「身分」分格(2026-08-12)。 + * + * 為什麼不能共用一格:地圖本身就是情報(哪些庫存在、各有多少關聯、核心 entity 是誰)。 + * 以帳密連線時只該看到自己有權限的庫;若跟服務級連線共用同一格快取,先連上的那個人 + * 會把自己的視野留給下一個人——那是跨帳號外洩,不是效能問題。 + */ +const instructionsCache = new Map(); /** 測試用:清掉 isolate 內快取(prod 不呼叫)。 */ export function __resetLibraryMapInstructionsCacheForTests(): void { - instructionsCache = null; + instructionsCache.clear(); } /** * 組 MCP server instructions 的藏書地圖段(design §4 / §6「session 啟動 → instructions 已含 * 全館地圖(push 零查詢)」)。任何失敗(超時/HTTP 錯/空庫/壞 JSON)→ null(caller 靜默略過)。 + * + * 以帳密連線(identity.kind === 'portal')時走 cypher `/portal/data/map`——只拿得到這個 + * 帳號有權限的庫;服務級憑據維持既有 KBDB `/map` 直連。舊 token(stale)不給地圖。 */ -export async function buildLibraryMapInstructions(env: Env): Promise { +export async function buildLibraryMapInstructions( + env: Env, + identity: KnowledgeIdentity, +): Promise { + if (identity.kind === "stale") return null; + // 快取 key:portal 用 session(=這個人這次登入),service 用固定字串。 + // session token 只當 Map 的 key 活在 isolate 記憶體內,不落地、不寫 log。 + const cacheKey = identity.kind === "portal" ? `portal:${identity.portal.session}` : "service"; const now = Date.now(); - if (instructionsCache && instructionsCache.expiresAt > now) return instructionsCache.text; + const hit = instructionsCache.get(cacheKey); + if (hit && hit.expiresAt > now) return hit.text; let text: string | null = null; try { const res = await Promise.race([ - kbdbFetch(env, "/map"), + identity.kind === "portal" + ? portalFetch(env, identity.portal.session, "/portal/data/map") + : kbdbFetch(env, "/map"), new Promise((_, reject) => setTimeout(() => reject(new Error("library map fetch timeout")), MAP_FETCH_TIMEOUT_MS), ), @@ -124,6 +145,13 @@ export async function buildLibraryMapInstructions(env: Env): Promise 64) { + for (const [k, v] of instructionsCache) if (v.expiresAt <= now) instructionsCache.delete(k); + } return text; } diff --git a/mcp/src/lib/portal-client.ts b/mcp/src/lib/portal-client.ts new file mode 100644 index 0000000..3c05a7f --- /dev/null +++ b/mcp/src/lib/portal-client.ts @@ -0,0 +1,113 @@ +/** + * Portal 資料面 client — 「授權的 AI」用登入者的身分查東西的唯一管道。 + * + * leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西; + * AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」 + * 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。 + * + * 所以這裡帶的是 **portal session token**(同意頁輸入帳密時 cypher 發的那張, + * 與人類在 portal 網頁上拿到的完全同一種),不是任何服務內部金鑰。 + * 端點是 cypher 的 `/portal/data/*`——庫過濾、租戶注入、停用即時生效全在那邊 server 側做完, + * 本檔不做任何判斷(薄殼鐵律 rule 07:能力長在 API,介面只轉換)。 + * + * 走既有 CYPHER_EXECUTOR service binding,不新增 binding、不新增金鑰。 + */ + +import type { Env } from "../types.js"; +import type { PortalIdentity } from "../oauth/store.js"; +import { errorResponse } from "./cypher-client.js"; + +export interface PortalCallOpts { + method?: string; + body?: unknown; + query?: Record; +} + +/** 用登入者的 session 打 cypher 的 portal 資料面。 */ +export async function portalFetch( + env: Env, + session: string, + path: string, + opts: PortalCallOpts = {}, +): Promise { + if (!env.CYPHER_EXECUTOR) { + throw new Error("CYPHER_EXECUTOR service binding not configured"); + } + const url = new URL(`https://cypher${path}`); + for (const [k, v] of Object.entries(opts.query ?? {})) { + if (v !== undefined && v !== "") url.searchParams.set(k, String(v)); + } + return env.CYPHER_EXECUTOR.fetch(url.toString(), { + method: opts.method ?? "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session}`, + }, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }); +} + +/** + * 知識面工具的身分解析結果。 + * + * 三態刻意分開,因為「查不到」和「沒有」不可以長得一樣(leo 的老原則): + * - portal :有登入者 → 走 portal 資料面(權限=這個人的權限) + * - service :服務級憑據(static token / partner key)→ 維持既有 KBDB 直連(零回歸) + * - stale :OAuth token 但沒帶身分(本次改版前簽發的舊 token)→ **誠實要求重新連線**, + * 不偷偷退回服務金鑰那條老路(那正是要修掉的「不管誰登入都看到同一格」) + */ +export type KnowledgeIdentity = + | { kind: "portal"; portal: PortalIdentity } + | { kind: "service" } + | { kind: "stale" }; + +export function resolveKnowledgeIdentity( + authPath: "oauth" | "service", + portal: PortalIdentity | undefined, +): KnowledgeIdentity { + if (authPath !== "oauth") return { kind: "service" }; + return portal?.session ? { kind: "portal", portal } : { kind: "stale" }; +} + +/** 舊 token(沒帶身分)時的統一回覆:講清楚怎麼修,不假裝查不到資料。 */ +export function staleIdentityError() { + return errorResponse( + "identity_missing", + "這條 MCP 連線是舊版簽發的 token,裡面沒有登入者身分,因此查不到任何知識內容。" + + "重新連線一次(在 claude.ai 的 connector 設定裡重新授權、輸入你的 Portal 帳密)即可——" + + "不需要另外找任何 credential 或金鑰。", + [ + "到 claude.ai → Settings → Connectors,把這個 connector 重新連線一次(會跳出輸入 Portal 帳密的頁面)", + "重連後 kbdb_* 全部工具都會用你這個帳號的權限查詢", + ], + ); +} + +/** + * portal 資料面的錯誤 → 給 AI 看的訊息。 + * 401/403 特別處理:那代表**登入階段過期或帳號被停用**,不是「資料不存在」—— + * 兩者混在一起會讓 AI 對使用者說「你的知識庫是空的」,那是畫面在說謊。 + */ +export async function portalError(res: Response, what: string) { + const detail = await res.text().catch(() => ""); + if (res.status === 401) { + return errorResponse( + "session_expired", + `${what}失敗:登入階段已過期(portal session 到期或已登出)。`, + [ + "到 claude.ai → Settings → Connectors 重新連線這個 connector(重新輸入 Portal 帳密)", + "重連後權限與你在 portal 網頁上看到的一致", + ], + detail, + ); + } + if (res.status === 403) { + return errorResponse( + "forbidden", + `${what}失敗:這個帳號沒有這項權限(帳號可能已停用,或沒有被授權該知識庫)。`, + ["請知識庫管理員在 portal 的帳號管理裡確認你的狀態與可用知識庫"], + detail, + ); + } + return errorResponse(`portal_${res.status}`, `${what}失敗(HTTP ${res.status})`, ["稍後重試"], detail); +} diff --git a/mcp/src/mcp-handler.ts b/mcp/src/mcp-handler.ts index 63a5bbd..8497568 100644 --- a/mcp/src/mcp-handler.ts +++ b/mcp/src/mcp-handler.ts @@ -2,6 +2,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 type { KnowledgeIdentity } from "./lib/portal-client.js"; import { Env } from "./types.js"; export async function handleMcpRequest( @@ -9,11 +10,16 @@ export async function handleMcpRequest( env: Env, orgNamespace: string, partnerToken: string, + identity: KnowledgeIdentity, ): Promise { // 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); + // + // 🔴 2026-08-12:以帳密連線時**改用登入者的身分**組地圖——否則 instructions 會把 + // 整個知識庫的庫名一次推給一個可能只有部分權限的帳號(地圖本身就是情報)。 + // 快取也因此改成 per-session key(見 lib/library-map.ts)。 + const mapInstructions = await buildLibraryMapInstructions(env, identity); // 2026-07-30(leo 問「人類說『幫我用 arcrun 寫 xxx』,Haiku 會知道要用這些資源嗎? // 如果不會,要寫什麼在外面讓它一聽到就知道?」): @@ -60,7 +66,7 @@ export async function handleMcpRequest( { instructions }, ); - registerAllTools(server, env, orgNamespace, partnerToken); + registerAllTools(server, env, orgNamespace, partnerToken, identity); await server.connect(transport); return transport.handleRequest(request); diff --git a/mcp/src/middleware/partner-auth.ts b/mcp/src/middleware/partner-auth.ts index 0206112..e3937e1 100644 --- a/mcp/src/middleware/partner-auth.ts +++ b/mcp/src/middleware/partner-auth.ts @@ -1,9 +1,19 @@ import { Context, Next } from "hono"; import { Env } from "../types.js"; -import { getAccessToken } from "../oauth/store.js"; +import { getAccessToken, type PortalIdentity } from "../oauth/store.js"; import { constantTimeEqual } from "../oauth/crypto.js"; import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.js"; +/** + * 這條連線是**用誰的身分**進來的。決定知識面(kbdb_*)走哪條路: + * - "oauth":有人在同意頁輸入過 Portal 帳密 → 帶著他的 portal session 走 portal 資料面, + * 權限=他在 portal 網頁上看得到的那些(庫過濾照吃)。 + * - "service":static token / partner key 這類**服務級**憑據(本身就是真祕密, + * 代表整個實例或整個租戶,不是某個人)→ 維持既有的 KBDB 直連行為,零回歸。 + * 兩條路刻意分開命名,因為「這張 token 背後有沒有一個人」正是本次要能分辨的事。 + */ +export type AuthPath = "oauth" | "service"; + /** * MCP / GUI 端點認證中介層。 * @@ -19,7 +29,15 @@ import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata. * 已從預設路徑移除;只在明確設 ALLOW_PLAINTEXT_NAMESPACE="true" 的遷移情境才恢復。 */ export async function partnerAuthMiddleware( - c: Context<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>, + c: Context<{ + Bindings: Env; + Variables: { + org_namespace: string; + partner_token: string; + portal?: PortalIdentity; + auth_path: AuthPath; + }; + }>, next: Next ) { const origin = originOf(c.req.url); @@ -50,6 +68,11 @@ export async function partnerAuthMiddleware( } c.set("org_namespace", at.namespace); c.set("partner_token", at.namespace); // 下游 cypher 用 namespace 當 X-Arcrun-API-Key(與 CLI 同一份身份) + // 登入者的身分(2026-08-12):知識面工具(kbdb_*)帶著它打 cypher 的 portal 資料面, + // 權限與這個人在 portal 網頁上看到的完全一致。舊 token 沒有這欄 → undefined, + // 知識面工具會要求重新連線(不偷偷退回服務金鑰那條老路)。 + c.set("portal", at.portal); + c.set("auth_path", "oauth"); await next(); return; } @@ -60,6 +83,7 @@ export async function partnerAuthMiddleware( const ns = c.env.MCP_OWNER_NAMESPACE || "leo"; c.set("org_namespace", ns); c.set("partner_token", ns); + c.set("auth_path", "service"); await next(); return; } @@ -79,6 +103,7 @@ export async function partnerAuthMiddleware( } c.set("org_namespace", info.org_namespace); c.set("partner_token", token); + c.set("auth_path", "service"); await next(); return; } @@ -89,6 +114,7 @@ export async function partnerAuthMiddleware( if (c.env.ALLOW_PLAINTEXT_NAMESPACE === "true") { c.set("org_namespace", token); c.set("partner_token", token); + c.set("auth_path", "service"); await next(); return; } diff --git a/mcp/src/oauth/routes.ts b/mcp/src/oauth/routes.ts index cb65153..6306316 100644 --- a/mcp/src/oauth/routes.ts +++ b/mcp/src/oauth/routes.ts @@ -14,6 +14,7 @@ import { consumeAuthCode, putAccessToken, AUTH_CODE_TTL_SECONDS, + type PortalIdentity, } from "./store.js"; import { originOf, @@ -34,10 +35,25 @@ const CORS_JSON = { "Cache-Control": "no-store", } as const; -function ownerNamespace(env: Env): string { +/** + * **工作流面**(arcrun_* 工具)的租戶代號。知識面(kbdb_*)已不再讀它—— + * 那邊改成跟著登入者的 portal session 走(見 store.ts PortalIdentity)。 + * + * 為什麼這裡還留著、而且還有預設值:cypher 的 workflow API 是用「租戶代號當 opaque key」 + * (X-Arcrun-API-Key)認的,不吃 portal session;要拆掉它得先在 cypher 開一組 + * 吃 portal session 的 workflow 端點。那是下一步,不在本次範圍—— + * 硬拆會把現在好好的 arcrun_* 弄壞。**誠實記在這裡,不假裝已經解決。** + * + * ⚠️ 預設值 "leo" 的**知識面**用法已消滅:它曾經是「不管誰登入都看到同一格」的根因 + * (namespace 直接當 KBDB 的 owner_id 用)。現在它只當工作流面的 API key。 + */ +function workflowTenant(env: Env): string { return env.MCP_OWNER_NAMESPACE || "leo"; } +/** portal session TTL 讀不到時的保守假設(秒):短的那邊贏,寧可早點要求重連。 */ +const FALLBACK_PORTAL_SESSION_TTL = 604800; // 7 天(cypher portal.ts 的預設值) + function tokenTtl(env: Env): number { const n = parseInt(env.MCP_TOKEN_TTL ?? "", 10); return Number.isFinite(n) && n > 0 ? n : DEFAULT_TOKEN_TTL; @@ -248,7 +264,13 @@ export function registerOAuthRoutes< } // 認證下沉到 cypher 的 /portal/login(唯一真相源;同樣吃它的節流與停用檢查)。 // 走 service binding(MCP 與 cypher 同帳號,屬 D28 允許的零件級組合)。 - let loginOk = false; + // + // 🔴 2026-08-12(leo:「用登入能做的 mcp 就應該能做,結果要你去打 MCP 時自己找 + // credential 問題很大」):這裡**接住登入回來的身分**,不再只留 `res.ok`。 + // 舊版把身分丟掉 ⇒ 查詢時無身分可帶 ⇒ 只好去撈服務內部金鑰(KBDB_INTERNAL_TOKEN) + // 直打 KBDB ⇒ 繞過所有庫過濾、而且不管誰登入都看到同一格。根因就在這幾行。 + let portal: PortalIdentity | null = null; + let portalTtl = FALLBACK_PORTAL_SESSION_TTL; try { const res = await c.env.CYPHER_EXECUTOR.fetch( new Request("https://cypher/portal/login", { @@ -257,11 +279,34 @@ export function registerOAuthRoutes< body: JSON.stringify({ email, password }), }), ); - loginOk = res.ok; + if (res.ok) { + const body = (await res.json().catch(() => null)) as { + session_token?: unknown; + display_name?: unknown; + role?: unknown; + libraries?: unknown; + session_expires_in?: unknown; + } | null; + const session = typeof body?.session_token === "string" ? body.session_token : ""; + if (session) { + portal = { + session, + display_name: typeof body?.display_name === "string" ? body.display_name : "", + role: typeof body?.role === "string" ? body.role : "user", + libraries: Array.isArray(body?.libraries) + ? body.libraries.filter((x): x is string => typeof x === "string") + : [], + }; + const ttl = Number(body?.session_expires_in); + if (Number.isFinite(ttl) && ttl > 0) portalTtl = ttl; + } + } } catch { return c.html(consentPage(consent, "暫時無法驗證帳密,請稍後再試。"), 503); } - if (!loginOk) { + if (!portal) { + // 帳密不對,或這台 cypher 舊到還不回 session_token。兩者都不可以發碼—— + // 發了也是一張沒有身分的 token,查什麼都得再找一次 credential,正是要修的病。 return c.html(consentPage(consent, "帳號或密碼不正確,請重試。"), 401); } if (!c.env.OAUTH_KV) { @@ -275,7 +320,9 @@ export function registerOAuthRoutes< code_challenge_method: "S256", scope: consent.scope, resource: consent.resource, - namespace: ownerNamespace(c.env), + namespace: workflowTenant(c.env), + portal, + portal_session_expires_in: portalTtl, }); const location = redirectWith(redirectUri, { code, @@ -318,7 +365,9 @@ export function registerOAuthRoutes< return err("invalid_grant", "PKCE verification failed"); } - const ttl = tokenTtl(c.env); + // token 活不過它底下的 portal session:否則第 8 天會出現「MCP 還連著、卻什麼都查不到」 + // ——使用者看到的是壞掉,實際是身分過期。兩者一起到期,重連就是重新輸入帳密,一次搞定。 + const ttl = Math.min(tokenTtl(c.env), data.portal_session_expires_in || FALLBACK_PORTAL_SESSION_TTL); const accessToken = randomToken(32); await putAccessToken( c.env.OAUTH_KV, @@ -327,6 +376,7 @@ export function registerOAuthRoutes< namespace: data.namespace, client_id: data.client_id, scope: data.scope, + portal: data.portal, // RFC 8707:aud 一律用「本 server canonical resource URI」(非 client 原樣值)。 // authorize 已只存 canonical,這裡再以當前 origin 重算一次確保與 partner-auth 嚴格比對一致。 aud: resourceUri(originOf(c.req.url)), diff --git a/mcp/src/oauth/store.ts b/mcp/src/oauth/store.ts index 4732db5..f409e58 100644 --- a/mcp/src/oauth/store.ts +++ b/mcp/src/oauth/store.ts @@ -4,6 +4,28 @@ // KV key 一律用 SHA-256 hex(不把 raw code/token 當 key)→ 就算 KV list 也拿不到可用憑證。 import { sha256Hex } from "./crypto.js"; +/** + * 登入者的身分(authorize 時用帳密換到,之後跟著 token 走)。 + * + * leo 2026-08-12:「掛上 MCP 並輸入帳密,那個動作本身就是授權。」 + * ⇒ 驗完帳密**不能只留一個布林值**——身分要接住並攜帶,下游才不必再要一次認證。 + * + * `session` 是 cypher `/portal/login` 發的 portal session token,與人類在 portal 網頁上 + * 拿到的完全同一種。它是「取得的暫時性認證」,正合本檔開頭的儲存鐵律(可進 KV、帶 TTL); + * access_token 的 TTL 會被夾到不超過它(見 routes.ts),兩者一起到期,不會出現 + * 「MCP 還連著、底下 session 早死」的鬼打牆。 + * + * display_name / role / libraries 只是**給人看的回報值**(arcrun_whoami)。 + * 真正的權限判定每次都由 cypher 回讀 user record 現算——這裡的副本不是判準, + * 所以管理員改權限或停用帳號會立刻生效,不必等 token 過期。 + */ +export interface PortalIdentity { + session: string; + display_name: string; + role: string; + libraries: string[]; +} + /** authorization code 綁定的資料(一次性;/token 驗證後即刪)。 */ export interface AuthCodeData { client_id: string; @@ -15,6 +37,10 @@ export interface AuthCodeData { resource: string; /** 換發後 token 綁定的資料分區(owner namespace)。 */ namespace: string; + /** 這張 code 是誰換的(帳密驗過的那個人)。 */ + portal: PortalIdentity; + /** portal session 剩餘秒數(authorize 當下);access_token TTL 不得超過它。 */ + portal_session_expires_in: number; } /** access token 綁定的資料。 */ @@ -26,6 +52,11 @@ export interface AccessTokenData { aud: string; /** 過期時間(epoch 秒),與 KV TTL 雙保險。 */ exp: number; + /** + * 持這張 token 的是誰。**舊 token(本次改版前簽發的)沒有這欄** → undefined, + * 知識面工具會誠實要求重新連線,而不是偷偷退回服務金鑰那條老路(fail-closed)。 + */ + portal?: PortalIdentity; } const CODE_PREFIX = "oauth:code:"; diff --git a/mcp/src/tools/arcrun_whoami.ts b/mcp/src/tools/arcrun_whoami.ts index 24ee23c..0dc67a0 100644 --- a/mcp/src/tools/arcrun_whoami.ts +++ b/mcp/src/tools/arcrun_whoami.ts @@ -5,31 +5,75 @@ * 治本是給 AI 無腦入口:問工具拿身份。CLI 有 acr whoami,MCP 必須對齊(薄殼一致,rule 07 §5)—— * 否則「AI 偏好 MCP」時又得繞回 curl。 * - * 薄殼:只回報 MCP 已解析的 orgNamespace(綁哪個帳號)+ cypher binding 連向,無業務邏輯。 + * 2026-08-12 改:以帳密連線時,「我是誰」的答案是**登入的那個人**(display_name / role / + * 可用知識庫),不是一個租戶代號。原本回的 account_namespace 是租戶字串——那東西一旦落到 + * 呼叫端手上就能拿去直打 /kbdb/*、繞過所有庫過濾(portal-data.ts 檔頭紅線),所以登入身分下 + * 不再回它。工作流面(arcrun_*)仍用它當 API key,但那只在 server 內部用。 + * + * 薄殼:只如實回報 MCP 已解析的身分,不做推斷、不打任何查詢。 */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { toolName } from "../brand.js"; import { Env } from "../types.js"; +import type { KnowledgeIdentity } from "../lib/portal-client.js"; -export function registerWhoami(server: McpServer, env: Env, orgNamespace: string) { +export function registerWhoami( + server: McpServer, + env: Env, + orgNamespace: string, + identity: KnowledgeIdentity, +) { server.tool( toolName("whoami"), - "回報這個 MCP 連線目前生效的身份:綁哪個帳號 / namespace、cypher 連向哪。" + - "部署 / 觸發 / 查 workflow 前先 call 此 tool 確認帳號,**不要自己 curl 猜帳號 URL**(會打到錯帳號)。", + "回報這個 MCP 連線目前生效的身份:以帳密連線時回「登入的是誰、能看哪些知識庫」;" + + "服務級 token 連線時回綁定的帳號 namespace。部署 / 觸發 / 查 workflow 前先 call 此 tool 確認身份," + + "**不要自己 curl 猜帳號 URL**(會打到錯帳號)。", {}, async () => { - // 薄殼:MCP 透過 service binding(CYPHER_EXECUTOR)連 cypher,binding 本身決定連哪台; - // 身份來自啟動時解析的 orgNamespace(綁哪個帳號的資料分區)。這裡只如實回報,不做推斷。 - const identity = { - account_namespace: orgNamespace || "(未設)", + const base = { cypher: "service-binding:CYPHER_EXECUTOR", kbdb: "service-binding:KBDB", - note: - "此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。", - }; - return { - content: [{ type: "text" as const, text: JSON.stringify(identity, null, 2) }], }; + + if (identity.kind === "portal") { + const { display_name, role, libraries } = identity.portal; + return json({ + ...base, + auth: "portal-login(這條連線是有人輸入 Portal 帳密授權的)", + logged_in_as: display_name || "(未設顯示名稱)", + role, + libraries: libraries.length ? libraries : ["(尚未被授權任何知識庫)"], + knowledge_scope: + libraries.includes("*") + ? "全部知識庫(此帳號有全庫權限)" + : `僅限上列知識庫——kbdb_* 查得到的東西與這個帳號在 portal 網頁上看得到的完全一致`, + note: + "你是「主人授權的 AI」:主人查得到的你查得到,主人查不到的你也查不到。" + + "kbdb_* 不需要任何額外的 credential / 金鑰 / kbdb_base——已經登入過了,不會再問第二次。", + }); + } + + if (identity.kind === "stale") { + return json({ + ...base, + auth: "舊版 token(沒有登入者身分)", + knowledge_scope: "查不到任何知識內容", + note: + "這條連線是本次改版前簽發的 token。到 claude.ai → Settings → Connectors " + + "重新連線一次(輸入 Portal 帳密)即可恢復,不需要找任何 credential。", + }); + } + + return json({ + ...base, + auth: "service token(static token / partner key,代表整個實例或租戶,不是某個人)", + account_namespace: orgNamespace || "(未設)", + note: "此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。", + }); }, ); } + +function json(obj: unknown) { + return { content: [{ type: "text" as const, text: JSON.stringify(obj, null, 2) }] }; +} diff --git a/mcp/src/tools/kbdb_data.ts b/mcp/src/tools/kbdb_data.ts index 793b066..404b155 100644 --- a/mcp/src/tools/kbdb_data.ts +++ b/mcp/src/tools/kbdb_data.ts @@ -1,23 +1,27 @@ /** * KBDB 資料層 MCP 薄殼(kbdb-base Phase 9.1,HANDOFF §2) * - * rule 07 §5(薄殼鐵律):能力長在基本盤 API,MCP 只做介面轉換 + 暴露,無業務邏輯。 - * 全走既有 kbdbFetch(KBDB service binding)打基本盤 HTTP API(kbdb/src/routes/*)。 + * rule 07 §5(薄殼鐵律):能力長在 API,MCP 只做介面轉換 + 暴露,無業務邏輯。 + * + * ── 2026-08-12:改用「登入進來的那個人的身分」查詢 ──────────────────────────── + * leo:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;AI 透過輸入帳密的 + * MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」 + * 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。 + * + * 之前的路:MCP 驗完帳密只留一個布林值 → 查詢時無身分可帶 → 只好帶**服務內部金鑰** + * (KBDB_INTERNAL_TOKEN)直打 KBDB。那條路繞過所有庫過濾,而且不管誰登入都看到同一格。 + * + * 現在的路(identity.kind === 'portal'):帶登入者的 portal session 打 cypher + * `/portal/data/*`——庫過濾/租戶注入/停用即時生效全在 server 側,與人類走 portal 網頁 + * 是**同一道閘、同一份權限**。MCP 這邊一個判斷都不做。 + * + * 服務級憑據(static token / partner key,identity.kind === 'service')維持既有 KBDB 直連, + * 零回歸——那類憑據本身就是真祕密、代表整個實例或租戶,不是某個人。 * * KBDB 鐵律(leo 2026-06-14,頂層 DECISION-kbdb-v3-baseplane.md): * - 任何人不准動表;**不提供建表 / SQL tool**。 - * - AI 想存新類型的資料時只有「建 template(name+slots)+ 填 record(slot→content)」可用 - * ——類 Supabase 萬用表,schema 由 template/slot 表達,不是真的 CREATE TABLE。 - * - 薄殼只調基本盤 HTTP API,不直連 D1、不寫 SQL。 - * - * 基本盤 API 契約(已存在,kbdb/src/routes): - * POST /templates { name, slots[], description?, created_by? } → { template } - * GET /templates → { templates[], count } - * GET /templates/:idOrName → { template } - * POST /records { template, values:{slot:content}, owner_id? } → { record } - * GET /records/by-template/:t ?owner_id= → { records[], count } - * GET /records/:recordId → { record } - * GET /entries/search ?q=&owner_id= → { entries[], count, mode:'keyword' } + * - AI 想存新類型的資料時只有「建 template(name+slots)+ 填 record(slot→content)」可用。 + * - 薄殼只調 HTTP API,不直連 D1、不寫 SQL。 */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -25,22 +29,32 @@ 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 { + portalFetch, + portalError, + staleIdentityError, + type KnowledgeIdentity, +} from "../lib/portal-client.js"; + +/** 走 portal 資料面時,呼叫端傳的 owner_id 一律無效(server 用登入者的歸屬)——如實告訴 AI。 */ +const OWNER_IGNORED_HINT = + "owner_id 在登入身分下不生效:查詢範圍由你的帳號權限決定(與你在 portal 網頁看到的一致)"; /** 註冊全部 KBDB 資料層工具(kbdb-base Phase 9.1)。不含建表/SQL tool(鐵律)。 */ -export function registerAllKbdbDataTools(server: McpServer, env: Env) { - registerCreateTemplate(server, env); - registerListTemplates(server, env); - registerCreateRecord(server, env); - registerGetRecord(server, env); - registerQuery(server, env); - registerSearch(server, env); +export function registerAllKbdbDataTools(server: McpServer, env: Env, identity: KnowledgeIdentity) { + registerCreateTemplate(server, env, identity); + registerListTemplates(server, env, identity); + registerCreateRecord(server, env, identity); + registerGetRecord(server, env, identity); + registerQuery(server, env, identity); + registerSearch(server, env, identity); } /** * kbdb_create_template — 建一個 template(= 萬用表裡的一種「虛擬表/資料形狀」)。 * 這是 AI 想存「新類型資料」時的唯一入口:沒有建表 API,改用 template + slots 描述欄位。 */ -export function registerCreateTemplate(server: McpServer, env: Env) { +export function registerCreateTemplate(server: McpServer, env: Env, identity: KnowledgeIdentity) { server.tool( "kbdb_create_template", "建一個 KBDB template(萬用表裡的一種資料形狀,類 Supabase 的虛擬表)。KBDB 不能建真的資料表——" + @@ -50,16 +64,24 @@ export function registerCreateTemplate(server: McpServer, env: Env) { name: z.string().min(1).describe("template 名稱(唯一識別,之後填 record 用這個名字),如 'contact' / 'note'"), slots: z.array(z.string().min(1)).min(1).describe("欄位名清單,如 ['name','email','phone']"), description: z.string().optional().describe("這個 template 用途的簡述(選填)"), - created_by: z.string().optional().describe("建立者標記(選填)"), + created_by: z.string().optional().describe("建立者標記(選填;登入身分下由 server 記錄,不吃此值)"), }, async ({ name, slots, description, created_by }) => { + if (identity.kind === "stale") return staleIdentityError(); try { - const res = await kbdbFetch(env, "/templates", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, slots, description, created_by }), - }); + const res = + identity.kind === "portal" + ? await portalFetch(env, identity.portal.session, "/portal/data/templates", { + method: "POST", + body: { name, slots, description }, + }) + : await kbdbFetch(env, "/templates", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, slots, description, created_by }), + }); if (!res.ok) { + if (identity.kind === "portal") return portalError(res, `建 template「${name}」`); return errorResponse("create_template_failed", `建 template 失敗`, ["檢查 name 是否重複", "確認 slots 是非空字串陣列"], await res.text().catch(() => "")); } const data = await res.json(); @@ -74,17 +96,28 @@ export function registerCreateTemplate(server: McpServer, env: Env) { } /** kbdb_list_templates — 列出所有已建的 template(看有哪些資料形狀可用)。 */ -export function registerListTemplates(server: McpServer, env: Env) { +export function registerListTemplates(server: McpServer, env: Env, identity: KnowledgeIdentity) { server.tool( "kbdb_list_templates", "列出 KBDB 裡所有 template(已定義的資料形狀)。要存資料前先看有沒有現成 template 可用,沒有再 kbdb_create_template。", {}, async () => { + if (identity.kind === "stale") return staleIdentityError(); try { - const res = await kbdbFetch(env, "/templates"); - if (!res.ok) return errorResponse("list_templates_failed", `列 template 失敗`, ["稍後重試"], await res.text().catch(() => "")); + const res = + identity.kind === "portal" + ? await portalFetch(env, identity.portal.session, "/portal/data/templates") + : await kbdbFetch(env, "/templates"); + if (!res.ok) { + if (identity.kind === "portal") return portalError(res, "列 template"); + return errorResponse("list_templates_failed", `列 template 失敗`, ["稍後重試"], await res.text().catch(() => "")); + } const data = await res.json(); - return successResponse(data, ["每個 template 的 slots_json 是它的欄位清單", "填資料用 kbdb_create_record"]); + return successResponse(data, [ + "每個 template 的 slots_json 是它的欄位清單", + "填資料用 kbdb_create_record", + "template 是全域共享的「資料形狀」定義(schema),不含任何人的內容——內容的權限在 record/entry 那層", + ]); } catch (e) { return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]); } @@ -93,7 +126,7 @@ export function registerListTemplates(server: McpServer, env: Env) { } /** kbdb_create_record — 依某 template 填一筆 record(slot → 內容)。 */ -export function registerCreateRecord(server: McpServer, env: Env) { +export function registerCreateRecord(server: McpServer, env: Env, identity: KnowledgeIdentity) { server.tool( "kbdb_create_record", "依某 template 填一筆 record(一列資料)。values 是 {slot名: 內容},slot 名要對得上 template 的 slots。" + @@ -101,23 +134,34 @@ export function registerCreateRecord(server: McpServer, env: Env) { { template: z.string().min(1).describe("template 的 name 或 id"), values: z.record(z.string()).describe("欄位內容 {slot名: 字串內容},如 {name:'Leo', email:'leo@x.com'}"), - owner_id: z.string().optional().describe("資料歸屬標記(選填,如專案 id / 用戶 id)"), + owner_id: z.string().optional().describe("資料歸屬標記(選填;登入身分下一律由 server 定成你的歸屬,不吃此值)"), }, async ({ template, values, owner_id }) => { + if (identity.kind === "stale") return staleIdentityError(); try { - const res = await kbdbFetch(env, "/records", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ template, values, owner_id }), - }); + const res = + identity.kind === "portal" + ? await portalFetch(env, identity.portal.session, "/portal/data/records", { + method: "POST", + body: { template, values }, + }) + : await kbdbFetch(env, "/records", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ template, values, owner_id }), + }); if (!res.ok) { + if (identity.kind === "portal") return portalError(res, `填 record(template「${template}」)`); return errorResponse("create_record_failed", `填 record 失敗`, [ `確認 template「${template}」存在(kbdb_list_templates)`, "values 的 slot 名要對得上 template 的 slots", ], await res.text().catch(() => "")); } const data = await res.json(); - return successResponse(data, [`已存入。用 kbdb_query(template='${template}') 列出此 template 的所有 record`]); + return successResponse(data, [ + `已存入。用 kbdb_query(template='${template}') 列出此 template 的所有 record`, + ...(identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []), + ]); } catch (e) { return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]); } @@ -126,7 +170,7 @@ export function registerCreateRecord(server: McpServer, env: Env) { } /** kbdb_get_record — 用 record_id 取單筆 record。 */ -export function registerGetRecord(server: McpServer, env: Env) { +export function registerGetRecord(server: McpServer, env: Env, identity: KnowledgeIdentity) { server.tool( "kbdb_get_record", "用 record_id 取一筆 record 的所有欄位內容。record_id 從 kbdb_create_record 回傳或 kbdb_query 列出取得。", @@ -134,10 +178,23 @@ export function registerGetRecord(server: McpServer, env: Env) { record_id: z.string().min(1).describe("record 的 id(rec_xxx)"), }, async ({ record_id }) => { + if (identity.kind === "stale") return staleIdentityError(); try { - const res = await kbdbFetch(env, `/records/${encodeURIComponent(record_id)}`); - if (res.status === 404) return errorResponse("not_found", `record「${record_id}」不存在`, ["確認 record_id 正確", "用 kbdb_query 列出某 template 的 record 取 id"]); - if (!res.ok) return errorResponse("get_record_failed", `取 record 失敗`, ["稍後重試"], await res.text().catch(() => "")); + const res = + identity.kind === "portal" + ? await portalFetch(env, identity.portal.session, `/portal/data/records/${encodeURIComponent(record_id)}`) + : await kbdbFetch(env, `/records/${encodeURIComponent(record_id)}`); + if (res.status === 404) { + // 登入身分下,「不是你的」與「不存在」刻意同回 404(不洩存在性,portal 同一條紅線)。 + return errorResponse("not_found", `查無 record「${record_id}」(不存在,或不在你的權限範圍內)`, [ + "確認 record_id 正確", + "用 kbdb_query 列出某 template 的 record 取 id", + ]); + } + if (!res.ok) { + if (identity.kind === "portal") return portalError(res, "取 record"); + return errorResponse("get_record_failed", `取 record 失敗`, ["稍後重試"], await res.text().catch(() => "")); + } const data = await res.json(); return successResponse(data); } catch (e) { @@ -148,21 +205,39 @@ export function registerGetRecord(server: McpServer, env: Env) { } /** kbdb_query — 列出某 template 底下的所有 record(結構化查詢)。 */ -export function registerQuery(server: McpServer, env: Env) { +export function registerQuery(server: McpServer, env: Env, identity: KnowledgeIdentity) { server.tool( "kbdb_query", "列出某 template 底下的所有 record(結構化查詢,按 template 取整批資料)。要按關鍵字找內容用 kbdb_search。", { template: z.string().min(1).describe("template 的 name 或 id"), - owner_id: z.string().optional().describe("只取某歸屬的 record(選填)"), + owner_id: z.string().optional().describe("只取某歸屬的 record(選填;登入身分下不生效,範圍由你的權限決定)"), }, async ({ template, owner_id }) => { + if (identity.kind === "stale") return staleIdentityError(); try { - const path = `/records/by-template/${encodeURIComponent(template)}` + (owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : ""); - const res = await kbdbFetch(env, path); - if (!res.ok) return errorResponse("query_failed", `查詢 record 失敗`, [`確認 template「${template}」存在`], await res.text().catch(() => "")); + const res = + identity.kind === "portal" + ? await portalFetch( + env, + identity.portal.session, + `/portal/data/records/by-template/${encodeURIComponent(template)}`, + ) + : await kbdbFetch( + env, + `/records/by-template/${encodeURIComponent(template)}` + + (owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : ""), + ); + if (!res.ok) { + if (identity.kind === "portal") return portalError(res, `查詢 template「${template}」的 record`); + return errorResponse("query_failed", `查詢 record 失敗`, [`確認 template「${template}」存在`], await res.text().catch(() => "")); + } const data = await res.json(); - return successResponse(data, ["用 kbdb_get_record(record_id) 取單筆全文", "按關鍵字找內容改用 kbdb_search"]); + return successResponse(data, [ + "用 kbdb_get_record(record_id) 取單筆全文", + "按關鍵字找內容改用 kbdb_search", + ...(identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []), + ]); } catch (e) { return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]); } @@ -175,26 +250,38 @@ export function registerQuery(server: McpServer, env: Env) { * 語義/關鍵字都在同一 KBDB MCP(用戶資料 RAG),不分散(issue #7 / D17 邊界)。 * mode=semantic 但沒開 vectorize → base 自動降級 keyword + 回 capability_hint(發現閉環,叫 CC 幫開)。 */ -export function registerSearch(server: McpServer, env: Env) { +export function registerSearch(server: McpServer, env: Env, identity: KnowledgeIdentity) { server.tool( "kbdb_search", "搜尋 KBDB 內容。mode='keyword'(預設,D1 LIKE 關鍵字,基本盤永遠可用)或 'semantic'(AI 向量語義搜尋," + "需先開 embed 模組)。語義沒開時會自動降級關鍵字並告訴你怎麼開。要按 template 取整批結構化資料用 kbdb_query。", { q: z.string().min(1).describe("搜尋關鍵字 / 語義查詢句"), - owner_id: z.string().optional().describe("限定某歸屬範圍內搜(選填)"), + owner_id: z.string().optional().describe("限定某歸屬範圍內搜(選填;登入身分下不生效,範圍由你的權限決定)"), source: z.string().optional().describe("只搜某來源(ingest source.uri,選填)"), mode: z.enum(["keyword", "semantic"]).optional().describe("keyword(預設)或 semantic(需開 vectorize)"), }, async ({ q, owner_id, source, mode }) => { + if (identity.kind === "stale") return staleIdentityError(); try { - const qs = new URLSearchParams({ q }); - if (owner_id) qs.set("owner_id", owner_id); - if (source) qs.set("source", source); - if (mode) qs.set("mode", mode); - const res = await kbdbFetch(env, `/entries/search?${qs.toString()}`); - if (!res.ok) return errorResponse("search_failed", `搜尋失敗`, ["稍後重試"], await res.text().catch(() => "")); - const data = (await res.json()) as { mode?: string; capability_hint?: string }; + let res: Response; + if (identity.kind === "portal") { + // /portal/data/search 只吃在權限範圍內「再收窄」的 filter;owner_id/library 由 server 定死。 + res = await portalFetch(env, identity.portal.session, "/portal/data/search", { + query: { q, mode }, + }); + } else { + const qs = new URLSearchParams({ q }); + if (owner_id) qs.set("owner_id", owner_id); + if (source) qs.set("source", source); + if (mode) qs.set("mode", mode); + res = await kbdbFetch(env, `/entries/search?${qs.toString()}`); + } + if (!res.ok) { + if (identity.kind === "portal") return portalError(res, "搜尋"); + return errorResponse("search_failed", `搜尋失敗`, ["稍後重試"], await res.text().catch(() => "")); + } + const data = (await res.json()) as { mode?: string; capability_hint?: string; note?: string }; // base 回 capability_hint → 語義沒開、已降級 keyword。把它當 next-step 傳給 AI(發現閉環)。 const hints = data.capability_hint @@ -202,6 +289,8 @@ export function registerSearch(server: McpServer, env: Env) { : data.mode === "semantic" ? ["mode:semantic = AI 向量語義搜尋"] : ["mode:keyword = D1 LIKE(基本盤)", "想要語義搜尋:mode='semantic'(需先開 vectorize)"]; + if (identity.kind === "portal") hints.push(OWNER_IGNORED_HINT); + if (data.note) hints.push(data.note); return successResponse(data, hints); } catch (e) { return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]); diff --git a/mcp/src/tools/kbdb_graph.ts b/mcp/src/tools/kbdb_graph.ts index a54d079..57208e1 100644 --- a/mcp/src/tools/kbdb_graph.ts +++ b/mcp/src/tools/kbdb_graph.ts @@ -23,6 +23,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { Env } from "../types.js"; import { cypherFetch, errorResponse, successResponse } from "../lib/cypher-client.js"; +import { + portalFetch, + portalError, + staleIdentityError, + type KnowledgeIdentity, +} from "../lib/portal-client.js"; /** graph 查詢 workflow 名(與 registry/examples/graph-neighbors/workflow.yaml 的 name 一致)。 */ export const GRAPH_NEIGHBORS_WORKFLOW = "graph_neighbors"; @@ -35,8 +41,13 @@ const INSTALL_HINTS = [ ]; /** 註冊全部 KBDB graph 查詢工具(issue #68)。 */ -export function registerAllKbdbGraphTools(server: McpServer, env: Env, orgNamespace: string) { - registerGraphNeighbors(server, env, orgNamespace); +export function registerAllKbdbGraphTools( + server: McpServer, + env: Env, + orgNamespace: string, + identity: KnowledgeIdentity, +) { + registerGraphNeighbors(server, env, orgNamespace, identity); // graph_traverse:repo 內目前只有 graph-neighbors 有 workflow 定義(registry/examples/), // traverse 尚無可對齊的 input 形狀 → 不猜、不過度工程;等 workflow 進 registry 再加薄殼。 } @@ -45,7 +56,12 @@ export function registerAllKbdbGraphTools(server: McpServer, env: Env, orgNamesp * kbdb_graph_neighbors — knowledge graph 1-hop/N-hop 鄰居查詢。 * 薄殼調 GET /q/{ns}/graph_neighbors,結果(最終節點輸出)原樣回給 MCP client。 */ -export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace: string) { +export function registerGraphNeighbors( + server: McpServer, + env: Env, + orgNamespace: string, + identity: KnowledgeIdentity, +) { server.tool( "kbdb_graph_neighbors", "knowledge graph 鄰居查詢(1-hop/N-hop 關係遍歷):給一個節點名,沿 KBDB triplet" + @@ -60,10 +76,10 @@ export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace depth: z.number().int().min(1).max(10).optional().describe( "最大跳數(N-hop),預設 1(只看直接鄰居)", ), - kbdb_base: z.string().min(1).describe( - "你自己部署的 KBDB 對外 base URL(如 https://arcrun-kbdb.<你的subdomain>.workers.dev " + - "或 KBDB custom domain)。workflow 刻意不寫死任何一家的庫——" + - "帶錯(或照抄別人的值)=查詢打進別人的庫", + kbdb_base: z.string().min(1).optional().describe( + "【登入身分下不需要,留空即可】你自己部署的 KBDB 對外 base URL。" + + "以帳密連線的 MCP 由 server 端自己知道要查哪個庫——不必、也不該由你指定" + + "(指定了也不會採用)。只有服務級 token(static token / partner key)連線時才需要填。", ), template: z.string().optional().describe( "triplet 記錄的 template 名,預設 'graph_triplet'(以實際部署的 kbdb-graph-plugin " + @@ -74,6 +90,43 @@ export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace ), }, async ({ subject, depth, kbdb_base, template, directed }) => { + if (identity.kind === "stale") return staleIdentityError(); + + // ── 登入身分:走 cypher 的 portal 資料面(與人類在 portal 按「關聯」同一支端點)── + // 那支已經有 D-4 graph 粗閘(沒有 graph 來源庫權限 → 403),也已經處理好 + // 「這台實例沒裝 graph plugin 就改用 tenant 的 graph_neighbors workflow」的兩條路。 + // ⇒ MCP 不必要 kbdb_base、不必知道租戶、不必再認證一次。 + if (identity.kind === "portal") { + try { + const res = await portalFetch( + env, + identity.portal.session, + `/portal/data/graph/neighbors/${encodeURIComponent(subject)}`, + { query: { depth: depth ?? 1 } }, + ); + if (!res.ok) return portalError(res, `查「${subject}」的鄰居`); + const out = (await res.json().catch(() => null)) as + | { neighbors?: unknown[]; edges?: unknown[]; count?: number } + | null; + return successResponse(out, [ + `${out?.count ?? 0} 個鄰居(depth 上限 ${depth ?? 1})`, + "count=0 且不確定資料有沒有進圖:kbdb_query(template='triplet') 看三元組記錄", + "找關鍵字內容改用 kbdb_search;取單筆全文用 kbdb_get_record", + "查詢範圍=你這個帳號被授權的知識庫(與 portal 網頁上的關聯檢視一致)", + ]); + } catch (e) { + return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]); + } + } + + // ── 服務級憑據:既有路徑(打 /q/:ns/graph_neighbors workflow),行為零變更 ── + if (!kbdb_base) { + return errorResponse( + "kbdb_base_required", + "以服務級 token 連線時,graph 查詢需要 kbdb_base(你自己 KBDB 的對外 URL)", + ["改用帳密連線(OAuth)則不需要此參數", "或帶上 kbdb_base 再試一次"], + ); + } if (!orgNamespace) { return errorResponse( "no_namespace", diff --git a/mcp/src/tools/kbdb_map.ts b/mcp/src/tools/kbdb_map.ts index 167b005..f5922bf 100644 --- a/mcp/src/tools/kbdb_map.ts +++ b/mcp/src/tools/kbdb_map.ts @@ -22,6 +22,12 @@ 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 時的指引(誠實回報+給下一步,鐵律:不假綠)。 @@ -39,8 +45,8 @@ const RECOMPUTE_HINTS = [ ]; /** 註冊全部藏書地圖工具(library-map M4)。 */ -export function registerAllKbdbMapTools(server: McpServer, env: Env) { - registerGetMap(server, env); +export function registerAllKbdbMapTools(server: McpServer, env: Env, identity: KnowledgeIdentity) { + registerGetMap(server, env, identity); } /** 單庫詳圖回傳形狀(GET /map/:library 的 map,slot 陣列已 parse 成物件)。 */ @@ -62,7 +68,7 @@ interface LibraryMapDetail { * kbdb_get_map — 藏書地圖。無參數=全館(每庫一行);帶 library=該庫詳圖。 * design §6 retrieval 流程的第一站:地圖 → get_map(library) 細節 → graph/search 進庫。 */ -export function registerGetMap(server: McpServer, env: Env) { +export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeIdentity) { server.tool( "kbdb_get_map", "藏書地圖:KBDB 全館導覽。不帶參數=全館地圖(每庫一行:庫名+narrative+核心 top 3 entities+" + @@ -73,15 +79,26 @@ export function registerGetMap(server: McpServer, env: Env) { library: z.string().min(1).optional().describe( "庫名(如 'kb'/'notes')。帶了回該庫詳圖;不帶回全館地圖(先看全館再挑庫)", ), - owner_id: z.string().optional().describe("限定某資料歸屬範圍(選填,與其他 kbdb_* 工具同義)"), + owner_id: z.string().optional().describe( + "限定某資料歸屬範圍(選填;登入身分下不生效,看得到哪些庫由你的帳號權限決定)", + ), }, async ({ library, owner_id }) => { + if (identity.kind === "stale") return staleIdentityError(); try { - const qs = owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : ""; + // 登入身分:走 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 kbdbFetch(env, `/map${qs}`); + const res = await mapFetch(`/map${qs}`); + if (!res.ok && isPortal) return portalError(res, "取全館地圖"); if (!res.ok) { return errorResponse( "map_fetch_failed", @@ -90,7 +107,7 @@ export function registerGetMap(server: McpServer, env: Env) { await res.text().catch(() => ""), ); } - const data = (await res.json()) as { libraries?: LibraryMapRow[]; count?: number }; + 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)。 @@ -101,8 +118,13 @@ export function registerGetMap(server: McpServer, env: Env) { // 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS // 註解),所以「地圖是空的」現在真的等於「這個租戶目前沒有任何三元組資料」, // 不再是「沒人跑過 recompute」那種曖昧狀態。 + // 登入身分下還有第二種可能:這個帳號一個庫都沒被授權——「沒權限看」與「沒有資料」 + // 不可以長得一樣,所以分開講(cypher 端會附 note 說明)。 return successResponse({ libraries: [], count: 0 }, [ - "全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)", + isPortal + ? "看不到任何庫:可能是這個知識庫真的還沒有三元組資料,也可能是你的帳號還沒被授權任何庫——請向管理員確認你的可用知識庫" + : "全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)", + ...(data.note ? [data.note] : []), ...RECOMPUTE_HINTS, ]); } @@ -113,7 +135,7 @@ export function registerGetMap(server: McpServer, env: Env) { } // 單庫詳圖:完整 slots(slot 陣列 parse 成物件再回)。 - const res = await kbdbFetch(env, `/map/${encodeURIComponent(library)}${qs}`); + const res = await mapFetch(`/map/${encodeURIComponent(library)}${qs}`); if (res.status === 404) { // 地圖是讀時即時核對重算的:只要這個庫「已知」(有三元組、entries 蓋過章、或登記過), // 上一步就會自動把它補成一筆 triplet_count:0 的地圖,走不到這個分支。真的落到 404, @@ -121,10 +143,13 @@ export function registerGetMap(server: McpServer, env: Env) { // (可能打錯字,或這個庫在別的租戶/別的 owner_id 底下)。 return errorResponse( "map_not_found", - `查無庫「${library}」——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute)`, - ["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名)", ...RECOMPUTE_HINTS], + 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", diff --git a/mcp/src/tools/registry.ts b/mcp/src/tools/registry.ts index c48fd47..363d384 100644 --- a/mcp/src/tools/registry.ts +++ b/mcp/src/tools/registry.ts @@ -20,8 +20,15 @@ import { registerAllKbdbDataTools } from "./kbdb_data.js"; import { registerAllKbdbGraphTools } from "./kbdb_graph.js"; import { registerAllKbdbMapTools } from "./kbdb_map.js"; import { registerWhoami } from "./arcrun_whoami.js"; +import type { KnowledgeIdentity } from "../lib/portal-client.js"; -export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) { +export function registerAllTools( + server: McpServer, + env: Env, + orgNamespace: string, + partnerToken: string, + identity: KnowledgeIdentity, +) { registerSearchComponents(server, env, orgNamespace); // 🔴 2026-07-21 leo 拍板停用:零件走 PR、專業等級;recipe/workflow/app 誰都可以做。 // 零件貢獻**只有一條路=PR 人審**(leo 2026-08-01:「已經沒有 publish 了, @@ -53,13 +60,15 @@ export function registerAllTools(server: McpServer, env: Env, orgNamespace: stri registerAllRecipeTools(server, env); // kbdb-base Phase 9.1: KBDB 資料層薄殼(template/record/query/search,HANDOFF §2) // 鐵律:不提供建表/SQL tool,AI 只有 template+slot 可用(類 Supabase 萬用表) - registerAllKbdbDataTools(server, env); + // 2026-08-12:知識面(kbdb_*)全部改吃 identity——以帳密連線者走 portal 資料面 + // (權限=那個人的權限),服務級憑據維持既有 KBDB 直連。見 lib/portal-client.ts。 + registerAllKbdbDataTools(server, env, identity); // issue #68: KBDB graph 查詢薄殼(kbdb_graph_neighbors,調 /q/:ns/graph_neighbors 同步查詢端點) // 補齊 D17「KBDB MCP=RAG 套餐」第三模式:關鍵字/語義之外的圖(關係遍歷) - registerAllKbdbGraphTools(server, env, orgNamespace); + registerAllKbdbGraphTools(server, env, orgNamespace, identity); // library-map SDD M4(Arcrun#39): 藏書地圖薄殼(kbdb_get_map,調 kbdb GET /map//map/:library) // retrieval 第一站:先看地圖定位庫,再 search/graph 進庫(design §6) - registerAllKbdbMapTools(server, env); + registerAllKbdbMapTools(server, env, identity); // §7.8 P1 D2: whoami(與 CLI acr whoami 對齊,AI 不繞 CLI 自己 curl 猜帳號) - registerWhoami(server, env, orgNamespace); + registerWhoami(server, env, orgNamespace, identity); } diff --git a/mcp/src/types.ts b/mcp/src/types.ts index fa3d968..1dd17fb 100644 --- a/mcp/src/types.ts +++ b/mcp/src/types.ts @@ -2,6 +2,15 @@ export interface Env { COMPONENT_REGISTRY: Fetcher; CYPHER_EXECUTOR: Fetcher; KBDB: Fetcher; + /** + * KBDB 的服務內部金鑰。 + * + * 2026-08-12 後**知識面(kbdb_*)以帳密連線時完全不用它**——那條路改走 cypher 的 + * `/portal/data/*`,帶的是登入者自己的 portal session。它現在只剩兩個用途: + * ① 官方 SaaS 的 partner-key 驗證(middleware/partner-auth.ts 第 3 條) + * ② 服務級 token(static token)連線時的既有 KBDB 直連(零回歸) + * 兩者都拆掉之後,這個 binding 才能從 MCP 移除。 + */ KBDB_INTERNAL_TOKEN: string; API_KEY?: string; // Platform telemetry / feedback aggregation key (optional) @@ -20,11 +29,16 @@ export interface Env { // 短效認證儲存:authorization code(TTL ~600s)+ access token(TTL = MCP_TOKEN_TTL)。 // 只放「取得的暫時性認證」,key 用 SHA-256 hash(KV list 不外洩可用 token)。長效機密不進 KV。 OAUTH_KV?: KVNamespace; - // Owner 祕密(CF Secret,非 KV、非明碼 var):/authorize 同意頁的把關密碼。 - // 只有 owner 知道 → 「只知 URL + 明碼 namespace」的人走不完 OAuth,拿不到 token。 - // 未設 → OAuth /authorize 回 503(拒絕在無把關下發碼,不留不安全預設)。 + // 【已停用,2026-07-30】舊的 owner 祕密。把關改成「使用者自己的 Portal 帳密」—— + // 沒人給得了封測者這把祕密(安裝器產生後從不顯示、CF secret 又讀不回), + // 而且全實例共用一把、分不出是誰連上來的。程式已不再讀它;欄位留著只為不讓舊 toml 炸掉。 MCP_OWNER_SECRET?: string; - // OAuth 換發出的 access_token 綁定的 namespace(owner 的資料分區)。預設 "leo"。 + // **工作流面**(arcrun_* 工具)的租戶代號,當 cypher 的 X-Arcrun-API-Key 用。預設 "leo"。 + // + // ⚠️ 2026-08-12 起**知識面(kbdb_*)不再讀這個欄位**:那邊改成跟著登入者的 portal session + // 走(oauth/store.ts PortalIdentity)。此欄位曾被當成 KBDB 的 owner_id ⇒ 不管誰登入 + // 都看到同一格、而且是全部——那個用法已經消滅。 + // 要連工作流面也拆掉它,得先在 cypher 開一組吃 portal session 的 workflow 端點(下一步)。 MCP_OWNER_NAMESPACE?: string; // access_token 存活秒數(同時是 KV TTL)。字串(toml var)。預設 2592000(30 天)。 // 過期後 claude.ai 重走 OAuth(owner 重輸祕密)——刻意不做 refresh token 以免長效機密落地。 diff --git a/mcp/tests/unit/oauth.test.ts b/mcp/tests/unit/oauth.test.ts index c8d0c3f..a0b46c9 100644 --- a/mcp/tests/unit/oauth.test.ts +++ b/mcp/tests/unit/oauth.test.ts @@ -59,14 +59,55 @@ async function pkcePair() { return { verifier, challenge }; } +/** + * cypher `/portal/login` 的假替身(2026-08-12 起 MCP 的把關就是這支——用使用者自己的 + * Portal 帳密,沒有另一把 owner secret)。帳密對 → 回 session_token + 身分欄位;不對 → 401。 + */ +const GOOD_EMAIL = "leo@example.com"; +const GOOD_PASSWORD = "correct horse"; + +function cypherMock( + over: { + /** null = 登入成功但**不回** session_token(舊版 cypher);預設回 "sess-abc" */ + sessionToken?: string | null; + displayName?: string; + role?: string; + libraries?: string[]; + sessionExpiresIn?: number; + } = {}, +): { fetcher: Fetcher; calls: Array<{ email: string; password: string }> } { + const calls: Array<{ email: string; password: string }> = []; + const fetcher = { + async fetch(req: Request) { + const body = (await req.json()) as { email: string; password: string }; + calls.push(body); + if (body.email !== GOOD_EMAIL || body.password !== GOOD_PASSWORD) { + return new Response(JSON.stringify({ error: "email 或密碼錯誤" }), { status: 401 }); + } + const sessionToken = over.sessionToken === undefined ? "sess-abc" : over.sessionToken; + return new Response( + JSON.stringify({ + success: true, + ...(sessionToken ? { session_token: sessionToken } : {}), + display_name: over.displayName ?? "Leo", + role: over.role ?? "admin", + libraries: over.libraries ?? ["*"], + session_expires_in: over.sessionExpiresIn ?? 604800, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }, + } as unknown as Fetcher; + return { fetcher, calls }; +} + function baseEnv(over: Partial = {}): Env { return { COMPONENT_REGISTRY: {} as Fetcher, - CYPHER_EXECUTOR: {} as Fetcher, + CYPHER_EXECUTOR: cypherMock().fetcher, KBDB: {} as Fetcher, KBDB_INTERNAL_TOKEN: "internal", OAUTH_KV: makeKV(), - MCP_OWNER_SECRET: "s3cr3t-owner", MCP_OWNER_NAMESPACE: "leo", ...over, } as Env; @@ -121,6 +162,8 @@ describe("oauth/store", () => { scope: "mcp", resource: "https://mcp/mcp", namespace: "leo", + portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] }, + portal_session_expires_in: 604800, }); const first = await consumeAuthCode(kv, "code-1"); expect(first?.namespace).toBe("leo"); @@ -271,7 +314,11 @@ describe("oauth flow (整合)", () => { )}&code_challenge=${challenge}&code_challenge_method=S256&state=xyz&scope=mcp`, ); expect(ok.status).toBe(200); - expect(await ok.text()).toContain("Owner 祕密"); + const consentHtml = await ok.text(); + // 同意頁問的是 Portal 帳密(不是另一把 owner secret) + expect(consentHtml).toContain("Portal"); + expect(consentHtml).toContain('name="email"'); + expect(consentHtml).toContain('name="password"'); // 缺 PKCE → 400 const bad = await app.req( `/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent( @@ -281,18 +328,19 @@ describe("oauth flow (整合)", () => { expect(bad.status).toBe(400); }); - it("GET /authorize:MCP_OWNER_SECRET 未設 → 503(不留不安全預設)", async () => { - const app = buildApp(baseEnv({ MCP_OWNER_SECRET: undefined })); + it("GET /authorize:不需要任何 owner 祕密就看得到同意頁(封測者接自己的 AI 不會死在這頁)", async () => { + // 舊行為:未設 MCP_OWNER_SECRET → 503 ⇒ 每個封測者都卡住。現在把關是 Portal 帳密。 + const app = buildApp(baseEnv()); const { challenge } = await pkcePair(); const r = await app.req( `/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent( "https://claude.ai/cb", )}&code_challenge=${challenge}&code_challenge_method=S256`, ); - expect(r.status).toBe(503); + expect(r.status).toBe(200); }); - it("完整 code→token:正確 owner 祕密 + 正確 verifier → access_token", async () => { + it("完整 code→token:正確 Portal 帳密 + 正確 verifier → access_token", async () => { const env = baseEnv(); const app = buildApp(env); const { verifier, challenge } = await pkcePair(); @@ -310,7 +358,8 @@ describe("oauth flow (整合)", () => { code_challenge_method: "S256", scope: "mcp", resource: "https://mcp.arcrun.dev/mcp", - owner_secret: "s3cr3t-owner", + email: GOOD_EMAIL, + password: GOOD_PASSWORD, }).toString(), redirect: "manual", }); @@ -344,6 +393,143 @@ describe("oauth flow (整合)", () => { expect(at?.aud).toBe("https://mcp.arcrun.dev/mcp"); }); + // ── 2026-08-12:身分要接住並攜帶(本次修的病根)───────────────────────────── + describe("登入者身分跟著 token 走(leo:掛上 MCP 並輸入帳密=授權,下游不得再問一次)", () => { + it("驗完帳密不是只留布林值:token 帶得出 portal session 與該帳號的可用知識庫", async () => { + const env = baseEnv({ CYPHER_EXECUTOR: cypherMock({ libraries: ["kb"], displayName: "小明", role: "user" }).fetcher }); + const app = buildApp(env); + const { verifier, challenge } = await pkcePair(); + const redirect = "https://claude.ai/cb"; + const authRes = await app.req("/authorize", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: "c1", + redirect_uri: redirect, + code_challenge: challenge, + code_challenge_method: "S256", + email: GOOD_EMAIL, + password: GOOD_PASSWORD, + }).toString(), + redirect: "manual", + }); + const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!; + const tokRes = await app.req("/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + code_verifier: verifier, + redirect_uri: redirect, + }).toString(), + }); + const at = await getAccessToken(env.OAUTH_KV!, (await tokRes.json()).access_token); + expect(at?.portal?.session).toBe("sess-abc"); + expect(at?.portal?.display_name).toBe("小明"); + expect(at?.portal?.role).toBe("user"); + expect(at?.portal?.libraries).toEqual(["kb"]); + }); + + it("**不同帳號登入 → token 帶的身分跟著換**(不是不管誰登入都同一格)", async () => { + // 兩個帳號權限不同:一個全庫、一個只有 kb。token 裡的身分必須各自不同。 + const envA = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-A", displayName: "Leo", libraries: ["*"] }).fetcher }); + const envB = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-B", displayName: "小明", libraries: ["kb"] }).fetcher }); + + async function tokenFor(env: Env) { + const app = buildApp(env); + const { verifier, challenge } = await pkcePair(); + const redirect = "https://claude.ai/cb"; + const a = await app.req("/authorize", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: "c1", + redirect_uri: redirect, + code_challenge: challenge, + code_challenge_method: "S256", + email: GOOD_EMAIL, + password: GOOD_PASSWORD, + }).toString(), + redirect: "manual", + }); + const code = new URL(a.headers.get("location")!).searchParams.get("code")!; + const t = await app.req("/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + code_verifier: verifier, + redirect_uri: redirect, + }).toString(), + }); + return getAccessToken(env.OAUTH_KV!, (await t.json()).access_token); + } + + const a = await tokenFor(envA); + const b = await tokenFor(envB); + expect(a?.portal?.session).not.toBe(b?.portal?.session); + expect(a?.portal?.libraries).toEqual(["*"]); + expect(b?.portal?.libraries).toEqual(["kb"]); + }); + + it("access_token 活不過它底下的 portal session(TTL 取兩者較小)", async () => { + const env = baseEnv({ + MCP_TOKEN_TTL: "2592000", // 30 天 + CYPHER_EXECUTOR: cypherMock({ sessionExpiresIn: 3600 }).fetcher, // session 只有 1 小時 + }); + const app = buildApp(env); + const { verifier, challenge } = await pkcePair(); + const redirect = "https://claude.ai/cb"; + const a = await app.req("/authorize", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: "c1", + redirect_uri: redirect, + code_challenge: challenge, + code_challenge_method: "S256", + email: GOOD_EMAIL, + password: GOOD_PASSWORD, + }).toString(), + redirect: "manual", + }); + const code = new URL(a.headers.get("location")!).searchParams.get("code")!; + const t = await app.req("/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + code_verifier: verifier, + redirect_uri: redirect, + }).toString(), + }); + expect((await t.json()).expires_in).toBe(3600); + }); + + it("cypher 回 200 但沒給 session_token(舊版 cypher)→ 不發碼(不發一張沒有身分的 token)", async () => { + const app = buildApp(baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: null }).fetcher })); + const { challenge } = await pkcePair(); + const r = await app.req("/authorize", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: "c1", + redirect_uri: "https://claude.ai/cb", + code_challenge: challenge, + code_challenge_method: "S256", + email: GOOD_EMAIL, + password: GOOD_PASSWORD, + }).toString(), + redirect: "manual", + }); + expect(r.status).toBe(401); + expect(r.headers.get("location")).toBeNull(); + }); + }); + it("錯誤 owner 祕密 → 401、不發 code", async () => { const app = buildApp(baseEnv()); const { challenge } = await pkcePair(); @@ -355,7 +541,8 @@ describe("oauth flow (整合)", () => { redirect_uri: "https://claude.ai/cb", code_challenge: challenge, code_challenge_method: "S256", - owner_secret: "WRONG", + email: GOOD_EMAIL, + password: "WRONG", }).toString(), redirect: "manual", }); @@ -377,7 +564,8 @@ describe("oauth flow (整合)", () => { redirect_uri: redirect, code_challenge: challenge, code_challenge_method: "S256", - owner_secret: "s3cr3t-owner", + email: GOOD_EMAIL, + password: GOOD_PASSWORD, }).toString(), redirect: "manual", }); @@ -445,7 +633,8 @@ describe("oauth resource(RFC 8707)簽發端把關", () => { code_challenge: challenge, code_challenge_method: "S256", resource, - owner_secret: "s3cr3t-owner", + email: GOOD_EMAIL, + password: GOOD_PASSWORD, }).toString(), redirect: "manual", }); @@ -570,7 +759,8 @@ describe("oauth store drift guard:OAUTH_KV 的 put 一律帶 TTL", () => { redirect_uri: redirect, code_challenge: challenge, code_challenge_method: "S256", - owner_secret: "s3cr3t-owner", + email: GOOD_EMAIL, + password: GOOD_PASSWORD, }).toString(), redirect: "manual", }); diff --git a/mcp/tests/unit/tools/kbdb-data-identity.test.ts b/mcp/tests/unit/tools/kbdb-data-identity.test.ts new file mode 100644 index 0000000..c8f3973 --- /dev/null +++ b/mcp/tests/unit/tools/kbdb-data-identity.test.ts @@ -0,0 +1,208 @@ +/** + * kbdb_* 資料層工具:**用登入進來的那個人的身分查詢**(2026-08-12)。 + * + * leo:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;AI 透過輸入帳密的 + * MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」 + * 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。 + * + * 本檔守三件事: + * ① 以帳密連線時,查詢**帶登入者的 portal session** 打 cypher `/portal/data/*` + * ——不再拿 KBDB 的服務內部金鑰直打 KBDB(那條路繞過所有庫過濾)。 + * ② 呼叫端自帶的 owner_id **一律不生效**(範圍由帳號權限決定,不由呼叫端指定)。 + * ③ 舊 token(沒有身分)**fail-closed**:誠實要求重新連線,不偷偷退回服務金鑰那條老路。 + * ④ 服務級憑據(static token / partner key)維持既有 KBDB 直連(零回歸)。 + */ +import { describe, it, expect } from "vitest"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { Env } from "../../../src/types.js"; +import { registerAllKbdbDataTools } from "../../../src/tools/kbdb_data.js"; +import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js"; + +type ToolHandler = (args: Record) => Promise<{ + content: { type: string; text: string }[]; + isError?: boolean; +}>; + +function makeServer() { + const tools = new Map(); + const server = { + tool(name: string, description: string, _schema: unknown, handler: ToolHandler) { + tools.set(name, { description, handler }); + }, + }; + return { server: server as unknown as McpServer, tools }; +} + +/** 兩個 binding 都掛上,才驗得出「該走哪一條」——走錯的那條會被記錄下來。 */ +function makeEnv(respond: (which: "cypher" | "kbdb", url: URL, init?: RequestInit) => Response) { + const cypherCalls: { url: URL; init?: RequestInit }[] = []; + const kbdbCalls: { url: URL; init?: RequestInit }[] = []; + const env = { + CYPHER_EXECUTOR: { + fetch: async (input: string, init?: RequestInit) => { + const url = new URL(input); + cypherCalls.push({ url, init }); + return respond("cypher", url, init); + }, + }, + KBDB: { + fetch: async (input: string, init?: RequestInit) => { + const url = new URL(input); + kbdbCalls.push({ url, init }); + return respond("kbdb", url, init); + }, + }, + KBDB_INTERNAL_TOKEN: "service-key-should-not-be-used-on-portal-path", + } as unknown as Env; + return { env, cypherCalls, kbdbCalls }; +} + +function parseResult(r: { content: { text: string }[] }) { + return JSON.parse(r.content[0].text) as Record; +} + +const PORTAL: KnowledgeIdentity = { + kind: "portal", + portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] }, +}; +const SERVICE: KnowledgeIdentity = { kind: "service" }; +const STALE: KnowledgeIdentity = { kind: "stale" }; + +function tools(identity: KnowledgeIdentity, respond: Parameters[0]) { + const { server, tools } = makeServer(); + const e = makeEnv(respond); + registerAllKbdbDataTools(server, e.env, identity); + return { tools, ...e }; +} + +const OK = () => new Response(JSON.stringify({ success: true, entries: [], records: [], count: 0 })); + +describe("kbdb_* 以登入者身分查詢(portal 資料面)", () => { + const cases: Array<{ tool: string; args: Record; path: string; method?: string }> = [ + { tool: "kbdb_search", args: { q: "火星座標" }, path: "/portal/data/search" }, + { tool: "kbdb_query", args: { template: "triplet" }, path: "/portal/data/records/by-template/triplet" }, + { tool: "kbdb_get_record", args: { record_id: "rec_1" }, path: "/portal/data/records/rec_1" }, + { tool: "kbdb_list_templates", args: {}, path: "/portal/data/templates" }, + { tool: "kbdb_create_template", args: { name: "contact", slots: ["name"] }, path: "/portal/data/templates", method: "POST" }, + { tool: "kbdb_create_record", args: { template: "contact", values: { name: "Leo" } }, path: "/portal/data/records", method: "POST" }, + ]; + + for (const c of cases) { + it(`${c.tool} → 打 ${c.path},帶登入者 session,完全不碰 KBDB 服務金鑰`, async () => { + const { tools: t, cypherCalls, kbdbCalls } = tools(PORTAL, OK); + const res = await t.get(c.tool)!.handler(c.args); + expect(res.isError).toBeUndefined(); + + // 走的是 cypher 的 portal 資料面,不是 KBDB 直連 + expect(kbdbCalls, `${c.tool} 不該直打 KBDB`).toHaveLength(0); + expect(cypherCalls).toHaveLength(1); + expect(cypherCalls[0].url.pathname).toBe(c.path); + expect(cypherCalls[0].init?.method ?? "GET").toBe(c.method ?? "GET"); + + // 帶的是「那個人的 session」,不是任何服務金鑰 + const auth = new Headers(cypherCalls[0].init!.headers as HeadersInit).get("Authorization"); + expect(auth).toBe("Bearer sess-abc"); + expect(auth).not.toContain("service-key"); + }); + } + + it("呼叫端自帶 owner_id 一律不生效(不讓呼叫端自己挑租戶/歸屬)", async () => { + const { tools: t, cypherCalls } = tools(PORTAL, OK); + await t.get("kbdb_search")!.handler({ q: "x", owner_id: "someone-else" }); + await t.get("kbdb_query")!.handler({ template: "triplet", owner_id: "someone-else" }); + for (const call of cypherCalls) { + expect(call.url.searchParams.get("owner_id")).toBeNull(); + } + }); + + it("寫入時 owner_id 不從呼叫端 body 走(server 定死成登入者的歸屬)", async () => { + const { tools: t, cypherCalls } = tools(PORTAL, OK); + await t.get("kbdb_create_record")!.handler({ + template: "contact", + values: { name: "Leo" }, + owner_id: "someone-else", + }); + const body = JSON.parse(String(cypherCalls[0].init!.body)) as Record; + expect(body).not.toHaveProperty("owner_id"); + }); + + it("越庫寫入被擋(403)→ 誠實講是權限問題", async () => { + const { tools: t } = tools(PORTAL, () => + new Response(JSON.stringify({ error: '無「secret」庫的權限,不能寫入該庫' }), { status: 403 }), + ); + const res = await t.get("kbdb_create_record")!.handler({ + template: "note", + values: { library: "secret", body: "x" }, + }); + expect(res.isError).toBe(true); + expect(parseResult(res).error_code).toBe("forbidden"); + }); + + it("查不是自己的 record(404)→ 與「不存在」同一句話(不洩存在性)", async () => { + const { tools: t } = tools(PORTAL, () => + new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }), + ); + const res = await t.get("kbdb_get_record")!.handler({ record_id: "rec_someone_else" }); + expect(res.isError).toBe(true); + expect(parseResult(res).error_code).toBe("not_found"); + expect(String(parseResult(res).human_message)).toContain("不在你的權限範圍內"); + }); + + it("session 過期(401)→ session_expired,不謊稱資料是空的", async () => { + const { tools: t } = tools(PORTAL, () => + new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }), + ); + const res = await t.get("kbdb_search")!.handler({ q: "x" }); + expect(res.isError).toBe(true); + expect(parseResult(res).error_code).toBe("session_expired"); + }); +}); + +describe("fail-closed:舊 token 沒有身分就查不到東西(不退回服務金鑰)", () => { + for (const name of [ + "kbdb_search", + "kbdb_query", + "kbdb_get_record", + "kbdb_list_templates", + "kbdb_create_template", + "kbdb_create_record", + ]) { + it(`${name} → identity_missing,且一個查詢都不發`, async () => { + const { tools: t, cypherCalls, kbdbCalls } = tools(STALE, OK); + const res = await t.get(name)!.handler({ + q: "x", + template: "t", + record_id: "r", + name: "n", + slots: ["a"], + values: { a: "b" }, + }); + expect(res.isError).toBe(true); + expect(parseResult(res).error_code).toBe("identity_missing"); + expect(cypherCalls).toHaveLength(0); + expect(kbdbCalls).toHaveLength(0); + }); + } +}); + +describe("回歸:服務級憑據維持既有 KBDB 直連", () => { + it("kbdb_search 仍直打 KBDB /entries/search,且照舊吃 owner_id", async () => { + const { tools: t, cypherCalls, kbdbCalls } = tools(SERVICE, OK); + const res = await t.get("kbdb_search")!.handler({ q: "x", owner_id: "leo" }); + expect(res.isError).toBeUndefined(); + expect(cypherCalls).toHaveLength(0); + expect(kbdbCalls).toHaveLength(1); + expect(kbdbCalls[0].url.pathname).toBe("/entries/search"); + expect(kbdbCalls[0].url.searchParams.get("owner_id")).toBe("leo"); + }); + + it("kbdb_query / kbdb_get_record 路徑不變", async () => { + const { tools: t, kbdbCalls } = tools(SERVICE, OK); + await t.get("kbdb_query")!.handler({ template: "triplet" }); + await t.get("kbdb_get_record")!.handler({ record_id: "rec_1" }); + expect(kbdbCalls.map((c) => c.url.pathname)).toEqual([ + "/records/by-template/triplet", + "/records/rec_1", + ]); + }); +}); diff --git a/mcp/tests/unit/tools/kbdb-graph.test.ts b/mcp/tests/unit/tools/kbdb-graph.test.ts index 6e25012..fa812fd 100644 --- a/mcp/tests/unit/tools/kbdb-graph.test.ts +++ b/mcp/tests/unit/tools/kbdb-graph.test.ts @@ -5,6 +5,17 @@ import { registerGraphNeighbors, GRAPH_NEIGHBORS_WORKFLOW, } from "../../../src/tools/kbdb_graph.js"; +import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js"; + +/** 服務級憑據(static token / partner key)——既有路徑,行為零變更。 */ +const SERVICE: KnowledgeIdentity = { kind: "service" }; +/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面。 */ +const PORTAL: KnowledgeIdentity = { + kind: "portal", + portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] }, +}; +/** 本次改版前簽發的舊 token(沒有身分)。 */ +const STALE: KnowledgeIdentity = { kind: "stale" }; // ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫 ───────────────────────── type ToolHandler = (args: Record) => Promise<{ @@ -45,7 +56,7 @@ describe("kbdb_graph_neighbors: registration", () => { it("registers under kbdb_* prefix (D17 KBDB MCP boundary)", () => { const { server, tools } = makeServer(); const { env } = makeEnv(() => new Response("{}")); - registerGraphNeighbors(server, env, "leo"); + registerGraphNeighbors(server, env, "leo", SERVICE); expect(tools.has("kbdb_graph_neighbors")).toBe(true); expect(tools.get("kbdb_graph_neighbors")!.description).toContain("graph"); }); @@ -61,7 +72,7 @@ describe("kbdb_graph_neighbors: request shape", () => { { status: 200 }, ), ); - registerGraphNeighbors(server, env, "leo"); + registerGraphNeighbors(server, env, "leo", SERVICE); const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "Arcrun", depth: 2, @@ -88,7 +99,7 @@ describe("kbdb_graph_neighbors: request shape", () => { const { env, calls } = makeEnv( () => new Response(JSON.stringify({ success: true, neighbors: [], count: 0 })), ); - registerGraphNeighbors(server, env, "leo"); + registerGraphNeighbors(server, env, "leo", SERVICE); await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A", kbdb_base: "https://kbdb.example.com", @@ -108,7 +119,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash const { env } = makeEnv( () => new Response(JSON.stringify({ error: '找不到 workflow "graph_neighbors"' }), { status: 404 }), ); - registerGraphNeighbors(server, env, "leo"); + registerGraphNeighbors(server, env, "leo", SERVICE); const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A", kbdb_base: "https://kbdb.example.com", @@ -124,7 +135,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash const { env } = makeEnv( () => new Response(JSON.stringify({ success: false, error: "boom", trace: [] }), { status: 500 }), ); - registerGraphNeighbors(server, env, "leo"); + registerGraphNeighbors(server, env, "leo", SERVICE); const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A", kbdb_base: "https://kbdb.example.com", @@ -143,7 +154,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash status: 200, }), ); - registerGraphNeighbors(server, env, "leo"); + registerGraphNeighbors(server, env, "leo", SERVICE); const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A", kbdb_base: "https://kbdb.example.com", @@ -156,7 +167,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash it("empty orgNamespace → no_namespace error, no fetch made", async () => { const { server, tools } = makeServer(); const { env, calls } = makeEnv(() => new Response("{}")); - registerGraphNeighbors(server, env, ""); + registerGraphNeighbors(server, env, "", SERVICE); const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A", kbdb_base: "https://kbdb.example.com", @@ -166,3 +177,77 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash expect(calls).toHaveLength(0); }); }); + +// ── 2026-08-12:以帳密連線時走登入者的身分(leo:主人查得到的,授權的 AI 就查得到)── +describe("kbdb_graph_neighbors: 登入身分(portal 資料面)", () => { + it("打 cypher 的 /portal/data/graph/neighbors,且帶的是登入者的 session(不是服務金鑰)", async () => { + const { server, tools } = makeServer(); + const { env, calls } = makeEnv( + () => + new Response( + JSON.stringify({ + neighbors: [{ node: "B", predicate: "uses", from: "A", depth: 1 }], + edges: [], + count: 1, + }), + { status: 200 }, + ), + ); + registerGraphNeighbors(server, env, "leo", PORTAL); + const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A", depth: 2 }); + + expect(calls).toHaveLength(1); + expect(calls[0].url.pathname).toBe("/portal/data/graph/neighbors/A"); + expect(calls[0].url.searchParams.get("depth")).toBe("2"); + const headers = new Headers(calls[0].init!.headers as HeadersInit); + expect(headers.get("Authorization")).toBe("Bearer sess-abc"); + + expect(res.isError).toBeUndefined(); + expect((parseResult(res).data as { count: number }).count).toBe(1); + }); + + it("**不需要 kbdb_base**:已經登入過了,不再要第二次「證明你是誰/你的庫在哪」", async () => { + const { server, tools } = makeServer(); + const { env, calls } = makeEnv( + () => new Response(JSON.stringify({ neighbors: [], edges: [], count: 0 })), + ); + registerGraphNeighbors(server, env, "leo", PORTAL); + const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" }); + expect(res.isError).toBeUndefined(); + expect(calls).toHaveLength(1); + // 呼叫端就算硬塞 kbdb_base 也不會被拿去用(server 自己知道要查哪個庫) + expect(calls[0].url.searchParams.get("kbdb_base")).toBeNull(); + }); + + it("session 過期(401)→ 誠實說是登入過期,不說「查不到資料」", async () => { + const { server, tools } = makeServer(); + const { env } = makeEnv( + () => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }), + ); + registerGraphNeighbors(server, env, "leo", PORTAL); + const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" }); + expect(res.isError).toBe(true); + expect(parseResult(res).error_code).toBe("session_expired"); + }); + + it("無 graph 權限(403)→ 誠實回沒權限,不假裝「沒有關聯」", async () => { + const { server, tools } = makeServer(); + const { env } = makeEnv( + () => new Response(JSON.stringify({ error: "無知識圖譜檢視權限" }), { status: 403 }), + ); + registerGraphNeighbors(server, env, "leo", PORTAL); + const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" }); + expect(res.isError).toBe(true); + expect(parseResult(res).error_code).toBe("forbidden"); + }); + + it("舊 token(沒有身分)→ 不偷偷退回服務金鑰那條老路,要求重新連線", async () => { + const { server, tools } = makeServer(); + const { env, calls } = makeEnv(() => new Response("{}")); + registerGraphNeighbors(server, env, "leo", STALE); + const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" }); + expect(res.isError).toBe(true); + expect(parseResult(res).error_code).toBe("identity_missing"); + expect(calls).toHaveLength(0); // 一個查詢都沒發出去(fail-closed) + }); +}); diff --git a/mcp/tests/unit/tools/kbdb-map.test.ts b/mcp/tests/unit/tools/kbdb-map.test.ts index 77ea6d3..a9faf0c 100644 --- a/mcp/tests/unit/tools/kbdb-map.test.ts +++ b/mcp/tests/unit/tools/kbdb-map.test.ts @@ -7,6 +7,32 @@ import { renderLibraryMapLines, __resetLibraryMapInstructionsCacheForTests, } from "../../../src/lib/library-map.js"; +import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js"; + +/** 服務級憑據(static token / partner key)——既有 KBDB 直連路徑,行為零變更。 */ +const SERVICE: KnowledgeIdentity = { kind: "service" }; +/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面(只看得到自己有權限的庫)。 */ +const PORTAL: KnowledgeIdentity = { + kind: "portal", + portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] }, +}; +/** 本次改版前簽發的舊 token(沒有身分)。 */ +const STALE: KnowledgeIdentity = { kind: "stale" }; + +/** 假 CYPHER_EXECUTOR binding(portal 資料面用)。 */ +function makePortalEnv(respond: (url: URL, init?: RequestInit) => Response) { + const calls: { url: URL; init?: RequestInit }[] = []; + const env = { + CYPHER_EXECUTOR: { + fetch: async (input: string, init?: RequestInit) => { + const url = new URL(input); + calls.push({ url, init }); + return respond(url, init); + }, + }, + } as unknown as Env; + return { env, calls }; +} // ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫(比照 kbdb-graph.test.ts)────── type ToolHandler = (args: Record) => Promise<{ @@ -56,7 +82,7 @@ describe("kbdb_get_map: registration", () => { it("registers under kbdb_* prefix (D17) with the 'call this first' hint in description", () => { const { server, tools } = makeServer(); const { env } = makeEnv(() => new Response("{}")); - registerGetMap(server, env); + registerGetMap(server, env, SERVICE); expect(tools.has("kbdb_get_map")).toBe(true); // 任務規格:description 必含「不確定該查什麼時,先呼叫此工具」 expect(tools.get("kbdb_get_map")!.description).toContain("不確定該查什麼時,先呼叫此工具"); @@ -69,7 +95,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => { const { env, calls } = makeEnv( () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })), ); - registerGetMap(server, env); + registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({}); expect(calls).toHaveLength(1); @@ -90,7 +116,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => { const { env, calls } = makeEnv( () => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })), ); - registerGetMap(server, env); + registerGetMap(server, env, SERVICE); await tools.get("kbdb_get_map")!.handler({ owner_id: "leo" }); expect(calls[0].url.searchParams.get("owner_id")).toBe("leo"); }); @@ -105,7 +131,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => { const { env } = makeEnv( () => new Response(JSON.stringify({ success: true, libraries: [row], count: 1 })), ); - registerGetMap(server, env); + registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({}); const data = parseResult(res).data as { libraries: { top_entities: string[]; triplet_count: number }[]; @@ -119,7 +145,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => { const { env } = makeEnv( () => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })), ); - registerGetMap(server, env); + registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({}); const body = parseResult(res); expect(body.ok).toBe(true); @@ -135,7 +161,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => { const { env } = makeEnv( () => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })), ); - registerGetMap(server, env); + registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({}); const body = parseResult(res); const hintsText = JSON.stringify(body.hints); @@ -149,7 +175,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => { it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => { const { server, tools } = makeServer(); const { env } = makeEnv(() => new Response("boom", { status: 500 })); - registerGetMap(server, env); + registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({}); expect(res.isError).toBe(true); const body = parseResult(res); @@ -178,7 +204,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => { const { env, calls } = makeEnv( () => new Response(JSON.stringify({ success: true, map: DETAIL })), ); - registerGetMap(server, env); + registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" }); expect(calls[0].url.pathname).toBe("/map/kb"); const map = (parseResult(res).data as { map: typeof DETAIL }).map; @@ -197,7 +223,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => { triplet_count: "111", }; const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: raw }))); - registerGetMap(server, env); + registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" }); expect(res.isError).toBeUndefined(); const map = (parseResult(res).data as { map: Record }).map; @@ -212,7 +238,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => { const { env } = makeEnv( () => new Response(JSON.stringify({ success: false, error: "not found" }), { status: 404 }), ); - registerGetMap(server, env); + registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({ library: "ghost" }); expect(res.isError).toBe(true); const body = parseResult(res); @@ -233,7 +259,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => { }, }, } as unknown as Env; - registerGetMap(server, env); + registerGetMap(server, env, SERVICE); const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" }); expect(res.isError).toBe(true); expect(parseResult(res).error_code).toBe("internal_error"); @@ -258,7 +284,7 @@ describe("buildLibraryMapInstructions", () => { }), ), ); - const text = await buildLibraryMapInstructions(env); + const text = await buildLibraryMapInstructions(env, SERVICE); expect(text).not.toBeNull(); // design §4 格式:{library}:{narrative}|核心:{top3}|{triplet_count} triplets expect(text!).toContain("kb:leo 的知識庫主庫|核心:00-INDEX、kb/00-INDEX、Gitea|111 triplets"); @@ -269,7 +295,7 @@ describe("buildLibraryMapInstructions", () => { it("HTTP error → null(靜默略過,不 throw 不擋連線)", async () => { const { env } = makeEnv(() => new Response("boom", { status: 500 })); - await expect(buildLibraryMapInstructions(env)).resolves.toBeNull(); + await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull(); }); it("binding throws → null(靜默略過)", async () => { @@ -280,22 +306,22 @@ describe("buildLibraryMapInstructions", () => { }, }, } as unknown as Env; - await expect(buildLibraryMapInstructions(env)).resolves.toBeNull(); + await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull(); }); it("empty libraries → null(沒地圖就不注入,不塞空段落)", async () => { const { env } = makeEnv( () => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })), ); - await expect(buildLibraryMapInstructions(env)).resolves.toBeNull(); + await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull(); }); it("caches within TTL:same isolate 第二次不再打 /map", async () => { const { env, calls } = makeEnv( () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })), ); - const first = await buildLibraryMapInstructions(env); - const second = await buildLibraryMapInstructions(env); + const first = await buildLibraryMapInstructions(env, SERVICE); + const second = await buildLibraryMapInstructions(env, SERVICE); expect(second).toBe(first); expect(calls).toHaveLength(1); }); @@ -318,3 +344,95 @@ describe("renderLibraryMapLines", () => { expect(renderLibraryMapLines([])).toBeNull(); }); }); + +// ── 2026-08-12:地圖也要跟著登入者的權限走 ──────────────────────────────────── +// 地圖本身就是情報(有哪些庫、各有多少關聯、核心 entity 是誰)——不能整館推給 +// 一個只有部分權限的帳號。 +describe("藏書地圖:登入身分(portal 資料面)", () => { + beforeEach(() => __resetLibraryMapInstructionsCacheForTests()); + + it("kbdb_get_map 打 /portal/data/map,帶登入者 session,不碰 KBDB 服務金鑰", async () => { + const { server, tools } = makeServer(); + const { env, calls } = makePortalEnv( + () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })), + ); + registerGetMap(server, env, PORTAL); + const res = await tools.get("kbdb_get_map")!.handler({}); + expect(res.isError).toBeUndefined(); + expect(calls).toHaveLength(1); + expect(calls[0].url.pathname).toBe("/portal/data/map"); + expect(new Headers(calls[0].init!.headers as HeadersInit).get("Authorization")).toBe("Bearer sess-abc"); + }); + + it("呼叫端硬塞 owner_id 也不生效(查詢範圍由帳號權限決定,不由呼叫端指定)", async () => { + const { server, tools } = makeServer(); + const { env, calls } = makePortalEnv( + () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })), + ); + registerGetMap(server, env, PORTAL); + await tools.get("kbdb_get_map")!.handler({ owner_id: "someone-else" }); + expect(calls[0].url.searchParams.get("owner_id")).toBeNull(); + }); + + it("查沒權限的庫 → 與「不存在」同一句話(不洩存在性)", async () => { + const { server, tools } = makeServer(); + const { env } = makePortalEnv(() => new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 })); + registerGetMap(server, env, PORTAL); + const res = await tools.get("kbdb_get_map")!.handler({ library: "secret-lib" }); + expect(res.isError).toBe(true); + const body = parseResult(res); + expect(body.error_code).toBe("map_not_found"); + expect(String(body.human_message)).toContain("不在你被授權"); + }); + + it("session 過期(401)→ session_expired,不說「地圖是空的」", async () => { + const { server, tools } = makeServer(); + const { env } = makePortalEnv( + () => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }), + ); + registerGetMap(server, env, PORTAL); + const res = await tools.get("kbdb_get_map")!.handler({}); + expect(res.isError).toBe(true); + expect(parseResult(res).error_code).toBe("session_expired"); + }); + + it("舊 token(沒身分)→ identity_missing,且一個查詢都不發(fail-closed)", async () => { + const { server, tools } = makeServer(); + const { env, calls } = makePortalEnv(() => new Response("{}")); + registerGetMap(server, env, STALE); + const res = await tools.get("kbdb_get_map")!.handler({}); + expect(res.isError).toBe(true); + expect(parseResult(res).error_code).toBe("identity_missing"); + expect(calls).toHaveLength(0); + }); + + it("instructions 的地圖也走 portal 資料面(連線開場推的庫名不得超出權限)", async () => { + const { env, calls } = makePortalEnv( + () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })), + ); + const text = await buildLibraryMapInstructions(env, PORTAL); + expect(text).toContain("kb"); + expect(calls[0].url.pathname).toBe("/portal/data/map"); + }); + + it("**快取不跨身分共用**:不同 session 各自打一次,不會拿到別人的視野", async () => { + const { env, calls } = makePortalEnv( + () => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })), + ); + const other: KnowledgeIdentity = { + kind: "portal", + portal: { session: "sess-other", display_name: "小明", role: "user", libraries: ["notes"] }, + }; + await buildLibraryMapInstructions(env, PORTAL); + await buildLibraryMapInstructions(env, other); + expect(calls).toHaveLength(2); // 兩次真的各打一次 + await buildLibraryMapInstructions(env, PORTAL); + expect(calls).toHaveLength(2); // 同一 session 第二次才吃快取 + }); + + it("舊 token → 不給地圖(instructions 不外洩任何庫名)", async () => { + const { env, calls } = makePortalEnv(() => new Response("{}")); + expect(await buildLibraryMapInstructions(env, STALE)).toBeNull(); + expect(calls).toHaveLength(0); + }); +});