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
StreamableHTTPServerTransport with sessionIdGenerator: 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_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:
- Setting
.enabled = true directly on _registeredTools[name] (bypassing enable() to avoid the broken notification)
- 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
Summary
When
tools/list_changedis triggered from within a tool handler (e.g., viaenable(),disable(), orupdate()), the notification is silently dropped on stateless Streamable HTTP transports because it lacks arelatedRequestId.Reproduction
StreamableHTTPServerTransportwithsessionIdGenerator: undefined(stateless)RegisteredTool.enable()handletools/list_changednotification is never delivered to the clientRoot cause
RegisteredTool.enable()callsupdate({ enabled: true })which callssendToolListChanged():sendToolListChanged()callsthis.server.notification(...)without options:In the transport's
send()method, messages withoutrelatedRequestIdare 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:
— Streamable HTTP transport spec (2025-03-26)
Workaround
The SDK already provides
extra.sendNotification()in tool handler callbacks, which correctly setsrelatedRequestId. We work around the issue by:.enabled = truedirectly on_registeredTools[name](bypassingenable()to avoid the broken notification)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(), orupdate()are called from within a request handler, the resultingsendToolListChanged()should include therelatedRequestIdof the in-flight request. This would route the notification to the POST response stream per the spec.One approach:
sendToolListChanged(and the othersend*ListChangedmethods) could accept an optionaloptionsparameter that gets forwarded tonotification():And
_createRegisteredTool'supdate()could accept and forward those options, or theRegisteredToolhandle could expose a way to pass request context.Alternatively, the SDK could use
AsyncLocalStorageto automatically capture therelatedRequestIdwhen a tool handler is executing, so existingenable()/disable()calls work without changes.Environment
@modelcontextprotocol/sdk: 1.29.0StreamableHTTPServerTransport(stateless,sessionIdGenerator: undefined)sendToolListChanged,sendResourceListChanged,sendPromptListChanged— all*ListChangednotifications have the same issue