Skip to content

Support subscriptions/listen on the stdio MCP server - #1397

Open
psp65 wants to merge 2 commits into
smithy-lang:mainfrom
psp65:mcp-stdio-subscriptions-listen
Open

psp65 wants to merge 2 commits into
smithy-lang:mainfrom
psp65:mcp-stdio-subscriptions-listen

Conversation

@psp65

@psp65 psp65 commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

What behavior changes?

The stdio MCP server now supports subscriptions/listen for clients that negotiate 2026-07-28.

  • server/discover advertises listChanged: true for tools and prompts on stdio.
  • subscriptions/listen is acknowledged with notifications/subscriptions/acknowledged. The ack's filter is the subset of the requested types the server can deliver: toolsListChanged and promptsListChanged. Resource types are dropped from the ack for now.
  • Tool and prompt list changes are delivered to each subscription that accepted the type, tagged with _meta["io.modelcontextprotocol/subscriptionId"]. This covers local changes (addService, addRemoteClient, refreshTools) and list_changed notifications from proxied remote servers.
  • notifications/cancelled for a listen request id ends that subscription. The id's JSON type must match, so "7" does not cancel 7.
  • On graceful shutdown, each open subscription gets its complete result.
  • Untagged notifications/*/list_changed, and resource notifications relayed from proxied servers, are only written once the connection has completed initialize. A 2026-07-28 connection only receives what it subscribed to. Handshake-era clients see the same bytes as before after initialize.
  • HTTP is unchanged: subscriptions/listen returns -32601, and server/discover does not advertise listChanged. HTTP delivery needs a streaming response, which McpHttpHandler can't produce today.

New API, all @SmithyUnstableApi or default methods:

  • McpCall.Listen, McpOutcome.Subscribed, McpSubscriptionFilter
  • McpOperations.listen(...), which throws UnsupportedOperationException by default
  • McpTransportContext.supportsSubscriptions(), which defaults to false; stdio returns true

McpEngine.execute(JsonRpcRequest, ProtocolVersion) returns null for an accepted listen, because the response is only sent when the subscription ends.

Why is this change needed?

Since 2026-07-28 is the default stdio protocol, a client that negotiates it learns about list changes only through subscriptions/listen. Before this change, discover omitted listChanged and listen was not implemented, so those clients never saw a proxied server's tool list change. Tools added at runtime stayed invisible until the client restarted. #1385 worked around this by keeping stdio on the handshake protocols. This change implements the stateless flow instead, as discussed in #1383.

How was this validated?

  • New StdioMcpServerSubscriptionsTest (20 tests) covers:
    • discover advertising and the ack filter subset
    • id type preservation and the cancellation type match
    • invalid filters (-32602), duplicate active ids (-32600), and listen on a handshake connection (-32601)
    • ack ordering against a change that races activation
    • delivery of local and remote changes, with the upstream subscription tag not echoed
    • resource and progress notification routing
    • mixed-era delivery, shutdown complete, EOF cleanup, an interceptor rejecting listen, and HTTP neither advertising nor accepting listen
  • New StdioSubscriptionsIntegrationTest: an HTTP mock upstream behind the stdio server. A 2026-07-28 client runs discover, listen, and tools/list, then calls a tool that makes the upstream add a tool. The tagged list_changed arrives before that call's result, the re-list includes the new tool, the new tool is callable, and shutdown completes the subscription.
  • ./gradlew :mcp:mcp-server:build passes: 259 unit and 125 integration tests, 0 failures, plus spotless and javadoc. I couldn't run the conformance suite locally.
  • Mutation check: disabling each of these makes at least one new test fail: the handshake gate, discover advertising, cancellation, filter intersection, shutdown completion, and resource routing.
  • Manual check with Claude Code 2.1.296 over stdio against a proxied mock server that adds a tool mid-session:
    • With this branch, the client subscribed, received the tagged list_changed, and called the new tool in the same turn (3/3).
    • With main, the client never subscribed and the new tool wasn't visible (2/2).
    • A handshake-era client still worked through initialize (2/2).
  • Fixed a TestOutputStream fixture bug: a single write ending in \n recorded the line twice.

What should reviewers focus on?

  • McpSubscriptionRegistry and its use in StdioMcpServer. The registry shares the output lock, so the ack and the registration happen atomically and no tagged notification can come before its ack. Also review cancellation handling in the reader thread, and shutdown completion before the request executor stops.
  • StdioMcpServer.publish and CatalogEvents, which route handshake and stateless notifications.
  • McpDomainOperations.discoverCapabilities and listen, which tie what discover advertises to what listen accepts.
  • Behavior that needs a decision:
    1. Untagged notifications are now held until initialize completes. Before, stdio wrote them as soon as the catalog changed.
    2. On shutdown, open subscriptions get a complete result, not notifications/cancelled.
    3. Resource notifications aren't subscribable yet.

Additional Links


By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

psp65 and others added 2 commits October 10, 2026 00:52
Clients that negotiate 2026-07-28 learn about tool and prompt list
changes only through subscriptions/listen. The stdio server did not
implement it, so server/discover omitted listChanged and those clients
never re-listed after a proxied server changed its tools.

- Decode subscriptions/listen (McpCall.Listen with an
  McpSubscriptionFilter) for 2026-07-28. The engine answers with
  McpOutcome.Subscribed, carrying the requested notification types the
  transport can deliver (tools and prompts).
- StdioMcpServer keeps a subscription registry per connection. It sends
  notifications/subscriptions/acknowledged before any notification on a
  subscription, tags each list-change notification with the
  subscription id, honors notifications/cancelled, and answers open
  subscriptions with a complete result on graceful shutdown.
- On stdio, server/discover advertises listChanged for tools and
  prompts.
- Untagged list-change notifications, and resource notifications relayed
  from proxied servers, are sent only to connections that completed
  initialize. A 2026-07-28 connection receives only what it subscribed
  to.
- HTTP is unchanged: listen is answered with -32601 and discover does not
  advertise listChanged.
- Fix TestOutputStream recording a line twice when a single write ends
  with a newline.
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.

1 participant