Skip to content

sendToolListChanged() notification silently dropped when no GET SSE channel is open #1771

Closed as not planned

Description

@abithap

Describe the bug
McpServer.sendToolListChanged() (and the parallel sendResourceListChanged / sendPromptListChanged) always routes the notification to the standalone GET SSE channel. If no GET channel is open — which is the case for stateless servers or any client that does not maintain a persistent SSE connection — the notification is silently dropped with no error.

The transport already supports inline delivery of notifications via relatedRequestId (the comment at line 674 of webStandardStreamableHttp explicitly documents this intent: "Ignore notifications from tools (which have relatedRequestId set) — Those will be sent via dedicated response SSE streams"). The public API simply never exposes the option, making it impossible to use as designed.

To Reproduce
Steps to reproduce the behavior:

  1. Create a stateless MCP server (sessionIdGenerator: undefined) or a server where the client does not open a GET SSE channel
  2. Register a tool that dynamically modifies the tool list mid-session (e.g. unlocks additional tools after a user selects an environment)
  3. Inside the tool handler, call server.sendToolListChanged() after modifying the tool set
  4. Observe: the method returns successfully with no error, but the client never receives the notification and never re-fetches tools/list

Expected behavior
sendToolListChanged() should accept an optional relatedRequestId parameter so the notification can be delivered inline in the active POST SSE response — the same HTTP response that triggered the tool list change. This removes the dependency on a persistent GET channel and enables stateless servers to reliably notify any MCP-compliant client.

Expected call from within a tool handler:

server.sendToolListChanged({ relatedRequestId: extra.requestId });
Note: extra.requestId is already available in tool handlers (set at protocol.js line 350) and is already used as relatedRequestId when sending tool responses (line 93). The transport's send() already routes messages with relatedRequestId to the correct POST SSE stream (lines 700–712). The only missing piece is the pass-through in the public API.

The fix is two lines across two methods:

// server/index.ts
async sendToolListChanged(options?: NotificationOptions) {
return this.notification({ method: 'notifications/tools/list_changed' }, options);
}

// server/mcp.ts
sendToolListChanged(options?: NotificationOptions) {
if (this.isConnected()) {
this.server.sendToolListChanged(options);
}
}
Apply the same change to sendResourceListChanged() and sendPromptListChanged().

Logs
On a stateless server, calling sendToolListChanged() with no GET channel open hits this path in webStandardStreamableHttp:

if (requestId === undefined) {
const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId);
if (standaloneSse === undefined) {
return; // ← notification silently dropped, no error thrown
}
}
No log entry, no error, no indication the notification was not delivered.

Additional context
The inline SSE delivery path (when relatedRequestId is set) already works correctly end-to-end:

handlePostRequest opens an SSE response stream and maps the request ID to it (_requestToStreamMapping, line 534)
send() with relatedRequestId set looks up the stream and writes the notification as an SSE event inline (lines 700–712) before the tool result is written and the stream is closed
The client receives both events in the same HTTP response and can re-fetch tools/list within the same turn — without requiring a persistent GET channel
This enables fully stateless servers that work correctly with all MCP clients regardless of whether they maintain a GET SSE channel, and allows tool list changes to be visible to the model in the same turn they are triggered rather than requiring a new conversation turn.

Activity

  1. changed the title [-]sendToolListChanged() silently drops notification on stateless servers — relatedRequestId pass-through missing from public API[/-] [+]sendToolListChanged() notification silently dropped when no GET SSE channel is open[/+] on Mar 26, 2026
  2. theamodhshetty commented on Mar 27, 2026

    @theamodhshetty

    Opened #1777. This threads NotificationOptions through the three list-changed helpers so streamable HTTP servers can pass relatedRequestId and deliver the notification inline on the active POST SSE response instead of dropping it when there is no standalone GET SSE channel. I also added a regression around sendToolListChanged() on the POST SSE path.

  3. added a commit that references this issue on Mar 28, 2026
    65daa43
  4. lanxevo3 commented on Mar 28, 2026

    @lanxevo3

    Fixed in PR #1802 — stateless servers can now use \sendToolListChanged({ relatedRequestId: extra.requestId })\ to deliver notifications inline via the POST SSE response stream.

  5. felixweinberger commented on Mar 31, 2026

    @felixweinberger
    Contributor

    Thanks for the detailed report. This is already achievable without an API change — inside a tool handler, ctx.mcpReq.notify automatically sets relatedRequestId to the current request ID (see protocol.ts:553):

    server.registerTool('select-env', { /* ... */ }, async (args, ctx) => {
        // modify tool set...
        await ctx.mcpReq.notify({ method: 'notifications/tools/list_changed' });
        return { content: [{ type: 'text', text: 'Environment selected' }] };
    });

    This routes inline on the POST SSE stream and works on stateless servers. We'll add this pattern to docs/server.md rather than expanding the send*ListChanged signatures.

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions