Files
Arcrun/mcp/src/tools/kbdb_data.ts
T

236 lines
12 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.
/**
* KBDB 資料層 MCP 薄殼(kbdb-base Phase 9.1HANDOFF §2
*
* rule 07 §5(薄殼鐵律):能力長在基本盤 API,MCP 只做介面轉換 + 暴露,無業務邏輯。
* 全走既有 kbdbFetchKBDB service binding)打基本盤 HTTP APIkbdb/src/routes/*)。
*
* KBDB 鐵律(leo 2026-06-14,頂層 DECISION-kbdb-v3-baseplane.md):
* - 任何人不准動表;**不提供建表 / SQL tool**。
* - AI 想存新類型的資料時只有「建 templatename+slots+ 填 recordslot→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(鐵律)。
*
* D28owner-mandatory):base 的 `POST /records` 已把 owner_id 設必填(缺 → 400)。
* MCP 是薄殼、是 caller——owner **由機制注入**org namespace),不靠 AI 記得帶。
* 仿 arcrun_report_feedback:把 orgNamespace 傳進寫入 tool,寫入時當 owner_id。
* 送到 base 的 owner_id 一定非空 → AI 漏不了。
*/
export function registerAllKbdbDataTools(server: McpServer, env: Env, orgNamespace: string) {
registerCreateTemplate(server, env);
registerListTemplates(server, env);
registerCreateRecord(server, env, orgNamespace);
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 填一筆 recordslot → 內容)。
*
* D28owner-mandatory):base 的 `POST /records` owner_id 必填(缺 → 400)。
* owner_id 由 MCP **機制注入**orgNamespace),不靠 AI 帶:
* - AI 傳的 owner_id 當「選填覆寫」(想歸到別的租戶/專案時才用)。
* - AI 沒傳 → 用注入的 orgNamespace。
* - 兩者皆空 → 直接回錯(不送必然 400 的請求),保證送到 base 的 owner_id 一定非空。
*/
export function registerCreateRecord(server: McpServer, env: Env, orgNamespace: string) {
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("資料歸屬標記(選填;預設用你的 org namespace,想歸到別的租戶/專案才傳)"),
},
async ({ template, values, owner_id }) => {
try {
// 機制注入:AI 傳的 owner_id 覆寫,缺則用 orgNamespace。兩者皆空 → 拒送(避免必然 400)。
const resolvedOwner = (owner_id && owner_id.trim()) || orgNamespace;
if (!resolvedOwner) {
return errorResponse(
"owner_missing",
"無法決定 owner_idAI 未傳 owner_id 且 org namespace 為空",
["確認 MCP 連線帶了有效的 Authorizationorg namespace", "或明確傳入 owner_id"],
);
}
const res = await kbdbFetch(env, "/records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template, values, owner_id: resolvedOwner }),
});
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 的 idrec_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 做搜尋。mode=keywordD1 LIKE,基本盤永遠可用)或 semantic(向量,需開 embed 模組)。
* 語義/關鍵字都在同一 KBDB MCP(用戶資料 RAG),不分散(issue #7 / D17 邊界)。
* mode=semantic 但沒開 vectorize → base 自動降級 keyword + 回 capability_hint(發現閉環,叫 CC 幫開)。
*/
export function registerSearch(server: McpServer, env: Env) {
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 }) => {
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 };
// 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"];
return successResponse(data, hints);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
}
},
);
}