7eeab9f26f
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUmjwkHLVBHM3ydhT1WSW3
169 lines
6.5 KiB
TypeScript
169 lines
6.5 KiB
TypeScript
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<string, unknown>) => Promise<{
|
||
content: { type: string; text: string }[];
|
||
isError?: boolean;
|
||
}>;
|
||
|
||
function makeServer() {
|
||
const tools = new Map<string, { description: string; handler: ToolHandler }>();
|
||
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<string, unknown>;
|
||
}
|
||
|
||
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);
|
||
});
|
||
});
|