Skip to content

sendToolListChanged drops notifications on stateless Streamable HTTP transports (missing relatedRequestId) #2232

@luizgribeiro

Description

@luizgribeiro

Summary

When tools/list_changed is triggered from within a tool handler (e.g., via enable(), disable(), or update()), the notification is silently dropped on stateless Streamable HTTP transports because it lacks a relatedRequestId.

Reproduction

  1. Create an MCP server using StreamableHTTPServerTransport with sessionIdGenerator: undefined (stateless)
  2. Register a tool that, when called, enables another tool via the RegisteredTool.enable() handle
  3. Call that tool from a client
  4. The tools/list_changed notification is never delivered to the client

Root cause

RegisteredTool.enable() calls update({ enabled: true }) which calls sendToolListChanged():

// mcp.js — _createRegisteredTool
update: updates => {
  // ...
  registeredTool.enabled = updates.enabled;
  this.sendToolListChanged(); // ← no request context
}

sendToolListChanged() calls this.server.notification(...) without options:

// server/index.js
async sendToolListChanged() {
  return this.notification({ method: 'notifications/tools/list_changed' });
  //                         ^ no relatedRequestId
}

In the transport's send() method, messages without relatedRequestId are routed exclusively to the standalone GET SSE stream. On stateless transports, no such stream exists, so the notification is silently dropped.

This contradicts the spec, which says:

The server MAY send JSON-RPC requests and notifications before sending a JSON-RPC response. These messages SHOULD relate to the originating client request.

Streamable HTTP transport spec (2025-03-26)

Workaround

The SDK already provides extra.sendNotification() in tool handler callbacks, which correctly sets relatedRequestId. We work around the issue by:

  1. Setting .enabled = true directly on _registeredTools[name] (bypassing enable() to avoid the broken notification)
  2. Sending tools/list_changed ourselves via extra.sendNotification({ method: 'notifications/tools/list_changed' })

This routes the notification onto the POST response SSE stream, making it work on both stateful and stateless transports.

Suggested fix

When enable(), disable(), or update() are called from within a request handler, the resulting sendToolListChanged() should include the relatedRequestId of the in-flight request. This would route the notification to the POST response stream per the spec.

One approach: sendToolListChanged (and the other send*ListChanged methods) could accept an optional options parameter that gets forwarded to notification():

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

And _createRegisteredTool's update() could accept and forward those options, or the RegisteredTool handle could expose a way to pass request context.

Alternatively, the SDK could use AsyncLocalStorage to automatically capture the relatedRequestId when a tool handler is executing, so existing enable()/disable() calls work without changes.

Environment

  • @modelcontextprotocol/sdk: 1.29.0
  • Transport: StreamableHTTPServerTransport (stateless, sessionIdGenerator: undefined)
  • Affects: sendToolListChanged, sendResourceListChanged, sendPromptListChanged — all *ListChanged notifications have the same issue

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions