Skip to content

Cancel open subscriptions/listen requests when the transport stops delivering messages - #1933

Open
pournasserian wants to merge 1 commit into
modelcontextprotocol:mainfrom
pournasserian:fix/stdio-exit-after-eof-with-listen
Open

pournasserian wants to merge 1 commit into
modelcontextprotocol:mainfrom
pournasserian:fix/stdio-exit-after-eof-with-listen

Conversation

@pournasserian

Copy link
Copy Markdown

Fixes #1914

Problem

When a stdio server's stdin reaches end-of-stream, McpSessionHandler.ProcessMessagesCoreAsync leaves its read loop and waits in its finally block for every in-flight handler to finish. A subscriptions/listen handler (built-in or a custom SubscriptionsListenHandler) only completes when its request is cancelled. Once the transport has stopped delivering messages, no notifications/cancelled can arrive, and the handler's token is linked only to the loop token, which nothing cancels at EOF. So the wait never ends, RunAsync never returns, and the server process stays alive. Clients that open subscriptions/listen on every connection leak one server process per closed session.

Fix

McpSessionHandler now tracks in-flight subscriptions/listen requests separately. In the read loop's finally, before it waits for in-flight handlers, it cancels those requests. This covers the built-in handler and any custom handler that honours its token.

Other in-flight handlers (e.g. a long tools/call) still run to completion as before, so side-effecting work isn't cut short at EOF. As noted on the issue, cancelling every handler when the transport completes would also cover that case, but it's a broader behaviour change, so I've left it out of this PR.

Streamable HTTP is unaffected in practice: its session transports only complete their channel on dispose, and StreamableHttpSession.DisposeAsync already cancels the server right after disposing the transport.

Tests

Added to RawStreamConformanceTests:

  • InputEndOfStream_WithOpenSubscriptionsListen_CompletesRunAsync: sends server/discover and subscriptions/listen, waits for the acknowledgement, completes the input pipe, and asserts RunAsync returns without its token being cancelled.
  • InputEndOfStream_WithOpenCustomSubscriptionsListen_CompletesRunAsync: the same scenario with a custom SubscriptionsListenHandler that waits for its token.

Both time out without the fix and pass with it. ModelContextProtocol.Tests passes on net10.0, net9.0, net8.0 and net472, and ModelContextProtocol.AspNetCore.Tests passes on net10.0 and net9.0, except for tests that need the npm packages (ClientIntegrationTests against the "everything" server and the ConformanceTests), which I couldn't run locally without npm ci.

…livering messages

A subscriptions/listen handler only completes when its request is cancelled.
Once the transport's input ends (stdin EOF on stdio), no notifications/cancelled
can arrive, so ProcessMessagesCoreAsync waited for it forever and RunAsync never
returned, leaving the server process alive. Cancel in-flight subscriptions/listen
requests before waiting for in-flight handlers; other handlers still run to
completion.

Fixes modelcontextprotocol#1914

This branch has not been deployed

No deployments
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.

Stdio server never exits after stdin EOF once a client has sent subscriptions/listen (2026-07-28)

1 participant