Claude Desktop ignores notifications/tools/list_changed — spec non-compliance in directMcpHost (v1.3109.0)

Resolved 💬 3 comments Opened Apr 18, 2026 by egemenNB Closed May 25, 2026

Claude Desktop ignores notifications/tools/list_changed — spec non-compliance in directMcpHost

Affected: Claude Desktop macOS v1.3109.0 (also verified in v1.2773.0 — bug present across 336 version bumps)
Component: .vite/build/mcp-runtime/directMcpHost.js + .vite/build/index.js (4 client construction sites)
Severity: Functional — dynamic tool registration is silently broken for every MCP server

Summary

Per the MCP spec — Tools › List Changed Notification, a server that advertises tools.listChanged: true SHOULD send notifications/tools/list_changed when its tool inventory changes, and the client SHOULD respond by re-issuing tools/list. The spec defines no client-side capability declaration as a precondition.

Claude Desktop's directMcpHost:

  1. Defines ToolListChangedNotificationSchema (line 4451) and the dispatcher case for notifications/tools/list_changed (line 15488) — so the message is parsed.
  2. Has a complete _setupListChangedHandler pipeline (lines 12891–13329) with debounce and auto-refresh — but it is gated on a config.listChanged.tools.onChanged callback that the bundle never passes anywhere.
  3. Constructs every MCP client with empty or partial capabilities (4 sites in index.js + 1 in directMcpHost.js).
  4. Serves a closure-captured frozen array to consumers via the proxy Server — so even if the notification reached the SDK handler, the proxy could not propagate the change.

Net effect: any MCP server that adds, removes, or renames a tool after initialize is invisible to Claude Desktop until the user fully restarts the app.

Reproduction

  1. Run an MCP server that advertises capabilities.tools.listChanged: true and dynamically registers a new tool 30 s after initialize.
  2. Connect it to Claude Desktop (any of: claude_desktop_config.json stdio, Custom 3P HTTP, Custom 3P SSE, OAuth Custom 3P, DXT).
  3. Server emits {"jsonrpc":"2.0","method":"notifications/tools/list_changed"}.
  4. Expected: Desktop issues tools/list, new tool appears in the model's tool catalog.
  5. Actual: Desktop receives the notification, parses it, and discards it. Tool catalog stays stale until app restart.

Live evidence (this machine)

n8n MCP server is correctly advertising the capability — captured from ~/Library/Logs/Claude/mcp-server-n8n-mcp.log:

{"jsonrpc":"2.0","id":0,"result":{
  "protocolVersion":"2025-06-18",
  "capabilities":{"tools":{"listChanged":true}},
  "serverInfo":{"name":"n8n MCP Server","version":"1.0.0"}
}}

12+ such handshakes recorded over recent days. Server side is spec-compliant; Desktop drops every subsequent list_changed on the floor.

Root cause walkthrough (v1.3109.0, freshly extracted from app.asar)

A. Client construction — all 4 sites pass empty/incomplete capabilities

| Site | File:offset | Capabilities passed |
|---|---|---|
| custom3p-main | index.js byte 6321640 | {capabilities:{}} |
| custom3p-desktop (HTTP/SSE) | index.js byte 6418384 | {capabilities:{}} |
| custom3p-desktop (OAuth) | index.js byte 6419226 | {capabilities:{}} |
| local-agent-mode | index.js byte 8916583 | {capabilities:{roots:{listChanged:!0}}}tools missing |
| connectRemote | directMcpHost.js:15829 | {capabilities:{}} |

The only two tools:{listChanged:!0} strings in index.js (offsets 7955135, 8802091) are server.registerCapabilities(...) for Desktop's own built-in MCP servers — i.e. server-side advertising, not client-side subscription.

B. SDK gate that never fires

// directMcpHost.js:12890
if (config2.tools && this._serverCapabilities?.tools?.listChanged) {
  this._setupListChangedHandler("tools", ToolListChangedNotificationSchema, config2.tools, async () => {
    const result = await this.listTools();
    return result.tools;
  });
}

config2.tools is the truthy object the SDK expects callers to supply (e.g. { autoRefresh: true, debounceMs: 500, onChanged: (err, tools) => ... }). Nothing in Desktop's bundle ever supplies it.

C. Frozen closure variable in the proxy

// directMcpHost.js:15837 — captured ONCE at connect time
const { tools } = await client.listTools(void 0, { signal: abort.signal });
return { client, tools };
// directMcpHost.js:15751 — closure ref, not a mutable holder
proxy.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: tools.map(t => ({
    name: t.name,
    description: t.description ?? t.name,
    inputSchema: t.inputSchema,
    ...t._meta != null && { _meta: t._meta }
  }))
}));

tools is a const from destructuring; there is no setter, no reassignment, no splice. Even if (B) were fixed, the proxy would still need a mutable reference (e.g. let toolsRef = tools updated inside onChanged).

Suggested fix

Two coordinated changes:

  1. Wire the SDK gate at all 5 client construction sites:

``js
const client = new Client(
{ name: "custom3p-desktop", version: config2.appVersion },
{
capabilities: {},
listChanged: {
tools: {
autoRefresh: true,
debounceMs: 500,
onChanged: (err, newTools) => {
if (!err && newTools) toolsRef = newTools;
}
}
}
}
);
``

  1. Make the proxy reference mutable so updates propagate:

``js
let toolsRef = tools;
proxy.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: toolsRef.map(t => ({ ... }))
}));
``

The same shape applies to the four index.js sites (koe / qfA minified ctor name).

Workarounds for connector developers (until fixed)

  • Declare every tool at startup; dispatch internally on a mode/action parameter.
  • Force a server-side process exit when tools change — LocalMcpServerManager will reconnect on next call and re-enumerate. (Does not work for Custom 3P paths through directMcpHost.)
  • Document the limitation; advise users to restart Claude Desktop after server-side schema changes.

Spec citation

"When the list of available tools changes, servers that declared the listChanged capability SHOULD send a notification: {"jsonrpc":"2.0","method":"notifications/tools/list_changed"}." — MCP spec, § Tools › List Changed Notification

Spec defines no client-side capability declaration; client eligibility is implicit through the established session.

Cross-version persistence

| Version | connectRemote capabilities | Status |
|---|---|---|
| 1.2773.0 | {} | broken |
| 1.3109.0 (current) | {} | broken |

336 version bumps, identical bug — strong signal it's not on the roadmap and worth surfacing.

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗