1mod 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 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 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 pub fn remove_route(&mut self, name: &str) {
457 self.map.remove(name);
458 }
459
460 pub fn has_route(&self, name: &str) -> bool {
463 self.map.contains_key(name) && !self.disabled.contains(name)
464 }
465
466 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 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 pub fn is_disabled(&self, name: &str) -> bool {
495 self.map.contains_key(name) && self.disabled.contains(name)
496 }
497
498 pub fn with_disabled(mut self, name: impl Into<Cow<'static, str>>) -> Self {
504 self.disabled.insert(name.into());
505 self
506 }
507
508 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 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 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 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}