-
Notifications
You must be signed in to change notification settings - Fork 35
feat: support StreamableHTTPClientTransport #64
Changes from 11 commits
0ca7648
b265abf
07ff66b
e88ef0c
05f4b67
ee26a0f
c193c20
b33a2de
0f42f28
ce96b23
84f0f70
972158c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,4 +5,5 @@ index.d.cts | |
| node_modules | ||
| dist | ||
| .yarn | ||
| .env | ||
| .env | ||
| .eslintcache | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,8 @@ This library provides a lightweight wrapper that makes [Anthropic Model Context | |
|
|
||
| - 🔌 **Transport Options** | ||
|
|
||
| - Connect to MCP servers via stdio (local) or SSE (remote) | ||
| - Connect to MCP servers via stdio (local) or Streamable HTTP (remote) | ||
| - Streamable HTTP automatically falls back to SSE for compatibility with legacy MCP server implementations | ||
| - Support for custom headers in SSE connections for authentication | ||
| - Configurable reconnection strategies for both transport types | ||
|
|
||
|
|
@@ -38,13 +39,13 @@ npm install @langchain/mcp-adapters | |
|
|
||
| ### Optional Dependencies | ||
|
|
||
| For SSE connections with custom headers in Node.js: | ||
| For SSE connections with custom headers in Node.js (does not apply to Streamable HTTP): | ||
|
|
||
| ```bash | ||
| npm install eventsource | ||
| ``` | ||
|
|
||
| For enhanced SSE header support: | ||
| For enhanced SSE header support (does not apply to Streamable HTTP): | ||
|
|
||
| ```bash | ||
| npm install extended-eventsource | ||
|
|
@@ -155,14 +156,19 @@ const client = new MultiServerMCPClient({ | |
| args: ["-y", "@modelcontextprotocol/server-filesystem"], | ||
| }, | ||
|
|
||
| // SSE transport example with reconnection configuration | ||
| // Sreamable HTTP transport example, with auth headers and automatic SSE fallback disabled (defaults to enabled) | ||
| weather: { | ||
| transport: "sse", | ||
| url: "https://example.com/mcp-weather", | ||
| url: "https://example.com/weather/mcp", | ||
| headers: { | ||
| Authorization: "Bearer token123", | ||
| }, | ||
| useNodeEventSource: true, | ||
| } | ||
| automaticSSEFallback: false | ||
| }, | ||
|
|
||
| // how to force SSE, for old servers that are known to only support SSE (streamable HTTP falls back automatically if unsure) | ||
| github: { | ||
| transport: "sse", // also works with "type" field instead of "transport" | ||
| url: "https://example.com/mcp", | ||
| reconnect: { | ||
| enabled: true, | ||
| maxAttempts: 5, | ||
|
|
@@ -212,8 +218,8 @@ When loading MCP tools either directly through `loadMcpTools` or via `MultiServe | |
| | Option | Type | Default | Description | | ||
| | ------------------------------ | ------- | ------- | ------------------------------------------------------------------------------------ | | ||
| | `throwOnLoadError` | boolean | `true` | Whether to throw an error if a tool fails to load | | ||
| | `prefixToolNameWithServerName` | boolean | `false` | If true, prefixes all tool names with the server name (e.g., `serverName__toolName`) | | ||
| | `additionalToolNamePrefix` | string | `""` | Additional prefix to add to tool names (e.g., `prefix__serverName__toolName`) | | ||
| | `prefixToolNameWithServerName` | boolean | `true` | If true, prefixes all tool names with the server name (e.g., `serverName__toolName`) | | ||
| | `additionalToolNamePrefix` | string | `mcp` | Additional prefix to add to tool names (e.g., `prefix__serverName__toolName`) | | ||
|
Comment on lines
+221
to
+222
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These defaults weren't actually changed in this PR (see checks in tests to verify) - just updated in README and config schema to reflect the actual defaults |
||
|
|
||
| ## Response Handling | ||
|
|
||
|
|
@@ -361,8 +367,8 @@ Example Zod error for an invalid SSE URL: | |
|
|
||
| When using in browsers: | ||
|
|
||
| - Native EventSource API doesn't support custom headers | ||
| - Consider using a proxy or pass authentication via query parameters | ||
| - EventSource API doesn't support custom headers for SSE | ||
| - Consider using a proxy or pass authentication via query parameters to avoid leaking credentials to client | ||
| - May require CORS configuration on the server side | ||
|
|
||
| ## Troubleshooting | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,9 @@ const { StdioClientTransport } = await import( | |
| const { SSEClientTransport } = await import( | ||
| "@modelcontextprotocol/sdk/client/sse.js" | ||
| ); | ||
| const { StreamableHTTPClientTransport } = await import( | ||
| "@modelcontextprotocol/sdk/client/streamableHttp.js" | ||
| ); | ||
|
|
||
| describe("MultiServerMCPClient", () => { | ||
| // Setup and teardown | ||
|
|
@@ -63,6 +66,17 @@ describe("MultiServerMCPClient", () => { | |
| // Additional assertions to verify the connection was processed correctly | ||
| }); | ||
|
|
||
| test("should process valid streamable HTTP connection config", () => { | ||
| const client = new MultiServerMCPClient({ | ||
| "test-server": { | ||
| transport: "streamable", | ||
| url: "http://localhost:8000/mcp", | ||
| }, | ||
| }); | ||
| expect(client).toBeDefined(); | ||
| // Additional assertions to verify the connection was processed correctly | ||
| }); | ||
|
|
||
| test("should have a compile time error and a runtime error when the config is invalid", () => { | ||
| expect(() => { | ||
| // eslint-disable-next-line no-new | ||
|
|
@@ -93,6 +107,7 @@ describe("MultiServerMCPClient", () => { | |
| command: "python", | ||
| args: ["./script.py"], | ||
| env: undefined, | ||
| stderr: "inherit", | ||
| }); | ||
|
|
||
| expect(Client).toHaveBeenCalled(); | ||
|
|
@@ -116,6 +131,24 @@ describe("MultiServerMCPClient", () => { | |
| expect(Client.prototype.listTools).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test("should initialize streamable HTTP connections correctly", async () => { | ||
| const client = new MultiServerMCPClient({ | ||
| "test-server": { | ||
| transport: "streamable", | ||
| url: "http://localhost:8000/mcp", | ||
| }, | ||
| }); | ||
|
|
||
| await client.initializeConnections(); | ||
|
|
||
| expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( | ||
| new URL("http://localhost:8000/mcp") | ||
| ); | ||
| expect(Client).toHaveBeenCalled(); | ||
| expect(Client.prototype.connect).toHaveBeenCalled(); | ||
| expect(Client.prototype.listTools).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test("should throw on connection failure", async () => { | ||
| (Client as Mock).mockImplementationOnce(() => ({ | ||
| connect: vi | ||
|
|
@@ -307,6 +340,10 @@ describe("MultiServerMCPClient", () => { | |
| transport: "sse", | ||
| url: "http://localhost:8000/sse", | ||
| }, | ||
| server3: { | ||
| transport: "streamable", | ||
|
||
| url: "http://localhost:8000/mcp", | ||
| }, | ||
| }); | ||
|
|
||
| await client.initializeConnections(); | ||
|
|
@@ -315,6 +352,7 @@ describe("MultiServerMCPClient", () => { | |
| // Verify that all transports were closed using the mock functions directly | ||
| expect(StdioClientTransport.prototype.close).toHaveBeenCalled(); | ||
| expect(SSEClientTransport.prototype.close).toHaveBeenCalled(); | ||
| expect(StreamableHTTPClientTransport.prototype.close).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test("should handle errors during cleanup gracefully", async () => { | ||
|
|
@@ -341,4 +379,105 @@ describe("MultiServerMCPClient", () => { | |
| expect(closeMock).toHaveBeenCalledOnce(); | ||
| }); | ||
| }); | ||
|
|
||
| // Streamable HTTP specific tests | ||
| describe("streamable HTTP transport", () => { | ||
| test("should throw when streamable HTTP config is missing required fields", () => { | ||
| expect(() => { | ||
| // eslint-disable-next-line no-new | ||
| new MultiServerMCPClient({ | ||
| // @ts-expect-error missing url field | ||
| "test-server": { | ||
| transport: "streamable", | ||
| // Missing url field | ||
| }, | ||
| }); | ||
| }).toThrow(ZodError); | ||
| }); | ||
|
|
||
| test("should throw when streamable HTTP URL is invalid", () => { | ||
| expect(() => { | ||
| // eslint-disable-next-line no-new | ||
| new MultiServerMCPClient({ | ||
| "test-server": { | ||
| transport: "streamable", | ||
| url: "invalid-url", // Invalid URL format | ||
| }, | ||
| }); | ||
| }).toThrow(ZodError); | ||
| }); | ||
|
|
||
| test("should handle mixed transport types including streamable HTTP", async () => { | ||
| const client = new MultiServerMCPClient({ | ||
| "stdio-server": { | ||
| transport: "stdio", | ||
| command: "python", | ||
| args: ["./script.py"], | ||
| }, | ||
| "sse-server": { | ||
| transport: "sse", | ||
| url: "http://localhost:8000/sse", | ||
| }, | ||
| "streamable-server": { | ||
| transport: "streamable", | ||
| url: "http://localhost:8000/mcp", | ||
| }, | ||
| }); | ||
|
|
||
| await client.initializeConnections(); | ||
|
|
||
| // Verify all transports were initialized | ||
| expect(StreamableHTTPClientTransport).toHaveBeenCalled(); | ||
| expect(SSEClientTransport).toHaveBeenCalled(); | ||
| expect(StdioClientTransport).toHaveBeenCalled(); | ||
|
|
||
| // Get tools from all servers | ||
| const tools = await client.getTools(); | ||
| expect(tools.length).toBeGreaterThan(0); | ||
| }); | ||
|
|
||
| test("should throw on streamable HTTP connection failure", async () => { | ||
| (Client as Mock).mockImplementationOnce(() => ({ | ||
| connect: vi | ||
| .fn() | ||
| .mockReturnValue(Promise.reject(new Error("Connection failed"))), | ||
| listTools: vi.fn().mockReturnValue(Promise.resolve({ tools: [] })), | ||
| })); | ||
|
|
||
| const client = new MultiServerMCPClient({ | ||
| "test-server": { | ||
| transport: "streamable", | ||
| url: "http://localhost:8000/mcp", | ||
| }, | ||
| }); | ||
|
|
||
| await expect(() => client.initializeConnections()).rejects.toThrow( | ||
| MCPClientError | ||
| ); | ||
| }); | ||
|
|
||
| test("should handle errors during streamable HTTP cleanup gracefully", async () => { | ||
| const closeMock = vi | ||
| .fn() | ||
| .mockReturnValue(Promise.reject(new Error("Close failed"))); | ||
|
|
||
| // Mock close to throw an error | ||
| (StreamableHTTPClientTransport as Mock).mockImplementationOnce(() => ({ | ||
| close: closeMock, | ||
| connect: vi.fn().mockReturnValue(Promise.resolve()), | ||
| })); | ||
|
|
||
| const client = new MultiServerMCPClient({ | ||
| "test-server": { | ||
| transport: "streamable", | ||
| url: "http://localhost:8000/mcp", | ||
| }, | ||
| }); | ||
|
|
||
| await client.initializeConnections(); | ||
| await client.close(); | ||
|
|
||
| expect(closeMock).toHaveBeenCalledOnce(); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,10 @@ | ||
| import { vi, describe, test, expect, beforeEach, type Mock } from "vitest"; | ||
| import { ZodError } from "zod"; | ||
| import { Connection } from "../src/client.js"; | ||
| import type { | ||
| ClientConfig, | ||
| Connection, | ||
| StdioConnection, | ||
| } from "../src/client.js"; | ||
|
|
||
| import "./mocks.js"; | ||
|
|
||
|
|
@@ -11,6 +15,9 @@ const { StdioClientTransport } = await import( | |
| const { SSEClientTransport } = await import( | ||
| "@modelcontextprotocol/sdk/client/sse.js" | ||
| ); | ||
| const { StreamableHTTPClientTransport } = await import( | ||
| "@modelcontextprotocol/sdk/client/streamableHttp.js" | ||
| ); | ||
| const { MultiServerMCPClient, MCPClientError } = await import( | ||
| "../src/client.js" | ||
| ); | ||
|
|
@@ -44,6 +51,23 @@ describe("MultiServerMCPClient", () => { | |
| expect(Client).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test("should process valid streamable HTTP connection config", async () => { | ||
| const config = { | ||
| "test-server": { | ||
| transport: "streamable" as const, | ||
| url: "http://localhost:8000/mcp", | ||
| }, | ||
| }; | ||
|
|
||
| const client = new MultiServerMCPClient(config); | ||
| expect(client).toBeDefined(); | ||
|
|
||
| // Initialize connections and verify | ||
| await client.initializeConnections(); | ||
| expect(StreamableHTTPClientTransport).toHaveBeenCalled(); | ||
| expect(Client).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test("should process valid SSE connection config", async () => { | ||
| const config = { | ||
| "test-server": { | ||
|
|
@@ -324,6 +348,10 @@ describe("MultiServerMCPClient", () => { | |
| }, | ||
| }); | ||
|
|
||
| const conf = client.config; | ||
| expect(conf.additionalToolNamePrefix).toBe("mcp"); | ||
| expect(conf.prefixToolNameWithServerName).toBe(true); | ||
|
Comment on lines
+351
to
+353
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added this during test troubleshooting because the defaults reverted due to me accidentally setting |
||
|
|
||
| await client.initializeConnections(); | ||
| const tools = await client.getTools(); | ||
|
|
||
|
|
@@ -522,5 +550,30 @@ describe("MultiServerMCPClient", () => { | |
| // Should not have created a client | ||
| expect(Client).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test("should throw on streamable HTTP transport creation errors", async () => { | ||
| // Force an error when creating transport | ||
| (StreamableHTTPClientTransport as Mock).mockImplementationOnce(() => { | ||
| throw new Error("Streamable HTTP transport creation failed"); | ||
| }); | ||
|
|
||
| const client = new MultiServerMCPClient({ | ||
| "test-server": { | ||
| transport: "streamable" as const, | ||
| url: "http://localhost:8000/mcp", | ||
| }, | ||
| }); | ||
|
|
||
| // Should throw error when connecting | ||
| await expect( | ||
| async () => await client.initializeConnections() | ||
| ).rejects.toThrow(); | ||
|
|
||
| // Should have attempted to create transport | ||
| expect(StreamableHTTPClientTransport).toHaveBeenCalled(); | ||
|
|
||
| // Should not have created a client | ||
| expect(Client).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.