Skip to content

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

Open
sushantkumar23 wants to merge 2 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 2 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.

`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
@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

⚠️ No Changeset found

Latest commit: dbea301

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a 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: dbea301

`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

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).

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

Labels

None yet

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