@@ -2,20 +2,20 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
22import type { AddressInfo } from "node:net" ;
33import 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
2020export 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
4853const DEFAULT_MAX_BYTES = 1 << 20 ;
54+ const DEFAULT_SSE_KEEPALIVE_MS = 30_000 ;
4955
5056export 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
114137function makeRequestHandler ( cfg : HandlerConfig ) : ( req : IncomingMessage , res : ServerResponse ) => void {
@@ -120,8 +143,8 @@ function makeRequestHandler(cfg: HandlerConfig): (req: IncomingMessage, res: Ser
120143async 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+
214346function matchesPath ( reqUrl : string | undefined , expected : string ) : boolean {
215347 if ( ! reqUrl ) return false ;
216348 const justPath = reqUrl . split ( "?" ) [ 0 ] ?? "" ;
0 commit comments