What happened?
McpServer sets capabilities.tools.listChanged to true by default whenever the constructor does not receive a capabilities.tools value at all, and there is no way to opt out short of passing an explicit false. For a server built as a fresh, single response McpServer instance per request, for example a factory function passed to createMcpHandler, this advertises a notification the server can never send. Nothing survives between requests to track a subscription against or to emit the notification from.
A client that opens subscriptions/listen with toolsListChanged: true against a server built this way gets an acknowledgment that echoes the filter back, then a stream that stays open for a notification that structurally cannot arrive.
Root cause
packages/server/src/server/mcp.ts, tag @modelcontextprotocol/server@2.0.0, commit cc4b416. The same default is still present on main today, commit 7f7a94c, 2026-09-25.
// mcp.ts:117-134
constructor(serverInfo: Implementation, options?: ServerOptions) {
this.server = new Server(serverInfo, options);
if (options?.capabilities?.tools) {
this.setToolRequestHandlers();
}
// ...resources, prompts follow the same pattern
}
// mcp.ts:161-173
private setToolRequestHandlers() {
if (this._toolHandlersInitialized) {
return;
}
this.server.assertCanSetRequestHandler('tools/list');
this.server.assertCanSetRequestHandler('tools/call');
this.server.registerCapabilities({
tools: {
listChanged: this.server.getCapabilities().tools?.listChanged ?? true
}
});
setToolRequestHandlers() runs on the first registerTool() call whenever nothing was declared at construction. At that point getCapabilities().tools is still undefined, so ?? true fires unconditionally. Nothing asks whether the factory or the transport around it is even able to deliver a later notification.
What the spec says the flag means
server/tools.mdx:
listChanged indicates whether the server will emit notifications when the list of available tools changes.
basic/patterns/subscriptions.mdx, describing the subscriptions/listen acknowledgment:
The notifications field in the acknowledgment reflects the subset the server agreed to honor. Notification types the server does not support are omitted.
A server whose architecture makes the notification structurally unreachable is not honoring the subset it echoes back.
Related issue, checked before filing
This overlaps with #2622, and I searched issues and pull requests for both before writing this up. #2622 reports a narrower problem that looks largely resolved on this line: an explicit capabilities: { tools: { listChanged: false } } passed to the constructor used to be silently overridden back to true. On server@2.0.0, getCapabilities().tools?.listChanged ?? true already respects an explicit false, since nullish coalescing does not touch it, so reading the source at this version, #2622's own repro should no longer trigger it. PR #2625, the open backport of that fix to v1.x, says so directly: it will "keep the existing listChanged: true default when no value is provided." That line is what this issue is about. A caller who supplies nothing still gets that default, and nothing tells them the architecture in front of it cannot deliver on it.
What did you expect?
Two things would help without touching what already works today. First, make the default depend on whether the server can plausibly deliver the notification. Short of that, a callout in the McpServer constructor and registerTool documentation naming the default and the stateless case it misrepresents would at least turn the fix into the one line capabilities: { tools: { listChanged: false } } instead of a repro someone has to build first. I do not think the default itself should simply flip, since that could break a server that registers tools dynamically after construction and genuinely wants true, and I have not checked how cheaply a host wrapper around createMcpHandler could detect the absence of a persistent transport, so the first option may be more work than it looks from the server package alone.
This came up while auditing a small production server built on server@2.0.0. It now passes the flag explicitly, so this is not a report about that server. It is a report about a default a caller has to already know exists before they know to override it.
Code to reproduce
import { McpServer } from "@modelcontextprotocol/server";
function createServer(): McpServer {
const server = new McpServer({ name: "example", version: "1.0.0" });
server.registerTool(
"example_tool",
{ description: "An example tool", inputSchema: { type: "object", properties: {} } },
async () => ({ content: [] }),
);
return server;
}
// No `capabilities` option is passed to the constructor.
// server/discover (and initialize on the legacy lane) now answers:
// { "capabilities": { "tools": { "listChanged": true } } }
SDK version
@modelcontextprotocol/server@2.0.0, commit cc4b416. The same default is confirmed present on main, commit 7f7a94c, 2026-09-25.
Area
Server
What happened?
McpServersetscapabilities.tools.listChangedtotrueby default whenever the constructor does not receive acapabilities.toolsvalue at all, and there is no way to opt out short of passing an explicitfalse. For a server built as a fresh, single responseMcpServerinstance per request, for example a factory function passed tocreateMcpHandler, this advertises a notification the server can never send. Nothing survives between requests to track a subscription against or to emit the notification from.A client that opens
subscriptions/listenwithtoolsListChanged: trueagainst a server built this way gets an acknowledgment that echoes the filter back, then a stream that stays open for a notification that structurally cannot arrive.Root cause
packages/server/src/server/mcp.ts, tag@modelcontextprotocol/server@2.0.0, commitcc4b416. The same default is still present onmaintoday, commit7f7a94c, 2026-09-25.setToolRequestHandlers()runs on the firstregisterTool()call whenever nothing was declared at construction. At that pointgetCapabilities().toolsis stillundefined, so?? truefires unconditionally. Nothing asks whether the factory or the transport around it is even able to deliver a later notification.What the spec says the flag means
server/tools.mdx:basic/patterns/subscriptions.mdx, describing thesubscriptions/listenacknowledgment:A server whose architecture makes the notification structurally unreachable is not honoring the subset it echoes back.
Related issue, checked before filing
This overlaps with #2622, and I searched issues and pull requests for both before writing this up. #2622 reports a narrower problem that looks largely resolved on this line: an explicit
capabilities: { tools: { listChanged: false } }passed to the constructor used to be silently overridden back totrue. Onserver@2.0.0,getCapabilities().tools?.listChanged ?? truealready respects an explicitfalse, since nullish coalescing does not touch it, so reading the source at this version, #2622's own repro should no longer trigger it. PR #2625, the open backport of that fix tov1.x, says so directly: it will "keep the existinglistChanged: truedefault when no value is provided." That line is what this issue is about. A caller who supplies nothing still gets that default, and nothing tells them the architecture in front of it cannot deliver on it.What did you expect?
Two things would help without touching what already works today. First, make the default depend on whether the server can plausibly deliver the notification. Short of that, a callout in the
McpServerconstructor andregisterTooldocumentation naming the default and the stateless case it misrepresents would at least turn the fix into the one linecapabilities: { tools: { listChanged: false } }instead of a repro someone has to build first. I do not think the default itself should simply flip, since that could break a server that registers tools dynamically after construction and genuinely wantstrue, and I have not checked how cheaply a host wrapper aroundcreateMcpHandlercould detect the absence of a persistent transport, so the first option may be more work than it looks from the server package alone.This came up while auditing a small production server built on
server@2.0.0. It now passes the flag explicitly, so this is not a report about that server. It is a report about a default a caller has to already know exists before they know to override it.Code to reproduce
SDK version
@modelcontextprotocol/server@2.0.0, commit cc4b416. The same default is confirmed present on main, commit 7f7a94c, 2026-09-25.
Area
Server