Skip to content
This repository was archived by the owner on May 16, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ index.d.cts
node_modules
dist
.yarn
.env
.env
.eslintcache
30 changes: 18 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The 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

Expand Down Expand Up @@ -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
Expand Down
139 changes: 139 additions & 0 deletions __tests__/client.basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: "http",
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
Expand Down Expand Up @@ -93,6 +107,7 @@ describe("MultiServerMCPClient", () => {
command: "python",
args: ["./script.py"],
env: undefined,
stderr: "inherit",
});

expect(Client).toHaveBeenCalled();
Expand All @@ -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: "http",
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
Expand Down Expand Up @@ -307,6 +340,10 @@ describe("MultiServerMCPClient", () => {
transport: "sse",
url: "http://localhost:8000/sse",
},
server3: {
transport: "http",
url: "http://localhost:8000/mcp",
},
});

await client.initializeConnections();
Expand All @@ -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 () => {
Expand All @@ -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: "http",
// 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: "http",
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: "http",
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: "http",
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: "http",
url: "http://localhost:8000/mcp",
},
});

await client.initializeConnections();
await client.close();

expect(closeMock).toHaveBeenCalledOnce();
});
});
});
55 changes: 54 additions & 1 deletion __tests__/client.comprehensive.test.ts
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";

Expand All @@ -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"
);
Expand Down Expand Up @@ -44,6 +51,23 @@ describe("MultiServerMCPClient", () => {
expect(Client).toHaveBeenCalled();
});

test("should process valid streamable HTTP connection config", async () => {
const config = {
"test-server": {
transport: "http" 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": {
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The 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 default(...).optional() in the zod schema updates (needs to be .optional().default(...) else the defaults aren't applied during parsing).


await client.initializeConnections();
const tools = await client.getTools();

Expand Down Expand Up @@ -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: "http" 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();
});
});
});
18 changes: 18 additions & 0 deletions __tests__/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,21 @@ vi.mock("@modelcontextprotocol/sdk/client/sse.js", () => {
SSEClientTransport,
};
});

vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => {
const streamableHTTPClientTransportPrototype = {
connect: vi.fn().mockReturnValue(Promise.resolve()),
send: vi.fn().mockReturnValue(Promise.resolve()),
close: vi.fn().mockReturnValue(Promise.resolve()),
};
const StreamableHTTPClientTransport = vi.fn().mockImplementation((config) => {
return {
...streamableHTTPClientTransportPrototype,
config,
};
});
StreamableHTTPClientTransport.prototype = streamableHTTPClientTransportPrototype;
return {
StreamableHTTPClientTransport,
};
});
Loading