Skip to content

Commit 5cb9344

Browse files
committed
Phase 8 streamable HTTP — GET/SSE + batched JSON-RPC
The httpTransport() now matches the full Streamable HTTP shape from the MCP spec: * GET /mcp opens an SSE stream for server-initiated notifications. The dispatcher today doesn't produce unsolicited messages, so the stream is keepalive-only — but the framing (event-stream content type, connection-confirmation comment, configurable keepalive interval) lands before v1 so future producers don't break wire compatibility. * POST /mcp respects 'Accept: text/event-stream'. When the client asks for SSE we wrap the JSON response as a single 'event: message' frame and close the stream, matching the spec's '...stream may contain multiple events, ending with the response to the originating request' shape. * POST /mcp accepts batched JSON-RPC arrays. Each entry dispatches independently; per-entry validation errors come back with the right id; an empty array is rejected with -32600. Six new tests, all green. stop() now releases active SSE streams before closing the underlying server so the transport doesn't hang on shutdown.
1 parent 63fb479 commit 5cb9344

3 files changed

Lines changed: 267 additions & 41 deletions

File tree

V1_PLAN.md

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,20 +39,17 @@ These are the items the previous audit-pass already filed as issues but
3939
flagged as defensive or low-priority. Re-evaluating them with v1 in
4040
mind: a few should ship, a few stay deferred. None require new design.
4141

42-
### 1.1 Phase 8 streamable HTTP — finish the spec
42+
### 1.1 Phase 8 streamable HTTP — finish the spec ✅ shipped v0.51.0
4343

44-
Today's `httpTransport()` only handles POST → single JSON response.
45-
Spec compliance for "Streamable HTTP" needs:
44+
`httpTransport()` now matches the full Streamable HTTP shape from the
45+
MCP spec:
4646

47-
- [ ] GET `/mcp` opens an SSE stream for server-initiated notifications.
48-
- [ ] `text/event-stream` response when the dispatcher emits multiple
49-
messages (currently it doesn't, but the framing should land
50-
before v1 ships so it's not a breaking change later).
51-
- [ ] Batched JSON-RPC arrays accepted on POST.
52-
53-
**Cost.** S — ~150 LoC across `transports/http.ts`.
54-
**Why now.** Adding SSE later means changing the response
55-
`Content-Type` heuristic, which is observable. Better in pre-1.0.
47+
- [x] GET `/mcp` opens an SSE stream for server-initiated notifications
48+
(keepalive-only today; framing in place for future producers).
49+
- [x] `text/event-stream` response on POST when the client sends
50+
`Accept: text/event-stream`.
51+
- [x] Batched JSON-RPC arrays accepted on POST; per-entry errors
52+
preserved with the right `id`.
5653

5754
### 1.2 Per-property invariants (Phase 6 deeper)
5855

@@ -236,7 +233,7 @@ Documented here so they don't bleed scope:
236233

237234
| Version | Theme | Notes |
238235
|---|---|---|
239-
| v0.51.0 | 1.1 | Streamable HTTP — SSE + batched JSON-RPC |
236+
| v0.51.0 | 1.1 | Streamable HTTP — GET/SSE + batched JSON-RPC |
240237
| v0.52.0 | 1.2 | Per-property invariants |
241238
| v0.53.0 | 3.1 | FHIRPath setValue / patch (#50) |
242239
| v0.54.0 | 2.1 + 2.3 | Document UCUM + FHIRPath-subset gaps |

packages/mcp/src/transports/http.ts

Lines changed: 158 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,20 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
22
import type { AddressInfo } from "node:net";
33
import type { Dispatcher, McpRequest, McpResponse, Transport } from "../types.js";
44

5-
// Streamable HTTP transport for MCP — POST JSON-RPC to a single endpoint;
6-
// server replies with `Content-Type: application/json`. Spec:
5+
// Streamable HTTP transport for MCP. Spec:
76
// https://modelcontextprotocol.io/docs/concepts/transports#streamable-http
87
//
9-
// Initial scope:
10-
// - POST /mcp accepts a JSON-RPC request body, returns a single response.
8+
// Surface:
9+
// - POST /mcp accepts a single JSON-RPC body or a batched array; returns
10+
// either application/json (default) or text/event-stream when the client
11+
// asks for it via the Accept header.
12+
// - GET /mcp opens an SSE stream for server-initiated notifications. The
13+
// dispatcher today only emits responses to POSTed requests, so the GET
14+
// stream is keepalive-only at the moment — the framing is in place so
15+
// adding producers later isn't an observable change.
1116
// - Optional CORS for browser-hosted clients.
1217
// - Optional authenticate hook — return false to short-circuit with 401.
1318
// - The transport binds its own http.Server unless caller provides one.
14-
//
15-
// Out of scope here (follow-ups):
16-
// - GET /mcp opening an SSE stream for server-initiated notifications.
17-
// - text/event-stream responses for streaming tool output.
18-
// - Batched JSON-RPC arrays.
1919

2020
export interface HttpTransportOptions {
2121
/** TCP port. Default 0 (ephemeral — read back via the returned `url()`). */
@@ -30,6 +30,11 @@ export interface HttpTransportOptions {
3030
authenticate?: (req: IncomingMessage) => boolean | Promise<boolean>;
3131
/** Hard cap on request body size — protects against memory exhaustion. Default 1 MiB. */
3232
maxRequestBytes?: number;
33+
/**
34+
* Interval (ms) for keepalive comments on long-lived SSE streams. Keeps
35+
* intermediaries from idling the connection out. Default 30s; 0 disables.
36+
*/
37+
sseKeepaliveMs?: number;
3338
/**
3439
* Optional pre-built http.Server. When provided, the transport mounts its
3540
* route handler on the existing server instead of starting its own — handy
@@ -46,18 +51,22 @@ export interface HttpTransport extends Transport {
4651
}
4752

4853
const DEFAULT_MAX_BYTES = 1 << 20;
54+
const DEFAULT_SSE_KEEPALIVE_MS = 30_000;
4955

5056
export function httpTransport(options: HttpTransportOptions = {}): HttpTransport {
5157
const path = options.path ?? "/mcp";
5258
const host = options.host ?? "127.0.0.1";
5359
const port = options.port ?? 0;
5460
const cors = options.cors === true;
5561
const maxBytes = options.maxRequestBytes ?? DEFAULT_MAX_BYTES;
62+
const keepaliveMs = options.sseKeepaliveMs ?? DEFAULT_SSE_KEEPALIVE_MS;
5663
const ownsServer = !options.server;
5764
const server = options.server ?? createServer();
5865

5966
let listening = false;
6067
let resolvedUrl: string | undefined;
68+
// Track active SSE streams so stop() can release them cleanly.
69+
const sseStreams = new Set<ServerResponse>();
6170

6271
const transport: HttpTransport = {
6372
async start(dispatcher: Dispatcher) {
@@ -66,7 +75,9 @@ export function httpTransport(options: HttpTransportOptions = {}): HttpTransport
6675
path,
6776
cors,
6877
maxBytes,
78+
keepaliveMs,
6979
authenticate: options.authenticate,
80+
sseStreams,
7081
});
7182
server.on("request", handler);
7283

@@ -85,6 +96,16 @@ export function httpTransport(options: HttpTransportOptions = {}): HttpTransport
8596
resolvedUrl = formatUrl(addr, host, path);
8697
},
8798
async stop() {
99+
// Close any in-flight SSE streams first — otherwise server.close()
100+
// hangs waiting for them.
101+
for (const res of sseStreams) {
102+
try {
103+
res.end();
104+
} catch {
105+
/* ignore */
106+
}
107+
}
108+
sseStreams.clear();
88109
if (!ownsServer || !listening) return;
89110
await new Promise<void>((resolve, reject) => {
90111
server.close((err) => (err ? reject(err) : resolve()));
@@ -108,7 +129,9 @@ interface HandlerConfig {
108129
path: string;
109130
cors: boolean;
110131
maxBytes: number;
132+
keepaliveMs: number;
111133
authenticate?: HttpTransportOptions["authenticate"];
134+
sseStreams: Set<ServerResponse>;
112135
}
113136

114137
function makeRequestHandler(cfg: HandlerConfig): (req: IncomingMessage, res: ServerResponse) => void {
@@ -120,8 +143,8 @@ function makeRequestHandler(cfg: HandlerConfig): (req: IncomingMessage, res: Ser
120143
async function handleRequest(req: IncomingMessage, res: ServerResponse, cfg: HandlerConfig): Promise<void> {
121144
if (cfg.cors) {
122145
res.setHeader("Access-Control-Allow-Origin", "*");
123-
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id");
124-
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
146+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, Mcp-Session-Id");
147+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
125148
}
126149

127150
// Path mismatch — let other handlers (e.g. user's own routes) try.
@@ -138,9 +161,24 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, cfg: Han
138161
return;
139162
}
140163

164+
if (req.method === "GET") {
165+
// Spec: GET on the streamable HTTP endpoint opens an SSE stream for
166+
// server-initiated notifications. Auth applies here too.
167+
if (cfg.authenticate) {
168+
const ok = await cfg.authenticate(req);
169+
if (!ok) {
170+
res.statusCode = 401;
171+
res.end();
172+
return;
173+
}
174+
}
175+
openServerStream(res, cfg);
176+
return;
177+
}
178+
141179
if (req.method !== "POST") {
142180
res.statusCode = 405;
143-
res.setHeader("Allow", "POST, OPTIONS");
181+
res.setHeader("Allow", "GET, POST, OPTIONS");
144182
res.end();
145183
return;
146184
}
@@ -169,48 +207,142 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, cfg: Han
169207
return;
170208
}
171209

172-
let request: McpRequest;
210+
let parsed: unknown;
173211
try {
174-
request = JSON.parse(body) as McpRequest;
212+
parsed = JSON.parse(body);
175213
} catch {
176-
sendJsonRpc(res, {
214+
sendJsonRpc(res, req, cfg, {
177215
jsonrpc: "2.0",
178216
id: null,
179217
error: { code: -32700, message: "Parse error: invalid JSON" },
180218
});
181219
return;
182220
}
183221

184-
if (request.jsonrpc !== "2.0" || typeof request.method !== "string") {
185-
sendJsonRpc(res, {
222+
// §Streamable HTTP: client may POST a single message or a batched array.
223+
const requests = Array.isArray(parsed) ? (parsed as unknown[]) : [parsed];
224+
225+
if (Array.isArray(parsed) && requests.length === 0) {
226+
// Empty array is malformed per JSON-RPC §2.7.
227+
sendJsonRpc(res, req, cfg, {
186228
jsonrpc: "2.0",
187-
id: request?.id ?? null,
188-
error: { code: -32600, message: "Invalid request" },
229+
id: null,
230+
error: { code: -32600, message: "Invalid request: empty batch" },
189231
});
190232
return;
191233
}
192234

193-
let response: McpResponse;
235+
const responses: McpResponse[] = [];
236+
for (const item of requests) {
237+
responses.push(await dispatchOne(item, cfg.dispatcher));
238+
}
239+
240+
if (Array.isArray(parsed)) {
241+
sendJsonRpc(res, req, cfg, responses);
242+
} else {
243+
sendJsonRpc(res, req, cfg, responses[0] ?? { jsonrpc: "2.0", id: null });
244+
}
245+
}
246+
247+
async function dispatchOne(raw: unknown, dispatcher: Dispatcher): Promise<McpResponse> {
248+
if (
249+
raw == null ||
250+
typeof raw !== "object" ||
251+
(raw as McpRequest).jsonrpc !== "2.0" ||
252+
typeof (raw as McpRequest).method !== "string"
253+
) {
254+
return {
255+
jsonrpc: "2.0",
256+
id: (raw as { id?: McpResponse["id"] } | null)?.id ?? null,
257+
error: { code: -32600, message: "Invalid request" },
258+
};
259+
}
260+
const request = raw as McpRequest;
194261
try {
195-
response = await cfg.dispatcher.handleRequest(request);
262+
return await dispatcher.handleRequest(request);
196263
} catch (err) {
197-
sendJsonRpc(res, {
264+
return {
198265
jsonrpc: "2.0",
199266
id: request.id ?? null,
200267
error: { code: -32603, message: `Internal error: ${err instanceof Error ? err.message : String(err)}` },
201-
});
202-
return;
268+
};
203269
}
270+
}
204271

205-
sendJsonRpc(res, response);
272+
function clientWantsSse(req: IncomingMessage): boolean {
273+
const accept = req.headers.accept;
274+
if (!accept) return false;
275+
return accept.split(",").some((entry) => entry.trim().toLowerCase().startsWith("text/event-stream"));
206276
}
207277

208-
function sendJsonRpc(res: ServerResponse, response: McpResponse): void {
278+
function sendJsonRpc(
279+
res: ServerResponse,
280+
req: IncomingMessage,
281+
cfg: HandlerConfig,
282+
response: McpResponse | McpResponse[],
283+
): void {
284+
if (clientWantsSse(req)) {
285+
sendSseResponse(res, cfg, response);
286+
return;
287+
}
209288
res.statusCode = 200;
210289
res.setHeader("Content-Type", "application/json");
211290
res.end(JSON.stringify(response));
212291
}
213292

293+
function sendSseResponse(res: ServerResponse, cfg: HandlerConfig, response: McpResponse | McpResponse[]): void {
294+
res.statusCode = 200;
295+
res.setHeader("Content-Type", "text/event-stream");
296+
res.setHeader("Cache-Control", "no-cache, no-transform");
297+
res.setHeader("Connection", "keep-alive");
298+
// Disable proxy buffering (nginx-friendly).
299+
res.setHeader("X-Accel-Buffering", "no");
300+
301+
const messages = Array.isArray(response) ? response : [response];
302+
for (const msg of messages) {
303+
res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`);
304+
}
305+
// Spec: the response stream closes after the final response to the
306+
// originating request is sent. The dispatcher today produces exactly one
307+
// reply per request, so we close immediately.
308+
res.end();
309+
cfg.sseStreams.delete(res);
310+
}
311+
312+
function openServerStream(res: ServerResponse, cfg: HandlerConfig): void {
313+
res.statusCode = 200;
314+
res.setHeader("Content-Type", "text/event-stream");
315+
res.setHeader("Cache-Control", "no-cache, no-transform");
316+
res.setHeader("Connection", "keep-alive");
317+
res.setHeader("X-Accel-Buffering", "no");
318+
res.flushHeaders?.();
319+
320+
// Initial connection-confirmation comment.
321+
res.write(": connected\n\n");
322+
323+
cfg.sseStreams.add(res);
324+
325+
let keepalive: NodeJS.Timeout | undefined;
326+
if (cfg.keepaliveMs > 0) {
327+
keepalive = setInterval(() => {
328+
try {
329+
res.write(": keepalive\n\n");
330+
} catch {
331+
clearInterval(keepalive);
332+
}
333+
}, cfg.keepaliveMs);
334+
keepalive.unref?.();
335+
}
336+
337+
const cleanup = () => {
338+
if (keepalive) clearInterval(keepalive);
339+
cfg.sseStreams.delete(res);
340+
};
341+
342+
res.on("close", cleanup);
343+
res.on("error", cleanup);
344+
}
345+
214346
function matchesPath(reqUrl: string | undefined, expected: string): boolean {
215347
if (!reqUrl) return false;
216348
const justPath = reqUrl.split("?")[0] ?? "";

0 commit comments

Comments
 (0)