Skip to content

feat(mcp): handle tools/list_changed so a server can change its toolset - #241

Merged
tmac1973 merged 1 commit into
mainfrom
fix/mcp-tools-list-changed
Sep 7, 2026
Merged

feat(mcp): handle tools/list_changed so a server can change its toolset#241
tmac1973 merged 1 commit into
mainfrom
fix/mcp-tools-list-changed

Conversation

@tmac1973

@tmac1973 tmac1973 commented Sep 7, 2026

Copy link
Copy Markdown
Owner

The last piece before the Godot integration works end to end, and the one deliberately left out of #238.

The problem

A running MCP server can add or remove tools, and nothing listened. Godot's server gates 27 of its 29 toolsets off and reveals one when the model calls godot_enable_toolset, firing notifications/tools/list_changed. Scene editing was therefore unreachable no matter what the user switched on: the tools it exposed stayed invisible until the server was restarted.

rmcp's ClientHandler::on_tool_list_changed defaults to a no-op, so this failed silently rather than erroring.

Shape of the fix

The server id has to be baked into the handler. A notification arrives with no indication of who sent it, and one handler belongs to one session — so HaruspexClient gains the id and a channel, and the supervisor builds one handler per session.

It stays a channel rather than an AppHandle for the same reason the orphan-registry path is a path: the supervisor has to be drivable in tests, where the receiver end is simply read directly. commands.rs owns the bridge from that channel to a Tauri event, which keeps it the only place this module meets Tauri, as its module docs claim.

Only the id crosses the boundary. Re-listing inside the notification handler would mean issuing a tools/list from a callback that wants no result, and the answer still has to reach the frontend registry — the thing that actually decides what the model sees — so the frontend asks for the new list when it is ready to install it.

server → rmcp → on_tool_list_changed → channel
       → commands.rs bridge → "mcp-tools-changed" event
       → refreshMcpTools(id) → mcp_list_tools → registerMcpTools

Two details worth calling out

Removal works, not just addition. registerMcpTools already unregisters the server first, so a refresh replaces rather than merges. That makes godot_disable_toolset behave: tools that went away are withdrawn instead of lingering as schemas the model can no longer call. There is a test for that direction.

Catalog defaults survive the refresh. They are re-read from the registry rather than passed in — the notification carries nothing but an id, and losing them would change which tools resolve as enabled. A newly revealed tool is not silently switched on.

Testing

Proven against a real server rather than a mock: mcp-era-server.js gains a list-changed mode that fires the notification from a tool call, the way Godot's does. The supervisor test asserts the id arrives on the channel.

I removed the handler and re-ran it to confirm the test is worth having:

test a_server_that_changes_its_toolset_says_which_one_did ... FAILED
panicked: the notification should arrive promptly: Elapsed(())

There is also a test that a supervisor with no listener attached still completes the call that triggered the notification.

950 Rust tests, 1810 frontend tests, clippy / fmt / prettier clean, no IPC drift.

One new lint warning: mcpServers.svelte.test.ts crosses the 400-line max-lines threshold (444). Splitting it would mean duplicating its fixture set into a second file; 45 such warnings already exist in the repo, including a 627-line test file, so I left it rather than trade real duplication for a warning count.

🤖 Generated with Claude Code

https://claude.ai/code/session_016z8Mdnd4sPugsUMickSwip

A running server can add or remove tools, and nothing listened. Godot's
gates 27 of its 29 toolsets off and reveals one when the model calls
`godot_enable_toolset`, firing `notifications/tools/list_changed` — so scene
editing was unreachable no matter what the user enabled, because the tools it
exposed stayed invisible until a restart.

rmcp's `ClientHandler::on_tool_list_changed` defaults to a no-op, so this was
silent rather than an error.

The id has to be baked into the handler: a notification arrives with no
indication of who sent it, and one handler belongs to one session. So
`HaruspexClient` gains the server id and a channel, and the supervisor builds
one handler per session. It stays a channel rather than an `AppHandle` for the
reason the orphan-registry path is a path — the supervisor has to be drivable
in tests, where the receiver is read directly. `commands.rs` owns the bridge
from that channel to a Tauri event, keeping it the only place this module
meets Tauri.

Only the id crosses. Re-listing in the notification handler would mean a
`tools/list` from inside a callback that wants no result, and the answer still
has to reach the frontend registry — the thing that actually decides what the
model sees — so the frontend asks when it is ready to install it.

Re-registration replaces rather than merges, because `registerMcpTools` already
unregisters the server first. That makes `godot_disable_toolset` work too:
tools that went away are withdrawn instead of lingering as schemas the model
can no longer call. The catalog defaults are re-read from the registry rather
than passed in, so a newly revealed tool is not silently switched on.

Proven against a real server: the `mcp-era-server.js` fixture gains a
`list-changed` mode that fires the notification from a tool call, the way
Godot does. Confirmed the test fails (5s timeout) with the handler removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016z8Mdnd4sPugsUMickSwip
@tmac1973
tmac1973 merged commit 1e0c3ad into main Sep 7, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant