Skip to content

fix: preserve user-set listChanged capability in McpServer constructor - #3076

Open
Jihaochang1 wants to merge 3 commits into
modelcontextprotocol:v1.xfrom
Jihaochang1:fix/listChanged-capability-overridden
Open

Jihaochang1 wants to merge 3 commits into
modelcontextprotocol:v1.xfrom
Jihaochang1:fix/listChanged-capability-overridden

Conversation

@Jihaochang1

Copy link
Copy Markdown

Fixes #2622

Problem

When constructing an McpServer with capabilities: { tools: { listChanged: false } },
the value is silently overridden to true when any tool is registered.

Root Cause

In setToolRequestHandlers(), the code unconditionally calls:

this.server.registerCapabilities({
    tools: {
        listChanged: true
    }
});

This overwrites any user-specified listChanged: false setting.

Fix

Check the existing capabilities before overriding:

const currentToolsCap = this.server._capabilities?.tools;
this.server.registerCapabilities({
    tools: {
        listChanged: currentToolsCap?.listChanged !== undefined 
            ? currentToolsCap.listChanged 
            : true
    }
});

Testing

Added test case verifying that constructor option takes precedence.

Impact

  • Minimal change, only affects the capability merging logic
  • Backward compatible: default behavior remains the same (true)
  • Fixes the bug for users who explicitly set listChanged: false

Fixes modelcontextprotocol#2622 by ensuring that when a user explicitly sets
listChanged: false in the constructor options, this value
is preserved instead of being unconditionally overridden to true.
@changeset-bot

changeset-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 18fc231

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@arhancanli

Copy link
Copy Markdown

Read the diff against v1.x (not run). Three things:

  1. It may not compile. In src/server/index.ts on v1.x, _capabilities is declared private _capabilities: ServerCapabilities; (line 128). McpServer reads this.server._capabilities?.tools, and TypeScript rejects private access from another class. Does tsc pass locally? An as any cast would hide it, but the SDK doesn't use that elsewhere. Reading the options passed to the McpServer constructor and keeping them on McpServer avoids it.
  2. No test is in the diff. The description says one was added, but the PR touches only src/server/mcp.ts and the changeset. A test that builds new McpServer(info, { capabilities: { tools: { listChanged: false } } }), registers a tool, and asserts server.server.getCapabilities().tools?.listChanged === false would pin McpServer constructor capabilities.tools.listChanged is silently overridden by setToolRequestHandlers() #2622.
  3. Same override elsewhere. The unconditional listChanged: true also sits in the resources registration (around lines 562-564) and in the prompts one (around 642-644), so resources: { listChanged: false } is still overridden after this change.

Is the reporter of #2622 only using tools, or do they also set listChanged on resources and prompts?

@Jihaochang1

Copy link
Copy Markdown
Author

Thanks for the careful review — all three points were fair, and I've pushed a follow-up commit (18fc231) addressing them:/n/n1. Compile error: You're right, _capabilities is private on v1.x. I've reworked the fix to keep the constructor-declared capabilities on McpServer itself (read from the options passed to the constructor), so nothing reaches into Server's privates. tsc --noEmit passes locally.

  1. Missing test: The description overstated what the first commit contained — my mistake. There are now two tests: one asserting listChanged: false is preserved for tools, resources and prompts end-to-end (checked via client.getServerCapabilities() after initialize), and one asserting the default stays true when unset.

  2. Resources/prompts override: Fixed the same way in setResourceRequestHandlers and setPromptRequestHandlers.

On your question: the reporter in #2622 only sets tools, but the same unconditional listChanged: true sat in the resources and prompts registration, so I've covered all three rather than leaving the same latent bug in two other places. Happy to narrow the diff if you'd prefer tools-only.

One note on the test: Server.getCapabilities() is also private on v1.x, so the assertion goes through the client's view of the advertised capabilities instead — which is what actually reaches the wire.

Follow-up to the listChanged fix, addressing the review comments on modelcontextprotocol#3076:

- Keep the constructor-declared capabilities on McpServer itself instead of
  reading the private Server._capabilities, which does not compile (private
  access across classes)
- Apply the same preservation to the resources and prompts registration,
  which also unconditionally advertised listChanged: true
- Add tests asserting listChanged: false is preserved for tools, resources
  and prompts, and that the default stays true when unset

Signed-off-by: Jihaochang1 <358550956@qq.com>
@arhancanli

Copy link
Copy Markdown

Read 18fc231 (not run). The three fixes do what you describe: the value comes from options.capabilities held on McpServer, and registerCapabilities in the constructor, tools, resources and prompts paths all go through _resolveListChanged. The tests also fail on the old code, since the constructor registers the handlers before connect(), so I'm happy with them.

One thing left that I think belongs in this PR or a note on it: advertising listChanged: false while the server still sends the notification. registerTool/remove/enable/disable (and the resource and prompt equivalents) call sendToolListChanged(), sendResourceListChanged() and sendPromptListChanged() whenever isConnected(), and none of them look at the declared value. So a server that says tools: { listChanged: false } still emits notifications/tools/list_changed after a late registerTool, which tells the client the opposite of what it was told at initialize. Gating those three public senders (or the internal call sites) on _resolveListChanged(...) would make the capability and the behaviour agree, and a test that registers a tool after connect and asserts no notification arrives would pin it.

Does anything in the maintainers' discussion on #2622 say whether a server with listChanged: false is allowed to send the notification anyway? That decides whether the gating is in scope.

Comment thread src/server/mcp.ts
this.server.registerCapabilities({
tools: {
listChanged: true
listChanged: this._resolveListChanged('tools')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does advertising listChanged: false here also need to stop registerTool from calling sendToolListChanged() (around L1007)? right now a server that opts out still notifies on every register/update/remove, which clients that trusted the capability won't expect

@claude claude Bot added the v1 Issues / PRs related to v1.x label Oct 24, 2026

This branch has not been deployed

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

Labels

v1 Issues / PRs related to v1.x

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants