/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { describe, expect, it } from "vitest"; import { createServer } from "../server.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); // Resolves to packages/graphiti/src/mcp/stdio.ts — spawned via tsx so the // FR-1.7 test exercises the live source with no build step or dist staleness. const STDIO_SRC = resolve(__dirname, "..", "stdio.ts"); // `echo` is registered only when GRAPHITI_ENABLE_ECHO === "1" (W-23148363: it is a // raw-reflecting transport stub kept off the default published surface). createServer // reads the flag at registration time, so we set it only around that call and restore // the prior value immediately — no cross-test env leakage. async function connect(opts?: { enableEcho?: boolean }): Promise<{ client: Client; server: McpServer; }> { const prev = process.env.GRAPHITI_ENABLE_ECHO; if (opts?.enableEcho) process.env.GRAPHITI_ENABLE_ECHO = "1"; else delete process.env.GRAPHITI_ENABLE_ECHO; let server: McpServer; try { server = createServer(); } finally { if (prev === undefined) delete process.env.GRAPHITI_ENABLE_ECHO; else process.env.GRAPHITI_ENABLE_ECHO = prev; } const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "graphiti-mcp-test-client", version: "0.0.0" }); await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); return { client, server }; } describe("mcp/server", () => { it("initialize round-trips with the expected server identity", async () => { const { client, server } = await connect(); try { const info = client.getServerVersion(); expect(info?.name).toBe("graphiti-mcp"); expect(info?.version).toBeTruthy(); const capabilities = client.getServerCapabilities(); expect(capabilities?.tools).toBeDefined(); } finally { await client.close(); await server.close(); } }); it("tools/list returns only the sf_gql_* tools by default (echo gated off)", async () => { const { client, server } = await connect(); try { const result = await client.listTools(); const names = result.tools.map((t) => t.name).sort(); expect(names).toEqual([ "sf_gql_aggregate", "sf_gql_connect", "sf_gql_create", "sf_gql_delete", "sf_gql_detail", "sf_gql_discover", "sf_gql_list", "sf_gql_raw", "sf_gql_update", ]); // W-23148363: the raw-reflecting echo stub must not appear on the default // published surface — only when GRAPHITI_ENABLE_ECHO is opted in. expect(names).not.toContain("echo"); } finally { await client.close(); await server.close(); } }); it("tools/list exposes echo when GRAPHITI_ENABLE_ECHO is opted in", async () => { const { client, server } = await connect({ enableEcho: true }); try { const result = await client.listTools(); const echo = result.tools.find((t) => t.name === "echo"); expect(echo).toBeDefined(); const inputSchema = echo?.inputSchema as | { type?: string; properties?: { message?: unknown } } | undefined; expect(inputSchema?.type).toBe("object"); expect(inputSchema?.properties?.message).toBeDefined(); } finally { await client.close(); await server.close(); } }); it("tools/call echo returns the message verbatim (when enabled)", async () => { const { client, server } = await connect({ enableEcho: true }); try { const result = await client.callTool({ name: "echo", arguments: { message: "hello graphiti-mcp" }, }); const content = result.content as { type: string; text?: string }[]; expect(content[0]?.type).toBe("text"); expect(content[0]?.text).toBe("hello graphiti-mcp"); } finally { await client.close(); await server.close(); } }); it("tools/call unknown tool returns FR-1.6 error envelope", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "does-not-exist" }); expect(result.isError).toBe(true); const content = result.content as { type: string; text?: string }[]; expect(content[0]?.type).toBe("text"); expect(content[0]?.text ?? "").toMatch(/does-not-exist/); } finally { await client.close(); await server.close(); } }); it("tools/call echo with missing required arg returns zod validation error", async () => { const { client, server } = await connect({ enableEcho: true }); try { const result = await client.callTool({ name: "echo", arguments: {} }); expect(result.isError).toBe(true); const content = result.content as { type: string; text?: string }[]; expect(content[0]?.type).toBe("text"); const text = content[0]?.text ?? ""; expect(text).toMatch(/Input validation error/); expect(text).toMatch(/message/); } finally { await client.close(); await server.close(); } }); it("FR-1.7: stdio bin exits cleanly on stdin close after handshake", async () => { // End-to-end coverage for FR-1.7: spawn the real bin, complete a // handshake, close stdin, and assert the child exits. This pins that // `main()` actually wires `transport.onclose` to a process-exit path — // regressions like dropping the `await closed` or returning early from // main fail this test. (It does *not* prove the synchronous-close race // fix in isolation; over a real subprocess stdin doesn't close fast // enough to fire onclose inside connect(). That race is guarded by // code review of stdio.ts ordering.) const transport = new StdioClientTransport({ command: "npx", args: ["tsx", STDIO_SRC], stderr: "pipe", }); const client = new Client({ name: "stdio-bin-test", version: "0.0.0" }, { capabilities: {} }); // StdioClientTransport fires onclose when the child process exits. const exitPromise = new Promise((resolve) => { transport.onclose = () => resolve(); }); await client.connect(transport); // Confirm the handshake actually completed before we try to drain. expect(client.getServerVersion()?.name).toBe("graphiti-mcp"); // Closing the client closes its writer, which closes the child's stdin, // which fires StdioServerTransport.onclose on the server side and // resolves runStdio's `closed` promise — so the child exits. await client.close(); const result = await Promise.race([ exitPromise.then(() => "exited" as const), new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)), ]); expect(result).toBe("exited"); }, 60_000); });