Files
Arcrun/mcp/tests/unit/tools/kbdb-graph.test.ts
T
Claude 7eeab9f26f feat(mcp): kbdb_graph_neighbors — graph 鄰居查詢薄殼(#68)
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
2026-07-19 07:55:16 +00:00

169 lines
6.5 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.
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 缺 startnode)參數" }), {
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);
});
});