934b9265d9
按 issue 分段標明(檔 #5/#8 改動交疊處無法乾淨拆檔,故併一個 commit): #4 thin-shell §3.1 自力救濟階梯 + code-node 規則(純文檔/規則,code-node 零件未實作) #5 KBDB source filter(json_extract metadata_json 零建表)+ 能力對照;documents 聚合與 DELETE proxy 部分擱置等頂層 T8 #7 base embed 模組(kbdb/src/embed.ts)+ vectorize 開關(deploy/config/wrangler.toml 註解範本) + 語義查詢降級閉環(mode=semantic 未開→LIKE+capability_hint) #8 部分(workflow-discovery): - KBDB /entries/search 加 base 通用 entry_type filter(entry-crud/embed/route/kbdb-proxy 透傳) - /webhooks/named 強制 description(空→400,訊息要求操盤 AI 據實寫一句) - 部署雙寫 entry_type=workflow embeddable entry(waitUntil 非阻塞,供 search) - cypher GET /workflows/search + MCP u6u_search_workflows(優先語意、降級 hint) - cypher POST /workflows/backfill-search-entries(無 desc 列出不編造) - GET /webhooks/named 補回 description/created_at 欄位(為 list 來源收斂備) ⚠️ tsc 綠 = code done,非完成(mindset §7 禁假綠): - #7/#8 端到端待 leo21c 部署驗(Vectorize 需官方憑證、CC 跑不了) - #8 ①-a(MCP deploy 改打 /webhooks/named)未做、MCP deploy 那半仍 404 - #8 端到端(強制填擋空/語義命中/租戶隔離/降級 hint)未驗 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
212 lines
11 KiB
TypeScript
212 lines
11 KiB
TypeScript
/**
|
||
* 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 做搜尋。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) {
|
||
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), ["稍後重試"]);
|
||
}
|
||
},
|
||
);
|
||
}
|