diff --git a/docs/src/content/docs/agents/mcp-tool-provider.mdx b/docs/src/content/docs/agents/mcp-tool-provider.mdx index 816ddbfd..aec3dc22 100644 --- a/docs/src/content/docs/agents/mcp-tool-provider.mdx +++ b/docs/src/content/docs/agents/mcp-tool-provider.mdx @@ -22,9 +22,18 @@ The async factory pattern is required because the agent needs tool definitions s ```bash npm install agent-squad +npm install @modelcontextprotocol/client # MCP SDK v2 — recommended +``` +Or the v1 SDK, if you only talk to servers on protocol 2025-11-25 or older: +```bash npm install @modelcontextprotocol/sdk ``` -`@modelcontextprotocol/sdk` is a peer dependency — it is never installed automatically, only when you explicitly add it. +Both are optional peer dependencies — never installed automatically. When both are present, `MCPToolProvider` uses v2. + +| Installed package | MCP protocol versions | Notes | +|---|---|---| +| `@modelcontextprotocol/client` >= 2.0.0 | 2026-07-28 **and** all legacy versions | auto-negotiated per server (`server/discover` probe with `initialize` fallback); Node >= 20 | +| `@modelcontextprotocol/sdk` >= 1.0.0 | 2025-11-25 and older | the v1 package will never support 2026-07-28 | ```bash @@ -139,7 +148,7 @@ const provider = await MCPToolProvider.create([ }, ]); ``` -Requires `@modelcontextprotocol/sdk` >= 1.10. +Requires `@modelcontextprotocol/client` (any version) or `@modelcontextprotocol/sdk` >= 1.10. ```python diff --git a/typescript/package.json b/typescript/package.json index d76bee85..73099c7d 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -48,12 +48,16 @@ }, "peerDependencies": { "@dakera-ai/dakera": "^0.11.100", + "@modelcontextprotocol/client": ">=2.0.0", "@modelcontextprotocol/sdk": ">=1.0.0" }, "peerDependenciesMeta": { "@dakera-ai/dakera": { "optional": true }, + "@modelcontextprotocol/client": { + "optional": true + }, "@modelcontextprotocol/sdk": { "optional": true } diff --git a/typescript/src/tools/mcpToolProvider.ts b/typescript/src/tools/mcpToolProvider.ts index db96d012..8830e15f 100644 --- a/typescript/src/tools/mcpToolProvider.ts +++ b/typescript/src/tools/mcpToolProvider.ts @@ -65,7 +65,13 @@ export interface MCPServerConfig { * await provider.disconnect(); * ``` * - * The `@modelcontextprotocol/sdk` package must be installed separately: + * An MCP SDK must be installed separately — either the v2 client package + * (speaks protocol 2026-07-28 and every older server via auto negotiation; + * preferred when both are installed): + * ``` + * npm install @modelcontextprotocol/client + * ``` + * or the v1 SDK (older servers only): * ``` * npm install @modelcontextprotocol/sdk * ``` @@ -123,40 +129,64 @@ export class MCPToolProvider extends AgentTools { let ClientClass: any; let StdioClientTransport: any; let SSEClientTransport: any; + let StreamableHTTPClientTransport: any; + // v2 (@modelcontextprotocol/client) speaks both the 2026-07-28 stateless + // protocol and the legacy handshake; preferred over v1 when installed. + let usingV2 = false; try { // @ts-ignore — optional peerDependency; not available in type-checking until installed - const clientMod = await import("@modelcontextprotocol/sdk/client/index.js"); - ClientClass = clientMod.Client; + const v2Mod = await import("@modelcontextprotocol/client"); + ClientClass = v2Mod.Client; + SSEClientTransport = v2Mod.SSEClientTransport; + StreamableHTTPClientTransport = v2Mod.StreamableHTTPClientTransport; + usingV2 = true; + try { + // @ts-ignore — optional peerDependency; Node-only subpath + const v2StdioMod = await import("@modelcontextprotocol/client/stdio"); + StdioClientTransport = v2StdioMod.StdioClientTransport; + } catch { + StdioClientTransport = null; + } } catch { - throw new Error( - "Install @modelcontextprotocol/sdk to use MCPToolProvider: npm install @modelcontextprotocol/sdk" - ); + usingV2 = false; } - try { - // @ts-ignore — optional peerDependency - const stdioMod = await import("@modelcontextprotocol/sdk/client/stdio.js"); - StdioClientTransport = stdioMod.StdioClientTransport; - } catch { - StdioClientTransport = null; - } + if (!usingV2) { + try { + // @ts-ignore — optional peerDependency + const clientMod = await import("@modelcontextprotocol/sdk/client/index.js"); + ClientClass = clientMod.Client; + } catch { + throw new Error( + "Install an MCP SDK to use MCPToolProvider: npm install @modelcontextprotocol/client " + + "(supports protocol 2026-07-28 and older servers) or npm install @modelcontextprotocol/sdk (older servers only)" + ); + } - try { - // @ts-ignore — optional peerDependency - const sseMod = await import("@modelcontextprotocol/sdk/client/sse.js"); - SSEClientTransport = sseMod.SSEClientTransport; - } catch { - SSEClientTransport = null; - } + try { + // @ts-ignore — optional peerDependency + const stdioMod = await import("@modelcontextprotocol/sdk/client/stdio.js"); + StdioClientTransport = stdioMod.StdioClientTransport; + } catch { + StdioClientTransport = null; + } - let StreamableHTTPClientTransport: any; - try { - // @ts-ignore — optional peerDependency; module exists since SDK ~1.10 - const streamableMod = await import("@modelcontextprotocol/sdk/client/streamableHttp.js"); - StreamableHTTPClientTransport = streamableMod.StreamableHTTPClientTransport; - } catch { - StreamableHTTPClientTransport = null; + try { + // @ts-ignore — optional peerDependency + const sseMod = await import("@modelcontextprotocol/sdk/client/sse.js"); + SSEClientTransport = sseMod.SSEClientTransport; + } catch { + SSEClientTransport = null; + } + + try { + // @ts-ignore — optional peerDependency; module exists since SDK ~1.10 + const streamableMod = await import("@modelcontextprotocol/sdk/client/streamableHttp.js"); + StreamableHTTPClientTransport = streamableMod.StreamableHTTPClientTransport; + } catch { + StreamableHTTPClientTransport = null; + } } const allTools: AgentTool[] = []; @@ -167,7 +197,8 @@ export class MCPToolProvider extends AgentTools { if (serverConfig.type === "stdio") { if (!StdioClientTransport) { throw new Error( - "StdioClientTransport not available — check your @modelcontextprotocol/sdk installation" + "StdioClientTransport not available — check your MCP SDK installation " + + `(${usingV2 ? "@modelcontextprotocol/client" : "@modelcontextprotocol/sdk"})` ); } if (!serverConfig.command) { @@ -183,7 +214,8 @@ export class MCPToolProvider extends AgentTools { } else if (serverConfig.type === "sse") { if (!SSEClientTransport) { throw new Error( - "SSEClientTransport not available — check your @modelcontextprotocol/sdk installation" + "SSEClientTransport not available — check your MCP SDK installation " + + `(${usingV2 ? "@modelcontextprotocol/client" : "@modelcontextprotocol/sdk"})` ); } if (!serverConfig.url) { @@ -191,13 +223,24 @@ export class MCPToolProvider extends AgentTools { "MCPServerConfig with type 'sse' requires a 'url' field" ); } - transport = new SSEClientTransport(new URL(serverConfig.url), { - headers: serverConfig.headers ?? {}, - }); + // v2 has no top-level `headers` option; POST-channel headers go via + // requestInit (GET-stream headers tracked in #640). + transport = usingV2 + ? new SSEClientTransport( + new URL(serverConfig.url), + serverConfig.headers + ? { requestInit: { headers: serverConfig.headers } } + : undefined + ) + : new SSEClientTransport(new URL(serverConfig.url), { + headers: serverConfig.headers ?? {}, + }); } else if (serverConfig.type === "streamable-http") { if (!StreamableHTTPClientTransport) { throw new Error( - "StreamableHTTPClientTransport not available — upgrade @modelcontextprotocol/sdk (requires >=1.10)" + usingV2 + ? "StreamableHTTPClientTransport not available — check your @modelcontextprotocol/client installation" + : "StreamableHTTPClientTransport not available — upgrade @modelcontextprotocol/sdk (requires >=1.10)" ); } if (!serverConfig.url) { @@ -217,10 +260,17 @@ export class MCPToolProvider extends AgentTools { ); } - const client = new ClientClass( - { name: "agent-squad-mcp-client", version: "1.0.0" }, - { capabilities: {} } - ); + // mode "auto" probes server/discover (protocol 2026-07-28) and falls back + // to the legacy initialize handshake, so both server eras work. + const client = usingV2 + ? new ClientClass( + { name: "agent-squad-mcp-client", version: "1.0.0" }, + { capabilities: {}, versionNegotiation: { mode: "auto" } } + ) + : new ClientClass( + { name: "agent-squad-mcp-client", version: "1.0.0" }, + { capabilities: {} } + ); await client.connect(transport); this.clients.push(client); diff --git a/typescript/tests/mcpToolProviderV2.test.ts b/typescript/tests/mcpToolProviderV2.test.ts new file mode 100644 index 00000000..f9762b3d --- /dev/null +++ b/typescript/tests/mcpToolProviderV2.test.ts @@ -0,0 +1,234 @@ +/** + * Unit tests for MCPToolProvider on the v2 MCP SDK (@modelcontextprotocol/client). + * + * The v2 package is virtually mocked; when present it must be preferred over v1 + * and the client must be constructed with versionNegotiation mode "auto" so both + * 2026-07-28 and legacy servers work. The v1 suite (mcpToolProvider.test.ts) does + * NOT mock the v2 package, which proves the v1 fallback path stays intact. + */ + +const mockCallTool = jest.fn(); +const mockListTools = jest.fn(); +const mockConnect = jest.fn(); +const mockClose = jest.fn(); +const mockReadResource = jest.fn(); + +const clientInstances: MockV2Client[] = []; +class MockV2Client { + connect = mockConnect; + listTools = mockListTools; + callTool = mockCallTool; + close = mockClose; + readResource = mockReadResource; + constructor(public info: any, public options: any) { + clientInstances.push(this); + } +} + +class MockV2StdioTransport { + constructor(public opts: any) {} +} + +const sseInstances: MockV2SSETransport[] = []; +class MockV2SSETransport { + constructor(public url: any, public opts: any) { + sseInstances.push(this); + } +} + +const streamableInstances: MockV2StreamableTransport[] = []; +class MockV2StreamableTransport { + constructor(public url: any, public opts: any) { + streamableInstances.push(this); + } +} + +// v1 mock: a sentinel that must never be reached while v2 is installed +const v1ClientConstructed = jest.fn(); +class MockV1Client { + constructor() { + v1ClientConstructed(); + } +} + +jest.mock( + "@modelcontextprotocol/client", + () => ({ + Client: MockV2Client, + SSEClientTransport: MockV2SSETransport, + StreamableHTTPClientTransport: MockV2StreamableTransport, + }), + { virtual: true } +); +jest.mock( + "@modelcontextprotocol/client/stdio", + () => ({ StdioClientTransport: MockV2StdioTransport }), + { virtual: true } +); +jest.mock( + "@modelcontextprotocol/sdk/client/index.js", + () => ({ Client: MockV1Client }), + { virtual: true } +); + +import { MCPToolProvider } from "../src/tools/mcpToolProvider"; + +const weatherTool = { + name: "get_weather", + description: "Returns weather for a location", + inputSchema: { + type: "object", + properties: { location: { type: "string" } }, + required: ["location"], + }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + clientInstances.length = 0; + sseInstances.length = 0; + streamableInstances.length = 0; + mockListTools.mockResolvedValue({ tools: [weatherTool] }); + mockConnect.mockResolvedValue(undefined); +}); + +describe("MCPToolProvider on the v2 SDK", () => { + it("prefers v2 over v1 and never constructs the v1 client", async () => { + const provider = new MCPToolProvider([ + { type: "stdio", command: "uvx", args: ["my-server"] }, + ]); + + await provider.ensureConnected(); + + expect(clientInstances).toHaveLength(1); + expect(v1ClientConstructed).not.toHaveBeenCalled(); + }); + + it("constructs the v2 client with versionNegotiation mode auto", async () => { + const provider = new MCPToolProvider([ + { type: "stdio", command: "uvx", args: ["my-server"] }, + ]); + + await provider.ensureConnected(); + + expect(clientInstances[0].info).toEqual({ + name: "agent-squad-mcp-client", + version: "1.0.0", + }); + expect(clientInstances[0].options).toEqual({ + capabilities: {}, + versionNegotiation: { mode: "auto" }, + }); + }); + + it("uses the v2 stdio transport from the /stdio subpath", async () => { + const provider = new MCPToolProvider([ + { + type: "stdio", + command: "uvx", + args: ["my-server"], + env: { API_KEY: "k" }, + }, + ]); + + await provider.ensureConnected(); + + expect(mockConnect).toHaveBeenCalledWith(expect.any(MockV2StdioTransport)); + const transport = mockConnect.mock.calls[0][0] as MockV2StdioTransport; + expect(transport.opts).toEqual({ + command: "uvx", + args: ["my-server"], + env: { API_KEY: "k" }, + }); + }); + + it("constructs the v2 streamable-http transport with requestInit headers", async () => { + const provider = new MCPToolProvider([ + { + type: "streamable-http", + url: "http://localhost:9000/mcp", + headers: { "x-api-key": "abc" }, + }, + ]); + + await provider.ensureConnected(); + + expect(streamableInstances).toHaveLength(1); + expect(streamableInstances[0].url.href).toBe("http://localhost:9000/mcp"); + expect(streamableInstances[0].opts).toEqual({ + requestInit: { headers: { "x-api-key": "abc" } }, + }); + }); + + it("constructs the v2 sse transport with requestInit headers (no dead headers key)", async () => { + const provider = new MCPToolProvider([ + { + type: "sse", + url: "http://localhost:9000/sse", + headers: { "x-api-key": "abc" }, + }, + ]); + + await provider.ensureConnected(); + + expect(sseInstances).toHaveLength(1); + expect(sseInstances[0].opts).toEqual({ + requestInit: { headers: { "x-api-key": "abc" } }, + }); + }); + + it("passes no options to the v2 sse transport when no headers are configured", async () => { + const provider = new MCPToolProvider([ + { type: "sse", url: "http://localhost:9000/sse" }, + ]); + + await provider.ensureConnected(); + + expect(sseInstances).toHaveLength(1); + expect(sseInstances[0].opts).toBeUndefined(); + }); + + it("lists and calls tools through the v2 client", async () => { + mockCallTool.mockResolvedValue({ + content: [{ type: "text", text: "sunny" }], + isError: false, + }); + + const provider = await MCPToolProvider.create([ + { type: "stdio", command: "uvx", args: ["my-server"] }, + ]); + + const formats = await provider.toBedrockFormat(); + expect(formats).toHaveLength(1); + expect(formats[0].toolSpec.name).toBe("get_weather"); + + const response = { + role: "assistant", + content: [ + { toolUse: { name: "get_weather", toolUseId: "1", input: { location: "Paris" } } }, + ], + }; + await provider.toolHandler( + response, + (b: any) => b.toolUse ?? null, + (b: any) => b.name, + (b: any) => b.toolUseId, + (b: any) => b.input + ); + + expect(mockCallTool).toHaveBeenCalledWith({ + name: "get_weather", + arguments: { location: "Paris" }, + }); + }); + + it("disconnect closes the v2 client", async () => { + const provider = await MCPToolProvider.create([ + { type: "stdio", command: "uvx", args: ["my-server"] }, + ]); + + await provider.disconnect(); + + expect(mockClose).toHaveBeenCalled(); + }); +}); diff --git a/typescript/tests/mcpToolProviderV2Partial.test.ts b/typescript/tests/mcpToolProviderV2Partial.test.ts new file mode 100644 index 00000000..fdc132ec --- /dev/null +++ b/typescript/tests/mcpToolProviderV2Partial.test.ts @@ -0,0 +1,49 @@ +/** + * Edge case: the v2 root package resolves but its Node-only /stdio subpath does + * not (e.g. a bundled/browser-ish environment). stdio configs must fail with an + * error naming the v2 package, and HTTP transports must keep working. + */ + +class MockV2Client { + connect = jest.fn().mockResolvedValue(undefined); + listTools = jest.fn().mockResolvedValue({ tools: [] }); + close = jest.fn(); +} + +class MockV2StreamableTransport { + constructor(public url: any, public opts: any) {} +} + +// Only the root subpath is mocked — "@modelcontextprotocol/client/stdio" stays +// unresolvable, so StdioClientTransport ends up null while usingV2 is true. +jest.mock( + "@modelcontextprotocol/client", + () => ({ + Client: MockV2Client, + SSEClientTransport: class {}, + StreamableHTTPClientTransport: MockV2StreamableTransport, + }), + { virtual: true } +); + +import { MCPToolProvider } from "../src/tools/mcpToolProvider"; + +describe("MCPToolProvider with v2 root but no /stdio subpath", () => { + it("fails stdio configs with an error naming @modelcontextprotocol/client", async () => { + const provider = new MCPToolProvider([ + { type: "stdio", command: "uvx", args: ["my-server"] }, + ]); + + await expect(provider.ensureConnected()).rejects.toThrow( + "@modelcontextprotocol/client" + ); + }); + + it("still connects streamable-http configs", async () => { + const provider = new MCPToolProvider([ + { type: "streamable-http", url: "http://localhost:9000/mcp" }, + ]); + + await provider.ensureConnected(); + }); +});