Skip to content

fix(server): close a listen stream that has honored nothing - #2651

Open
sushantkumar23 wants to merge 3 commits into
modelcontextprotocol:mainfrom
sushantkumar23:fix/listen-close-when-nothing-honored
Open

fix(server): close a listen stream that has honored nothing#2651
sushantkumar23 wants to merge 3 commits into
modelcontextprotocol:mainfrom
sushantkumar23:fix/listen-close-when-nothing-honored

Conversation

@sushantkumar23

Copy link
Copy Markdown

Closes #2650.

The bug

listenRouter.serve() computes honoredSubset(filter, capabilities) — the set it will actually deliver — and then opens the SSE stream, acks, subscribes to the bus and arms a keepalive without ever consulting it.

When a server declares no listChanged and no resources.subscribe, honored is {}. listenFilterAccepts({}, event) returns false for every event kind, so the bus subscription is provably a no-op and nothing can follow the ack. Nothing closes the stream either — teardown runs only on client disconnect or abort. The connection is held open indefinitely to deliver a set the server has already told the client is empty.

The change

Once the ack is enqueued, if honored has no entries, close via the existing teardown(true) — the same graceful path closeAll() uses. The client still learns exactly what was honored, and still receives the resultType: "complete" result, so it can distinguish this from a transport drop.

Streams honoring at least one type are untouched.

Why this is in spec

The 2026-07-28 transport binding ends the listen stream "until the client or server closes the stream", so a server-side close is explicitly permitted. The ack-first MUST is preserved — the acknowledged notification is still the first frame.

Impact

Most visible on request-scoped runtimes. An invocation held for a subscription that can never deliver runs until the platform kills it, and the client immediately reopens:

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

Each connected client permanently occupies one invocation; the timeout only changes how the same wall-clock time is sliced. #2650 has production logs showing the handler resolving in 44 ms and the invocation then dying at the ceiling, plus a before/after where refusing the method at the route removes the timeouts entirely with tool calls unaffected.

Tests

Two added to createMcpHandlerListen.test.ts:

  • a server with capabilities: {} acks with notifications: {} and the stream ends on its own, final frame being the complete result
  • a server honoring only tools still holds the stream, so the narrowing does not close streams that can still deliver

packages/server: 470 tests pass, prettier and typecheck clean.

@sushantkumar23
sushantkumar23 requested a review from a team as a code owner August 12, 2026 08:48
@changeset-bot

changeset-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f847f46

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/server Patch
@modelcontextprotocol/client Patch
@modelcontextprotocol/codemod Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/core-internal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2651

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2651

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2651

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2651

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2651

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2651

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2651

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2651

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2651

commit: f847f46

@sushantkumar23

Copy link
Copy Markdown
Author

Flagging that the second commit touches an existing e2e test, since that deserves a look rather than being taken on trust.

subscriptions:listen:capacity-guard opened both of its subscriptions with notifications: {}. An empty filter honors nothing, which is precisely the case this PR now closes — so under the fix the first subscription acks, completes and releases its slot before the second arrives, and the second gets a stream instead of the -32603 the test asserts:

AssertionError: expected 'text/event-stream' to contain 'application/json'
 ❯ scenarios/subscriptions.test.ts:286:48

The test is about maxSubscriptions, and the empty filter was only a way to open a subscription. makeServer() registers a tool, so toolsListChanged is advertised and honored — the subscription then stays open and occupies the slot the guard exists to defend. The assertions are unchanged; only the filter in the request moved.

If you would rather the guard keep testing the empty-filter case specifically, the alternative is to narrow the fix so it only closes when a client asked for something the server cannot honor, leaving an explicitly-empty filter holding the stream. I did not take that route because it seems the wrong way round — a client that asks for nothing has less claim on an open stream, not more — but I am happy to switch if you disagree.

Full e2e suite passes locally with the change (44 files, 2637 tests).

@claude claude Bot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Aug 18, 2026
`serve()` computes `honoredSubset(filter, capabilities)` and then opens the
stream, acks, subscribes to the bus and arms a keepalive without consulting
it. When a server declares no `listChanged` and no `resources.subscribe`,
`honored` is `{}` — `listenFilterAccepts({}, event)` is false for every
event kind, so the subscription is provably a no-op and nothing can follow
the ack. Nothing closes the stream either: `teardown` runs only on client
disconnect or abort.

Close gracefully once the ack is out, reusing the same `teardown(true)`
path `closeAll()` uses, so the client still learns exactly what was
honored and still receives the `resultType: "complete"` result. Streams
that honor at least one type are untouched.

The transport binding for 2026-07-28 ends the listen stream "until the
client or server closes the stream", so a server-side close is in spec.

This is most visible on request-scoped runtimes: an invocation held for a
subscription that can never deliver runs until the platform kills it, and
the client immediately reopens. Observed in production as a continuous
reconnect cycle, one held invocation per connected client, independent of
the configured function timeout.

Refs modelcontextprotocol#2650
`subscriptions:listen:capacity-guard` opened both subscriptions with an
empty filter. That is the one case the preceding commit now closes: an
empty filter honors nothing, so the first subscription acks, completes and
releases its slot before the second arrives — which then gets a stream
instead of the `-32603` the test expects.

The empty filter was incidental. What the test is about is `maxSubscriptions`,
and `makeServer()` registers a tool, so `toolsListChanged` is advertised and
honored: the subscription stays open and occupies the slot the guard is
there to defend. The assertions are unchanged, and the guard is now
exercised by a subscription that a server would really hold.
@sushantkumar23
sushantkumar23 force-pushed the fix/listen-close-when-nothing-honored branch from adcd29b to f847f46 Compare September 3, 2026 19:51
@sushantkumar23

Copy link
Copy Markdown
Author

Ready for review whenever someone has a moment — rebased onto current main, changeset added, 14/14 checks green.

@felixweinberger @mattzcarey — tagging you two specifically because of the code rather than to cast a wide net: Felix wrote listenRouter.ts as part of 2026-07-28 (#2286), and Matt last touched it in July for the SSE keep-alive frames, which is exactly the thing this change stops arming when nothing was honored. Apologies if either of you is the wrong shout; happy to be redirected.

The linked issue #2650 is labelled bug / P1 / ready for work, and a contributor has offered to take it on — I have said there that I am glad either way, so please do not let this PR block anyone. Flagging it only so the same fix does not get written twice.

Two things worth a reviewer's eye:

  • serve() now writes the acknowledgement and takes the existing graceful teardown(true) path when honoredSubset() is empty, so the client still gets the ack and the resultType: "complete" result. Streams honoring at least one type are untouched.
  • It changes an existing e2e test. subscriptions:listen:capacity-guard opened both subscriptions with notifications: {}, which is the exact case this closes, so the first now completes and frees its slot. The filter becomes { toolsListChanged: true } — advertised by makeServer() — so the cap is still exercised by a subscription a server would really hold. Assertions unchanged, but it is a test you own and I would rather it were looked at than taken on trust.

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant