Skip to content

[v2] tools.listChanged defaults to true even when a server can never send the notification #2868

Description

@erekola

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions