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 }))
177}
178
179#[async_trait]
226pub trait ToolHandler: Send + Sync + 'static {
227 async fn call(&self, invocation: ToolInvocation) -> Result<ToolResult, Error>;
229}
230
231#[cfg(feature = "derive")]
289pub fn define_tool<P, F, Fut>(
290 name: impl Into<String>,
291 description: impl Into<String>,
292 handler: F,
293) -> Tool
294where
295 P: schemars::JsonSchema + serde::de::DeserializeOwned + Send + 'static,
296 F: Fn(ToolInvocation, P) -> Fut + Send + Sync + 'static,
297 Fut: std::future::Future<Output = Result<ToolResult, Error>> + Send + 'static,
298{
299 struct FnHandler<P, F> {
300 handler: F,
301 _marker: std::marker::PhantomData<fn(P)>,
302 }
303
304 #[async_trait]
305 impl<P, F, Fut> ToolHandler for FnHandler<P, F>
306 where
307 P: schemars::JsonSchema + serde::de::DeserializeOwned + Send + 'static,
308 F: Fn(ToolInvocation, P) -> Fut + Send + Sync + 'static,
309 Fut: std::future::Future<Output = Result<ToolResult, Error>> + Send + 'static,
310 {
311 async fn call(&self, mut invocation: ToolInvocation) -> Result<ToolResult, Error> {
312 let arguments = std::mem::take(&mut invocation.arguments);
313 let params: P = serde_json::from_value(arguments)?;
314 (self.handler)(invocation, params).await
315 }
316 }
317
318 Tool {
319 name: name.into(),
320 description: description.into(),
321 parameters: tool_parameters(schema_for::<P>()),
322 ..Default::default()
323 }
324 .with_handler(std::sync::Arc::new(FnHandler {
325 handler,
326 _marker: std::marker::PhantomData,
327 }))
328}
329
330#[cfg(feature = "derive")]
352pub fn define_tool_declaration<P>(name: impl Into<String>, description: impl Into<String>) -> Tool
353where
354 P: schemars::JsonSchema,
355{
356 Tool {
357 name: name.into(),
358 description: description.into(),
359 parameters: tool_parameters(schema_for::<P>()),
360 ..Default::default()
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use crate::types::SessionId;
368
369 struct EchoTool;
370
371 fn echo_tool() -> Tool {
372 Tool {
373 name: "echo".to_string(),
374 description: "Echo the input".to_string(),
375 parameters: tool_parameters(serde_json::json!({"type": "object"})),
376 ..Default::default()
377 }
378 .with_handler(std::sync::Arc::new(EchoTool))
379 }
380
381 #[async_trait]
382 impl ToolHandler for EchoTool {
383 async fn call(&self, inv: ToolInvocation) -> Result<ToolResult, Error> {
384 Ok(ToolResult::Text(inv.arguments.to_string()))
385 }
386 }
387
388 #[test]
389 fn tool_handler_returns_tool_definition() {
390 let def = echo_tool();
391 assert_eq!(def.name, "echo");
392 assert_eq!(def.description, "Echo the input");
393 assert!(def.parameters.contains_key("type"));
394 assert!(def.handler.is_some());
395 }
396
397 #[test]
398 fn try_tool_parameters_rejects_non_object_schema() {
399 let err = try_tool_parameters(serde_json::json!(["not", "an", "object"]))
400 .expect_err("non-object schemas should be rejected");
401
402 assert!(err.is_data());
403 }
404
405 #[test]
406 fn tool_parameters_serialize_in_deterministic_order() {
407 let schema = serde_json::json!({
412 "type": "object",
413 "properties": {
414 "url": { "type": "string" },
415 "count": { "type": "integer" }
416 },
417 "required": ["url"],
418 "additionalProperties": false
419 });
420
421 let build = || Tool {
422 name: "fetch".to_string(),
423 parameters: tool_parameters(schema.clone()),
424 ..Default::default()
425 };
426
427 let expected = serde_json::to_string(&build()).expect("serialize tool");
428 for _ in 0..64 {
429 let actual = serde_json::to_string(&build()).expect("serialize tool");
430 assert_eq!(actual, expected);
431 }
432
433 let tool = build();
436 let keys: Vec<&str> = tool.parameters.keys().map(String::as_str).collect();
437 assert_eq!(
438 keys,
439 ["additionalProperties", "properties", "required", "type"]
440 );
441 }
442
443 #[test]
444 fn convert_mcp_call_tool_result_collects_text_and_binary_content() {
445 let result = convert_mcp_call_tool_result(&serde_json::json!({
446 "isError": true,
447 "content": [
448 { "type": "text", "text": "hello" },
449 { "type": "image", "data": "aW1n", "mimeType": "image/png" },
450 {
451 "type": "resource",
452 "resource": {
453 "uri": "file:///tmp/data.bin",
454 "blob": "Ymlu",
455 "mimeType": "application/octet-stream",
456 "text": "resource text"
457 }
458 }
459 ]
460 }))
461 .expect("valid CallToolResult should convert");
462
463 let ToolResult::Expanded(expanded) = result else {
464 panic!("expected expanded tool result");
465 };
466
467 assert_eq!(expanded.text_result_for_llm, "hello\nresource text");
468 assert_eq!(expanded.result_type, "failure");
469 let binary_results = expanded
470 .binary_results_for_llm
471 .expect("binary results should be captured");
472 assert_eq!(binary_results.len(), 2);
473 assert_eq!(binary_results[0].r#type, "image");
474 assert_eq!(binary_results[0].data, "aW1n");
475 assert_eq!(binary_results[0].mime_type, "image/png");
476 assert_eq!(
477 binary_results[1].description.as_deref(),
478 Some("file:///tmp/data.bin")
479 );
480 }
481
482 #[test]
483 fn convert_mcp_call_tool_result_converts_image_content() {
484 let result = convert_mcp_call_tool_result(&serde_json::json!({
485 "content": [
486 { "type": "image", "data": "aW1hZ2U=", "mimeType": "image/jpeg" }
487 ]
488 }))
489 .expect("valid CallToolResult should convert");
490
491 let ToolResult::Expanded(expanded) = result else {
492 panic!("expected expanded tool result");
493 };
494
495 assert_eq!(expanded.text_result_for_llm, "");
496 assert_eq!(expanded.result_type, "success");
497 let binary_results = expanded
498 .binary_results_for_llm
499 .expect("image result should be captured");
500 assert_eq!(binary_results.len(), 1);
501 assert_eq!(binary_results[0].data, "aW1hZ2U=");
502 assert_eq!(binary_results[0].mime_type, "image/jpeg");
503 assert_eq!(binary_results[0].r#type, "image");
504 assert!(binary_results[0].description.is_none());
505 }
506
507 #[test]
508 fn convert_mcp_call_tool_result_converts_resource_blob_content() {
509 let result = convert_mcp_call_tool_result(&serde_json::json!({
510 "content": [
511 {
512 "type": "resource",
513 "resource": {
514 "uri": "file:///tmp/report.pdf",
515 "blob": "cGRm",
516 "mimeType": "application/pdf"
517 }
518 }
519 ]
520 }))
521 .expect("valid CallToolResult should convert");
522
523 let ToolResult::Expanded(expanded) = result else {
524 panic!("expected expanded tool result");
525 };
526
527 let binary_results = expanded
528 .binary_results_for_llm
529 .expect("resource result should be captured");
530 assert_eq!(binary_results.len(), 1);
531 assert_eq!(binary_results[0].data, "cGRm");
532 assert_eq!(binary_results[0].mime_type, "application/pdf");
533 assert_eq!(binary_results[0].r#type, "resource");
534 assert_eq!(
535 binary_results[0].description.as_deref(),
536 Some("file:///tmp/report.pdf")
537 );
538 }
539
540 #[test]
541 fn convert_mcp_call_tool_result_defaults_resource_blob_mime_type() {
542 let result = convert_mcp_call_tool_result(&serde_json::json!({
543 "content": [
544 {
545 "type": "resource",
546 "resource": {
547 "uri": "file:///tmp/data.bin",
548 "blob": "Ymlu"
549 }
550 },
551 {
552 "type": "resource",
553 "resource": {
554 "blob": "YmluMg==",
555 "mimeType": ""
556 }
557 }
558 ]
559 }))
560 .expect("valid CallToolResult should convert");
561
562 let ToolResult::Expanded(expanded) = result else {
563 panic!("expected expanded tool result");
564 };
565
566 let binary_results = expanded
567 .binary_results_for_llm
568 .expect("resource blobs should be captured");
569 assert_eq!(binary_results.len(), 2);
570 assert_eq!(binary_results[0].mime_type, "application/octet-stream");
571 assert_eq!(binary_results[1].mime_type, "application/octet-stream");
572 }
573
574 #[test]
575 fn convert_mcp_call_tool_result_omits_binary_results_without_binary_content() {
576 let result = convert_mcp_call_tool_result(&serde_json::json!({
577 "content": [
578 { "type": "text", "text": "hello" },
579 {
580 "type": "resource",
581 "resource": {
582 "uri": "file:///tmp/readme.md",
583 "text": "resource text"
584 }
585 }
586 ]
587 }))
588 .expect("valid CallToolResult should convert");
589
590 let ToolResult::Expanded(expanded) = result else {
591 panic!("expected expanded tool result");
592 };
593
594 assert_eq!(expanded.text_result_for_llm, "hello\nresource text");
595 assert!(expanded.binary_results_for_llm.is_none());
596 }
597
598 #[tokio::test]
599 async fn tool_handler_call_returns_result() {
600 let tool = EchoTool;
601 let inv = ToolInvocation {
602 session_id: SessionId::from("s1"),
603 tool_call_id: "tc1".to_string(),
604 tool_name: "echo".to_string(),
605 arguments: serde_json::json!({"msg": "hello"}),
606 traceparent: None,
607 tracestate: None,
608 };
609
610 let result = tool.call(inv).await.unwrap();
611 match result {
612 ToolResult::Text(s) => assert!(s.contains("hello")),
613 _ => panic!("expected Text result"),
614 }
615 }
616
617 #[cfg(feature = "derive")]
618 #[tokio::test]
619 async fn define_tool_builds_schema_and_dispatches() {
620 use serde::Deserialize;
621
622 #[derive(Deserialize, schemars::JsonSchema)]
623 struct Params {
624 city: String,
625 }
626
627 let tool = define_tool(
628 "weather",
629 "Get the weather for a city",
630 |_inv, params: Params| async move {
631 Ok(ToolResult::Text(format!("sunny in {}", params.city)))
632 },
633 );
634
635 assert_eq!(tool.name, "weather");
636 assert_eq!(tool.description, "Get the weather for a city");
637 assert_eq!(tool.parameters["type"], "object");
638 assert!(tool.parameters["properties"]["city"].is_object());
639 let handler = tool.handler.as_ref().expect("define_tool attaches handler");
640
641 let inv = ToolInvocation {
642 session_id: SessionId::from("s1"),
643 tool_call_id: "tc1".to_string(),
644 tool_name: "weather".to_string(),
645 arguments: serde_json::json!({"city": "Seattle"}),
646 traceparent: None,
647 tracestate: None,
648 };
649 match handler.call(inv).await.unwrap() {
650 ToolResult::Text(s) => assert_eq!(s, "sunny in Seattle"),
651 _ => panic!("expected Text result"),
652 }
653 }
654
655 #[cfg(feature = "derive")]
657 mod derive_tests {
658 use serde::Deserialize;
659
660 use super::super::*;
661 use crate::{ErrorKind, SessionId};
662
663 #[derive(Deserialize, schemars::JsonSchema)]
664 struct GetWeatherParams {
665 city: String,
667 unit: Option<String>,
669 }
670
671 #[test]
672 fn schema_for_generates_clean_schema() {
673 let schema = schema_for::<GetWeatherParams>();
674 assert_eq!(schema["type"], "object");
675 assert!(schema["properties"]["city"].is_object());
676 assert!(schema["properties"]["unit"].is_object());
677 let required = schema["required"].as_array().unwrap();
679 assert!(required.contains(&serde_json::json!("city")));
680 assert!(!required.contains(&serde_json::json!("unit")));
681 assert!(schema.get("$schema").is_none());
683 assert!(schema.get("title").is_none());
684 }
685
686 struct GetWeatherTool;
687
688 fn get_weather_tool() -> Tool {
689 Tool {
690 name: "get_weather".to_string(),
691 description: "Get weather for a city".to_string(),
692 parameters: tool_parameters(schema_for::<GetWeatherParams>()),
693 ..Default::default()
694 }
695 .with_handler(std::sync::Arc::new(GetWeatherTool))
696 }
697
698 #[async_trait]
699 impl ToolHandler for GetWeatherTool {
700 async fn call(&self, inv: ToolInvocation) -> Result<ToolResult, Error> {
701 let params: GetWeatherParams = serde_json::from_value(inv.arguments)?;
702 Ok(ToolResult::Text(format!(
703 "{} {}",
704 params.city,
705 params.unit.unwrap_or_default()
706 )))
707 }
708 }
709
710 #[test]
711 fn tool_handler_with_schema_for() {
712 let def = get_weather_tool();
713 assert_eq!(def.name, "get_weather");
714 let schema = serde_json::to_value(&def.parameters).expect("serialize tool parameters");
715 assert_eq!(schema["type"], "object");
716 assert!(schema["properties"]["city"].is_object());
717 assert!(def.handler.is_some());
718 }
719
720 #[tokio::test]
721 async fn tool_handler_deserializes_typed_params() {
722 let tool = GetWeatherTool;
723 let inv = ToolInvocation {
724 session_id: SessionId::from("s1"),
725 tool_call_id: "tc1".to_string(),
726 tool_name: "get_weather".to_string(),
727 arguments: serde_json::json!({"city": "Seattle", "unit": "celsius"}),
728 traceparent: None,
729 tracestate: None,
730 };
731
732 let result = tool.call(inv).await.unwrap();
733 match result {
734 ToolResult::Text(s) => assert_eq!(s, "Seattle celsius"),
735 _ => panic!("expected Text result"),
736 }
737 }
738
739 #[tokio::test]
740 async fn tool_handler_returns_error_on_bad_params() {
741 let tool = GetWeatherTool;
742 let inv = ToolInvocation {
743 session_id: SessionId::from("s1"),
744 tool_call_id: "tc1".to_string(),
745 tool_name: "get_weather".to_string(),
746 arguments: serde_json::json!({"wrong_field": 42}),
747 traceparent: None,
748 tracestate: None,
749 };
750
751 let err = tool.call(inv).await.unwrap_err();
752 assert!(matches!(err.kind(), ErrorKind::Json));
753 }
754
755 #[tokio::test]
756 async fn schema_for_derived_tool_round_trips_through_call() {
757 let tool = GetWeatherTool;
758
759 let result = tool
763 .call(ToolInvocation {
764 session_id: SessionId::from("s1"),
765 tool_call_id: "tc1".to_string(),
766 tool_name: "get_weather".to_string(),
767 arguments: serde_json::json!({"city": "Portland"}),
768 traceparent: None,
769 tracestate: None,
770 })
771 .await
772 .expect("ToolHandler::call should succeed for matching args");
773 match result {
774 ToolResult::Text(s) => assert!(s.contains("Portland")),
775 _ => panic!("expected ToolResult::Text"),
776 }
777 }
778 }
779}