MCP tools/list_changed notifications not honored in headless --print --output-format stream-json mode

Status Closed — not planned
Reported on v2.1.152
Maintainer reply None cached
Activity 6 comments · opened May 27, 2026 · closed Aug 12, 2026

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_changed mid-session (e.g. when a tenant/context switch dynamically expands the tool set)

Reproduction

  1. Configure an MCP server that exposes some base tools at initialize time, then dynamically adds new tools mid-session (e.g. after a "select tenant/cluster/context" tool call) and emits notifications/tools/list_changed.
  2. 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
``

  1. 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
  1. Inspect the stdout stream-json transcript.

Expected

  • Either: stream-json emits some tool_list_updated / deferred_tools_delta envelope when list_changed arrives between turns
  • Or at minimum: the next turn's system/init envelope's tools array includes the newly added tools and mcp_servers[].status reflects the refreshed inventory

Actual

  • No tool-update event is emitted on stdout between turns
  • Every turn's system/init re-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 after list_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 claude process 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 gaps
  • v2.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:

  1. Document that headless --print --output-format stream-json does not honor list_changed and recommend a different entry point (Agent SDK?) for programmatic long-lived sessions with dynamic MCP tool sets, or
  2. 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.

View original on GitHub ↗

6 Comments

github-actions[bot] · 4 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/62058
  2. https://github.com/anthropics/claude-code/issues/13646
  3. https://github.com/anthropics/claude-code/issues/50515

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

davideagle · 4 months ago

Additional reproduction: interactive TUI mode also affected (not just headless --print stream-json)

Filing this as complementary evidence — we hit the same root cause (notifications/tools/list_changed not refreshing the in-process tool registry) in interactive TUI mode, which the original report assumed worked correctly.

Environment

  • Claude Code: interactive TUI, Opus 4.7 (1M context), latest available 2026-05-29
  • MCP transport: streamable HTTP via a gateway
  • MCP server: a dynamic-promotion server that adds/removes tools mid-session in response to promote_tools(service_id=...) and emits notifications/tools/list_changed

Reproduction (interactive TUI)

  1. Connect a streamable-HTTP MCP server whose tool surface can be expanded mid-session via a tool call (e.g. promote_tools(service_id="<some-backend>")).
  2. In an interactive TUI session, observe initial tool list. The dynamic-promotion tool is present; the backend's tools are not yet promoted.
  3. Call promote_tools(service_id="<backend>"). Server responds successfully and reports _meta.notification_fired = "notifications/tools/list_changed" in the tool result.
  4. Immediately try to discover or call any newly-promoted tool by exact name (e.g. via the harness's deferred-tool search by name).
  5. Observed: the newly-promoted tools are not visible to the session — searches by exact tool name return "no matching tools." The client's tool catalog is frozen at the snapshot taken at session start / initialize.
  6. Restart Claude Code; the same tools are now visible without re-promoting.

Spec reference

MCP 2025-11-25 §tools/list_changed. The notification is fired and acknowledged on the wire; the client simply does not re-issue tools/list or refresh its cached catalog.

Impact

This blocks any dynamic-tool-rotation pattern at the application layer:

  • A "front proxy" MCP server that exposes a curated subset of backend tools per session cannot grow the surface during the session — the operator has to restart to use the tools they just promoted.
  • Per-tenant / per-context tool gating loses its main UX win: tools promoted in response to user intent in turn N cannot be called until turn N+1 of a fresh process.

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-json mode 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.

Austin519 · 4 months ago

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

  • Claude Code: interactive TUI session on Linux, Opus 4.7 1M context, as of 2026-05-29.
  • MCP topology: a single user-scope MCP (mcp-toggle, source) acts as a runtime proxy. On mcp_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 emits notifications/tools/list_changed. On mcp_disable("<name>") it tears down and emits another.
  • Real use case: lazy-loading on-demand MCPs (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)

  1. mcp_enable("context7") returns success: tools_loaded: 2, tool_prefix: "context7__".
  2. Same-turn dispatch of context7__resolve-library-id → No such tool available: context7__resolve-library-id.
  3. Tried both unprefixed (context7__...) and harness-prefixed (mcp__mcp-toggle__context7__...) forms — both fail.

Reproduction — streamable HTTP child (playwright)

  1. mcp_enable("playwright") returns success: tools_loaded: 23, tool_prefix: "playwright__".
  2. Same-turn dispatch of 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 ClientSession from a python script) outside of Claude Code:

  • list_tools before enable: 5 tools (control surface)
  • After mcp_enable("context7"): list_tools returns 7 tools — control + context7__resolve-library-id + context7__query-docs
  • Calling context7__resolve-library-id from 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 needs foo, calls foo__something in the next dispatch, then mcp_disable("foo") when done." That entirely depends on notifications/tools/list_changed being honored mid-session. Today the only workaround is pre-registering every conceivable MCP as always-on via claude 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)

  1. Honor notifications/tools/list_changed in interactive TUI mode (and headless, per the original report) by re-issuing tools/list and updating the in-process tool registry.
  2. Until then: documentation that tools/list_changed is 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.

davideagle · 3 months ago

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 via promote_tools) become "Unknown tool" mid-session after the session has been running long enough. /mcp reconnect only partially recovers — the fresh initialize re-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_changed after every promotion and includes a _meta.notification_fired confirmation 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_changed is 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.

github-actions[bot] · 2 months ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.

anythingoah · 1 month ago

Hey, I saw your issue about tools/list_changed not 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?