modelcontextprotocol / modelcontextprotocol/typescript-sdk
sendToolListChanged drops notifications on stateless Streamable HTTP transports (missing relatedRequestId)
Nobody has claimed this yet.
- 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
- Create an MCP server using
StreamableHTTPServerTransportwithsessionIdGenerator: undefined(stateless) - Register a tool that, when called, enables another tool via the
RegisteredTool.enable()handle - Call that tool from a client
- The
tools/list_changednotification 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:
- Setting
.enabled = truedirectly on_registeredTools[name](bypassingenable()to avoid the broken notification) - Sending
tools/list_changedourselves viaextra.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*ListChangednotifications have the same issue
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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