feat(kbdb,mcp): KBDB 資料層薄殼 + self-hosted MCP 認證 + cypher KBDB proxy
三件一條鏈(HANDOFF §2/§3b,kbdb-base Phase 9):
A. KBDB MCP 薄殼(9.1):mcp/src/tools/kbdb_data.ts 6 工具
template/record/query/search,調基本盤 API。鐵律:不給建表/SQL,只 template+slot。
B. MCP self-hosted 認證 401(mcp-account-source §5.5):
- partner-auth.ts:MULTI_TENANT=false 時 Bearer 明碼直接當 org_namespace,
繞 KBDB partner 驗證(對齊 cypher 的 opaque-key 模型)。官方 SaaS 行為不變、共用同碼。
- mcp-setup.ts:把 namespace/api_key 寫進 .mcp.json headers.Authorization。
- 新增 self-hosted vs SaaS 分支單測(9 tests 綠)。
C. cypher KBDB proxy(9.5)+ CLI 薄殼(9.2):
- routes/kbdb-proxy.ts 純轉發 /kbdb/* → KBDB 基本盤(KBDB_BASE_URL HTTP fetch,
不新增 service binding)。讓 CLI(只認證到 cypher)能達獨立 KBDB worker。
- 租戶隔離:X-Arcrun-API-Key 自動當 owner_id 注入 records/entries(強制覆寫防跨租戶);
templates 全域共享(虛擬表定義是 schema 非資料)。
- cli/src/commands/kbdb.ts:acr kbdb template/record/query/search,與 MCP kbdb_* 同能力。
- kbdb base:entries 加 page_name 過濾(9.3)。
cypher + cli + mcp tsc exit 0。未驗收:端到端需 deploy + KBDB_BASE_URL 可達後實測。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,19 @@ export async function partnerAuthMiddleware(
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
|
||||
// Self-hosted 單租戶(MULTI_TENANT=false):Bearer 帶的是 namespace 明碼,不是平台 partner key。
|
||||
// 與 cypher-executor 一致——cypher 把 X-Arcrun-API-Key 當「不驗證的 opaque 分區 key」(namespace
|
||||
// 是明碼分區標籤非密碼,mindset §3 arcrun 不做授權判斷)。故 self-hosted 模式不打 KBDB partner
|
||||
// 驗證,直接把 token 當 org_namespace。SDD: mcp-account-source.md;HANDOFF §3b。
|
||||
if (c.env.MULTI_TENANT === 'false') {
|
||||
c.set('org_namespace', token);
|
||||
c.set('partner_token', token); // 下游轉發給 cypher 當 X-Arcrun-API-Key(與 CLI 同一份身份)
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// 官方 SaaS(MULTI_TENANT 未設 / "true"):維持 partner-key 驗證(行為不變)。
|
||||
const resp = await c.env.KBDB.fetch(
|
||||
`http://kbdb/partners/${encodeURIComponent(token)}/info`,
|
||||
{
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* KBDB 資料層 MCP 薄殼(kbdb-base Phase 9.1,HANDOFF §2)
|
||||
*
|
||||
* rule 07 §5(薄殼鐵律):能力長在基本盤 API,MCP 只做介面轉換 + 暴露,無業務邏輯。
|
||||
* 全走既有 kbdbFetch(KBDB service binding)打基本盤 HTTP API(kbdb/src/routes/*)。
|
||||
*
|
||||
* 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' }
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
/** 註冊全部 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* kbdb_create_template — 建一個 template(= 萬用表裡的一種「虛擬表/資料形狀」)。
|
||||
* 這是 AI 想存「新類型資料」時的唯一入口:沒有建表 API,改用 template + slots 描述欄位。
|
||||
*/
|
||||
export function registerCreateTemplate(server: McpServer, env: Env) {
|
||||
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("建立者標記(選填)"),
|
||||
},
|
||||
async ({ name, slots, description, created_by }) => {
|
||||
try {
|
||||
const res = await kbdbFetch(env, "/templates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, slots, description, created_by }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
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) {
|
||||
server.tool(
|
||||
"kbdb_list_templates",
|
||||
"列出 KBDB 裡所有 template(已定義的資料形狀)。要存資料前先看有沒有現成 template 可用,沒有再 kbdb_create_template。",
|
||||
{},
|
||||
async () => {
|
||||
try {
|
||||
const res = await kbdbFetch(env, "/templates");
|
||||
if (!res.ok) return errorResponse("list_templates_failed", `列 template 失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
const data = await res.json();
|
||||
return successResponse(data, ["每個 template 的 slots_json 是它的欄位清單", "填資料用 kbdb_create_record"]);
|
||||
} 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) {
|
||||
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("資料歸屬標記(選填,如專案 id / 用戶 id)"),
|
||||
},
|
||||
async ({ template, values, owner_id }) => {
|
||||
try {
|
||||
const res = await kbdbFetch(env, "/records", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ template, values, owner_id }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
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`]);
|
||||
} 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) {
|
||||
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 }) => {
|
||||
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 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) {
|
||||
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 }) => {
|
||||
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 data = await res.json();
|
||||
return successResponse(data, ["用 kbdb_get_record(record_id) 取單筆全文", "按關鍵字找內容改用 kbdb_search"]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** kbdb_search — 對 entries 做 D1 LIKE 關鍵字搜尋(基本盤,非語義)。 */
|
||||
export function registerSearch(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"kbdb_search",
|
||||
"對 KBDB 內容做關鍵字搜尋(D1 LIKE,基本盤層;語義搜尋是另外的 embed 模組,基本盤沒有)。" +
|
||||
"回命中的 entries。要按 template 取整批結構化資料用 kbdb_query。",
|
||||
{
|
||||
q: z.string().min(1).describe("搜尋關鍵字"),
|
||||
owner_id: z.string().optional().describe("限定某歸屬範圍內搜(選填)"),
|
||||
},
|
||||
async ({ q, owner_id }) => {
|
||||
try {
|
||||
const path = `/entries/search?q=${encodeURIComponent(q)}` + (owner_id ? `&owner_id=${encodeURIComponent(owner_id)}` : "");
|
||||
const res = await kbdbFetch(env, path);
|
||||
if (!res.ok) return errorResponse("search_failed", `搜尋失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
const data = await res.json();
|
||||
return successResponse(data, ["mode:keyword = D1 LIKE(基本盤)", "找不到時換個關鍵字,或用 kbdb_query 按 template 列出"]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { registerAllIntrospectionTools } from "./arcrun_introspection.js";
|
||||
import { registerAllWorkflowCrudTools } from "./arcrun_workflow_crud.js";
|
||||
import { registerAllSkillExampleTools } from "./arcrun_skills_examples.js";
|
||||
import { registerAllRecipeTools } from "./arcrun_recipe.js";
|
||||
import { registerAllKbdbDataTools } from "./kbdb_data.js";
|
||||
import { registerWhoami } from "./arcrun_whoami.js";
|
||||
|
||||
export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
|
||||
@@ -50,6 +51,9 @@ export function registerAllTools(server: McpServer, env: Env, orgNamespace: stri
|
||||
registerAllSkillExampleTools(server, env);
|
||||
// kbdb-base §7.5.i: recipe 公庫/私庫工具(與 CLI 六能力對齊,rule 07 §5 MCP 不落後)
|
||||
registerAllRecipeTools(server, env);
|
||||
// kbdb-base Phase 9.1: KBDB 資料層薄殼(template/record/query/search,HANDOFF §2)
|
||||
// 鐵律:不提供建表/SQL tool,AI 只有 template+slot 可用(類 Supabase 萬用表)
|
||||
registerAllKbdbDataTools(server, env);
|
||||
// §7.8 P1 D2: whoami(與 CLI acr whoami 對齊,AI 不繞 CLI 自己 curl 猜帳號)
|
||||
registerWhoami(server, env, orgNamespace);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@ export interface Env {
|
||||
// 設了會把 agent-feedback / agent-telemetry block 都寫到 platform user_id 下;
|
||||
// 沒設則 fallback 寫進 user 自己的 namespace
|
||||
PLATFORM_API_KEY?: string;
|
||||
// Self-hosted 單租戶模式旗標(與 cypher-executor 同名同義)。
|
||||
// "false" = self-hosted:Bearer 帶的是 namespace 明碼(非平台 partner key),
|
||||
// 不打 KBDB partner 驗證,直接當 org_namespace(對齊 cypher 的 opaque-key 模型)。
|
||||
// 未設 / "true" = 官方 SaaS:維持 partner-key 驗證(行為完全不變)。
|
||||
// SDD: sdk-and-website/mcp-account-source.md;HANDOFF §3b。
|
||||
MULTI_TENANT?: string;
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
|
||||
@@ -40,3 +40,41 @@ describe("partner-auth: KBDB response validation", () => {
|
||||
expect(info.org_namespace).toBe("org-a");
|
||||
});
|
||||
});
|
||||
|
||||
// HANDOFF §3b / mcp-account-source.md §5.5:self-hosted(MULTI_TENANT=false)下
|
||||
// Bearer 帶的是 namespace 明碼,不打 KBDB partner 驗證,直接當 org_namespace。
|
||||
// 與 cypher-executor 的 opaque-key 模型對齊(X-Arcrun-API-Key 不驗證直接當分區 key)。
|
||||
function resolveNamespace(
|
||||
multiTenant: string | undefined,
|
||||
token: string,
|
||||
validatePartner: (t: string) => { valid: boolean; org_namespace: string },
|
||||
): { ok: boolean; org_namespace?: string } {
|
||||
if (multiTenant === "false") {
|
||||
// self-hosted:Bearer 明碼即 namespace,繞 partner 驗證
|
||||
return { ok: true, org_namespace: token };
|
||||
}
|
||||
// SaaS:維持 partner-key 驗證(行為不變)
|
||||
const info = validatePartner(token);
|
||||
return info.valid ? { ok: true, org_namespace: info.org_namespace } : { ok: false };
|
||||
}
|
||||
|
||||
describe("partner-auth: self-hosted (MULTI_TENANT=false) bypasses partner validation", () => {
|
||||
const partnerValidatorThatAlwaysRejects = () => ({ valid: false, org_namespace: "" });
|
||||
|
||||
it("self-hosted: namespace 明碼直接當 org_namespace,不打 partner 驗證", () => {
|
||||
const r = resolveNamespace("false", "leo", partnerValidatorThatAlwaysRejects);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.org_namespace).toBe("leo");
|
||||
});
|
||||
|
||||
it("SaaS (未設 MULTI_TENANT):仍走 partner 驗證,明碼被擋", () => {
|
||||
const r = resolveNamespace(undefined, "leo", partnerValidatorThatAlwaysRejects);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("SaaS:合法 partner key 通過並取 org_namespace", () => {
|
||||
const r = resolveNamespace("true", "pk_live_x", () => ({ valid: true, org_namespace: "org-a" }));
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.org_namespace).toBe("org-a");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,15 @@ compatibility_date = "2024-11-27"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
workers_dev = true # 對齊 arcrun 部署慣例(rule 05):deploy 掃描自動啟用 workers.dev URL
|
||||
|
||||
# ── 租戶模式(self-hosted fork 必看)─────────────────────────────────────────────
|
||||
# 官方 SaaS:不設 MULTI_TENANT(預設多租戶)→ MCP 走 partner-key 驗證(pk_live)。
|
||||
# self-hosted 單租戶:fork 後設 MULTI_TENANT = "false" → MCP 接受 Bearer = namespace 明碼,
|
||||
# 不打 KBDB partner 驗證,直接當 org_namespace(與 cypher-executor 的 MULTI_TENANT=false 對齊)。
|
||||
# self-hosted 用戶用 namespace 明碼即可連 MCP(不需平台 partner key)。
|
||||
# SDD: sdk-and-website/mcp-account-source.md(self-hosted 認證);HANDOFF §3b。
|
||||
# [vars]
|
||||
# MULTI_TENANT = "false"
|
||||
|
||||
# Service Bindings
|
||||
# 2026-05-07:COMPONENT_REGISTRY 從 inkstone-component-registry 改為 arcrun-registry
|
||||
# 原因:舊的 inkstone-component-registry 期望不同 query 參數名,MCP search 失敗。
|
||||
|
||||
Reference in New Issue
Block a user