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>
This commit is contained in:
@@ -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) }] };
|
||||
}
|
||||
|
||||
+148
-59
@@ -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), ["稍後重試"]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
+36
-11
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user