Skip to content

[v2] subscriptions/listen holds a stream open even when it has honoured nothing #2650

Description

@sushantkumar23

What happened?

Summary

listenRouter.serve() computes honoredSubset(filter, capabilities) — the set of
notification types it will actually deliver — and then opens the SSE stream,
sends the acknowledgement, subscribes to the event bus and arms a keep-alive
timer without ever consulting that set.

When a server advertises no listChanged capabilities and no
resources.subscribe, honored is {}. The stream can never carry a single
notification, and nothing in the router ever closes it: teardown runs only on
client disconnect or signal abort. The connection is held open, indefinitely, to
deliver a set that is provably empty.

On a long-lived process this costs a socket and a timer. On a request-scoped
runtime (Vercel, Lambda, Cloud Run) it consumes the entire invocation until the
platform kills it.

Where

@modelcontextprotocol/serverlistenRouter.serve():

const honored = honoredSubset(filter, capabilities);   // may be {}
// ...
const readable = new ReadableStream({
  start(streamController) {
    controller = streamController;
    const ack = stampSubscriptionId({
      method: "notifications/subscriptions/acknowledged",
      params: { notifications: honored },              // {} — nothing agreed
    }, subscriptionId);
    writeNotification(ack.method, ack.params);
    unsubscribe = bus.subscribe(/* ... */);            // subscribed anyway
    keepAliveTimer = armSseKeepAlive(/* ... */);       // held open anyway
    open.add(teardown);
  },
  cancel() { teardown(false); },
});

There is no branch on honored being empty.

Reproduction

  1. Build a server with registerTool only — no prompts, no resources, no
    dynamic tool list.
  2. Connect any 2026-07-28 client (Claude and Claude Code both do this
    automatically).
  3. The client sends subscriptions/listen; the server acks with
    notifications: {} and holds the stream until something external kills it.

Observed in production

A small Next.js server on Vercel (mcp-handler 2.1.0, maxDuration 15):

POST /mcp  200
  [mcp] REQUEST_RECEIVED  subscriptions/listen
  [mcp] REQUEST_COMPLETED subscriptions/listen 44ms      <- handler resolves
  Vercel Runtime Timeout Error: Task timed out after 15 seconds

The handler resolves in 3–59 ms; the invocation then runs to the ceiling. Two
connected clients, reconnecting the moment each stream is killed, produced a
17-second cycle and roughly 420 timed-out invocations per hour. Raising
maxDuration does not help — it lengthens each hold proportionally.

No other method does this. tools/call, tools/list and server/discover all
complete normally on the same deployment.

Impact

Because the client reopens the stream as soon as the platform kills it, the hold
is effectively continuous: each connected client permanently occupies one
invocation
, and maxDuration only changes how the same wall-clock time is
sliced.

maxDuration reconnect cycle invocations/hour/client function-time held
60 s ~62 s ~58 ~100 %
15 s ~17 s ~212 ~100 %

Two connected clients therefore hold two invocations open, permanently, to
deliver nothing. Lowering the ceiling makes it cheaper per invocation and more
frequent; raising it does the reverse. Neither reduces the total.

This scales linearly with adoption of 2026-07-28. The same deployment has ~88
clients still on an earlier revision, which never send subscriptions/listen;
as they migrate, each becomes another permanently-held invocation.

Before / after, same deployment cutover

Old build, subscriptions/listen served by the SDK:

08:06:32  POST /mcp 200   subscriptions/listen 44ms  + Timeout after 15 seconds
08:06:32  POST /mcp 200   subscriptions/listen 51ms  + Timeout after 15 seconds
08:06:15  POST /mcp 200   subscriptions/listen 50ms  + Timeout after 15 seconds
08:06:14  POST /mcp 200   subscriptions/listen 44ms  + Timeout after 15 seconds

New build, same clients, method refused at the route:

08:06:59  POST /mcp 404   (milliseconds, no hold)
08:06:54  POST /mcp 404
08:06:50  POST /mcp 404
08:06:49  POST /mcp 404
08:07:01  POST /mcp 200   tools/call 20ms      <- clients unaffected
08:06:58  POST /mcp 200   tools/call 50ms
08:06:55  POST /mcp 200   tools/call 20ms

Timeouts stopped at the cutover and have not recurred. Tool calls were
unaffected, and no client downgraded protocol revision or lost its session.

Why this is a bug rather than a deployment concern

The emptiness is already known at the point the stream is opened. A subscription
that has honoured nothing has no reason to stay open on any runtime — the server
has told the client, in the acknowledgement, that it will send nothing.

The 2026-07-28 transport binding explicitly permits the server to close:

Client->>Server: POST subscriptions/listen (notification filter)
Server-->>Client: SSE: notifications/subscriptions/acknowledged
note over Client,Server: Stream stays open
...
note over Client,Server: Until the client or server closes the stream

What did you expect?

A subscription that has honoured nothing to be acknowledged and closed, rather than held open to deliver a set the server has already said is empty.

Suggested fix

When honored has no entries, write the acknowledgement and then close the
stream — the graceful path teardown(true) already implements, including the
resultType: "complete" result:

const honored = honoredSubset(filter, capabilities);
const nothingHonored = Object.keys(honored).length === 0;
// ... inside start(), after writeNotification(ack...):
if (nothingHonored) {
  teardown(true);   // ack, then complete — no bus subscription, no keep-alive
  return;
}

This keeps the acknowledgement contract intact (the client still learns exactly
what was honoured), avoids subscribing to a bus whose events can never pass the
filter, and leaves behaviour unchanged for every server that does advertise a
capability.

Workaround

Refusing the method at the route, ahead of the SDK, with the 404 +
-32601 shape the transport spec mandates for an unimplemented method. Clients
accept it and stop asking; the versioning rules confirm a 404 carrying a
recognised modern JSON-RPC error does not trigger a protocol downgrade. Timeouts
went to zero immediately, with tool calls unaffected.

That is a per-server patch for something the SDK can decide correctly on its
own, which is why this is filed rather than kept local.

Versions

  • @modelcontextprotocol/server (as bundled by mcp-handler 2.1.0)
  • mcp-handler 2.1.0
  • Node 24, Next.js on Vercel

SDK version: @modelcontextprotocol/server@2.0.0, @modelcontextprotocol/core@2.0.0 (via mcp-handler@2.1.0)

Area: Server / Transports

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions