MCP tools/list_changed notifications not honored in headless --print --output-format stream-json mode
Summary
When Claude Code runs in headless mode via claude --print --input-format stream-json --output-format stream-json --verbose and is driven as a long-running process (stdin kept open, multiple user events fed across turns), MCP servers' notifications/tools/list_changed notifications do not refresh the in-process tool registry. Subsequent turns' system/init envelopes continue to advertise the original tool snapshot.
The same MCP server, when connected from interactive claude (TUI mode), correctly handles list_changed and the session JSONL at ~/.claude/projects/.../<session-id>.jsonl records attachment.type=deferred_tools_delta events with the new tool names.
Environment
- Claude Code:
2.1.152 - OS: macOS (Darwin 25.5.0); also reproduces in a Linux container
- MCP transport: streamable HTTP (also expected to repro over stdio)
- MCP server: any server that emits
notifications/tools/list_changedmid-session (e.g. when a tenant/context switch dynamically expands the tool set)
Reproduction
- Configure an MCP server that exposes some base tools at
initializetime, then dynamically adds new tools mid-session (e.g. after a "select tenant/cluster/context" tool call) and emitsnotifications/tools/list_changed. - Start Claude Code in headless persistent mode:
``bash``
claude --print \\
--input-format stream-json \\
--output-format stream-json \\
--verbose \\
--mcp-config /path/to/mcp.json
- Feed two user events on stdin, one per \"turn\":
- Turn 1: ask Claude to call the tool that triggers
list_changed - Turn 2: ask Claude to list available tools, or to call one of the newly added tools
- Inspect the stdout stream-json transcript.
Expected
- Either: stream-json emits some
tool_list_updated/deferred_tools_deltaenvelope whenlist_changedarrives between turns - Or at minimum: the next turn's
system/initenvelope'stoolsarray includes the newly added tools andmcp_servers[].statusreflects the refreshed inventory
Actual
- No tool-update event is emitted on stdout between turns
- Every turn's
system/initre-declares the same tool list that existed at session start (post-initialize handshake); newly added tools never appear - Calling a newly added tool in Turn 2 fails with a \"tool not found\"-style error
Sample evidence
Across 5 system/init events in one persistent-session transcript (one per turn):
| init # | tool count | notes |
| --- | --- | --- |
| 0 | 30 | session start, MCP still pending |
| 1 | 64 | MCP connected, base tools loaded |
| 2 | 64 | after list_changed (should have grown) |
| 3 | 64 | unchanged |
| 4 | 64 | unchanged |
The same MCP server connected from interactive claude produces an attachment event of type deferred_tools_delta in ~/.claude/projects/.../<session-id>.jsonl containing the new tool names.
Workarounds tried
claude --print --resume <session-id> \"<prompt>\"per turn (one process per turn, resuming the IDE session JSONL): same issue, refreshed tools do not appear afterlist_changed- Sending an explicit \"please refresh tools\" prompt in the persistent session: no effect (Claude has no way to force a re-read of the MCP tool list)
- Tearing down and re-spawning the entire
claudeprocess between turns works, but defeats the purpose of a persistent session and loses prompt/KV cache locality
Related changelog hints
v2.1.121: \"MCP server reconfiguration works in headless mode\" — suggests headless MCP paths have had historical gapsv2.1.128: \"tool re-announcement summarized by server prefix\" — internal mechanism exists, but doesn't appear to surface in headless stream-json output
Ask
Either:
- Document that headless
--print --output-format stream-jsondoes not honorlist_changedand recommend a different entry point (Agent SDK?) for programmatic long-lived sessions with dynamic MCP tool sets, or - Make headless mode behave consistently with interactive mode — refresh the in-process tool registry and surface the change in the next turn's
system/init(and ideally as a discrete event on the stream-json output).
Happy to provide a minimal MCP server reproduction repo if helpful.
6 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
Additional reproduction: interactive TUI mode also affected (not just headless
--printstream-json)Filing this as complementary evidence — we hit the same root cause (
notifications/tools/list_changednot refreshing the in-process tool registry) in interactive TUI mode, which the original report assumed worked correctly.Environment
promote_tools(service_id=...)and emitsnotifications/tools/list_changedReproduction (interactive TUI)
promote_tools(service_id="<some-backend>")).promote_tools(service_id="<backend>"). Server responds successfully and reports_meta.notification_fired = "notifications/tools/list_changed"in the tool result.initialize.Spec reference
MCP 2025-11-25 §
tools/list_changed. The notification is fired and acknowledged on the wire; the client simply does not re-issuetools/listor refresh its cached catalog.Impact
This blocks any dynamic-tool-rotation pattern at the application layer:
Workaround
Restart Claude Code after every
promote_tools/unpromote_tools/ session-rotation call.Suggested fix scope
Whatever wiring the original report proposes for headless
stream-jsonmode should be applied uniformly to interactive TUI sessions as well. From this end, both surfaces look like they share the same "snapshot at initialize, never refresh" bug — just observed via different output channels.Happy to provide more detailed traces or a minimal repro MCP server if useful.
Additional corroboration: interactive TUI + Opus 4.7 1M, both stdio AND streamable HTTP transports
Filing as further corroboration of @davideagle's comment above — same root cause, expanded transport coverage.
Environment
mcp-toggle, source) acts as a runtime proxy. Onmcp_enable("<name>")it spawns/connects to a child MCP, lists its tools, re-publishes them under a<name>__prefix to its parent's tool list, and emitsnotifications/tools/list_changed. Onmcp_disable("<name>")it tears down and emits another.chrome-devtools,playwright,context7, etc.) so they aren't paying the context cost when not actively in use — the same lazy-loading pattern called out in #62058's original ticket.Reproduction — stdio child (context7)
mcp_enable("context7")returns success:tools_loaded: 2, tool_prefix: "context7__".context7__resolve-library-id→No such tool available: context7__resolve-library-id.context7__...) and harness-prefixed (mcp__mcp-toggle__context7__...) forms — both fail.Reproduction — streamable HTTP child (playwright)
mcp_enable("playwright")returns success:tools_loaded: 23, tool_prefix: "playwright__".playwright__browser_navigate→No such tool available: playwright__browser_navigate.Confirmation that mcp-toggle itself is doing the right thing
Driving mcp-toggle as a standalone stdio child (fresh
ClientSessionfrom a python script) outside of Claude Code:list_toolsbefore enable: 5 tools (control surface)mcp_enable("context7"):list_toolsreturns 7 tools — control +context7__resolve-library-id+context7__query-docscontext7__resolve-library-idfrom the same client returns real upstream data (Astro library lookup ✓)So the proxy logic, the tool re-publication, and the
send_tool_list_changed()call are all functioning correctly on the server side. The newly-published tools simply don't make it into Claude Code's callable-tool catalog in the running session, regardless of whether the child transport is stdio or streamable HTTP.Why this matters in practice
The mcp-toggle pattern is specifically the lazy-loading use case #62058 proposed. We're sitting on roughly 10 MCPs the workstation can spawn on demand, and the desired ergonomic is "Claude calls
mcp_enable("foo")mid-turn when it needsfoo, callsfoo__somethingin the next dispatch, thenmcp_disable("foo")when done." That entirely depends onnotifications/tools/list_changedbeing honored mid-session. Today the only workaround is pre-registering every conceivable MCP as always-on viaclaude mcp add ...at session start — which both defeats the lazy-loading goal and inflates the per-turn context budget with tools that won't be used.Asks (echoing #62844)
notifications/tools/list_changedin interactive TUI mode (and headless, per the original report) by re-issuingtools/listand updating the in-process tool registry.tools/list_changedis currently a no-op in Claude Code would save other proxy/gateway authors the same investigation cycle.Happy to share the mcp-toggle source, traces, or a minimal repro server.
Production update — third corroboration from a long-running front-proxy MCP
Following up on prior comments. Confirming the same symptom in interactive TUI mode against a production front-proxy MCP server with on-demand tool promotion.
Sharper symptom: base-surface tools (registered at
initialize, not viapromote_tools) become "Unknown tool" mid-session after the session has been running long enough./mcpreconnect only partially recovers — the freshinitializere-establishes the base surface, but any tools promoted earlier in the session stay unreachable.Server-side wire flow is healthy. We control our server and can verify it emits
notifications/tools/list_changedafter every promotion and includes a_meta.notification_firedconfirmation field in the tool result. Per MCP 2025-11-25 §tools, the server's SHOULD-level obligation is met. Client refresh is the gap.Severity:
notifications/tools/list_changedis the spec mechanism the lazy-loading / front-proxy pattern depends on. Today it's a no-op in Claude Code. Workarounds available to server authors: either pre-register every conceivable tool at session start (defeats the whole point) or restart the CLI after every promotion (defeats persistence).Issue has been stale-labeled ~30 days. Could we get triage on whether the in-process tool registry refresh is on the roadmap, even behind a flag initially? Happy to share traces or a minimal reproduction.
Closing for now — inactive for too long. Please open a new issue if this is still relevant.
Hey, I saw your issue about
tools/list_changednot refreshing the MCP tool registry when Claude Code runs as a long-running headless process.Since you're running this in a more production-style workflow, I'm curious: when the MCP server's tools change, do you have anything outside the client that tracks those changes?
Or is discovering drift mostly dependent on the client/session behavior?