Skip to content

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

Closed

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/server — listenRouter.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

Activity

  1. added
    bugSomething isn't working
    P1Significant bug affecting many users, highly requested feature
    ready for workEnough information for someone to start working on
    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes
    on Aug 17, 2026
  2. added
    spec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revision
    on Aug 18, 2026
  3. SoulmatelynchVFX commented on Sep 1, 2026

    @SoulmatelynchVFX

    I’m taking this on. I’ll add a focused regression test covering an empty honoured notification set, then make the smallest change to acknowledge and complete that subscription without creating a bus subscription or keep-alive timer. I’ll keep normal subscribed streams unchanged.

  4. sushantkumar23 commented on Sep 3, 2026

    @sushantkumar23
    Author

    @SoulmatelynchVFX — heads up before you spend time on this: #2651 has been open since 12 Aug and does what you described. Flagging it only so the work is not duplicated.

    It adds a regression test for the empty honoured set, and makes serve() write the acknowledgement and then take the existing graceful teardown(true) path when honoredSubset() comes back empty — no bus subscription, no keep-alive. Streams that honour at least one type are untouched. CI is green across Node 20/22/24, both conformance suites, bun and deno.

    One thing in it worth a second pair of eyes either way: the e2e test subscriptions:listen:capacity-guard opened both of its subscriptions with notifications: {}, which is precisely the case this closes — so under the fix the first subscription completes and frees its slot, and the second no longer hits the cap. The PR changes that filter to { toolsListChanged: true }, which makeServer() advertises, so the guard is still exercised by a subscription a server would really hold. Assertions unchanged.

    Genuinely happy either way — if you would rather carry your own patch, please do and I will close mine; the fix landing is the point. If it is easier to take #2651 forward, it is ready and I am glad to rebase or adjust it to whatever shape you prefer.

  5. felixweinberger commented on Sep 28, 2026

    @felixweinberger
    Contributor

    Fixed by #2651 for a server that honors none of the requested types. A server with default settings still honors tool changes, so its stream is held as before. That case is tracked in #2873.

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

    P1Significant bug affecting many users, highly requested featurebugSomething isn't workingready for workEnough information for someone to start working onspec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revisionv2Ideas, 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