Skip to content

Commit 5315fed

Browse files
committed
feat(mcp): support remote servers over Streamable HTTP
Add HttpTransport implementing the MCP Streamable HTTP protocol (spec 2025-03-26): each JSON-RPC message is sent with an HTTP POST and the reply is read as either a single JSON object or an SSE stream. The Mcp-Session-Id returned on initialize is echoed on later requests, and custom headers (e.g. Authorization) are forwarded. createMcpClient now selects HttpTransport when the server config has `type: "http"` or a `url`, and the stdio transport otherwise. Covered by an integration test that runs a local Streamable HTTP server exercising both the JSON and SSE response paths plus session-id echoing.
1 parent ed145e0 commit 5315fed

3 files changed

Lines changed: 287 additions & 1 deletion

File tree

packages/core/src/mcp/mcp-client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { McpServerConfig } from "../settings";
22
import type { McpTransport } from "./mcp-transport";
33
import { StdioTransport, createMcpSpawnSpec } from "./mcp-stdio-transport";
4+
import { HttpTransport } from "./mcp-http-transport";
45

56
export { createMcpSpawnSpec };
67
export type { McpSpawnSpec } from "./mcp-stdio-transport";
@@ -317,6 +318,9 @@ export function createMcpClient(
317318
onNotification?: McpNotificationHandler,
318319
onDisconnect?: (reason: string) => void
319320
): McpClient {
320-
const transport: McpTransport = new StdioTransport(serverName, config.command ?? "", config.args ?? [], config.env);
321+
const isRemote = config.type === "http" || (config.type !== "stdio" && !!config.url);
322+
const transport: McpTransport = isRemote
323+
? new HttpTransport(serverName, { url: config.url ?? "", headers: config.headers })
324+
: new StdioTransport(serverName, config.command ?? "", config.args ?? [], config.env);
321325
return new McpClient(serverName, transport, onNotification, onDisconnect);
322326
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import type { McpTransport, McpTransportHandlers } from "./mcp-transport";
2+
3+
export type HttpTransportOptions = {
4+
url: string;
5+
headers?: Record<string, string>;
6+
};
7+
8+
/**
9+
* Remote transport implementing the MCP "Streamable HTTP" protocol
10+
* (spec 2025-03-26). Each outbound JSON-RPC message is delivered with an HTTP
11+
* POST; the server replies either with a single JSON object or with an SSE
12+
* stream that may carry the response together with server notifications.
13+
*
14+
* The `Mcp-Session-Id` returned on initialize is echoed on every subsequent
15+
* request. The legacy two-endpoint HTTP+SSE transport (2024-11-05) is not
16+
* handled here.
17+
*/
18+
export class HttpTransport implements McpTransport {
19+
private handlers: McpTransportHandlers | null = null;
20+
private sessionId: string | null = null;
21+
private closed = false;
22+
private readonly activeRequests = new Set<AbortController>();
23+
24+
constructor(
25+
private readonly serverName: string,
26+
private readonly options: HttpTransportOptions
27+
) {}
28+
29+
async start(handlers: McpTransportHandlers): Promise<void> {
30+
this.handlers = handlers;
31+
this.closed = false;
32+
this.sessionId = null;
33+
// Streamable HTTP is connectionless until the first message is sent, so
34+
// there is nothing to open here.
35+
}
36+
37+
send(message: object): void {
38+
void this.post(message);
39+
}
40+
41+
close(): void {
42+
this.closed = true;
43+
for (const controller of this.activeRequests) {
44+
try {
45+
controller.abort();
46+
} catch {
47+
// ignore abort errors
48+
}
49+
}
50+
this.activeRequests.clear();
51+
}
52+
53+
isConnected(): boolean {
54+
return !this.closed;
55+
}
56+
57+
decorateError(message: string): Error {
58+
return new Error(message);
59+
}
60+
61+
private async post(message: object): Promise<void> {
62+
if (this.closed) return;
63+
64+
const controller = new AbortController();
65+
this.activeRequests.add(controller);
66+
try {
67+
const headers: Record<string, string> = {
68+
"content-type": "application/json",
69+
accept: "application/json, text/event-stream",
70+
...(this.options.headers ?? {}),
71+
};
72+
if (this.sessionId) {
73+
headers["mcp-session-id"] = this.sessionId;
74+
}
75+
76+
const response = await fetch(this.options.url, {
77+
method: "POST",
78+
headers,
79+
body: JSON.stringify(message),
80+
signal: controller.signal,
81+
});
82+
83+
const sessionId = response.headers.get("mcp-session-id");
84+
if (sessionId) {
85+
this.sessionId = sessionId;
86+
}
87+
88+
// 202 Accepted (or 204) acknowledges a notification/response with no body.
89+
if (response.status === 202 || response.status === 204) {
90+
return;
91+
}
92+
93+
if (!response.ok) {
94+
const body = await safeReadText(response);
95+
this.reportClose(
96+
`MCP server "${this.serverName}" returned HTTP ${response.status}${body ? `: ${truncate(body)}` : ""}`
97+
);
98+
return;
99+
}
100+
101+
const contentType = response.headers.get("content-type") ?? "";
102+
if (contentType.includes("text/event-stream") && response.body) {
103+
await this.consumeEventStream(response.body);
104+
} else {
105+
const text = await safeReadText(response);
106+
if (text) {
107+
this.dispatchPayload(text);
108+
}
109+
}
110+
} catch (err) {
111+
if (this.closed || controller.signal.aborted) {
112+
return;
113+
}
114+
const message = err instanceof Error ? err.message : String(err);
115+
this.reportClose(`MCP server "${this.serverName}" request failed: ${message}`);
116+
} finally {
117+
this.activeRequests.delete(controller);
118+
}
119+
}
120+
121+
private async consumeEventStream(body: ReadableStream<Uint8Array>): Promise<void> {
122+
const reader = body.getReader();
123+
const decoder = new TextDecoder();
124+
let buffer = "";
125+
try {
126+
for (;;) {
127+
const { done, value } = await reader.read();
128+
if (done) break;
129+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
130+
131+
let boundary: number;
132+
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
133+
const rawEvent = buffer.slice(0, boundary);
134+
buffer = buffer.slice(boundary + 2);
135+
const data = readSseData(rawEvent);
136+
if (data) {
137+
this.dispatchPayload(data);
138+
}
139+
}
140+
141+
if (this.closed) break;
142+
}
143+
} catch {
144+
// Stream aborted or errored; close handling happens elsewhere.
145+
} finally {
146+
try {
147+
reader.releaseLock();
148+
} catch {
149+
// ignore
150+
}
151+
}
152+
}
153+
154+
private dispatchPayload(text: string): void {
155+
let parsed: unknown;
156+
try {
157+
parsed = JSON.parse(text);
158+
} catch {
159+
return;
160+
}
161+
if (Array.isArray(parsed)) {
162+
for (const item of parsed) {
163+
if (item && typeof item === "object") {
164+
this.handlers?.onMessage(item as object);
165+
}
166+
}
167+
return;
168+
}
169+
if (parsed && typeof parsed === "object") {
170+
this.handlers?.onMessage(parsed as object);
171+
}
172+
}
173+
174+
private reportClose(reason: string): void {
175+
if (this.closed) return;
176+
this.closed = true;
177+
this.handlers?.onClose(reason);
178+
}
179+
}
180+
181+
function readSseData(rawEvent: string): string {
182+
const dataLines: string[] = [];
183+
for (const line of rawEvent.split("\n")) {
184+
if (line.startsWith("data:")) {
185+
dataLines.push(line.slice(5).replace(/^ /, ""));
186+
}
187+
// event:, id:, retry: and comment (":") lines are ignored.
188+
}
189+
return dataLines.join("\n");
190+
}
191+
192+
async function safeReadText(response: Response): Promise<string> {
193+
try {
194+
return (await response.text()).trim();
195+
} catch {
196+
return "";
197+
}
198+
}
199+
200+
function truncate(text: string, max = 500): string {
201+
return text.length > max ? `${text.slice(0, max)}…` : text;
202+
}

packages/core/src/tests/mcp-client.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
33
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import path from "node:path";
6+
import http from "node:http";
7+
import type { AddressInfo } from "node:net";
68
import { createMcpClient, createMcpSpawnSpec } from "../mcp/mcp-client";
79

810
test("createMcpSpawnSpec keeps non-Windows MCP launches shell-free", () => {
@@ -108,3 +110,81 @@ test("McpClient starts a PATH-resolved cmd MCP server on Windows", { skip: proce
108110
rmSync(serverDir, { recursive: true, force: true });
109111
}
110112
});
113+
114+
test("McpClient connects to a Streamable HTTP server and echoes the session id", async () => {
115+
const SESSION_ID = "sess-123";
116+
const seenSessionIds: Array<string | undefined> = [];
117+
118+
const server = http.createServer((req, res) => {
119+
const chunks: Buffer[] = [];
120+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
121+
req.on("end", () => {
122+
const message = JSON.parse(Buffer.concat(chunks).toString("utf8"));
123+
const sessionHeader = req.headers["mcp-session-id"];
124+
seenSessionIds.push(Array.isArray(sessionHeader) ? sessionHeader[0] : sessionHeader);
125+
126+
// Notifications (no id) are acknowledged with 202 and no body.
127+
if (message.id === undefined) {
128+
res.writeHead(202).end();
129+
return;
130+
}
131+
132+
if (message.method === "initialize") {
133+
// Single JSON response branch + session assignment via header.
134+
res.writeHead(200, {
135+
"content-type": "application/json",
136+
"mcp-session-id": SESSION_ID,
137+
});
138+
res.end(
139+
JSON.stringify({
140+
jsonrpc: "2.0",
141+
id: message.id,
142+
result: {
143+
protocolVersion: "2025-03-26",
144+
capabilities: {},
145+
serverInfo: { name: "remote", version: "1.0.0" },
146+
},
147+
})
148+
);
149+
return;
150+
}
151+
152+
if (message.method === "tools/list") {
153+
// SSE response branch: the JSON-RPC response is delivered as one event.
154+
res.writeHead(200, { "content-type": "text/event-stream" });
155+
const payload = JSON.stringify({
156+
jsonrpc: "2.0",
157+
id: message.id,
158+
result: { tools: [{ name: "remote_tool", inputSchema: { type: "object", properties: {} } }] },
159+
});
160+
res.end(`event: message\ndata: ${payload}\n\n`);
161+
return;
162+
}
163+
164+
res.writeHead(200, { "content-type": "application/json" });
165+
res.end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: {} }));
166+
});
167+
});
168+
169+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
170+
const { port } = server.address() as AddressInfo;
171+
const client = createMcpClient("remote", { type: "http", url: `http://127.0.0.1:${port}/mcp` });
172+
173+
try {
174+
await client.connect(5_000);
175+
const tools = await client.listTools(5_000);
176+
assert.deepEqual(
177+
tools.map((tool) => tool.name),
178+
["remote_tool"]
179+
);
180+
// initialize carries no session id; every request after the handshake must echo it.
181+
assert.equal(seenSessionIds[0], undefined);
182+
assert.ok(
183+
seenSessionIds.slice(1).every((id) => id === SESSION_ID),
184+
`expected later requests to carry session id, saw ${JSON.stringify(seenSessionIds)}`
185+
);
186+
} finally {
187+
client.disconnect();
188+
await new Promise<void>((resolve) => server.close(() => resolve()));
189+
}
190+
});

0 commit comments

Comments
 (0)