From 8f1bdf5b675bc008d40af4ebcb62078ba3b6a523 Mon Sep 17 00:00:00 2001 From: Sushant Date: Wed, 12 Aug 2026 14:17:20 +0530 Subject: [PATCH 1/2] 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 7a0d2c8675..adb39833ab 100644 --- a/packages/server/test/server/createMcpHandlerListen.test.ts +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -261,6 +261,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 dbea301ff349aa13f1e86eabff6429f60f808735 Mon Sep 17 00:00:00 2001 From: Sushant Date: Wed, 12 Aug 2026 19:21:34 +0530 Subject: [PATCH 2/2] 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 45753afc57..d2ceaa9592 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 } } }) }) );