Summary
On a serverless host (Vercel, Node runtime, maxDuration = 60), createMcpHandler(factory, { responseMode: 'json', legacy: 'stateless' }) serves subscriptions/listen as an SSE stream with a 15s keep-alive. The stream ends only when request.signal aborts or the body is cancelled. A 2026-07-28 client holds the stream open, so every such invocation runs until the platform kills it: Task timed out after 60 seconds. The client then reconnects immediately, and the cycle repeats every ~61s for as long as the client stays connected.
This is separate from #2650 (empty honoured set, fixed by #2651). Here the subscription honours toolsListChanged, which the server advertises.
Why this bites serverless in particular
The default InMemoryServerEventBus is per instance. On a serverless function nothing can publish to it across invocations, so a listen stream there can never deliver a notification. It only holds the function open.
Observed
Four production deployments, 24 Sep 2026. Of /api/mcp invocations, between 50% and 64% ended with the 60s kill after answering 200. On one deployment a fresh instance cold-started every ~61.5s. Every tools/call completed normally.
Reproduction
const handler = createMcpHandler(() => server, { responseMode: 'json' });
const res = await handler.fetch(new Request(url, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream',
'mcp-protocol-version': '2026-07-28', 'mcp-method': 'subscriptions/listen' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'subscriptions/listen',
params: { notifications: { toolsListChanged: true }, _meta: { 'io.modelcontextprotocol/protocolVersion': '2026-07-28', 'io.modelcontextprotocol/clientInfo': { name: 'r', version: '1' }, 'io.modelcontextprotocol/clientCapabilities': {} } } })
}));
await res.text(); // never resolves
A second trap in the same place
A host that rebuilds the request before calling handler.fetch (for example, to read the body for logging) and forwards request.signal gets a bound that only works some of the time on Node 20 and 22. undici 6 links a Request's signal to its parent through a controller that only the Request holds strongly. On Node 20, a bound that relied only on the forwarded signal failed 6 of 15 test runs. A body-level bound that held the signal but not the request failed 2 of 30. Holding the request itself failed 0 of 30.
Suggested fixes
- A
maxStreamDurationMs (or listenTtlMs) option on createMcpHandler. It would end the listen stream gracefully with the complete result that teardown(true) already writes, so hosts with a function time limit can set it just below that limit.
- Or document that
subscriptions/listen needs an event bus shared across instances plus a host-side bound on serverless, and show the pattern.
Related, lower priority
The legacy stateless fallback builds its WebStandardStreamableHTTPServerTransport without enableJsonResponse, so 2025-era requests are answered as text/event-stream even with responseMode: 'json'. The constructor warning names only the modern effect. Either passing the mode through or saying so in the warning would avoid surprise.
Workaround we shipped
The host wraps any text/event-stream response body and cancels it on whichever comes first:
- the handler finishing the stream
- the server cancelling the body
- the inbound request's signal aborting (the host holds the request itself, not only its signal)
- a timer set at
maxDuration - 5s
Deployed across the same four endpoints, it took the 60s kills to zero, with listen streams now closing between 54,999 and 55,017ms. It works, but every host on a time-limited platform has to rediscover it, which is the case for a bound in the SDK.
Summary
On a serverless host (Vercel, Node runtime,
maxDuration = 60),createMcpHandler(factory, { responseMode: 'json', legacy: 'stateless' })servessubscriptions/listenas an SSE stream with a 15s keep-alive. The stream ends only whenrequest.signalaborts or the body is cancelled. A 2026-07-28 client holds the stream open, so every such invocation runs until the platform kills it:Task timed out after 60 seconds. The client then reconnects immediately, and the cycle repeats every ~61s for as long as the client stays connected.This is separate from #2650 (empty honoured set, fixed by #2651). Here the subscription honours
toolsListChanged, which the server advertises.Why this bites serverless in particular
The default
InMemoryServerEventBusis per instance. On a serverless function nothing can publish to it across invocations, so a listen stream there can never deliver a notification. It only holds the function open.Observed
Four production deployments, 24 Sep 2026. Of
/api/mcpinvocations, between 50% and 64% ended with the 60s kill after answering 200. On one deployment a fresh instance cold-started every ~61.5s. Everytools/callcompleted normally.Reproduction
A second trap in the same place
A host that rebuilds the request before calling
handler.fetch(for example, to read the body for logging) and forwardsrequest.signalgets a bound that only works some of the time on Node 20 and 22. undici 6 links aRequest's signal to its parent through a controller that only theRequestholds strongly. On Node 20, a bound that relied only on the forwarded signal failed 6 of 15 test runs. A body-level bound that held the signal but not the request failed 2 of 30. Holding the request itself failed 0 of 30.Suggested fixes
maxStreamDurationMs(orlistenTtlMs) option oncreateMcpHandler. It would end the listen stream gracefully with thecompleteresult thatteardown(true)already writes, so hosts with a function time limit can set it just below that limit.subscriptions/listenneeds an event bus shared across instances plus a host-side bound on serverless, and show the pattern.Related, lower priority
The
legacystateless fallback builds itsWebStandardStreamableHTTPServerTransportwithoutenableJsonResponse, so 2025-era requests are answered astext/event-streameven withresponseMode: 'json'. The constructor warning names only the modern effect. Either passing the mode through or saying so in the warning would avoid surprise.Workaround we shipped
The host wraps any
text/event-streamresponse body and cancels it on whichever comes first:maxDuration - 5sDeployed across the same four endpoints, it took the 60s kills to zero, with listen streams now closing between 54,999 and 55,017ms. It works, but every host on a time-limited platform has to rediscover it, which is the case for a bound in the SDK.