Skip to main content

rmcp/handler/server/router/
tool.rs

1//! Tools for MCP servers.
2//!
3//! It's straightforward to define tools using [`tool_router`][crate::tool_router] and
4//! [`tool`][crate::tool] macro.
5//!
6//! ```rust
7//! # use rmcp::{
8//! #     tool_router, tool,
9//! #     handler::server::{wrapper::{Parameters, Json}, tool::ToolRouter},
10//! #     schemars
11//! # };
12//! # use serde::{Serialize, Deserialize};
13//! struct Server;
14//!
15//! #[derive(Deserialize, schemars::JsonSchema, Default)]
16//! struct AddParameter {
17//!     left: usize,
18//!     right: usize
19//! }
20//! #[derive(Serialize, schemars::JsonSchema)]
21//! struct AddOutput {
22//!     sum: usize
23//! }
24//! #[tool_router(server_handler)]
25//! impl Server {
26//!     #[tool(name = "adder", description = "Modular add two integers")]
27//!     fn add(
28//!         &self,
29//!         Parameters(AddParameter { left, right }): Parameters<AddParameter>
30//!     ) -> Json<AddOutput> {
31//!         Json(AddOutput { sum: left.wrapping_add(right) })
32//!     }
33//! }
34//! ```
35//!
36//! The `server_handler` flag emits `#[tool_handler]` for you (tools-only servers). For custom
37//! `#[tool_handler(...)]` options or multiple handler macros on one `impl ServerHandler`, write
38//! `#[tool_router]` and `#[tool_handler] impl ServerHandler for ...` explicitly—see
39//! [`tool_router`][crate::tool_router] and [`tool_handler`][crate::tool_handler].
40//!
41//! Using the macro-based code pattern above is suitable for small MCP servers with simple interfaces.
42//! When the business logic become larger, it is recommended that each tool should reside
43//! in individual file, combined into MCP server using [`SyncTool`] and [`AsyncTool`] traits.
44//!
45//! ```rust
46//! # use rmcp::{
47//! #     handler::server::{
48//! #         tool::ToolRouter,
49//! #         router::tool::{SyncTool, AsyncTool, ToolBase},
50//! #     },
51//! #     schemars, ErrorData
52//! # };
53//! # pub struct MyCustomError;
54//! # impl From<MyCustomError> for ErrorData {
55//! #     fn from(err: MyCustomError) -> ErrorData { unimplemented!() }
56//! # }
57//! # use serde::{Serialize, Deserialize};
58//! # use std::borrow::Cow;
59//! // In tool1.rs
60//! pub struct ComplexTool1;
61//! #[derive(Deserialize, schemars::JsonSchema, Default)]
62//! pub struct ComplexTool1Input { /* ... */ }
63//! #[derive(Serialize, schemars::JsonSchema)]
64//! pub struct ComplexTool1Output { /* ... */ }
65//!
66//! impl ToolBase for ComplexTool1 {
67//!     type Parameter = ComplexTool1Input;
68//!     type Output = ComplexTool1Output;
69//!     type Error = MyCustomError;
70//!     fn name() -> Cow<'static, str> {
71//!         "complex-tool1".into()
72//!     }
73//!
74//!     fn description() -> Option<Cow<'static, str>> {
75//!         Some("...".into())
76//!     }
77//! }
78//! impl SyncTool<MyToolServer> for ComplexTool1 {
79//!     fn invoke(service: &MyToolServer, param: Self::Parameter) -> Result<Self::Output, Self::Error> {
80//!         // ...
81//! #       unimplemented!()
82//!     }
83//! }
84//! // In tool2.rs
85//! pub struct ComplexTool2;
86//! #[derive(Deserialize, schemars::JsonSchema, Default)]
87//! pub struct ComplexTool2Input { /* ... */ }
88//! #[derive(Serialize, schemars::JsonSchema)]
89//! pub struct ComplexTool2Output { /* ... */ }
90//!
91//! impl ToolBase for ComplexTool2 {
92//!     type Parameter = ComplexTool2Input;
93//!     type Output = ComplexTool2Output;
94//!     type Error = MyCustomError;
95//!     fn name() -> Cow<'static, str> {
96//!         "complex-tool2".into()
97//!     }
98//!
99//!     fn description() -> Option<Cow<'static, str>> {
100//!         Some("...".into())
101//!     }
102//! }
103//! impl AsyncTool<MyToolServer> for ComplexTool2 {
104//!     async fn invoke(service: &MyToolServer, param: Self::Parameter) -> Result<Self::Output, Self::Error> {
105//!         // ...
106//! #       unimplemented!()
107//!     }
108//! }
109//!
110//! // In tool_router.rs
111//! struct MyToolServer {
112//!     tool_router: ToolRouter<Self>,
113//! }
114//! impl MyToolServer {
115//!     pub fn tool_router() -> ToolRouter<Self> {
116//!         ToolRouter::new()
117//!             .with_sync_tool::<ComplexTool1>()
118//!             .with_async_tool::<ComplexTool2>()
119//!     }
120//! }
121//! ```
122//!
123//! It's also possible to use macro-based and trait-based tool definition together: Since
124//! [`ToolRouter`] implements [`Add`][std::ops::Add], you can add two tool routers into final
125//! router as showed in [the documentation of `tool_router`][crate::tool_router].
126
127mod tool_traits;
128
129use std::{borrow::Cow, sync::Arc};
130
131use schemars::JsonSchema;
132pub use tool_traits::{AsyncTool, SyncTool, ToolBase};
133
134use crate::{
135    handler::server::{
136        common::schema_for_input,
137        tool::{CallToolHandler, DynCallToolHandler, ToolCallContext},
138        tool_name_validation::validate_and_warn_tool_name,
139    },
140    model::{CallToolResponse, CallToolResult, ContentBlock, ErrorCode, Tool, ToolAnnotations},
141    service::{MaybeBoxFuture, MaybeSend},
142};
143
144const TOOL_ARGUMENT_DESERIALIZATION_ERROR_PREFIX: &str = "failed to deserialize parameters:";
145
146fn into_tool_argument_error(error: crate::ErrorData) -> Result<CallToolResponse, crate::ErrorData> {
147    if error.code == ErrorCode::INVALID_PARAMS
148        && error
149            .message
150            .starts_with(TOOL_ARGUMENT_DESERIALIZATION_ERROR_PREFIX)
151    {
152        return Ok(CallToolResult::error(vec![ContentBlock::text(error.message)]).into());
153    }
154
155    Err(error)
156}
157
158#[non_exhaustive]
159pub struct ToolRoute<S> {
160    #[allow(clippy::type_complexity)]
161    pub call: Arc<DynCallToolHandler<S>>,
162    pub attr: crate::model::Tool,
163}
164
165impl<S> std::fmt::Debug for ToolRoute<S> {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.debug_struct("ToolRoute")
168            .field("name", &self.attr.name)
169            .field("description", &self.attr.description)
170            .field("input_schema", &self.attr.input_schema)
171            .finish()
172    }
173}
174
175impl<S> Clone for ToolRoute<S> {
176    fn clone(&self) -> Self {
177        Self {
178            call: self.call.clone(),
179            attr: self.attr.clone(),
180        }
181    }
182}
183
184impl<S: MaybeSend + 'static> ToolRoute<S> {
185    pub fn new<C, A>(attr: impl Into<Tool>, call: C) -> Self
186    where
187        C: CallToolHandler<S, A> + MaybeSend + Clone + 'static,
188    {
189        Self {
190            call: Arc::new(move |context: ToolCallContext<S>| {
191                let call = call.clone();
192                context.invoke(call)
193            }),
194            attr: attr.into(),
195        }
196    }
197    pub fn new_dyn<C>(attr: impl Into<Tool>, call: C) -> Self
198    where
199        C: for<'a> Fn(
200                ToolCallContext<'a, S>,
201            )
202                -> MaybeBoxFuture<'a, Result<CallToolResponse, crate::ErrorData>>
203            + MaybeSend
204            + 'static,
205    {
206        Self {
207            call: Arc::new(call),
208            attr: attr.into(),
209        }
210    }
211    pub fn name(&self) -> &str {
212        &self.attr.name
213    }
214}
215
216pub trait IntoToolRoute<S, A> {
217    fn into_tool_route(self) -> ToolRoute<S>;
218}
219
220impl<S, C, A, T> IntoToolRoute<S, A> for (T, C)
221where
222    S: MaybeSend + 'static,
223    C: CallToolHandler<S, A> + MaybeSend + Clone + 'static,
224    T: Into<Tool>,
225{
226    fn into_tool_route(self) -> ToolRoute<S> {
227        ToolRoute::new(self.0.into(), self.1)
228    }
229}
230
231impl<S> IntoToolRoute<S, ()> for ToolRoute<S>
232where
233    S: MaybeSend + 'static,
234{
235    fn into_tool_route(self) -> ToolRoute<S> {
236        self
237    }
238}
239
240#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
241pub struct ToolAttrGenerateFunctionAdapter;
242impl<S, F> IntoToolRoute<S, ToolAttrGenerateFunctionAdapter> for F
243where
244    S: MaybeSend + 'static,
245    F: Fn() -> ToolRoute<S>,
246{
247    fn into_tool_route(self) -> ToolRoute<S> {
248        (self)()
249    }
250}
251
252pub trait CallToolHandlerExt<S, A>: Sized
253where
254    Self: CallToolHandler<S, A> + MaybeSend + Clone + 'static,
255{
256    fn name(self, name: impl Into<Cow<'static, str>>) -> WithToolAttr<Self, S, A>;
257}
258
259impl<C, S, A> CallToolHandlerExt<S, A> for C
260where
261    C: CallToolHandler<S, A> + MaybeSend + Clone + 'static,
262{
263    fn name(self, name: impl Into<Cow<'static, str>>) -> WithToolAttr<Self, S, A> {
264        WithToolAttr {
265            attr: Tool::new(
266                name.into(),
267                "",
268                schema_for_input::<crate::model::JsonObject>().unwrap_or_else(|e| {
269                    panic!("Invalid input schema for JsonObject: {e}");
270                }),
271            ),
272            call: self,
273            _marker: std::marker::PhantomData,
274        }
275    }
276}
277
278#[non_exhaustive]
279pub struct WithToolAttr<C, S, A>
280where
281    C: CallToolHandler<S, A> + MaybeSend + Clone + 'static,
282{
283    pub attr: crate::model::Tool,
284    pub call: C,
285    pub _marker: std::marker::PhantomData<fn(S, A)>,
286}
287
288impl<C, S, A> IntoToolRoute<S, A> for WithToolAttr<C, S, A>
289where
290    C: CallToolHandler<S, A> + MaybeSend + Clone + 'static,
291    S: MaybeSend + 'static,
292{
293    fn into_tool_route(self) -> ToolRoute<S> {
294        ToolRoute::new(self.attr, self.call)
295    }
296}
297
298impl<C, S, A> WithToolAttr<C, S, A>
299where
300    C: CallToolHandler<S, A> + MaybeSend + Clone + 'static,
301{
302    pub fn description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
303        self.attr.description = Some(description.into());
304        self
305    }
306    pub fn parameters<T: JsonSchema + 'static>(mut self) -> Self {
307        self.attr.input_schema = schema_for_input::<T>().unwrap_or_else(|e| {
308            panic!(
309                "Invalid input schema for `{}`: {e}",
310                std::any::type_name::<T>()
311            )
312        });
313        self
314    }
315    pub fn parameters_value(mut self, schema: serde_json::Value) -> Self {
316        self.attr.input_schema = crate::model::object(schema).into();
317        self
318    }
319    pub fn annotation(mut self, annotation: impl Into<ToolAnnotations>) -> Self {
320        self.attr.annotations = Some(annotation.into());
321        self
322    }
323}
324#[non_exhaustive]
325pub struct ToolRouter<S> {
326    #[allow(clippy::type_complexity)]
327    pub map: std::collections::HashMap<Cow<'static, str>, ToolRoute<S>>,
328
329    pub transparent_when_not_found: bool,
330
331    disabled: std::collections::HashSet<Cow<'static, str>>,
332
333    notifier: Option<Arc<dyn Fn() + Send + Sync>>,
334}
335
336impl<S> std::fmt::Debug for ToolRouter<S> {
337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        f.debug_struct("ToolRouter")
339            .field("map", &self.map)
340            .field(
341                "transparent_when_not_found",
342                &self.transparent_when_not_found,
343            )
344            .field("disabled", &self.disabled)
345            .field("notifier", &self.notifier.as_ref().map(|_| "..."))
346            .finish()
347    }
348}
349
350impl<S> Default for ToolRouter<S> {
351    fn default() -> Self {
352        Self {
353            map: std::collections::HashMap::new(),
354            transparent_when_not_found: false,
355            disabled: std::collections::HashSet::new(),
356            notifier: None,
357        }
358    }
359}
360
361impl<S> Clone for ToolRouter<S> {
362    fn clone(&self) -> Self {
363        Self {
364            map: self.map.clone(),
365            transparent_when_not_found: self.transparent_when_not_found,
366            disabled: self.disabled.clone(),
367            notifier: self.notifier.clone(),
368        }
369    }
370}
371
372impl<S> IntoIterator for ToolRouter<S> {
373    type Item = ToolRoute<S>;
374    type IntoIter = std::collections::hash_map::IntoValues<Cow<'static, str>, ToolRoute<S>>;
375
376    fn into_iter(self) -> Self::IntoIter {
377        let mut map = self.map;
378        for name in &self.disabled {
379            map.remove(name);
380        }
381        map.into_values()
382    }
383}
384
385impl<S> ToolRouter<S>
386where
387    S: MaybeSend + 'static,
388{
389    pub fn new() -> Self {
390        Self::default()
391    }
392    pub fn with_route<R, A>(mut self, route: R) -> Self
393    where
394        R: IntoToolRoute<S, A>,
395    {
396        self.add_route(route.into_tool_route());
397        self
398    }
399
400    /// Add a tool that implements [`SyncTool`]
401    pub fn with_sync_tool<T>(self) -> Self
402    where
403        T: SyncTool<S> + 'static,
404    {
405        if T::input_schema().is_some() {
406            self.with_route((
407                tool_traits::tool_attribute::<T>(),
408                tool_traits::sync_tool_wrapper::<S, T>,
409            ))
410        } else {
411            self.with_route((
412                tool_traits::tool_attribute::<T>(),
413                tool_traits::sync_tool_wrapper_with_empty_params::<S, T>,
414            ))
415        }
416    }
417
418    /// Add a tool that implements [`AsyncTool`]
419    pub fn with_async_tool<T>(self) -> Self
420    where
421        T: AsyncTool<S> + 'static,
422    {
423        if T::input_schema().is_some() {
424            self.with_route((
425                tool_traits::tool_attribute::<T>(),
426                tool_traits::async_tool_wrapper::<S, T>,
427            ))
428        } else {
429            self.with_route((
430                tool_traits::tool_attribute::<T>(),
431                tool_traits::async_tool_wrapper_with_empty_params::<S, T>,
432            ))
433        }
434    }
435
436    pub fn add_route(&mut self, item: ToolRoute<S>) {
437        let new_name = &item.attr.name;
438        validate_and_warn_tool_name(new_name);
439        self.map.insert(new_name.clone(), item);
440    }
441
442    pub fn merge(&mut self, other: ToolRouter<S>) {
443        self.disabled.extend(other.disabled);
444        for item in other.map.into_values() {
445            self.add_route(item);
446        }
447    }
448
449    /// Remove a tool route from the router.
450    ///
451    /// The disabled state is **preserved**: if the name was in the disabled
452    /// set, it stays there so that a future [`add_route`](Self::add_route)
453    /// or [`merge`](Self::merge) with the same name will inherit the
454    /// disabled state. To also clear the disabled marker, call
455    /// [`enable_route`](Self::enable_route) afterwards.
456    pub fn remove_route(&mut self, name: &str) {
457        self.map.remove(name);
458    }
459
460    /// Returns `true` if the tool is registered **and** not currently
461    /// disabled.
462    pub fn has_route(&self, name: &str) -> bool {
463        self.map.contains_key(name) && !self.disabled.contains(name)
464    }
465
466    /// Disable a tool by name. Hidden from `list_all`, `get`, rejected by
467    /// `call`. Re-enable with [`enable_route`](Self::enable_route).
468    ///
469    /// Returns `true` if the name was newly added to the disabled set.
470    /// The name is recorded even if no matching route exists yet, so routes
471    /// added later will inherit the disabled state.
472    pub fn disable_route(&mut self, name: impl Into<Cow<'static, str>>) -> bool {
473        let name = name.into();
474        let was_visible = self.map.contains_key(&name) && !self.disabled.contains(&name);
475        if was_visible {
476            self.notify_if_visible(&name);
477        }
478        self.disabled.insert(name)
479    }
480
481    /// Re-enable a previously disabled tool. Returns `true` if the name
482    /// was in the disabled set.
483    pub fn enable_route(&mut self, name: &str) -> bool {
484        let removed = self.disabled.remove(name);
485        if removed {
486            self.notify_if_visible(name);
487        }
488        removed
489    }
490
491    /// Returns `true` if the tool exists in the router **and** is currently
492    /// disabled. Returns `false` if the tool does not exist or if the name
493    /// was pre-disabled without a matching route.
494    pub fn is_disabled(&self, name: &str) -> bool {
495        self.map.contains_key(name) && self.disabled.contains(name)
496    }
497
498    /// Builder-style variant of [`disable_route`](Self::disable_route).
499    ///
500    /// The name is recorded even if no matching route has been added yet,
501    /// so it can be called before [`with_route`](Self::with_route) in a
502    /// builder chain.
503    pub fn with_disabled(mut self, name: impl Into<Cow<'static, str>>) -> Self {
504        self.disabled.insert(name.into());
505        self
506    }
507
508    /// Install a callback invoked when the visible tool list changes.
509    pub fn set_notifier(&mut self, f: impl Fn() + Send + Sync + 'static) {
510        self.notifier = Some(Arc::new(f));
511    }
512
513    pub fn clear_notifier(&mut self) {
514        self.notifier = None;
515    }
516
517    /// Install a notifier that sends `notifications/tools/list_changed`
518    /// via the given peer.
519    pub fn bind_peer_notifier(&mut self, peer: &crate::service::Peer<crate::RoleServer>) {
520        let peer = peer.clone();
521        self.set_notifier(move || {
522            let peer = peer.clone();
523            tokio::spawn(async move {
524                if let Err(e) = peer.notify_tool_list_changed().await {
525                    tracing::warn!("failed to send tools/list_changed notification: {e}");
526                }
527            });
528        });
529    }
530
531    /// Deferred notifier: no-op until the peer slot is filled.
532    pub(crate) fn deferred_peer_notifier() -> (
533        impl Fn() + Send + Sync + 'static,
534        Arc<std::sync::OnceLock<crate::service::Peer<crate::RoleServer>>>,
535    ) {
536        let peer_slot =
537            Arc::new(std::sync::OnceLock::<crate::service::Peer<crate::RoleServer>>::new());
538        let slot_clone = peer_slot.clone();
539        let notifier = move || {
540            if let Some(peer) = slot_clone.get() {
541                let peer = peer.clone();
542                tokio::spawn(async move {
543                    if let Err(e) = peer.notify_tool_list_changed().await {
544                        tracing::warn!("failed to send tools/list_changed notification: {e}");
545                    }
546                });
547            }
548        };
549        (notifier, peer_slot)
550    }
551
552    fn notify_if_visible(&self, name: &str) {
553        if self.map.contains_key(name)
554            && let Some(notifier) = &self.notifier
555        {
556            notifier();
557        }
558    }
559
560    pub async fn call(
561        &self,
562        context: ToolCallContext<'_, S>,
563    ) -> Result<crate::model::CallToolResponse, crate::ErrorData> {
564        let name = context.name();
565        if self.disabled.contains(name) {
566            return Err(crate::ErrorData::invalid_params("tool not found", None));
567        }
568        let item = self
569            .map
570            .get(name)
571            .ok_or_else(|| crate::ErrorData::invalid_params("tool not found", None))?;
572
573        let result = match (item.call)(context).await {
574            Ok(result) => result,
575            Err(error) => return into_tool_argument_error(error),
576        };
577
578        Ok(result)
579    }
580
581    pub fn list_all(&self) -> Vec<crate::model::Tool> {
582        let mut tools: Vec<_> = self
583            .map
584            .values()
585            .filter(|item| !self.disabled.contains(&item.attr.name))
586            .map(|item| item.attr.clone())
587            .collect();
588        tools.sort_by(|a, b| a.name.cmp(&b.name));
589        tools
590    }
591
592    /// Get a tool definition by name.
593    ///
594    /// Returns the tool if found and enabled, or `None` if the tool does not
595    /// exist or is disabled.
596    pub fn get(&self, name: &str) -> Option<&crate::model::Tool> {
597        if self.disabled.contains(name) {
598            return None;
599        }
600        self.map.get(name).map(|r| &r.attr)
601    }
602}
603
604impl<S> std::ops::Add<ToolRouter<S>> for ToolRouter<S>
605where
606    S: MaybeSend + 'static,
607{
608    type Output = Self;
609
610    fn add(mut self, other: ToolRouter<S>) -> Self::Output {
611        self.merge(other);
612        self
613    }
614}
615
616impl<S> std::ops::AddAssign<ToolRouter<S>> for ToolRouter<S>
617where
618    S: MaybeSend + 'static,
619{
620    fn add_assign(&mut self, other: ToolRouter<S>) {
621        self.merge(other);
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use std::sync::Arc;
628
629    use super::*;
630    use crate::{
631        RoleServer,
632        handler::server::wrapper::Parameters,
633        model::{CallToolRequestParams, ErrorCode, NumberOrString},
634        service::{AtomicU32RequestIdProvider, Peer, RequestContext},
635    };
636
637    struct DummyService;
638    impl crate::handler::server::ServerHandler for DummyService {}
639
640    #[derive(serde::Deserialize, schemars::JsonSchema)]
641    struct RequiredParams {
642        project: String,
643    }
644
645    fn requires_params(Parameters(params): Parameters<RequiredParams>) -> String {
646        params.project
647    }
648
649    #[tokio::test]
650    async fn test_argument_deserialization_error_returns_tool_error_result() {
651        let service = DummyService;
652        let router = ToolRouter::new().with_route(ToolRoute::new(
653            crate::model::Tool::new(
654                "requires_params",
655                "requires params",
656                Arc::new(Default::default()),
657            ),
658            requires_params,
659        ));
660
661        let id_provider: Arc<dyn crate::service::RequestIdProvider> =
662            Arc::new(AtomicU32RequestIdProvider::default());
663        let (peer, _rx) = Peer::<RoleServer>::new(id_provider, None);
664        let ctx = crate::handler::server::tool::ToolCallContext::new(
665            &service,
666            CallToolRequestParams {
667                meta: None,
668                name: Cow::Borrowed("requires_params"),
669                arguments: Some(Default::default()),
670                input_responses: None,
671                request_state: None,
672            },
673            RequestContext::new(NumberOrString::Number(1), peer),
674        );
675
676        let result = router
677            .call(ctx)
678            .await
679            .expect("argument validation should be a tool result");
680        let CallToolResponse::Complete(result) = result else {
681            panic!("expected complete CallToolResult");
682        };
683        assert_eq!(result.is_error, Some(true));
684
685        let text = result
686            .content
687            .first()
688            .and_then(|content| content.as_text())
689            .map(|text| text.text.as_str())
690            .expect("tool error result should include text");
691        assert!(text.contains("failed to deserialize parameters"));
692        assert!(text.contains("missing field `project`"));
693    }
694
695    #[tokio::test]
696    async fn test_call_disabled_tool_returns_error() {
697        let service = DummyService;
698        let mut router = ToolRouter::new().with_route(ToolRoute::new_dyn(
699            crate::model::Tool::new("test_tool", "a test tool", Arc::new(Default::default())),
700            |_ctx| Box::pin(async { Ok(CallToolResult::default().into()) }),
701        ));
702        router.disable_route("test_tool");
703
704        let id_provider: Arc<dyn crate::service::RequestIdProvider> =
705            Arc::new(AtomicU32RequestIdProvider::default());
706        let (peer, _rx) = Peer::<RoleServer>::new(id_provider, None);
707        let ctx = crate::handler::server::tool::ToolCallContext::new(
708            &service,
709            CallToolRequestParams {
710                meta: None,
711                name: Cow::Borrowed("test_tool"),
712                arguments: None,
713                input_responses: None,
714                request_state: None,
715            },
716            RequestContext::new(NumberOrString::Number(1), peer),
717        );
718
719        let err = router
720            .call(ctx)
721            .await
722            .expect_err("disabled tool should reject");
723        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
724        assert_eq!(err.message, "tool not found");
725    }
726}