Skip to content
This repository was archived by the owner on May 16, 2025. It is now read-only.

Commit 68f130e

Browse files
leo237Abhilash Panigrahibenjamincburns
authored
feat: support StreamableHTTPClientTransport (#64)
Co-authored-by: Abhilash Panigrahi <[email protected]> Co-authored-by: Ben Burns <[email protected]>
1 parent 03172e3 commit 68f130e

File tree

13 files changed

+1453
-416
lines changed

13 files changed

+1453
-416
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ index.d.cts
55
node_modules
66
dist
77
.yarn
8-
.env
8+
.env
9+
.eslintcache

README.md

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ This library provides a lightweight wrapper that makes [Anthropic Model Context
99

1010
- 🔌 **Transport Options**
1111

12-
- Connect to MCP servers via stdio (local) or SSE (remote)
12+
- Connect to MCP servers via stdio (local) or Streamable HTTP (remote)
13+
- Streamable HTTP automatically falls back to SSE for compatibility with legacy MCP server implementations
1314
- Support for custom headers in SSE connections for authentication
1415
- Configurable reconnection strategies for both transport types
1516

@@ -38,13 +39,13 @@ npm install @langchain/mcp-adapters
3839

3940
### Optional Dependencies
4041

41-
For SSE connections with custom headers in Node.js:
42+
For SSE connections with custom headers in Node.js (does not apply to Streamable HTTP):
4243

4344
```bash
4445
npm install eventsource
4546
```
4647

47-
For enhanced SSE header support:
48+
For enhanced SSE header support (does not apply to Streamable HTTP):
4849

4950
```bash
5051
npm install extended-eventsource
@@ -155,14 +156,19 @@ const client = new MultiServerMCPClient({
155156
args: ["-y", "@modelcontextprotocol/server-filesystem"],
156157
},
157158

158-
// SSE transport example with reconnection configuration
159+
// Sreamable HTTP transport example, with auth headers and automatic SSE fallback disabled (defaults to enabled)
159160
weather: {
160-
transport: "sse",
161-
url: "https://example.com/mcp-weather",
161+
url: "https://example.com/weather/mcp",
162162
headers: {
163163
Authorization: "Bearer token123",
164-
},
165-
useNodeEventSource: true,
164+
}
165+
automaticSSEFallback: false
166+
},
167+
168+
// how to force SSE, for old servers that are known to only support SSE (streamable HTTP falls back automatically if unsure)
169+
github: {
170+
transport: "sse", // also works with "type" field instead of "transport"
171+
url: "https://example.com/mcp",
166172
reconnect: {
167173
enabled: true,
168174
maxAttempts: 5,
@@ -212,8 +218,8 @@ When loading MCP tools either directly through `loadMcpTools` or via `MultiServe
212218
| Option | Type | Default | Description |
213219
| ------------------------------ | ------- | ------- | ------------------------------------------------------------------------------------ |
214220
| `throwOnLoadError` | boolean | `true` | Whether to throw an error if a tool fails to load |
215-
| `prefixToolNameWithServerName` | boolean | `false` | If true, prefixes all tool names with the server name (e.g., `serverName__toolName`) |
216-
| `additionalToolNamePrefix` | string | `""` | Additional prefix to add to tool names (e.g., `prefix__serverName__toolName`) |
221+
| `prefixToolNameWithServerName` | boolean | `true` | If true, prefixes all tool names with the server name (e.g., `serverName__toolName`) |
222+
| `additionalToolNamePrefix` | string | `mcp` | Additional prefix to add to tool names (e.g., `prefix__serverName__toolName`) |
217223

218224
## Response Handling
219225

@@ -361,8 +367,8 @@ Example Zod error for an invalid SSE URL:
361367

362368
When using in browsers:
363369

364-
- Native EventSource API doesn't support custom headers
365-
- Consider using a proxy or pass authentication via query parameters
370+
- EventSource API doesn't support custom headers for SSE
371+
- Consider using a proxy or pass authentication via query parameters to avoid leaking credentials to client
366372
- May require CORS configuration on the server side
367373

368374
## Troubleshooting

__tests__/client.basic.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ const { StdioClientTransport } = await import(
2121
const { SSEClientTransport } = await import(
2222
"@modelcontextprotocol/sdk/client/sse.js"
2323
);
24+
const { StreamableHTTPClientTransport } = await import(
25+
"@modelcontextprotocol/sdk/client/streamableHttp.js"
26+
);
2427

2528
describe("MultiServerMCPClient", () => {
2629
// Setup and teardown
@@ -63,6 +66,17 @@ describe("MultiServerMCPClient", () => {
6366
// Additional assertions to verify the connection was processed correctly
6467
});
6568

69+
test("should process valid streamable HTTP connection config", () => {
70+
const client = new MultiServerMCPClient({
71+
"test-server": {
72+
transport: "http",
73+
url: "http://localhost:8000/mcp",
74+
},
75+
});
76+
expect(client).toBeDefined();
77+
// Additional assertions to verify the connection was processed correctly
78+
});
79+
6680
test("should have a compile time error and a runtime error when the config is invalid", () => {
6781
expect(() => {
6882
// eslint-disable-next-line no-new
@@ -93,6 +107,7 @@ describe("MultiServerMCPClient", () => {
93107
command: "python",
94108
args: ["./script.py"],
95109
env: undefined,
110+
stderr: "inherit",
96111
});
97112

98113
expect(Client).toHaveBeenCalled();
@@ -116,6 +131,24 @@ describe("MultiServerMCPClient", () => {
116131
expect(Client.prototype.listTools).toHaveBeenCalled();
117132
});
118133

134+
test("should initialize streamable HTTP connections correctly", async () => {
135+
const client = new MultiServerMCPClient({
136+
"test-server": {
137+
transport: "http",
138+
url: "http://localhost:8000/mcp",
139+
},
140+
});
141+
142+
await client.initializeConnections();
143+
144+
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
145+
new URL("http://localhost:8000/mcp")
146+
);
147+
expect(Client).toHaveBeenCalled();
148+
expect(Client.prototype.connect).toHaveBeenCalled();
149+
expect(Client.prototype.listTools).toHaveBeenCalled();
150+
});
151+
119152
test("should throw on connection failure", async () => {
120153
(Client as Mock).mockImplementationOnce(() => ({
121154
connect: vi
@@ -307,6 +340,10 @@ describe("MultiServerMCPClient", () => {
307340
transport: "sse",
308341
url: "http://localhost:8000/sse",
309342
},
343+
server3: {
344+
transport: "http",
345+
url: "http://localhost:8000/mcp",
346+
},
310347
});
311348

312349
await client.initializeConnections();
@@ -315,6 +352,7 @@ describe("MultiServerMCPClient", () => {
315352
// Verify that all transports were closed using the mock functions directly
316353
expect(StdioClientTransport.prototype.close).toHaveBeenCalled();
317354
expect(SSEClientTransport.prototype.close).toHaveBeenCalled();
355+
expect(StreamableHTTPClientTransport.prototype.close).toHaveBeenCalled();
318356
});
319357

320358
test("should handle errors during cleanup gracefully", async () => {
@@ -341,4 +379,105 @@ describe("MultiServerMCPClient", () => {
341379
expect(closeMock).toHaveBeenCalledOnce();
342380
});
343381
});
382+
383+
// Streamable HTTP specific tests
384+
describe("streamable HTTP transport", () => {
385+
test("should throw when streamable HTTP config is missing required fields", () => {
386+
expect(() => {
387+
// eslint-disable-next-line no-new
388+
new MultiServerMCPClient({
389+
// @ts-expect-error missing url field
390+
"test-server": {
391+
transport: "http",
392+
// Missing url field
393+
},
394+
});
395+
}).toThrow(ZodError);
396+
});
397+
398+
test("should throw when streamable HTTP URL is invalid", () => {
399+
expect(() => {
400+
// eslint-disable-next-line no-new
401+
new MultiServerMCPClient({
402+
"test-server": {
403+
transport: "http",
404+
url: "invalid-url", // Invalid URL format
405+
},
406+
});
407+
}).toThrow(ZodError);
408+
});
409+
410+
test("should handle mixed transport types including streamable HTTP", async () => {
411+
const client = new MultiServerMCPClient({
412+
"stdio-server": {
413+
transport: "stdio",
414+
command: "python",
415+
args: ["./script.py"],
416+
},
417+
"sse-server": {
418+
transport: "sse",
419+
url: "http://localhost:8000/sse",
420+
},
421+
"streamable-server": {
422+
transport: "http",
423+
url: "http://localhost:8000/mcp",
424+
},
425+
});
426+
427+
await client.initializeConnections();
428+
429+
// Verify all transports were initialized
430+
expect(StreamableHTTPClientTransport).toHaveBeenCalled();
431+
expect(SSEClientTransport).toHaveBeenCalled();
432+
expect(StdioClientTransport).toHaveBeenCalled();
433+
434+
// Get tools from all servers
435+
const tools = await client.getTools();
436+
expect(tools.length).toBeGreaterThan(0);
437+
});
438+
439+
test("should throw on streamable HTTP connection failure", async () => {
440+
(Client as Mock).mockImplementationOnce(() => ({
441+
connect: vi
442+
.fn()
443+
.mockReturnValue(Promise.reject(new Error("Connection failed"))),
444+
listTools: vi.fn().mockReturnValue(Promise.resolve({ tools: [] })),
445+
}));
446+
447+
const client = new MultiServerMCPClient({
448+
"test-server": {
449+
transport: "http",
450+
url: "http://localhost:8000/mcp",
451+
},
452+
});
453+
454+
await expect(() => client.initializeConnections()).rejects.toThrow(
455+
MCPClientError
456+
);
457+
});
458+
459+
test("should handle errors during streamable HTTP cleanup gracefully", async () => {
460+
const closeMock = vi
461+
.fn()
462+
.mockReturnValue(Promise.reject(new Error("Close failed")));
463+
464+
// Mock close to throw an error
465+
(StreamableHTTPClientTransport as Mock).mockImplementationOnce(() => ({
466+
close: closeMock,
467+
connect: vi.fn().mockReturnValue(Promise.resolve()),
468+
}));
469+
470+
const client = new MultiServerMCPClient({
471+
"test-server": {
472+
transport: "http",
473+
url: "http://localhost:8000/mcp",
474+
},
475+
});
476+
477+
await client.initializeConnections();
478+
await client.close();
479+
480+
expect(closeMock).toHaveBeenCalledOnce();
481+
});
482+
});
344483
});

__tests__/client.comprehensive.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { vi, describe, test, expect, beforeEach, type Mock } from "vitest";
22
import { ZodError } from "zod";
3-
import { Connection } from "../src/client.js";
3+
import type {
4+
ClientConfig,
5+
Connection,
6+
StdioConnection,
7+
} from "../src/client.js";
48

59
import "./mocks.js";
610

@@ -11,6 +15,9 @@ const { StdioClientTransport } = await import(
1115
const { SSEClientTransport } = await import(
1216
"@modelcontextprotocol/sdk/client/sse.js"
1317
);
18+
const { StreamableHTTPClientTransport } = await import(
19+
"@modelcontextprotocol/sdk/client/streamableHttp.js"
20+
);
1421
const { MultiServerMCPClient, MCPClientError } = await import(
1522
"../src/client.js"
1623
);
@@ -44,6 +51,23 @@ describe("MultiServerMCPClient", () => {
4451
expect(Client).toHaveBeenCalled();
4552
});
4653

54+
test("should process valid streamable HTTP connection config", async () => {
55+
const config = {
56+
"test-server": {
57+
transport: "http" as const,
58+
url: "http://localhost:8000/mcp",
59+
},
60+
};
61+
62+
const client = new MultiServerMCPClient(config);
63+
expect(client).toBeDefined();
64+
65+
// Initialize connections and verify
66+
await client.initializeConnections();
67+
expect(StreamableHTTPClientTransport).toHaveBeenCalled();
68+
expect(Client).toHaveBeenCalled();
69+
});
70+
4771
test("should process valid SSE connection config", async () => {
4872
const config = {
4973
"test-server": {
@@ -324,6 +348,10 @@ describe("MultiServerMCPClient", () => {
324348
},
325349
});
326350

351+
const conf = client.config;
352+
expect(conf.additionalToolNamePrefix).toBe("mcp");
353+
expect(conf.prefixToolNameWithServerName).toBe(true);
354+
327355
await client.initializeConnections();
328356
const tools = await client.getTools();
329357

@@ -522,5 +550,30 @@ describe("MultiServerMCPClient", () => {
522550
// Should not have created a client
523551
expect(Client).not.toHaveBeenCalled();
524552
});
553+
554+
test("should throw on streamable HTTP transport creation errors", async () => {
555+
// Force an error when creating transport
556+
(StreamableHTTPClientTransport as Mock).mockImplementationOnce(() => {
557+
throw new Error("Streamable HTTP transport creation failed");
558+
});
559+
560+
const client = new MultiServerMCPClient({
561+
"test-server": {
562+
transport: "http" as const,
563+
url: "http://localhost:8000/mcp",
564+
},
565+
});
566+
567+
// Should throw error when connecting
568+
await expect(
569+
async () => await client.initializeConnections()
570+
).rejects.toThrow();
571+
572+
// Should have attempted to create transport
573+
expect(StreamableHTTPClientTransport).toHaveBeenCalled();
574+
575+
// Should not have created a client
576+
expect(Client).not.toHaveBeenCalled();
577+
});
525578
});
526579
});

__tests__/mocks.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,21 @@ vi.mock("@modelcontextprotocol/sdk/client/sse.js", () => {
7070
SSEClientTransport,
7171
};
7272
});
73+
74+
vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => {
75+
const streamableHTTPClientTransportPrototype = {
76+
connect: vi.fn().mockReturnValue(Promise.resolve()),
77+
send: vi.fn().mockReturnValue(Promise.resolve()),
78+
close: vi.fn().mockReturnValue(Promise.resolve()),
79+
};
80+
const StreamableHTTPClientTransport = vi.fn().mockImplementation((config) => {
81+
return {
82+
...streamableHTTPClientTransportPrototype,
83+
config,
84+
};
85+
});
86+
StreamableHTTPClientTransport.prototype = streamableHTTPClientTransportPrototype;
87+
return {
88+
StreamableHTTPClientTransport,
89+
};
90+
});

0 commit comments

Comments
 (0)