Skip to content

feat(router): support runtime disabling of tools#809

Open
lutz-grex wants to merge 1 commit intomodelcontextprotocol:mainfrom
lutz-grex:feat/tool-router-disable
Open

feat(router): support runtime disabling of tools#809
lutz-grex wants to merge 1 commit intomodelcontextprotocol:mainfrom
lutz-grex:feat/tool-router-disable

Conversation

@lutz-grex
Copy link
Copy Markdown

Add methods to disable/enable tools at runtime.
Disabled tools are hidden from listing, lookup,
and execution, including in composed routers.

Closes #477

Motivation and Context

Users of the #[tool] + #[tool_handler] macro system had no way to disable specific tools at runtime. The only workaround was manually implementing ServerHandler, losing all macro convenience, or using remove_route which permanently destroys the tool.
This change adds reversible disable/enable support directly to ToolRouter, composing naturally with the existing macro workflow:

let mut router = Self::tool_router();
router.disable_route("dangerous_tool");
// later...
router.enable_route("dangerous_tool");

How Has This Been Tested?

  • 9 new unit tests in test_tool_routers.rs covering:
    disable, enable, builder pattern, merge preservation,
    remove cleanup, pre-disable before add, iterator
    filtering, and full invisibility across all query methods
  • All 261 existing tests pass (cargo test -p rmcp)
  • Before/after validation: confirmed disable_route and
    enable_route do not compile on the baseline and work
    correctly after the change
  • cargo clippy --all-targets --all-features clean
  • cargo +nightly fmt --all clean

Breaking Changes

has_route now returns false for disabled tools (previously
it only checked map membership). Code relying on has_route
to test structural presence regardless of enabled state should
use is_disabled instead. The ToolRouter struct is
#[non_exhaustive], so adding the private disabled field is
not a breaking change.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Three files changed:

  • crates/rmcp/src/handler/server/router/tool.rs — core
    feature: disabled HashSet field, disable/enable/is_disabled
    /with_disabled methods, updated list_all/call/get/has_route
    /merge/remove_route/IntoIterator
  • crates/rmcp/src/handler/server/router.rs — one-line fix:
    transparent_when_not_found dispatch now checks is_disabled
    so disabled tools cannot leak through to the inner service
  • crates/rmcp/tests/test_tool_routers.rs — 9 new tests

Add methods to disable/enable tools at runtime.
Disabled tools are hidden from listing, lookup,
and execution, including in composed routers.

Closes modelcontextprotocol#477
@lutz-grex lutz-grex requested a review from a team as a code owner April 15, 2026 09:53
@github-actions github-actions bot added T-test Testing related changes T-core Core library changes T-handler Handler implementation changes labels Apr 15, 2026
Copy link
Copy Markdown
Member

@DaleSeo DaleSeo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lutz-grex How should clients learn about runtime disable/enable? I'm worried that the list_tools results will go stale until the client re-lists. I believe the MCP spec has notifications/tools/list_changed for this.

https://modelcontextprotocol.io/specification/2025-11-25/server/tools#list-changed-notification

/// Returns `true` if the tool exists in the router but is currently
/// disabled.
pub fn is_disabled(&self, name: &str) -> bool {
self.map.contains_key(name) && self.disabled.contains(name)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if a tool is disabled before it's added? Is this supposed to return false?

Comment on lines +428 to +429
pub fn disable_route(&mut self, name: &str) {
self.disabled.insert(Cow::Owned(name.to_owned()));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with_disabled accepts impl Into<Cow<'static, str>>, but disable_route accepts &str. Can we align these two APIs so that callers don't have to choose between builder and mutable styles based on how memory is allocated?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-core Core library changes T-handler Handler implementation changes T-test Testing related changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Custom disabling of specific tools

2 participants