tools/list_changed ignored on local stdio MCP — resources/list_changed works on the same server
Summary
Claude Code CLI 2.1.113 caches the stdio MCP server's tool list at startup and never refreshes it, even after a notifications/tools/list_changed from the server. The same server's notifications/resources/list_changed IS processed correctly on the same turn, proving the notification reaches the client and the server is compliant — it's specifically the tools path that's dead.
Environment
- Claude Code CLI 2.1.113
- Node 22.22.0
- Windows 11
- Local stdio MCP server (not remote / not Anthropic proxy)
Server (minimal, self-contained)
Plugin MCP server using the official @modelcontextprotocol/sdk. Full source for a stripped-down repro:
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
const server = new Server(
{ name: 'listchanged-repro', version: '0.0.0' },
{ capabilities: { tools: { listChanged: true }, resources: { listChanged: true } } },
);
let extraTools: Array<{ name: string; description: string; inputSchema: unknown }> = [];
let extraResources: Array<{ uri: string; name: string; description: string; mimeType: string }> = [];
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'add_more',
description: 'Call this to dynamically register a new tool and a new resource.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
},
...extraTools,
],
}));
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: extraResources }));
server.setRequestHandler(ReadResourceRequestSchema, async (req) => ({
contents: [{ uri: req.params.uri, mimeType: 'text/plain', text: 'hello from dynamic resource' }],
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
if (req.params.name === 'add_more') {
extraTools.push({
name: 'dynamic_tool',
description: 'Registered mid-session via notifications/tools/list_changed.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
});
extraResources.push({
uri: 'repro://dynamic/one',
name: 'dynamic_resource',
description: 'Registered mid-session via notifications/resources/list_changed.',
mimeType: 'text/plain',
});
await server.sendToolListChanged(); // ⬅ ignored by Claude Code
await server.sendResourceListChanged(); // ⬅ processed correctly
return { content: [{ type: 'text', text: 'registered dynamic_tool + repro://dynamic/one' }] };
}
if (req.params.name === 'dynamic_tool') {
return { content: [{ type: 'text', text: 'dynamic_tool invoked successfully' }] };
}
return { content: [{ type: 'text', text: `unknown tool ${req.params.name}` }], isError: true };
});
await server.connect(new StdioServerTransport());
Register as a stdio MCP server in ~/.claude/settings.json:
{ "mcpServers": { "listchanged-repro": { "command": "node", "args": ["./repro.js"] } } }
Reproduction
- Start Claude Code.
ToolSearchshows onlymcp__listchanged_repro__add_more. - Call
mcp__listchanged_repro__add_more— server registersdynamic_tool+repro://dynamic/oneand sends bothnotifications/tools/list_changedandnotifications/resources/list_changed. - Same turn:
ListMcpResourcesTool()→ ✅ returnsrepro://dynamic/one.ReadMcpResourceToolon it returns"hello from dynamic resource". Resource notification was processed. - Same turn:
ToolSearch({query: "select:mcp__listchanged_repro__dynamic_tool"})→ "No matching deferred tools found". - Send a new user message (new turn). Re-run step 4. Still "No matching deferred tools found". The deferred list never picks up
dynamic_tool.
Expected
Matching behavior for both notifications. Either both refresh, or both don't. Today resources refreshes and tools doesn't, which makes dynamic tool surfaces fundamentally unusable from Claude Code for the whole life of the session.
Actual
The tool cache is frozen at the startup tools/list response. Fresh turns don't help. Killing + respawning the server process (which Claude Code auto-does on a dead pipe) doesn't re-read tools/list either.
Why this isn't a server bug
Same process, same transport, same SDK. The server calls sendToolListChanged() and sendResourceListChanged() on the exact same line. Resources refresh proves the notification reaches the client.
Related
- #4118 — 2-year-old feature request
- #50339 — detailed Claude Desktop repro (same symptom in a different binary, still open with detailed root cause)
- #31893 — spec-compliance roundup (closed stale) notes "does NOT work within the same turn" and a startup race condition
- #41123 — remote-proxy variant (closed duplicate)
This report provides a clean same-session, same-server A/B: resources notification → works, tools notification → broken. That should narrow the fix to whatever differs between the two list-changed handlers in the client.
Workaround for connector developers (in lieu of a fix)
Statically register one meta-tool at startup that dispatches to any dynamic action by name:
// in ListToolsRequestSchema handler
{
name: 'my_server__invoke',
description: 'Fallback dispatcher when the client does not refresh tools/list_changed.',
inputSchema: {
type: 'object',
properties: {
action: { type: 'string' },
args: { type: 'object', additionalProperties: true },
},
required: ['action'],
},
}
Because this tool is present at startup, it survives the frozen cache. Users can then call my_server__invoke({action: 'dynamic_tool', args: {...}}) and the server routes internally. Not a substitute for fixing the notification path — the whole point of list_changed is letting real tool surfaces evolve — but it unblocks users today.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗