MCP tools/list_changed notification does not invalidate deferred tool cache or refetch tools/list
Environment
- Codex CLI/core:
0.144.1 - Platform: Linux x86_64 (Linux Mint 22.3 / kernel 6.8)
- MCP transport: stdio
- Relevant enabled feature:
tool_search_always_defer_mcp_tools - Reproducer server: RainbowXie/headless-ida-mcp-server, stdio stub entry point
headless-ida-mcp-stub
What issue are you seeing?
Codex does not refresh an MCP server's tool registry after the server sends the standard notifications/tools/list_changed notification.
The MCP server initially exposes one cheap bootstrap tool, start_daemon, to avoid starting idalib and exposing roughly 89 tools when IDA is not being used. After start_daemon completes, the stdio server:
- fetches and caches the daemon's real tool list;
- sends
notifications/tools/list_changed; - returns the full tool list on every subsequent
tools/listrequest.
Codex calls start_daemon successfully, but never issues another tools/list request. The deferred tool_search surface remains based on the initial one-tool response, so none of the newly available tools can be discovered or called.
This also affects MCP servers with plugin systems: enabling or disabling a plugin changes the tool surface and emits tools/list_changed, but Codex continues using the stale cached surface.
Steps to reproduce
Use an MCP stdio server with this behavior:
- During initialization, advertise:
``json``
{
"capabilities": {
"tools": {
"listChanged": true
}
}
}
- On the initial
tools/list, return onlystart_daemon.
- When
tools/callinvokesstart_daemon:
- change the server's tool list so it now contains additional tools;
- send
notifications/tools/list_changed; - return success.
- Ask Codex to use one of the newly added tools, or inspect the MCP requests made by Codex.
Observed Codex request sequence:
initialize
tools/list # returns only start_daemon
tools/call start_daemon # server sends notifications/tools/list_changed
# no second tools/list request
The same server was verified end-to-end with:
- an in-memory MCP client with a warm daemon;
- an in-memory MCP client with a cold daemon;
- a real stdio subprocess using the Python MCP client;
- a direct live call against the configured server.
Those clients receive the notification, refetch tools/list, and see all 89 tools.
Historical Codex sessions using the same server design before the deferred/lazy tool surface successfully called tools such as decompile, lookup_funcs, and server_health. Current sessions only see/call start_daemon.
Expected behavior
When Codex receives notifications/tools/list_changed, it should:
- invalidate the cached tool list for that MCP server;
- refetch
tools/list; - rebuild or update the deferred
tool_searchindex for that server; - make newly added tools discoverable and callable;
- remove tools that are no longer returned.
Repeated notifications can be debounced or coalesced, but the resulting tool registry must eventually reflect the latest server response.
Additional investigation
The server-side notification path appears correct. The Python MCP clients refetch successfully, and the server advertises tools.listChanged=true.
Codex embeds rmcp. In rmcp, ToolListChangedNotification is dispatched to the application's notification handler; rmcp does not automatically invalidate or refetch the tool list. Codex's application layer appears to cache MCP tools and build the deferred tool_search surface from that cache. My current hypothesis is that the notification handler does not invalidate that cache or trigger rediscovery.
The MCP protocol only lets a server request a refetch; it cannot push a replacement tool list. Therefore, a server cannot preserve a small lazy initial surface or expose dynamic plugin tools reliably if the client ignores this notification.
Related but distinct issues:
- #19425: tools returned by the initial
tools/listare not exposed to Desktop threads. - #21503: an already indexed deferred tool can be missed by
tool_searchranking.
This issue occurs earlier: Codex never refetches the changed list, so the new tools never enter the deferred index at all.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗