From 99434701bef02ecdefe9e5d9926a763b166ca195 Mon Sep 17 00:00:00 2001 From: Sushant Date: Wed, 12 Aug 2026 14:17:20 +0530 Subject: [PATCH 1/5] fix(server): close a listen stream that has honored nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 #2650 --- packages/server/src/server/listenRouter.ts | 18 +++++++ .../server/createMcpHandlerListen.test.ts | 51 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/packages/server/src/server/listenRouter.ts b/packages/server/src/server/listenRouter.ts index 40c4a38cf2..408aa04c09 100644 --- a/packages/server/src/server/listenRouter.ts +++ b/packages/server/src/server/listenRouter.ts @@ -213,6 +213,24 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter { ); writeNotification(ack.method, ack.params); + // Nothing honored: the ack has already told the client this + // stream carries nothing, and `listenFilterAccepts({}, event)` + // is false for every event kind, so the subscription below is + // provably a no-op. Close gracefully rather than hold an idle + // connection and a keepalive timer for a set that is empty — + // the spec's transport binding ends the listen stream "until + // the client or server closes" it, and this is the server + // closing with the same result frame `closeAll()` emits. + // + // Matters most where a connection is request-scoped: a + // serverless invocation held for a subscription that can never + // deliver runs until the platform kills it, and the client + // reopens, indefinitely. + if (Object.keys(honored).length === 0) { + teardown(true); + return; + } + // Only after the ack frame is enqueued does delivery activate. unsubscribe = bus.subscribe(event => { if (closed || !listenFilterAccepts(honored, event)) return; diff --git a/packages/server/test/server/createMcpHandlerListen.test.ts b/packages/server/test/server/createMcpHandlerListen.test.ts index fe17aa7536..0d9bb8a74d 100644 --- a/packages/server/test/server/createMcpHandlerListen.test.ts +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -264,6 +264,57 @@ describe('createMcpHandler — subscriptions/listen', () => { }); }); + it('acks and closes when capabilities honor nothing, instead of holding the stream', async () => { + // A server declaring no listChanged and no resources.subscribe honors + // nothing, so the stream can never carry a notification: every + // listenFilterAccepts({}, event) is false. It should end, not idle. + const bareFactory = () => new McpServer({ name: 'listen-test-server', version: '1.0.0' }, { capabilities: {} }); + const handler = createMcpHandler(bareFactory, { keepAliveMs: 0 }); + const response = await handler.fetch( + listenRequest(7, { toolsListChanged: true, resourcesListChanged: true, resourceSubscriptions: ['file:///a'] }) + ); + + // The stream ends on its own: draining it terminates without anything + // cancelling the reader. + const messages = await readMessages(response, 2); + + expect(messages).toEqual([ + { + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { notifications: {}, _meta: { [SUBSCRIPTION_ID_META_KEY]: 7 } } + }, + { + jsonrpc: '2.0', + id: 7, + result: { + resultType: 'complete', + _meta: { + [SUBSCRIPTION_ID_META_KEY]: 7, + 'io.modelcontextprotocol/serverInfo': { name: 'listen-test-server', version: '1.0.0' } + } + } + } + ]); + }); + + it('still holds the stream when at least one type is honored', async () => { + // The narrowing must not close a stream that can still deliver: tools + // is honored here, resources is not. + const partialFactory = () => + new McpServer({ name: 'listen-test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } }); + const handler = createMcpHandler(partialFactory, { keepAliveMs: 0 }); + const response = await handler.fetch(listenRequest(8, { toolsListChanged: true, resourcesListChanged: true })); + + const [ack] = await readMessages(response, 1); + expect(ack).toEqual({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { notifications: { toolsListChanged: true }, _meta: { [SUBSCRIPTION_ID_META_KEY]: 8 } } + }); + await handler.close(); + }); + it('legacy-classified listen never reaches the entry listen router (no ack delivered)', async () => { const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 }); // No envelope claim → classified legacy → dispatched through the From c39f689f8918aa319f9319fbc6b7f7a4e31e5d79 Mon Sep 17 00:00:00 2001 From: Sushant Date: Wed, 12 Aug 2026 19:21:34 +0530 Subject: [PATCH 2/5] test(e2e): give the capacity guard a subscription that holds a slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- test/e2e/scenarios/subscriptions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/scenarios/subscriptions.test.ts b/test/e2e/scenarios/subscriptions.test.ts index ce644fa430..ef3a42e444 100644 --- a/test/e2e/scenarios/subscriptions.test.ts +++ b/test/e2e/scenarios/subscriptions.test.ts @@ -276,7 +276,7 @@ verifies('subscriptions:listen:capacity-guard', async () => { jsonrpc: '2.0', id, method: 'subscriptions/listen', - params: { _meta: modernEnvelopeMeta(), notifications: {} } + params: { _meta: modernEnvelopeMeta(), notifications: { toolsListChanged: true } } }) }) ); From f847f46009dbad5d25aa8bd26ee0300c3a3bc2be Mon Sep 17 00:00:00 2001 From: Sushant Date: Fri, 4 Sep 2026 01:19:33 +0530 Subject: [PATCH 3/5] Add a changeset for the listen-stream fix --- .../listen-close-when-nothing-honored.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .changeset/listen-close-when-nothing-honored.md diff --git a/.changeset/listen-close-when-nothing-honored.md b/.changeset/listen-close-when-nothing-honored.md new file mode 100644 index 0000000000..69992f948e --- /dev/null +++ b/.changeset/listen-close-when-nothing-honored.md @@ -0,0 +1,22 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Close a `subscriptions/listen` stream that has honored nothing. + +`listenRouter.serve()` computed `honoredSubset(filter, capabilities)` and then +opened the stream, acknowledged it, subscribed to the event bus and armed a +keep-alive without consulting the result. A server declaring no `listChanged` +capabilities and no `resources.subscribe` produced an empty set, so +`listenFilterAccepts({}, event)` was false for every event kind and the +subscription could never deliver anything — yet nothing closed it, since +`teardown` ran only on client disconnect or abort. + +It now writes the acknowledgement and then takes the same graceful +`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 notification type are unaffected. + +This is most visible on request-scoped runtimes, where an invocation held open +for a subscription that can never deliver runs until the platform kills it and +the client immediately reconnects. From 827c75c2fc8cad1c8d6c9074249f2bcd6c1b695b Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:25:50 +0000 Subject: [PATCH 4/5] test(server): read the empty listen stream to its end The test read two frames and cancelled, so it also passed when the server sent the result and kept the stream open. It now reads the body to its end, which only resolves when the server ends the stream. --- .../server/test/server/createMcpHandlerListen.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/server/test/server/createMcpHandlerListen.test.ts b/packages/server/test/server/createMcpHandlerListen.test.ts index 0d9bb8a74d..392887f0b3 100644 --- a/packages/server/test/server/createMcpHandlerListen.test.ts +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -274,9 +274,12 @@ describe('createMcpHandler — subscriptions/listen', () => { listenRequest(7, { toolsListChanged: true, resourcesListChanged: true, resourceSubscriptions: ['file:///a'] }) ); - // The stream ends on its own: draining it terminates without anything - // cancelling the reader. - const messages = await readMessages(response, 2); + // Reading the body to its end only resolves when the server ends the stream itself. + const body = await response.text(); + const messages = body + .split('\n\n') + .filter(frame => frame.length > 0) + .map(frame => JSON.parse(frame.slice(frame.indexOf('data: ') + 'data: '.length)) as unknown); expect(messages).toEqual([ { From 30cce202b5f921dcdb1ad821f47a0173147f5419 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:46:15 +0000 Subject: [PATCH 5/5] chore: shorten the changeset and the code comment --- .../listen-close-when-nothing-honored.md | 19 +------------------ packages/server/src/server/listenRouter.ts | 14 +------------- 2 files changed, 2 insertions(+), 31 deletions(-) diff --git a/.changeset/listen-close-when-nothing-honored.md b/.changeset/listen-close-when-nothing-honored.md index 69992f948e..74e61fdaae 100644 --- a/.changeset/listen-close-when-nothing-honored.md +++ b/.changeset/listen-close-when-nothing-honored.md @@ -2,21 +2,4 @@ '@modelcontextprotocol/server': patch --- -Close a `subscriptions/listen` stream that has honored nothing. - -`listenRouter.serve()` computed `honoredSubset(filter, capabilities)` and then -opened the stream, acknowledged it, subscribed to the event bus and armed a -keep-alive without consulting the result. A server declaring no `listChanged` -capabilities and no `resources.subscribe` produced an empty set, so -`listenFilterAccepts({}, event)` was false for every event kind and the -subscription could never deliver anything — yet nothing closed it, since -`teardown` ran only on client disconnect or abort. - -It now writes the acknowledgement and then takes the same graceful -`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 notification type are unaffected. - -This is most visible on request-scoped runtimes, where an invocation held open -for a subscription that can never deliver runs until the platform kills it and -the client immediately reconnects. +`createMcpHandler` now ends a `subscriptions/listen` stream right after the acknowledgement when it honored none of the requested notification types, instead of holding the stream open with nothing to deliver. The client receives the acknowledgement and then the `resultType: "complete"` result. Streams that honor at least one type are unchanged. diff --git a/packages/server/src/server/listenRouter.ts b/packages/server/src/server/listenRouter.ts index 408aa04c09..32ae8f26df 100644 --- a/packages/server/src/server/listenRouter.ts +++ b/packages/server/src/server/listenRouter.ts @@ -213,19 +213,7 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter { ); writeNotification(ack.method, ack.params); - // Nothing honored: the ack has already told the client this - // stream carries nothing, and `listenFilterAccepts({}, event)` - // is false for every event kind, so the subscription below is - // provably a no-op. Close gracefully rather than hold an idle - // connection and a keepalive timer for a set that is empty — - // the spec's transport binding ends the listen stream "until - // the client or server closes" it, and this is the server - // closing with the same result frame `closeAll()` emits. - // - // Matters most where a connection is request-scoped: a - // serverless invocation held for a subscription that can never - // deliver runs until the platform kills it, and the client - // reopens, indefinitely. + // Nothing honored means nothing can ever be delivered: complete the subscription instead of holding the stream. if (Object.keys(honored).length === 0) { teardown(true); return;