Skip to content

MCP server: list-change notifications are lost for clients that negotiate 2026-07-28 (no subscriptions/listen) #1383

Open

Description

@psp65

Summary

When a client negotiates protocol revision 2026-07-28 (via server/discover), a StdioMcpServer cannot deliver tool or prompt list changes to it. A server that proxies MCP servers with dynamic tool discovery therefore stops working for those clients. In that pattern, an upstream adds tools during a tools/call and emits notifications/tools/list_changed, but the client never re-lists, so the new tools are never offered to the model.

Details

  • In 2026-07-28, list-change notifications are only delivered on a subscriptions/listen stream that the client opened with toolsListChanged: true.
    • Each notification on that stream is tagged with io.modelcontextprotocol/subscriptionId.
    • notifications/subscriptions/acknowledged comes first.
    • The spec says "the server MUST NOT send notification types the client has not explicitly requested" (see also Tools › List Changed Notification).
  • mcp-server does not implement subscriptions/listen (it returns -32601). McpDomainOperations.discoverCapabilities therefore correctly advertises tools: {} and prompts: {} without listChanged, as planned in MCP server: support protocol revision 2026-07-28 (stateless / modern era) #1337.
  • McpCatalog.onRemoteNotification still writes the upstream's untagged notifications/tools/list_changed to the transport, regardless of the negotiated revision. A client that negotiated 2026-07-28 ignores it, because the capability was not advertised and it has no subscription.
  • The same clients work against the same server when it only offers the handshake versions. They fall back to initialize, the server advertises tools.listChanged: true, and the forwarded notification triggers a re-list.

Reproduction

  1. Build a StdioMcpServer with an HttpMcpClient remote. On tools/call discover, the remote adds a tool and writes notifications/tools/list_changed followed by the result into the SSE response.
  2. The client sends server/discover with _meta["io.modelcontextprotocol/protocolVersion"] = "2026-07-28". The result lists 2026-07-28 first, and capabilities.tools has no listChanged.
  3. The client calls discover. The server writes an untagged list_changed and the result. A 2026-07-28 client does not re-list, and tools/call for the new tool fails with -32602 No such tool until something else triggers a tools/list.
  4. If listChanged: true is advertised, a client sends subscriptions/listen with {"notifications":{"toolsListChanged":true,"promptsListChanged":true}} and receives -32601.

Proposed fix

  • Short term (PR to follow): add an opt-in protocolVersions(...) to McpEngine.Builder and StdioMcpServerBuilder, so a server that relays list changes can serve the handshake versions only. A client that prefers 2026-07-28 then receives -32022 with the supported list from server/discover, falls back to initialize, and keeps receiving list changes.
  • Longer term: implement subscriptions/listen on stdio and advertise listChanged from server/discover once it is available. Points that came up while looking at this:
    • Use a subscription registry owned by the connection and keyed by the listen request id, preserving the id's JSON type.
    • Send the acknowledgement strictly before any notification on that subscription.
    • Handle notifications/cancelled for the listen id, and send the completion result on graceful shutdown.
    • Define explicit behavior when handshake and stateless requests share one stdio connection; today there is one mutable session version.
    • Install notification callbacks for upstreams that are reached through 2026-07-28; today they are installed only during initialize.
    • HTTP needs a streaming POST response before it can support this.
    • Separately: do not send untagged list-change notifications to 2026-07-28 clients that have not subscribed.

Activity

  1. adwsingh commented on Oct 8, 2026

    @adwsingh
    Contributor

    I would just implement subscriptions/listen, are there any blockers to doing it now?

  2. psp65 commented on Oct 8, 2026

    @psp65
    ContributorAuthor

    No hard blockers. It's mostly plumbing. From reading the code, here's what it needs:

    Stdio (downstream)

    • Decode subscriptions/listen and notifications/cancelled. Both are UnknownCall today, so listen gets -32601 and cancellation is silently dropped.
    • Add a subscription registry per connection in StdioMcpServer. Key it by the listen request id, keeping the id's JSON type, and store the accepted filter (toolsListChanged / promptsListChanged; resources omitted).
    • Send notifications/subscriptions/acknowledged before any notification on that subscription. Keep the listen request open until notifications/cancelled arrives, or until shutdown, which answers it with {resultType: "complete", _meta: {subscriptionId}}.
    • Route catalog changes through the registry with _meta.subscriptionId: remote list_changed, addService / addRemoteClient, and refreshTools. Stop writing untagged list_changed to 2026-07-28 clients that haven't subscribed. Handshake-era clients keep today's untagged broadcast.
    • Advertise listChanged from discoverCapabilities only on transports that can deliver it.
    • Handle both eras on one connection. McpSession keeps a single mutable version that each request's _meta overwrites, so "initialized via handshake" needs tracking separately to choose between untagged delivery and subscription delivery.

    Upstreams (only relevant when the proxied server itself speaks 2026-07-28)

    • prepareRemoteClient skips initialize for stateless-capable remotes, but McpRemoteClient installs the notification consumer only in initialize. A stateless upstream's list changes are therefore never seen. The client side would have to open its own subscriptions/listen to the upstream, which over HTTP means a long-lived POST SSE stream.

    HTTP (downstream)

    • McpHttpHandler.Response is (statusCode, body) and can't hold a long-lived streaming response. HTTP would keep not advertising listChanged until that's supported.

    A reasonable split would be one PR for stdio downstream (listen, cancel, ack ordering, tagged delivery, listChanged in discover, no unsolicited notifications), with HTTP and stateless upstreams as follow-ups. #1385 can then be closed, or kept as a configuration option — your call.

  3. psp65 commented on Oct 13, 2026

    @psp65
    ContributorAuthor

    #1385 was merged as immediate unblocker.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions