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"; import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js"; /** 服務級憑據(static token / partner key)——既有路徑,行為零變更。 */ const SERVICE: KnowledgeIdentity = { kind: "service" }; /** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面。 */ const PORTAL: KnowledgeIdentity = { kind: "portal", portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] }, }; /** 本次改版前簽發的舊 token(沒有身分)。 */ const STALE: KnowledgeIdentity = { kind: "stale" }; // ── 假 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", SERVICE); 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", SERVICE); 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", SERVICE); 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", SERVICE); 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", SERVICE); 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", SERVICE); 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, "", SERVICE); 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); }); }); // ── 2026-08-12:以帳密連線時走登入者的身分(leo:主人查得到的,授權的 AI 就查得到)── describe("kbdb_graph_neighbors: 登入身分(portal 資料面)", () => { it("打 cypher 的 /portal/data/graph/neighbors,且帶的是登入者的 session(不是服務金鑰)", async () => { const { server, tools } = makeServer(); const { env, calls } = makeEnv( () => new Response( JSON.stringify({ neighbors: [{ node: "B", predicate: "uses", from: "A", depth: 1 }], edges: [], count: 1, }), { status: 200 }, ), ); registerGraphNeighbors(server, env, "leo", PORTAL); const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A", depth: 2 }); expect(calls).toHaveLength(1); expect(calls[0].url.pathname).toBe("/portal/data/graph/neighbors/A"); expect(calls[0].url.searchParams.get("depth")).toBe("2"); const headers = new Headers(calls[0].init!.headers as HeadersInit); expect(headers.get("Authorization")).toBe("Bearer sess-abc"); expect(res.isError).toBeUndefined(); expect((parseResult(res).data as { count: number }).count).toBe(1); }); it("**不需要 kbdb_base**:已經登入過了,不再要第二次「證明你是誰/你的庫在哪」", async () => { const { server, tools } = makeServer(); const { env, calls } = makeEnv( () => new Response(JSON.stringify({ neighbors: [], edges: [], count: 0 })), ); registerGraphNeighbors(server, env, "leo", PORTAL); const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" }); expect(res.isError).toBeUndefined(); expect(calls).toHaveLength(1); // 呼叫端就算硬塞 kbdb_base 也不會被拿去用(server 自己知道要查哪個庫) expect(calls[0].url.searchParams.get("kbdb_base")).toBeNull(); }); it("session 過期(401)→ 誠實說是登入過期,不說「查不到資料」", async () => { const { server, tools } = makeServer(); const { env } = makeEnv( () => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }), ); registerGraphNeighbors(server, env, "leo", PORTAL); const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" }); expect(res.isError).toBe(true); expect(parseResult(res).error_code).toBe("session_expired"); }); it("無 graph 權限(403)→ 誠實回沒權限,不假裝「沒有關聯」", async () => { const { server, tools } = makeServer(); const { env } = makeEnv( () => new Response(JSON.stringify({ error: "無知識圖譜檢視權限" }), { status: 403 }), ); registerGraphNeighbors(server, env, "leo", PORTAL); const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" }); expect(res.isError).toBe(true); expect(parseResult(res).error_code).toBe("forbidden"); }); it("舊 token(沒有身分)→ 不偷偷退回服務金鑰那條老路,要求重新連線", async () => { const { server, tools } = makeServer(); const { env, calls } = makeEnv(() => new Response("{}")); registerGraphNeighbors(server, env, "leo", STALE); const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" }); expect(res.isError).toBe(true); expect(parseResult(res).error_code).toBe("identity_missing"); expect(calls).toHaveLength(0); // 一個查詢都沒發出去(fail-closed) }); });