1use async_trait::async_trait;
17use indexmap::IndexMap;
18#[cfg(feature = "derive")]
20pub use schemars::JsonSchema;
21
22use crate::Error;
23#[cfg(any(feature = "derive", test))]
24use crate::types::Tool;
25use crate::types::{ToolBinaryResult, ToolInvocation, ToolResult, ToolResultExpanded};
26
27#[cfg(feature = "derive")]
48pub fn schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
49 let schema = schemars::schema_for!(T);
50 let mut value = serde_json::to_value(schema).expect("JSON Schema serialization cannot fail");
51 if let Some(obj) = value.as_object_mut() {
52 obj.remove("$schema");
53 obj.remove("title");
54 }
55 value
56}
57
58pub fn tool_parameters(schema: serde_json::Value) -> IndexMap<String, serde_json::Value> {
83 try_tool_parameters(schema).expect("tool parameter schema must be a JSON object")
84}
85
86pub fn try_tool_parameters(
88 schema: serde_json::Value,
89) -> Result<IndexMap<String, serde_json::Value>, serde_json::Error> {
90 serde_json::from_value(schema)
91}
92
93pub fn convert_mcp_call_tool_result(value: &serde_json::Value) -> Option<ToolResult> {
97 let content = value.get("content")?.as_array()?;
98 let mut text_parts = Vec::new();
99 let mut binary_results = Vec::new();
100
101 for block in content {
102 match block.get("type").and_then(serde_json::Value::as_str) {
103 Some("text") => {
104 if let Some(text) = block.get("text").and_then(serde_json::Value::as_str) {
105 text_parts.push(text.to_string());
106 }
107 }
108 Some("image") => {
109 let data = block
110 .get("data")
111 .and_then(serde_json::Value::as_str)
112 .filter(|s| !s.is_empty());
113 let mime_type = block
114 .get("mimeType")
115 .and_then(serde_json::Value::as_str)
116 .filter(|s| !s.is_empty());
117 if let (Some(data), Some(mime_type)) = (data, mime_type) {
118 binary_results.push(ToolBinaryResult {
119 data: data.to_string(),
120 mime_type: mime_type.to_string(),
121 r#type: "image".to_string(),
122 description: None,
123 });
124 }
125 }
126 Some("resource") => {
127 let Some(resource) = block.get("resource").and_then(serde_json::Value::as_object)
128 else {
129 continue;
130 };
131 if let Some(text) = resource
132 .get("text")
133 .and_then(serde_json::Value::as_str)
134 .filter(|s| !s.is_empty())
135 {
136 text_parts.push(text.to_string());
137 }
138 if let Some(blob) = resource
139 .get("blob")
140 .and_then(serde_json::Value::as_str)
141 .filter(|s| !s.is_empty())
142 {
143 let mime_type = resource
144 .get("mimeType")
145 .and_then(serde_json::Value::as_str)
146 .filter(|s| !s.is_empty())
147 .unwrap_or("application/octet-stream");
148 let description = resource
149 .get("uri")
150 .and_then(serde_json::Value::as_str)
151 .filter(|s| !s.is_empty())
152 .map(ToString::to_string);
153 binary_results.push(ToolBinaryResult {
154 data: blob.to_string(),
155 mime_type: mime_type.to_string(),
156 r#type: "resource".to_string(),
157 description,
158 });
159 }
160 }
161 _ => {}
162 }
163 }
164
165 Some(ToolResult::Expanded(ToolResultExpanded {
166 text_result_for_llm: text_parts.join("\n"),
167 result_type: if value.get("isError").and_then(serde_json::Value::as_bool) == Some(true) {
168 "failure".to_string()
169 } else {
170 "success".to_string()
171 },
172 binary_results_for_llm: (!binary_results.is_empty()).then_some(binary_results),
173 session_log: None,
174 error: None,
175 tool_telemetry: None,
176 tool_references: None,
177 }))
178}
179
180#[async_trait]
227pub trait ToolHandler: Send + Sync + 'static {
228 async fn call(&self, invocation: ToolInvocation) -> Result<ToolResult, Error>;
230}
231
232#[cfg(feature = "derive")]
290pub fn define_tool<P, F, Fut>(
291 name: impl Into<String>,
292 description: impl Into<String>,
293 handler: F,
294) -> Tool
295where
296 P: schemars::JsonSchema + serde::de::DeserializeOwned + Send + 'static,
297 F: Fn(ToolInvocation, P) -> Fut + Send + Sync + 'static,
298 Fut: std::future::Future<Output = Result<ToolResult, Error>> + Send + 'static,
299{
300 struct FnHandler<P, F> {
301 handler: F,
302 _marker: std::marker::PhantomData<fn(P)>,
303 }
304
305 #[async_trait]
306 impl<P, F, Fut> ToolHandler for FnHandler<P, F>
307 where
308 P: schemars::JsonSchema + serde::de::DeserializeOwned + Send + 'static,
309 F: Fn(ToolInvocation, P) -> Fut + Send + Sync + 'static,
310 Fut: std::future::Future<Output = Result<ToolResult, Error>> + Send + 'static,
311 {
312 async fn call(&self, mut invocation: ToolInvocation) -> Result<ToolResult, Error> {
313 let arguments = std::mem::take(&mut invocation.arguments);
314 let params: P = serde_json::from_value(arguments)?;
315 (self.handler)(invocation, params).await
316 }
317 }
318
319 Tool {
320 name: name.into(),
321 description: description.into(),
322 parameters: tool_parameters(schema_for::<P>()),
323 ..Default::default()
324 }
325 .with_handler(std::sync::Arc::new(FnHandler {
326 handler,
327 _marker: std::marker::PhantomData,
328 }))
329}
330
331#[cfg(feature = "derive")]
353pub fn define_tool_declaration<P>(name: impl Into<String>, description: impl Into<String>) -> Tool
354where
355 P: schemars::JsonSchema,
356{
357 Tool {
358 name: name.into(),
359 description: description.into(),
360 parameters: tool_parameters(schema_for::<P>()),
361 ..Default::default()
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::types::SessionId;
369
370 struct EchoTool;
371
372 fn echo_tool() -> Tool {
373 Tool {
374 name: "echo".to_string(),
375 description: "Echo the input".to_string(),
376 parameters: tool_parameters(serde_json::json!({"type": "object"})),
377 ..Default::default()
378 }
379 .with_handler(std::sync::Arc::new(EchoTool))
380 }
381
382 #[async_trait]
383 impl ToolHandler for EchoTool {
384 async fn call(&self, inv: ToolInvocation) -> Result<ToolResult, Error> {
385 Ok(ToolResult::Text(inv.arguments.to_string()))
386 }
387 }
388
389 #[test]
390 fn tool_handler_returns_tool_definition() {
391 let def = echo_tool();
392 assert_eq!(def.name, "echo");
393 assert_eq!(def.description, "Echo the input");
394 assert!(def.parameters.contains_key("type"));
395 assert!(def.handler.is_some());
396 }
397
398 #[test]
399 fn try_tool_parameters_rejects_non_object_schema() {
400 let err = try_tool_parameters(serde_json::json!(["not", "an", "object"]))
401 .expect_err("non-object schemas should be rejected");
402
403 assert!(err.is_data());
404 }
405
406 #[test]
407 fn tool_parameters_serialize_in_deterministic_order() {
408 let schema = serde_json::json!({
413 "type": "object",
414 "properties": {
415 "url": { "type": "string" },
416 "count": { "type": "integer" }
417 },
418 "required": ["url"],
419 "additionalProperties": false
420 });
421
422 let build = || Tool {
423 name: "fetch".to_string(),
424 parameters: tool_parameters(schema.clone()),
425 ..Default::default()
426 };
427
428 let expected = serde_json::to_string(&build()).expect("serialize tool");
429 for _ in 0..64 {
430 let actual = serde_json::to_string(&build()).expect("serialize tool");
431 assert_eq!(actual, expected);
432 }
433
434 let tool = build();
437 let keys: Vec<&str> = tool.parameters.keys().map(String::as_str).collect();
438 assert_eq!(
439 keys,
440 ["additionalProperties", "properties", "required", "type"]
441 );
442 }
443
444 #[test]
445 fn convert_mcp_call_tool_result_collects_text_and_binary_content() {
446 let result = convert_mcp_call_tool_result(&serde_json::json!({
447 "isError": true,
448 "content": [
449 { "type": "text", "text": "hello" },
450 { "type": "image", "data": "aW1n", "mimeType": "image/png" },
451 {
452 "type": "resource",
453 "resource": {
454 "uri": "file:///tmp/data.bin",
455 "blob": "Ymlu",
456 "mimeType": "application/octet-stream",
457 "text": "resource text"
458 }
459 }
460 ]
461 }))
462 .expect("valid CallToolResult should convert");
463
464 let ToolResult::Expanded(expanded) = result else {
465 panic!("expected expanded tool result");
466 };
467
468 assert_eq!(expanded.text_result_for_llm, "hello\nresource text");
469 assert_eq!(expanded.result_type, "failure");
470 let binary_results = expanded
471 .binary_results_for_llm
472 .expect("binary results should be captured");
473 assert_eq!(binary_results.len(), 2);
474 assert_eq!(binary_results[0].r#type, "image");
475 assert_eq!(binary_results[0].data, "aW1n");
476 assert_eq!(binary_results[0].mime_type, "image/png");
477 assert_eq!(
478 binary_results[1].description.as_deref(),
479 Some("file:///tmp/data.bin")
480 );
481 }
482
483 #[test]
484 fn convert_mcp_call_tool_result_converts_image_content() {
485 let result = convert_mcp_call_tool_result(&serde_json::json!({
486 "content": [
487 { "type": "image", "data": "aW1hZ2U=", "mimeType": "image/jpeg" }
488 ]
489 }))
490 .expect("valid CallToolResult should convert");
491
492 let ToolResult::Expanded(expanded) = result else {
493 panic!("expected expanded tool result");
494 };
495
496 assert_eq!(expanded.text_result_for_llm, "");
497 assert_eq!(expanded.result_type, "success");
498 let binary_results = expanded
499 .binary_results_for_llm
500 .expect("image result should be captured");
501 assert_eq!(binary_results.len(), 1);
502 assert_eq!(binary_results[0].data, "aW1hZ2U=");
503 assert_eq!(binary_results[0].mime_type, "image/jpeg");
504 assert_eq!(binary_results[0].r#type, "image");
505 assert!(binary_results[0].description.is_none());
506 }
507
508 #[test]
509 fn convert_mcp_call_tool_result_converts_resource_blob_content() {
510 let result = convert_mcp_call_tool_result(&serde_json::json!({
511 "content": [
512 {
513 "type": "resource",
514 "resource": {
515 "uri": "file:///tmp/report.pdf",
516 "blob": "cGRm",
517 "mimeType": "application/pdf"
518 }
519 }
520 ]
521 }))
522 .expect("valid CallToolResult should convert");
523
524 let ToolResult::Expanded(expanded) = result else {
525 panic!("expected expanded tool result");
526 };
527
528 let binary_results = expanded
529 .binary_results_for_llm
530 .expect("resource result should be captured");
531 assert_eq!(binary_results.len(), 1);
532 assert_eq!(binary_results[0].data, "cGRm");
533 assert_eq!(binary_results[0].mime_type, "application/pdf");
534 assert_eq!(binary_results[0].r#type, "resource");
535 assert_eq!(
536 binary_results[0].description.as_deref(),
537 Some("file:///tmp/report.pdf")
538 );
539 }
540
541 #[test]
542 fn convert_mcp_call_tool_result_defaults_resource_blob_mime_type() {
543 let result = convert_mcp_call_tool_result(&serde_json::json!({
544 "content": [
545 {
546 "type": "resource",
547 "resource": {
548 "uri": "file:///tmp/data.bin",
549 "blob": "Ymlu"
550 }
551 },
552 {
553 "type": "resource",
554 "resource": {
555 "blob": "YmluMg==",
556 "mimeType": ""
557 }
558 }
559 ]
560 }))
561 .expect("valid CallToolResult should convert");
562
563 let ToolResult::Expanded(expanded) = result else {
564 panic!("expected expanded tool result");
565 };
566
567 let binary_results = expanded
568 .binary_results_for_llm
569 .expect("resource blobs should be captured");
570 assert_eq!(binary_results.len(), 2);
571 assert_eq!(binary_results[0].mime_type, "application/octet-stream");
572 assert_eq!(binary_results[1].mime_type, "application/octet-stream");
573 }
574
575 #[test]
576 fn convert_mcp_call_tool_result_omits_binary_results_without_binary_content() {
577 let result = convert_mcp_call_tool_result(&serde_json::json!({
578 "content": [
579 { "type": "text", "text": "hello" },
580 {
581 "type": "resource",
582 "resource": {
583 "uri": "file:///tmp/readme.md",
584 "text": "resource text"
585 }
586 }
587 ]
588 }))
589 .expect("valid CallToolResult should convert");
590
591 let ToolResult::Expanded(expanded) = result else {
592 panic!("expected expanded tool result");
593 };
594
595 assert_eq!(expanded.text_result_for_llm, "hello\nresource text");
596 assert!(expanded.binary_results_for_llm.is_none());
597 }
598
599 #[tokio::test]
600 async fn tool_handler_call_returns_result() {
601 let tool = EchoTool;
602 let inv = ToolInvocation {
603 session_id: SessionId::from("s1"),
604 tool_call_id: "tc1".to_string(),
605 tool_name: "echo".to_string(),
606 arguments: serde_json::json!({"msg": "hello"}),
607 available_tools: None,
608 traceparent: None,
609 tracestate: None,
610 };
611
612 let result = tool.call(inv).await.unwrap();
613 match result {
614 ToolResult::Text(s) => assert!(s.contains("hello")),
615 _ => panic!("expected Text result"),
616 }
617 }
618
619 #[cfg(feature = "derive")]
620 #[tokio::test]
621 async fn define_tool_builds_schema_and_dispatches() {
622 use serde::Deserialize;
623
624 #[derive(Deserialize, schemars::JsonSchema)]
625 struct Params {
626 city: String,
627 }
628
629 let tool = define_tool(
630 "weather",
631 "Get the weather for a city",
632 |_inv, params: Params| async move {
633 Ok(ToolResult::Text(format!("sunny in {}", params.city)))
634 },
635 );
636
637 assert_eq!(tool.name, "weather");
638 assert_eq!(tool.description, "Get the weather for a city");
639 assert_eq!(tool.parameters["type"], "object");
640 assert!(tool.parameters["properties"]["city"].is_object());
641 let handler = tool.handler.as_ref().expect("define_tool attaches handler");
642
643 let inv = ToolInvocation {
644 session_id: SessionId::from("s1"),
645 tool_call_id: "tc1".to_string(),
646 tool_name: "weather".to_string(),
647 arguments: serde_json::json!({"city": "Seattle"}),
648 available_tools: None,
649 traceparent: None,
650 tracestate: None,
651 };
652 match handler.call(inv).await.unwrap() {
653 ToolResult::Text(s) => assert_eq!(s, "sunny in Seattle"),
654 _ => panic!("expected Text result"),
655 }
656 }
657
658 #[cfg(feature = "derive")]
660 mod derive_tests {
661 use serde::Deserialize;
662
663 use super::super::*;
664 use crate::{ErrorKind, SessionId};
665
666 #[derive(Deserialize, schemars::JsonSchema)]
667 struct GetWeatherParams {
668 city: String,
670 unit: Option<String>,
672 }
673
674 #[test]
675 fn schema_for_generates_clean_schema() {
676 let schema = schema_for::<GetWeatherParams>();
677 assert_eq!(schema["type"], "object");
678 assert!(schema["properties"]["city"].is_object());
679 assert!(schema["properties"]["unit"].is_object());
680 let required = schema["required"].as_array().unwrap();
682 assert!(required.contains(&serde_json::json!("city")));
683 assert!(!required.contains(&serde_json::json!("unit")));
684 assert!(schema.get("$schema").is_none());
686 assert!(schema.get("title").is_none());
687 }
688
689 struct GetWeatherTool;
690
691 fn get_weather_tool() -> Tool {
692 Tool {
693 name: "get_weather".to_string(),
694 description: "Get weather for a city".to_string(),
695 parameters: tool_parameters(schema_for::<GetWeatherParams>()),
696 ..Default::default()
697 }
698 .with_handler(std::sync::Arc::new(GetWeatherTool))
699 }
700
701 #[async_trait]
702 impl ToolHandler for GetWeatherTool {
703 async fn call(&self, inv: ToolInvocation) -> Result<ToolResult, Error> {
704 let params: GetWeatherParams = serde_json::from_value(inv.arguments)?;
705 Ok(ToolResult::Text(format!(
706 "{} {}",
707 params.city,
708 params.unit.unwrap_or_default()
709 )))
710 }
711 }
712
713 #[test]
714 fn tool_handler_with_schema_for() {
715 let def = get_weather_tool();
716 assert_eq!(def.name, "get_weather");
717 let schema = serde_json::to_value(&def.parameters).expect("serialize tool parameters");
718 assert_eq!(schema["type"], "object");
719 assert!(schema["properties"]["city"].is_object());
720 assert!(def.handler.is_some());
721 }
722
723 #[tokio::test]
724 async fn tool_handler_deserializes_typed_params() {
725 let tool = GetWeatherTool;
726 let inv = ToolInvocation {
727 session_id: SessionId::from("s1"),
728 tool_call_id: "tc1".to_string(),
729 tool_name: "get_weather".to_string(),
730 arguments: serde_json::json!({"city": "Seattle", "unit": "celsius"}),
731 available_tools: None,
732 traceparent: None,
733 tracestate: None,
734 };
735
736 let result = tool.call(inv).await.unwrap();
737 match result {
738 ToolResult::Text(s) => assert_eq!(s, "Seattle celsius"),
739 _ => panic!("expected Text result"),
740 }
741 }
742
743 #[tokio::test]
744 async fn tool_handler_returns_error_on_bad_params() {
745 let tool = GetWeatherTool;
746 let inv = ToolInvocation {
747 session_id: SessionId::from("s1"),
748 tool_call_id: "tc1".to_string(),
749 tool_name: "get_weather".to_string(),
750 arguments: serde_json::json!({"wrong_field": 42}),
751 available_tools: None,
752 traceparent: None,
753 tracestate: None,
754 };
755
756 let err = tool.call(inv).await.unwrap_err();
757 assert!(matches!(err.kind(), ErrorKind::Json));
758 }
759
760 #[tokio::test]
761 async fn schema_for_derived_tool_round_trips_through_call() {
762 let tool = GetWeatherTool;
763
764 let result = tool
768 .call(ToolInvocation {
769 session_id: SessionId::from("s1"),
770 tool_call_id: "tc1".to_string(),
771 tool_name: "get_weather".to_string(),
772 arguments: serde_json::json!({"city": "Portland"}),
773 available_tools: None,
774 traceparent: None,
775 tracestate: None,
776 })
777 .await
778 .expect("ToolHandler::call should succeed for matching args");
779 match result {
780 ToolResult::Text(s) => assert!(s.contains("Portland")),
781 _ => panic!("expected ToolResult::Text"),
782 }
783 }
784 }
785}