From 7eeab9f26fef68a2f837fac5b90ba8a804861e74 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 07:55:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20kbdb=5Fgraph=5Fneighbors=20?= =?UTF-8?q?=E2=80=94=20graph=20=E9=84=B0=E5=B1=85=E6=9F=A5=E8=A9=A2?= =?UTF-8?q?=E8=96=84=E6=AE=BC=EF=BC=88#68=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP 面補齊 D17 KBDB RAG 套餐第三模式(關鍵字/語義/圖): - 新 tool kbdb_graph_neighbors:subject(必填)/depth(預設 1)/kbdb_base/ template(預設 graph_triplet)/directed,形狀對齊 registry/examples/graph-neighbors/workflow.yaml 的 input - 薄殼調 GET /q/{orgNamespace}/graph_neighbors 同步查詢端點(#28 地基), 走既有 CYPHER_EXECUTOR service binding,不新增 binding、不碰 D1、 不動 workflow 引擎本體 - workflow 未部署 → 404 誠實回 workflow_not_installed +安裝指引,不 crash; HTTP 200 但 workflow 層 success:false 也不假綠 - graph_traverse 不加:repo 內無該 workflow 定義可對齊 input 形狀,不猜 - 測試:tests/unit/tools/kbdb-graph.test.ts(7 測,全綠);tsc --noEmit 乾淨; mcp 全套 vitest 59/59 綠 merge 後需 gated redeploy arcrun-mcp(leo 閘)。 關聯 #68 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JUmjwkHLVBHM3ydhT1WSW3 --- mcp/src/tools/kbdb_graph.ts | 158 ++++++++++++++++++++++ mcp/src/tools/registry.ts | 4 + mcp/tests/unit/tools/kbdb-graph.test.ts | 168 ++++++++++++++++++++++++ system-dev/wiki/status.md | 7 +- 4 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 mcp/src/tools/kbdb_graph.ts create mode 100644 mcp/tests/unit/tools/kbdb-graph.test.ts diff --git a/mcp/src/tools/kbdb_graph.ts b/mcp/src/tools/kbdb_graph.ts new file mode 100644 index 0000000..a54d079 --- /dev/null +++ b/mcp/src/tools/kbdb_graph.ts @@ -0,0 +1,158 @@ +/** + * KBDB graph 查詢 MCP 薄殼(issue #68,D17 KBDB MCP 面) + * + * rule 07 §5(薄殼鐵律):能力長在 workflow(graph_neighbors,registry/examples/graph-neighbors), + * MCP 只做介面轉換 + 暴露,無業務邏輯——不碰 D1、不動 workflow 引擎本體。 + * + * D17「KBDB MCP=RAG 套餐」三模式:關鍵字(kbdb_search keyword)/語義(kbdb_search semantic) + * /圖(本檔)。本檔補上 MCP 面缺的第三模式:關係遍歷(1-hop/N-hop 鄰居)。 + * + * 走 #28 地基的同步查詢端點(cypher-executor webhooks-named.ts): + * GET /q/:ns/:name — 同步執行 workflow,直接回「最終節點輸出」本身(200 直出,非 202、非信封)。 + * 本工具打 GET /q/{orgNamespace}/graph_neighbors(經既有 CYPHER_EXECUTOR service binding, + * 不新增 binding),namespace 用 MCP token 解析出的 orgNamespace(與 whoami 同源)。 + * + * workflow 未部署(用戶沒裝 graph_neighbors)→ 404 → 誠實回錯誤+怎麼裝,不 crash(鐵律)。 + * + * graph_neighbors workflow 的 input 形狀(registry/examples/graph-neighbors/workflow.yaml): + * node(起點)、depth(預設 1)、template(triplet template 名)、namespace(owner_id)、 + * kbdb_base(呼叫者自己的 KBDB 對外 URL——workflow 刻意不寫死任何一家的庫)、directed。 + */ + +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"; + +/** graph 查詢 workflow 名(與 registry/examples/graph-neighbors/workflow.yaml 的 name 一致)。 */ +export const GRAPH_NEIGHBORS_WORKFLOW = "graph_neighbors"; + +/** 未裝 workflow 時的安裝指引(404 與內層錯誤共用,訊息一致)。 */ +const INSTALL_HINTS = [ + `graph 查詢由「${GRAPH_NEIGHBORS_WORKFLOW}」workflow 承載(薄殼設計,MCP 本身不算圖)`, + "安裝:拿 registry/examples/graph-neighbors/workflow.yaml,call arcrun_push_workflow 部署(name 必須是 graph_neighbors)", + "或用 CLI:acr push registry/examples/graph-neighbors/workflow.yaml", +]; + +/** 註冊全部 KBDB graph 查詢工具(issue #68)。 */ +export function registerAllKbdbGraphTools(server: McpServer, env: Env, orgNamespace: string) { + registerGraphNeighbors(server, env, orgNamespace); + // graph_traverse:repo 內目前只有 graph-neighbors 有 workflow 定義(registry/examples/), + // traverse 尚無可對齊的 input 形狀 → 不猜、不過度工程;等 workflow 進 registry 再加薄殼。 +} + +/** + * 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) { + server.tool( + "kbdb_graph_neighbors", + "knowledge graph 鄰居查詢(1-hop/N-hop 關係遍歷):給一個節點名,沿 KBDB triplet" + + "(subject-predicate-object)記錄做 BFS,回傳 depth 跳內的鄰居清單" + + "([{node, predicate, from, depth}])。與 kbdb_search(關鍵字/語義)互補:" + + "找「跟 X 有關係的東西」用本工具,找「內容含關鍵字的東西」用 kbdb_search。" + + "需要 namespace 裡已部署 graph_neighbors workflow(沒裝會回安裝指引,不會 crash)。", + { + subject: z.string().min(1).describe( + "起點節點名(graph triplet 的 subject/object 值),如 'Arcrun'", + ), + 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 刻意不寫死任何一家的庫——" + + "帶錯(或照抄別人的值)=查詢打進別人的庫", + ), + template: z.string().optional().describe( + "triplet 記錄的 template 名,預設 'graph_triplet'(以實際部署的 kbdb-graph-plugin " + + "triplet template 為準,不確定可 kbdb_list_templates 查)", + ), + directed: z.boolean().optional().describe( + "true=只走 subject→object 有向邊;預設 false(把 triplet 當雙向邊,無向鄰居)", + ), + }, + async ({ subject, depth, kbdb_base, template, directed }) => { + if (!orgNamespace) { + return errorResponse( + "no_namespace", + "此 MCP 連線沒有解析出 namespace,無法定位 graph workflow", + ["call arcrun_whoami 確認身份", "確認 MCP token / OAuth 設定正確"], + ); + } + try { + // input 形狀對齊 graph_neighbors workflow(node/depth/template/namespace/kbdb_base/directed)。 + const query: Record = { + node: subject, + depth: depth ?? 1, + template: template ?? "graph_triplet", + namespace: orgNamespace, + kbdb_base, + }; + if (directed) query.directed = "true"; + + // /q/:ns/:name:namespace 走 path(cypher opaque-key 模型,orgNamespace 即分區 key), + // 走既有 CYPHER_EXECUTOR service binding(cypherFetch),不新增 binding。 + const res = await cypherFetch( + env, + `/q/${encodeURIComponent(orgNamespace)}/${GRAPH_NEIGHBORS_WORKFLOW}`, + { apiKey: orgNamespace, query }, + ); + + if (res.status === 404) { + // workflow 沒裝 → 誠實 + 給安裝路徑(鐵律:不 crash、不假綠)。 + return errorResponse( + "workflow_not_installed", + `namespace「${orgNamespace}」尚未部署 ${GRAPH_NEIGHBORS_WORKFLOW} workflow,graph 查詢無法使用`, + INSTALL_HINTS, + ); + } + + const bodyText = await res.text(); + let data: unknown = null; + try { + data = bodyText ? JSON.parse(bodyText) : null; + } catch { + data = bodyText; + } + + if (!res.ok) { + // 409=paused(無法同步查詢)、413=輸出過大、500=執行失敗,端點都回 {error,...}。 + const err = (data ?? {}) as { error?: string }; + return errorResponse( + "graph_query_failed", + `graph 查詢失敗 HTTP ${res.status}: ${err.error ?? "unknown"}`, + [ + "確認 kbdb_base 是你自己 KBDB 的對外 URL 且可被 cypher-executor fetch(1042 陷阱:優先 custom domain)", + "確認 template 名對得上實際 triplet template(kbdb_list_templates)", + "call arcrun_list_recent_executions('graph_neighbors') 看 trace", + ], + typeof data === "string" ? data : JSON.stringify(data), + ); + } + + // 200 = workflow 跑完,body 即最終節點輸出本身。但 workflow 內部仍可能回 + // { success:false, error }(如缺參數)——不把它假裝成成功結果。 + const out = data as { success?: boolean; error?: string; count?: number } | null; + if (out && typeof out === "object" && out.success === false) { + return errorResponse( + "graph_query_failed", + out.error ?? "graph_neighbors workflow 回報失敗", + ["確認 subject 非空", "確認參數形狀(depth 為正整數)"], + JSON.stringify(out), + ); + } + + // 原樣回給 MCP client(薄殼:不加工、不重排)。 + return successResponse(out, [ + `${out?.count ?? 0} 個鄰居(depth 上限 ${depth ?? 1})`, + "count=0 且不確定資料有沒有進圖:kbdb_query(template='graph_triplet') 看 triplet 記錄", + "找關鍵字內容改用 kbdb_search;取單筆全文用 kbdb_get_record", + ]); + } catch (e) { + return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]); + } + }, + ); +} diff --git a/mcp/src/tools/registry.ts b/mcp/src/tools/registry.ts index 3790ab4..285ee5a 100644 --- a/mcp/src/tools/registry.ts +++ b/mcp/src/tools/registry.ts @@ -18,6 +18,7 @@ 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 { registerAllKbdbGraphTools } from "./kbdb_graph.js"; import { registerWhoami } from "./arcrun_whoami.js"; export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) { @@ -49,6 +50,9 @@ export function registerAllTools(server: McpServer, env: Env, orgNamespace: stri // kbdb-base Phase 9.1: KBDB 資料層薄殼(template/record/query/search,HANDOFF §2) // 鐵律:不提供建表/SQL tool,AI 只有 template+slot 可用(類 Supabase 萬用表) registerAllKbdbDataTools(server, env); + // issue #68: KBDB graph 查詢薄殼(kbdb_graph_neighbors,調 /q/:ns/graph_neighbors 同步查詢端點) + // 補齊 D17「KBDB MCP=RAG 套餐」第三模式:關鍵字/語義之外的圖(關係遍歷) + registerAllKbdbGraphTools(server, env, orgNamespace); // §7.8 P1 D2: whoami(與 CLI acr whoami 對齊,AI 不繞 CLI 自己 curl 猜帳號) registerWhoami(server, env, orgNamespace); } diff --git a/mcp/tests/unit/tools/kbdb-graph.test.ts b/mcp/tests/unit/tools/kbdb-graph.test.ts new file mode 100644 index 0000000..6e25012 --- /dev/null +++ b/mcp/tests/unit/tools/kbdb-graph.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from "vitest"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { Env } from "../../../src/types.js"; +import { + registerGraphNeighbors, + GRAPH_NEIGHBORS_WORKFLOW, +} from "../../../src/tools/kbdb_graph.js"; + +// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫 ───────────────────────── +type ToolHandler = (args: Record) => Promise<{ + content: { type: string; text: string }[]; + isError?: boolean; +}>; + +function makeServer() { + const tools = new Map(); + const server = { + tool(name: string, description: string, _schema: unknown, handler: ToolHandler) { + tools.set(name, { description, handler }); + }, + }; + return { server: server as unknown as McpServer, tools }; +} + +// ── 假 CYPHER_EXECUTOR binding:記錄請求、回預設 response ─────────────────────── +function makeEnv(respond: (url: URL, init?: RequestInit) => Response) { + const calls: { url: URL; init?: RequestInit }[] = []; + const env = { + CYPHER_EXECUTOR: { + fetch: async (input: string, init?: RequestInit) => { + const url = new URL(input); + calls.push({ url, init }); + return respond(url, init); + }, + }, + } as unknown as Env; + return { env, calls }; +} + +function parseResult(r: { content: { text: string }[] }) { + return JSON.parse(r.content[0].text) as Record; +} + +describe("kbdb_graph_neighbors: registration", () => { + it("registers under kbdb_* prefix (D17 KBDB MCP boundary)", () => { + const { server, tools } = makeServer(); + const { env } = makeEnv(() => new Response("{}")); + registerGraphNeighbors(server, env, "leo"); + expect(tools.has("kbdb_graph_neighbors")).toBe(true); + expect(tools.get("kbdb_graph_neighbors")!.description).toContain("graph"); + }); +}); + +describe("kbdb_graph_neighbors: request shape", () => { + it("hits GET /q/:ns/graph_neighbors with workflow input shape", async () => { + const { server, tools } = makeServer(); + const { env, calls } = makeEnv( + () => + new Response( + JSON.stringify({ success: true, start: "Arcrun", depth: 2, neighbors: [], count: 0 }), + { status: 200 }, + ), + ); + registerGraphNeighbors(server, env, "leo"); + const res = await tools.get("kbdb_graph_neighbors")!.handler({ + subject: "Arcrun", + depth: 2, + kbdb_base: "https://kbdb.example.com", + }); + + expect(calls).toHaveLength(1); + const url = calls[0].url; + expect(url.pathname).toBe(`/q/leo/${GRAPH_NEIGHBORS_WORKFLOW}`); + // workflow input 形狀(registry/examples/graph-neighbors/workflow.yaml) + expect(url.searchParams.get("node")).toBe("Arcrun"); + expect(url.searchParams.get("depth")).toBe("2"); + expect(url.searchParams.get("template")).toBe("graph_triplet"); // 預設 + expect(url.searchParams.get("namespace")).toBe("leo"); // orgNamespace 注入 + expect(url.searchParams.get("kbdb_base")).toBe("https://kbdb.example.com"); + expect(url.searchParams.get("directed")).toBeNull(); // 預設無向 → 不帶 + + const body = parseResult(res); + expect(body.ok).toBe(true); + }); + + it("depth defaults to 1, directed=true is forwarded, template overridable", async () => { + const { server, tools } = makeServer(); + const { env, calls } = makeEnv( + () => new Response(JSON.stringify({ success: true, neighbors: [], count: 0 })), + ); + registerGraphNeighbors(server, env, "leo"); + await tools.get("kbdb_graph_neighbors")!.handler({ + subject: "A", + kbdb_base: "https://kbdb.example.com", + template: "my_triplet", + directed: true, + }); + const url = calls[0].url; + expect(url.searchParams.get("depth")).toBe("1"); + expect(url.searchParams.get("directed")).toBe("true"); + expect(url.searchParams.get("template")).toBe("my_triplet"); + }); +}); + +describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash)", () => { + it("404 → workflow_not_installed with install hints, not a crash", async () => { + const { server, tools } = makeServer(); + const { env } = makeEnv( + () => new Response(JSON.stringify({ error: '找不到 workflow "graph_neighbors"' }), { status: 404 }), + ); + registerGraphNeighbors(server, env, "leo"); + const res = await tools.get("kbdb_graph_neighbors")!.handler({ + subject: "A", + kbdb_base: "https://kbdb.example.com", + }); + expect(res.isError).toBe(true); + const body = parseResult(res); + expect(body.error_code).toBe("workflow_not_installed"); + expect(JSON.stringify(body.next_actions)).toContain("graph-neighbors/workflow.yaml"); + }); + + it("500 execution failure → graph_query_failed, error passed through", async () => { + const { server, tools } = makeServer(); + const { env } = makeEnv( + () => new Response(JSON.stringify({ success: false, error: "boom", trace: [] }), { status: 500 }), + ); + registerGraphNeighbors(server, env, "leo"); + const res = await tools.get("kbdb_graph_neighbors")!.handler({ + subject: "A", + kbdb_base: "https://kbdb.example.com", + }); + expect(res.isError).toBe(true); + const body = parseResult(res); + expect(body.error_code).toBe("graph_query_failed"); + expect(String(body.human_message)).toContain("boom"); + }); + + it("HTTP 200 but workflow-level success:false → not faked as success", async () => { + const { server, tools } = makeServer(); + const { env } = makeEnv( + () => + new Response(JSON.stringify({ success: false, error: "graph_neighbors 缺 start(node)參數" }), { + status: 200, + }), + ); + registerGraphNeighbors(server, env, "leo"); + const res = await tools.get("kbdb_graph_neighbors")!.handler({ + subject: "A", + kbdb_base: "https://kbdb.example.com", + }); + expect(res.isError).toBe(true); + const body = parseResult(res); + expect(body.error_code).toBe("graph_query_failed"); + }); + + it("empty orgNamespace → no_namespace error, no fetch made", async () => { + const { server, tools } = makeServer(); + const { env, calls } = makeEnv(() => new Response("{}")); + registerGraphNeighbors(server, env, ""); + const res = await tools.get("kbdb_graph_neighbors")!.handler({ + subject: "A", + kbdb_base: "https://kbdb.example.com", + }); + expect(res.isError).toBe(true); + expect(parseResult(res).error_code).toBe("no_namespace"); + expect(calls).toHaveLength(0); + }); +}); diff --git a/system-dev/wiki/status.md b/system-dev/wiki/status.md index 78b4a42..8d6cfc0 100644 --- a/system-dev/wiki/status.md +++ b/system-dev/wiki/status.md @@ -3,7 +3,7 @@ name: status description: 當前進度、進行中 Phase、已知問題、下一步(動態文件,每 session 更新) metadata: type: project - last_updated: 2026-07-14 + last_updated: 2026-07-19 --- # 當前進度(動態) @@ -15,6 +15,11 @@ metadata: ## 📍 當前位置 +> **2026-07-19(#68 MCP graph tool)**:`kbdb_graph_neighbors` PR 已開(分支 +> `feat/mcp-graph-neighbors-tool`),等審+gated 部署(merge 後需 leo 閘 redeploy arcrun-mcp)。 +> 薄殼:MCP 新 tool 調 `/q/:ns/graph_neighbors` 同步查詢端點(#28 地基),補齊 D17 KBDB MCP +> 三模式(關鍵字/語義/圖)。graph_traverse 未加(repo 內無該 workflow 定義可對齊,不猜)。 + > **2026-07-14 本 session(bugfix:PBKDF2 CF runtime 上限+http_request 1042 flag,分支 `fix-pbkdf2-cf-limit`,PR 待總管審不 merge)**: > - **T6-cloud 部署抓到的框架蟲**:CF Workers **正式 runtime** PBKDF2 上限 100,000 iterations—— > portal-auth 設 600k → `crypto.subtle.deriveBits` 真雲直接拒絕 → `/portal/admin/bootstrap` 500