Skip to content

[v2] createMcpHandler: subscriptions/listen streams have no lifetime bound, so every serverless invocation serving one is killed by the platform #2873

Description

@mcoleborne

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions