Skip to content

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

@abithap

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.

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