modelcontextprotocol / modelcontextprotocol/typescript-sdk

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

Open
#2,232 7 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug fix proposed P2 ready for work
Dominant language
TypeScript
Stars
13.4k
Forks
2.2k
Avg merge
3d 15h
Merged PRs (30d)
4

Description

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Trace _createRegisteredTool in mcp.js, sendToolListChanged in server/index.js, and the transport send() path. Reproduce the issue with stateless StreamableHTTPServerTransport, then verify that tools, resources, and prompts list-changed notifications reach the POST response stream when triggered during a handler, while stateful transports continue to work.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.