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

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

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

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

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

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

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

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

114 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, string | number | undefined>;
}
/** 用登入者的 session 打 cypher 的 portal 資料面。 */
export async function portalFetch(
env: Env,
session: string,
path: string,
opts: PortalCallOpts = {},
): Promise<Response> {
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);
}