diff options
Diffstat (limited to 'packages/mcp/src/client.test.ts')
| -rw-r--r-- | packages/mcp/src/client.test.ts | 456 |
1 files changed, 261 insertions, 195 deletions
diff --git a/packages/mcp/src/client.test.ts b/packages/mcp/src/client.test.ts index 695bdcc..4542a36 100644 --- a/packages/mcp/src/client.test.ts +++ b/packages/mcp/src/client.test.ts @@ -3,204 +3,270 @@ import { McpClient } from "./client.js"; import type { Connection } from "./transport.js"; function makeMockConnection(): Connection & { - responses: Map<string, unknown>; - feedResponse: (method: string, result: unknown) => void; - notifications: Array<{ method: string; params: unknown }>; + responses: Map<string, unknown>; + feedResponse: (method: string, result: unknown) => void; + notifications: Array<{ method: string; params: unknown }>; } { - const responses = new Map<string, unknown>(); - const pendingRequests = new Map<number, { method: string; resolve: (v: unknown) => void }>(); - let nextId = 1; - const notifications: Array<{ method: string; params: unknown }> = []; - const notificationHandlers = new Map<string, (params: unknown) => void>(); - - return { - responses, - notifications, - feedResponse: (_method: string, _result: unknown) => {}, - send: (method: string, _params?: unknown) => { - const id = nextId++; - return new Promise((resolve) => { - pendingRequests.set(id, { method, resolve }); - // Auto-respond for initialize - if (method === "initialize") { - resolve({ - protocolVersion: "2025-11-25", - capabilities: { tools: { listChanged: true } }, - serverInfo: { name: "test-server", version: "1.0.0" }, - }); - } else if (method === "tools/list") { - resolve({ - tools: [ - { - name: "test_tool", - description: "A test tool", - inputSchema: { type: "object", properties: { input: { type: "string" } } }, - }, - ], - }); - } else if (method === "tools/call") { - resolve({ - content: [{ type: "text", text: "result from tool" }], - isError: false, - }); - } - }); - }, - notify: (method: string, params?: unknown) => { - notifications.push({ method, params }); - }, - onNotification: (method: string, handler: (params: unknown) => void) => { - notificationHandlers.set(method, handler); - }, - close: () => {}, - pid: 999, - }; + const responses = new Map<string, unknown>(); + const pendingRequests = new Map<number, { method: string; resolve: (v: unknown) => void }>(); + let nextId = 1; + const notifications: Array<{ method: string; params: unknown }> = []; + const notificationHandlers = new Map<string, (params: unknown) => void>(); + + return { + responses, + notifications, + feedResponse: (_method: string, _result: unknown) => {}, + send: (method: string, _params?: unknown) => { + const id = nextId++; + return new Promise((resolve) => { + pendingRequests.set(id, { method, resolve }); + // Auto-respond for initialize + if (method === "initialize") { + resolve({ + protocolVersion: "2025-11-25", + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: "test-server", version: "1.0.0" }, + }); + } else if (method === "tools/list") { + resolve({ + tools: [ + { + name: "test_tool", + description: "A test tool", + inputSchema: { type: "object", properties: { input: { type: "string" } } }, + }, + ], + }); + } else if (method === "tools/call") { + resolve({ + content: [{ type: "text", text: "result from tool" }], + isError: false, + }); + } + }); + }, + notify: (method: string, params?: unknown) => { + notifications.push({ method, params }); + }, + onNotification: (method: string, handler: (params: unknown) => void) => { + notificationHandlers.set(method, handler); + }, + close: () => {}, + pid: 999, + }; } describe("McpClient", () => { - it("initialize sends correct protocolVersion + capabilities", async () => { - const conn = makeMockConnection(); - const client = new McpClient({ connection: conn }); - - const result = await client.initialize(); - - expect(result.protocolVersion).toBe("2025-11-25"); - expect(result.capabilities.tools?.listChanged).toBe(true); - expect(result.serverInfo.name).toBe("test-server"); - expect(client.getState()).toBe("connected"); + it("initialize sends correct protocolVersion + capabilities", async () => { + const conn = makeMockConnection(); + const client = new McpClient({ connection: conn }); + + const result = await client.initialize(); + + expect(result.protocolVersion).toBe("2025-11-25"); + expect(result.capabilities.tools?.listChanged).toBe(true); + expect(result.serverInfo.name).toBe("test-server"); + expect(client.getState()).toBe("connected"); - // Should have sent notifications/initialized - expect(conn.notifications.length).toBe(1); - expect(conn.notifications[0].method).toBe("notifications/initialized"); - }); - - it("listTools returns parsed tools", async () => { - const conn = makeMockConnection(); - const client = new McpClient({ connection: conn }); - - await client.initialize(); - const tools = await client.listTools(); - - expect(tools.length).toBe(1); - expect(tools[0].name).toBe("test_tool"); - expect(tools[0].description).toBe("A test tool"); - }); - - it("callTool sends name + arguments", async () => { - const conn = makeMockConnection(); - let callParams: unknown = null; - const origSend = conn.send.bind(conn); - conn.send = (method: string, params?: unknown) => { - if (method === "tools/call") callParams = params; - return origSend(method, params); - }; - - const client = new McpClient({ connection: conn }); - - await client.initialize(); - const result = await client.callTool("test_tool", { input: "hello" }); - - expect(callParams).toEqual({ name: "test_tool", arguments: { input: "hello" } }); - expect(result.content).toEqual([{ type: "text", text: "result from tool" }]); - expect(result.isError).toBe(false); - }); - - it("list_changed triggers re-list", async () => { - const conn = makeMockConnection(); - const notificationHandlers = new Map<string, (params: unknown) => void>(); - conn.onNotification = (method: string, handler: (params: unknown) => void) => { - notificationHandlers.set(method, handler); - }; - - const client = new McpClient({ connection: conn }); - - let toolsChangedFired = false; - client.onToolsChanged(() => { - toolsChangedFired = true; - }); - - await client.initialize(); - - // Simulate list_changed notification - const handler = notificationHandlers.get("notifications/tools/list_changed"); - expect(handler).toBeDefined(); - handler?.(undefined); - - expect(toolsChangedFired).toBe(true); - }); - - it("handles server error on initialize", async () => { - const conn = makeMockConnection(); - conn.send = (method: string) => { - if (method === "initialize") { - return Promise.reject(new Error("Server startup failed")); - } - return Promise.resolve({}); - }; - - const client = new McpClient({ connection: conn }); - - await expect(client.initialize()).rejects.toThrow("Server startup failed"); - expect(client.getState()).toBe("error"); - }); - - it("callTool rejects when not connected", async () => { - const conn = makeMockConnection(); - const client = new McpClient({ connection: conn }); - - await expect(client.callTool("test", {})).rejects.toThrow("Client not connected"); - }); - - it("listTools rejects when not connected", async () => { - const conn = makeMockConnection(); - const client = new McpClient({ connection: conn }); - - await expect(client.listTools()).rejects.toThrow("Client not connected"); - }); - - it("close sets state to disconnected", async () => { - const conn = makeMockConnection(); - const client = new McpClient({ connection: conn }); - - await client.initialize(); - expect(client.getState()).toBe("connected"); - - client.close(); - expect(client.getState()).toBe("disconnected"); - }); - - it("callTool with abort signal", async () => { - const conn = makeMockConnection(); - let resolveRequest: ((v: unknown) => void) | null = null; - conn.send = (method: string) => { - if (method === "tools/call") { - return new Promise((resolve) => { - resolveRequest = resolve; - }); - } - if (method === "initialize") { - return Promise.resolve({ - protocolVersion: "2025-11-25", - capabilities: {}, - serverInfo: { name: "test", version: "1.0.0" }, - }); - } - return Promise.resolve({}); - }; - - const client = new McpClient({ connection: conn }); - await client.initialize(); - - const controller = new AbortController(); - const callPromise = client.callTool("test", {}, controller.signal); - - controller.abort(); - - await expect(callPromise).rejects.toThrow("Aborted"); - - // Clean up - resolveRequest?.({ - content: [{ type: "text", text: "too late" }], - }); - }); + // Should have sent notifications/initialized + expect(conn.notifications.length).toBe(1); + expect(conn.notifications[0].method).toBe("notifications/initialized"); + }); + + it("listTools returns parsed tools", async () => { + const conn = makeMockConnection(); + const client = new McpClient({ connection: conn }); + + await client.initialize(); + const tools = await client.listTools(); + + expect(tools.length).toBe(1); + expect(tools[0].name).toBe("test_tool"); + expect(tools[0].description).toBe("A test tool"); + }); + + it("callTool sends name + arguments", async () => { + const conn = makeMockConnection(); + let callParams: unknown = null; + const origSend = conn.send.bind(conn); + conn.send = (method: string, params?: unknown) => { + if (method === "tools/call") callParams = params; + return origSend(method, params); + }; + + const client = new McpClient({ connection: conn }); + + await client.initialize(); + const result = await client.callTool("test_tool", { input: "hello" }); + + expect(callParams).toEqual({ name: "test_tool", arguments: { input: "hello" } }); + expect(result.content).toEqual([{ type: "text", text: "result from tool" }]); + expect(result.isError).toBe(false); + }); + + it("list_changed triggers re-list", async () => { + const conn = makeMockConnection(); + const notificationHandlers = new Map<string, (params: unknown) => void>(); + conn.onNotification = (method: string, handler: (params: unknown) => void) => { + notificationHandlers.set(method, handler); + }; + + const client = new McpClient({ connection: conn }); + + let toolsChangedFired = false; + client.onToolsChanged(() => { + toolsChangedFired = true; + }); + + await client.initialize(); + + // Simulate list_changed notification + const handler = notificationHandlers.get("notifications/tools/list_changed"); + expect(handler).toBeDefined(); + handler?.(undefined); + + expect(toolsChangedFired).toBe(true); + }); + + it("handles server error on initialize", async () => { + const conn = makeMockConnection(); + conn.send = (method: string) => { + if (method === "initialize") { + return Promise.reject(new Error("Server startup failed")); + } + return Promise.resolve({}); + }; + + const client = new McpClient({ connection: conn }); + + await expect(client.initialize()).rejects.toThrow("Server startup failed"); + expect(client.getState()).toBe("error"); + }); + + it("callTool rejects when not connected", async () => { + const conn = makeMockConnection(); + const client = new McpClient({ connection: conn }); + + await expect(client.callTool("test", {})).rejects.toThrow("Client not connected"); + }); + + it("listTools rejects when not connected", async () => { + const conn = makeMockConnection(); + const client = new McpClient({ connection: conn }); + + await expect(client.listTools()).rejects.toThrow("Client not connected"); + }); + + it("close sets state to disconnected", async () => { + const conn = makeMockConnection(); + const client = new McpClient({ connection: conn }); + + await client.initialize(); + expect(client.getState()).toBe("connected"); + + client.close(); + expect(client.getState()).toBe("disconnected"); + }); + + it("callTool with abort signal", async () => { + const conn = makeMockConnection(); + let resolveRequest: ((v: unknown) => void) | null = null; + conn.send = (method: string) => { + if (method === "tools/call") { + return new Promise((resolve) => { + resolveRequest = resolve; + }); + } + if (method === "initialize") { + return Promise.resolve({ + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test", version: "1.0.0" }, + }); + } + return Promise.resolve({}); + }; + + const client = new McpClient({ connection: conn }); + await client.initialize(); + + const controller = new AbortController(); + const callPromise = client.callTool("test", {}, controller.signal); + + controller.abort(); + + await expect(callPromise).rejects.toThrow("Aborted"); + + // Clean up + resolveRequest?.({ + content: [{ type: "text", text: "too late" }], + }); + }); + + /** A connection whose initialize never responds (simulates a framing- + * incompatible server like chrome-devtools-mcp under Content-Length framing): + * the pending JSON-RPC request would hang forever without a timeout/abort. */ + function makeHangingConnection(): Connection { + const never = new Promise<unknown>(() => {}); + return { + send: () => never, + notify: () => {}, + onNotification: () => {}, + close: () => {}, + pid: 1, + }; + } + + it("initialize raises McpTimeoutError when the server never responds", async () => { + const { McpTimeoutError } = await import("./timeout.js"); + const client = new McpClient({ connection: makeHangingConnection() }); + await expect(client.initialize(undefined, 20)).rejects.toBeInstanceOf(McpTimeoutError); + expect(client.getState()).toBe("error"); + }); + + it("initialize is abortable: an aborting signal rejects immediately", async () => { + const client = new McpClient({ connection: makeHangingConnection() }); + const controller = new AbortController(); + const p = client.initialize(controller.signal, 50_000); + controller.abort(); + await expect(p).rejects.toThrow("Aborted"); + expect(client.getState()).toBe("error"); + }); + + it("initialize rejects immediately when the signal is already aborted", async () => { + const client = new McpClient({ connection: makeHangingConnection() }); + const controller = new AbortController(); + controller.abort(); + await expect(client.initialize(controller.signal, 50_000)).rejects.toThrow("Aborted"); + }); + + it("listTools raises McpTimeoutError when the server never responds", async () => { + const { McpTimeoutError } = await import("./timeout.js"); + // Reach "connected" with a fast (auto-responding) connection, then swap in + // a hanging connection for the tools/list call. + const fast = makeMockConnection(); + const client = new McpClient({ connection: fast }); + await client.initialize(); + + const hanging = makeHangingConnection(); + // Swap the connection so tools/list hangs. + (client as unknown as { connection: Connection }).connection = hanging; + + await expect(client.listTools(undefined, 20)).rejects.toBeInstanceOf(McpTimeoutError); + }); + + it("listTools is abortable", async () => { + const fast = makeMockConnection(); + const client = new McpClient({ connection: fast }); + await client.initialize(); + + const hanging = makeHangingConnection(); + (client as unknown as { connection: Connection }).connection = hanging; + + const controller = new AbortController(); + const p = client.listTools(controller.signal, 50_000); + controller.abort(); + await expect(p).rejects.toThrow("Aborted"); + }); }); |
