/** * KBDB 資料層 MCP 薄殼(kbdb-base Phase 9.1,HANDOFF §2) * * 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)」可用。 * - 薄殼只調 HTTP API,不直連 D1、不寫 SQL。 */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { Env } from "../types.js"; import { kbdbFetch } from "../lib/kbdb-client.js"; import { errorResponse, successResponse } from "../lib/cypher-client.js"; import { 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, 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, identity: KnowledgeIdentity) { server.tool( "kbdb_create_template", "建一個 KBDB template(萬用表裡的一種資料形狀,類 Supabase 的虛擬表)。KBDB 不能建真的資料表——" + "要存「新類型」的結構化資料時,就建一個 template 並用 slots 列出它的欄位名,之後用 kbdb_create_record 填值。" + "例:name='contact', slots=['name','email','phone']。", { 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("建立者標記(選填;登入身分下由 server 記錄,不吃此值)"), }, async ({ name, slots, description, created_by }) => { if (identity.kind === "stale") return staleIdentityError(); try { 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(); return successResponse(data, [ `template「${name}」已建。用 kbdb_create_record(template='${name}', values={...}) 填一筆資料`, ]); } catch (e) { return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]); } }, ); } /** kbdb_list_templates — 列出所有已建的 template(看有哪些資料形狀可用)。 */ 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 = 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", "template 是全域共享的「資料形狀」定義(schema),不含任何人的內容——內容的權限在 record/entry 那層", ]); } catch (e) { return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]); } }, ); } /** kbdb_create_record — 依某 template 填一筆 record(slot → 內容)。 */ export function registerCreateRecord(server: McpServer, env: Env, identity: KnowledgeIdentity) { server.tool( "kbdb_create_record", "依某 template 填一筆 record(一列資料)。values 是 {slot名: 內容},slot 名要對得上 template 的 slots。" + "template 不存在會失敗——先 kbdb_list_templates 確認,或 kbdb_create_template 建一個。", { 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("資料歸屬標記(選填;登入身分下一律由 server 定成你的歸屬,不吃此值)"), }, async ({ template, values, owner_id }) => { if (identity.kind === "stale") return staleIdentityError(); try { 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`, ...(identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []), ]); } catch (e) { return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]); } }, ); } /** kbdb_get_record — 用 record_id 取單筆 record。 */ export function registerGetRecord(server: McpServer, env: Env, identity: KnowledgeIdentity) { server.tool( "kbdb_get_record", "用 record_id 取一筆 record 的所有欄位內容。record_id 從 kbdb_create_record 回傳或 kbdb_query 列出取得。", { record_id: z.string().min(1).describe("record 的 id(rec_xxx)"), }, async ({ record_id }) => { if (identity.kind === "stale") return staleIdentityError(); try { 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) { return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]); } }, ); } /** kbdb_query — 列出某 template 底下的所有 record(結構化查詢)。 */ 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(選填;登入身分下不生效,範圍由你的權限決定)"), }, async ({ template, owner_id }) => { if (identity.kind === "stale") return staleIdentityError(); try { 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", ...(identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []), ]); } catch (e) { return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]); } }, ); } /** * kbdb_search — 對 entries 做搜尋。mode=keyword(D1 LIKE,基本盤永遠可用)或 semantic(向量,需開 embed 模組)。 * 語義/關鍵字都在同一 KBDB MCP(用戶資料 RAG),不分散(issue #7 / D17 邊界)。 * mode=semantic 但沒開 vectorize → base 自動降級 keyword + 回 capability_hint(發現閉環,叫 CC 幫開)。 */ 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("限定某歸屬範圍內搜(選填;登入身分下不生效,範圍由你的權限決定)"), 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 { 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 ? [data.capability_hint, "要開:跟用戶確認後,CC 可代開(寫 config kbdb_embed:true + acr update)"] : 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), ["稍後重試"]); } }, ); }