diff options
Diffstat (limited to 'packages/mcp/src/manager.test.ts')
| -rw-r--r-- | packages/mcp/src/manager.test.ts | 417 |
1 files changed, 221 insertions, 196 deletions
diff --git a/packages/mcp/src/manager.test.ts b/packages/mcp/src/manager.test.ts index 275b078..d6179b7 100644 --- a/packages/mcp/src/manager.test.ts +++ b/packages/mcp/src/manager.test.ts @@ -4,213 +4,238 @@ import type { Connection } from "./transport.js"; import type { ResolvedMcpServer } from "./types.js"; function makeMockConnection(): Connection { - return { - send: async (method: string) => { - if (method === "initialize") { - return { - protocolVersion: "2025-11-25", - capabilities: { tools: { listChanged: false } }, - serverInfo: { name: "test", version: "1.0.0" }, - }; - } - if (method === "tools/list") { - return { - tools: [ - { - name: "tool_a", - description: "Tool A", - inputSchema: { type: "object" }, - }, - ], - }; - } - return {}; - }, - notify: () => {}, - onNotification: () => {}, - close: () => {}, - pid: 100, - }; + return { + send: async (method: string) => { + if (method === "initialize") { + return { + protocolVersion: "2025-11-25", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "test", version: "1.0.0" }, + }; + } + if (method === "tools/list") { + return { + tools: [ + { + name: "tool_a", + description: "Tool A", + inputSchema: { type: "object" }, + }, + ], + }; + } + return {}; + }, + notify: () => {}, + onNotification: () => {}, + close: () => {}, + pid: 100, + }; } function makeBrokenConnection(): Connection { - return { - send: async () => { - throw new Error("Connection refused"); - }, - notify: () => {}, - onNotification: () => {}, - close: () => {}, - pid: 101, - }; + return { + send: async () => { + throw new Error("Connection refused"); + }, + notify: () => {}, + onNotification: () => {}, + close: () => {}, + pid: 101, + }; } function makeManager( - connectionFactory: (_server: ResolvedMcpServer) => { - connection: Connection; - promise: Promise<void>; - }, + connectionFactory: (_server: ResolvedMcpServer) => { + connection: Connection; + promise: Promise<void>; + }, ): McpManager { - const currentTime = 1000; - const deps: McpManagerDeps = { - spawn: () => ({ - stdin: { write: () => {} }, - stdout: { on: () => {} }, - pid: 1, - kill: () => {}, - }), - now: () => currentTime, - }; - - const factory = (_server: ResolvedMcpServer, _cwd: string) => connectionFactory(_server); - - const manager = new McpManager(deps, factory); - return manager; + const currentTime = 1000; + const deps: McpManagerDeps = { + spawn: () => ({ + stdin: { write: () => {} }, + stdout: { on: () => {} }, + pid: 1, + kill: () => {}, + }), + now: () => currentTime, + }; + + const factory = (_server: ResolvedMcpServer, _cwd: string) => connectionFactory(_server); + + const manager = new McpManager(deps, factory); + return manager; } const testServer: ResolvedMcpServer = { - id: "test-server", - command: ["test-cmd"], - configSource: ".dispatch/mcp.json", + id: "test-server", + command: ["test-cmd"], + configSource: ".dispatch/mcp.json", }; describe("McpManager", () => { - it("lazy-spawn on first access", async () => { - const conn = makeMockConnection(); - let spawnCount = 0; - const manager = makeManager((_server) => { - spawnCount++; - return { connection: conn, promise: Promise.resolve() }; - }); - - const client = await manager.ensureConnected(testServer, "/tmp"); - expect(client).toBeDefined(); - expect(spawnCount).toBe(1); - }); - - it("reuses existing client on second access", async () => { - const conn = makeMockConnection(); - let spawnCount = 0; - const manager = makeManager(() => { - spawnCount++; - return { connection: conn, promise: Promise.resolve() }; - }); - - const client1 = await manager.ensureConnected(testServer, "/tmp"); - const client2 = await manager.ensureConnected(testServer, "/tmp"); - expect(client1).toBe(client2); - expect(spawnCount).toBe(1); - }); - - it("status returns server states", async () => { - const conn = makeMockConnection(); - const manager = makeManager(() => { - return { connection: conn, promise: Promise.resolve() }; - }); - - // Before connecting - let statuses = manager.status([testServer]); - expect(statuses.length).toBe(1); - expect(statuses[0].state).toBe("disconnected"); - - // After connecting - await manager.ensureConnected(testServer, "/tmp"); - statuses = manager.status([testServer]); - expect(statuses.length).toBe(1); - expect(statuses[0].state).toBe("connected"); - expect(statuses[0].toolCount).toBe(1); - }); - - it("shutdownAll kills all clients", async () => { - let closed = false; - const conn: Connection = { - send: async (method: string) => { - if (method === "initialize") { - return { - protocolVersion: "2025-11-25", - capabilities: {}, - serverInfo: { name: "test", version: "1.0.0" }, - }; - } - if (method === "tools/list") return { tools: [] }; - return {}; - }, - notify: () => {}, - onNotification: () => {}, - close: () => { - closed = true; - }, - pid: 200, - }; - - const manager = makeManager(() => { - return { connection: conn, promise: Promise.resolve() }; - }); - - await manager.ensureConnected(testServer, "/tmp"); - manager.shutdownAll(); - - expect(closed).toBe(true); - const statuses = manager.status([testServer]); - expect(statuses[0].state).toBe("disconnected"); - }); - - it("broken server reports error state", async () => { - const manager = makeManager(() => { - return { connection: makeBrokenConnection(), promise: Promise.resolve() }; - }); - - await expect(manager.ensureConnected(testServer, "/tmp")).rejects.toThrow(); - - const statuses = manager.status([testServer]); - expect(statuses[0].state).toBe("error"); - expect(statuses[0].error).toContain("test-server"); - }); - - it("broken server retries after backoff", async () => { - let currentTime = 1000; - const brokenConn = makeBrokenConnection(); - const goodConn = makeMockConnection(); - let useGood = false; - - const deps: McpManagerDeps = { - spawn: () => ({ - stdin: { write: () => {} }, - stdout: { on: () => {} }, - pid: 1, - kill: () => {}, - }), - now: () => currentTime, - }; - - const factory = (_server: ResolvedMcpServer, _cwd: string) => { - const conn = useGood ? goodConn : brokenConn; - return { connection: conn, promise: Promise.resolve() }; - }; - - const manager = new McpManager(deps, factory); - - // First attempt fails - await expect(manager.ensureConnected(testServer, "/tmp")).rejects.toThrow(); - expect(manager.status([testServer])[0].state).toBe("error"); - - // Not enough time passed — still broken - currentTime += 29_000; - expect(manager.status([testServer])[0].state).toBe("error"); - - // After backoff — should allow retry - useGood = true; - currentTime += 2_000; - const statuses = manager.status([testServer]); - // After backoff, status() clears the broken entry - expect(statuses[0].state).toBe("disconnected"); - }); - - it("getClient returns undefined for unknown server", () => { - const manager = makeManager(() => { - return { connection: makeMockConnection(), promise: Promise.resolve() }; - }); - - expect(manager.getClient("nonexistent")).toBeUndefined(); - }); + it("lazy-spawn on first access", async () => { + const conn = makeMockConnection(); + let spawnCount = 0; + const manager = makeManager((_server) => { + spawnCount++; + return { connection: conn, promise: Promise.resolve() }; + }); + + const client = await manager.ensureConnected(testServer, "/tmp"); + expect(client).toBeDefined(); + expect(spawnCount).toBe(1); + }); + + it("reuses existing client on second access", async () => { + const conn = makeMockConnection(); + let spawnCount = 0; + const manager = makeManager(() => { + spawnCount++; + return { connection: conn, promise: Promise.resolve() }; + }); + + const client1 = await manager.ensureConnected(testServer, "/tmp"); + const client2 = await manager.ensureConnected(testServer, "/tmp"); + expect(client1).toBe(client2); + expect(spawnCount).toBe(1); + }); + + it("status returns server states", async () => { + const conn = makeMockConnection(); + const manager = makeManager(() => { + return { connection: conn, promise: Promise.resolve() }; + }); + + // Before connecting + let statuses = manager.status([testServer]); + expect(statuses.length).toBe(1); + expect(statuses[0].state).toBe("disconnected"); + + // After connecting + await manager.ensureConnected(testServer, "/tmp"); + statuses = manager.status([testServer]); + expect(statuses.length).toBe(1); + expect(statuses[0].state).toBe("connected"); + expect(statuses[0].toolCount).toBe(1); + }); + + it("shutdownAll kills all clients", async () => { + let closed = false; + const conn: Connection = { + send: async (method: string) => { + if (method === "initialize") { + return { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test", version: "1.0.0" }, + }; + } + if (method === "tools/list") return { tools: [] }; + return {}; + }, + notify: () => {}, + onNotification: () => {}, + close: () => { + closed = true; + }, + pid: 200, + }; + + const manager = makeManager(() => { + return { connection: conn, promise: Promise.resolve() }; + }); + + await manager.ensureConnected(testServer, "/tmp"); + manager.shutdownAll(); + + expect(closed).toBe(true); + const statuses = manager.status([testServer]); + expect(statuses[0].state).toBe("disconnected"); + }); + + it("broken server reports error state", async () => { + const manager = makeManager(() => { + return { connection: makeBrokenConnection(), promise: Promise.resolve() }; + }); + + await expect(manager.ensureConnected(testServer, "/tmp")).rejects.toThrow(); + + const statuses = manager.status([testServer]); + expect(statuses[0].state).toBe("error"); + expect(statuses[0].error).toContain("test-server"); + }); + + it("broken server retries after backoff", async () => { + let currentTime = 1000; + const brokenConn = makeBrokenConnection(); + const goodConn = makeMockConnection(); + let useGood = false; + + const deps: McpManagerDeps = { + spawn: () => ({ + stdin: { write: () => {} }, + stdout: { on: () => {} }, + pid: 1, + kill: () => {}, + }), + now: () => currentTime, + }; + + const factory = (_server: ResolvedMcpServer, _cwd: string) => { + const conn = useGood ? goodConn : brokenConn; + return { connection: conn, promise: Promise.resolve() }; + }; + + const manager = new McpManager(deps, factory); + + // First attempt fails + await expect(manager.ensureConnected(testServer, "/tmp")).rejects.toThrow(); + expect(manager.status([testServer])[0].state).toBe("error"); + + // Not enough time passed — still broken + currentTime += 29_000; + expect(manager.status([testServer])[0].state).toBe("error"); + + // After backoff — should allow retry + useGood = true; + currentTime += 2_000; + const statuses = manager.status([testServer]); + // After backoff, status() clears the broken entry + expect(statuses[0].state).toBe("disconnected"); + }); + + it("getClient returns undefined for unknown server", () => { + const manager = makeManager(() => { + return { connection: makeMockConnection(), promise: Promise.resolve() }; + }); + + expect(manager.getClient("nonexistent")).toBeUndefined(); + }); + + it("forwards the abort signal: a hanging initialize is interrupted", async () => { + // A connection whose initialize never resolves (a misbehaving / + // framing-incompatible server). Without a signal this hangs forever. + const hangingConn: Connection = { + send: () => new Promise(() => {}), + notify: () => {}, + onNotification: () => {}, + close: () => {}, + pid: 300, + }; + const manager = makeManager(() => { + return { connection: hangingConn, promise: Promise.resolve() }; + }); + + const controller = new AbortController(); + const connectPromise = manager.ensureConnected(testServer, "/tmp", controller.signal); + + // Abort mid-connect — the signal must reach initialize() and break it. + controller.abort(); + + await expect(connectPromise).rejects.toThrow(); + // The server is recorded as broken (not silently wedged). + expect(manager.status([testServer])[0].state).toBe("error"); + }); }); |
