tools/list_changed ignored on local stdio MCP — resources/list_changed works on the same server

Resolved 💬 2 comments Opened Apr 18, 2026 by Eigenwise Closed May 26, 2026

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

  1. Start Claude Code. ToolSearch shows only mcp__listchanged_repro__add_more.
  2. Call mcp__listchanged_repro__add_more — server registers dynamic_tool + repro://dynamic/one and sends both notifications/tools/list_changed and notifications/resources/list_changed.
  3. Same turn: ListMcpResourcesTool() → ✅ returns repro://dynamic/one. ReadMcpResourceTool on it returns "hello from dynamic resource". Resource notification was processed.
  4. Same turn: ToolSearch({query: "select:mcp__listchanged_repro__dynamic_tool"})"No matching deferred tools found".
  5. 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.

View original on GitHub ↗

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