feat(mcp): handle tools/list_changed so a server can change its toolset - #241
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, firingnotifications/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_changeddefaults 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
HaruspexClientgains the id and a channel, and the supervisor builds one handler per session.It stays a channel rather than an
AppHandlefor 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.rsowns 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/listfrom 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.Two details worth calling out
Removal works, not just addition.
registerMcpToolsalready unregisters the server first, so a refresh replaces rather than merges. That makesgodot_disable_toolsetbehave: 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.jsgains alist-changedmode 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:
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.tscrosses the 400-linemax-linesthreshold (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