Claude Desktop ignores notifications/tools/list_changed — spec non-compliance in directMcpHost (v1.3109.0)
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:
- Defines
ToolListChangedNotificationSchema(line 4451) and the dispatcher case fornotifications/tools/list_changed(line 15488) — so the message is parsed. - Has a complete
_setupListChangedHandlerpipeline (lines 12891–13329) with debounce and auto-refresh — but it is gated on aconfig.listChanged.tools.onChangedcallback that the bundle never passes anywhere. - Constructs every MCP client with empty or partial capabilities (4 sites in
index.js+ 1 indirectMcpHost.js). - 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
- Run an MCP server that advertises
capabilities.tools.listChanged: trueand dynamically registers a new tool 30 s afterinitialize. - Connect it to Claude Desktop (any of:
claude_desktop_config.jsonstdio, Custom 3P HTTP, Custom 3P SSE, OAuth Custom 3P, DXT). - Server emits
{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}. - Expected: Desktop issues
tools/list, new tool appears in the model's tool catalog. - 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:
- 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;
}
}
}
}
);
- 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/actionparameter. - Force a server-side process exit when tools change —
LocalMcpServerManagerwill reconnect on next call and re-enumerate. (Does not work for Custom 3P paths throughdirectMcpHost.) - 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 thelistChangedcapability 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.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗