-
Notifications
You must be signed in to change notification settings - Fork 550
Add support for Tool.outputSchema and CallToolResult.structuredContent
#316
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
c2f65cc
5d24acc
efdb55f
c3a9ba7
6cad6c5
366d0af
b16fd38
d82056f
b174b63
cb28342
1b03666
3ed064d
70bf2b1
43a72da
4001d65
cff51c4
33b4d59
1274857
a1ef39e
f752f66
767d3ae
cf52be9
43f08bf
5109fcb
906812e
d6807ce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,8 @@ pub struct ToolAttribute { | |
| pub description: Option<String>, | ||
| /// A JSON Schema object defining the expected parameters for the tool | ||
| pub input_schema: Option<Expr>, | ||
| /// An optional JSON Schema object defining the structure of the tool's output | ||
| pub output_schema: Option<Expr>, | ||
| /// Optional additional tool information. | ||
| pub annotations: Option<ToolAnnotationsAttribute>, | ||
| } | ||
|
|
@@ -18,6 +20,7 @@ pub struct ResolvedToolAttribute { | |
| pub name: String, | ||
| pub description: Option<String>, | ||
| pub input_schema: Expr, | ||
| pub output_schema: Option<Expr>, | ||
| pub annotations: Expr, | ||
| } | ||
|
|
||
|
|
@@ -27,19 +30,26 @@ impl ResolvedToolAttribute { | |
| name, | ||
| description, | ||
| input_schema, | ||
| output_schema, | ||
| annotations, | ||
| } = self; | ||
| let description = if let Some(description) = description { | ||
| quote! { Some(#description.into()) } | ||
| } else { | ||
| quote! { None } | ||
| }; | ||
| let output_schema = if let Some(output_schema) = output_schema { | ||
| quote! { Some(#output_schema) } | ||
| } else { | ||
| quote! { None } | ||
| }; | ||
| let tokens = quote! { | ||
| pub fn #fn_ident() -> rmcp::model::Tool { | ||
| rmcp::model::Tool { | ||
| name: #name.into(), | ||
| description: #description, | ||
| input_schema: #input_schema, | ||
| output_schema: #output_schema, | ||
| annotations: #annotations, | ||
| } | ||
| } | ||
|
|
@@ -192,12 +202,70 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> { | |
| } else { | ||
| none_expr() | ||
| }; | ||
| // Handle output_schema - either explicit or generated from return type | ||
| let output_schema_expr = if let Some(output_schema) = attribute.output_schema { | ||
| Some(output_schema) | ||
| } else { | ||
| // Try to generate schema from return type | ||
| // Look for Result<T, E> where T is not CallToolResult | ||
| match &fn_item.sig.output { | ||
| syn::ReturnType::Type(_, ret_type) => { | ||
| if let syn::Type::Path(type_path) = &**ret_type { | ||
| if let Some(last_segment) = type_path.path.segments.last() { | ||
| if last_segment.ident == "Result" { | ||
| if let syn::PathArguments::AngleBracketed(args) = | ||
| &last_segment.arguments | ||
| { | ||
| if let Some(syn::GenericArgument::Type(ok_type)) = args.args.first() | ||
| { | ||
| // Check if the type is NOT CallToolResult | ||
| let is_call_tool_result = | ||
| if let syn::Type::Path(ok_path) = ok_type { | ||
| ok_path | ||
| .path | ||
| .segments | ||
| .last() | ||
| .map(|seg| seg.ident == "CallToolResult") | ||
| .unwrap_or(false) | ||
| } else { | ||
| false | ||
| }; | ||
|
|
||
| if !is_call_tool_result { | ||
| // Generate schema for the Ok type | ||
| syn::parse2::<Expr>(quote! { | ||
| rmcp::handler::server::tool::cached_schema_for_type::<#ok_type>() | ||
| }).ok() | ||
| } else { | ||
| None | ||
| } | ||
| } else { | ||
| None | ||
| } | ||
| } else { | ||
| None | ||
| } | ||
| } else { | ||
| None | ||
| } | ||
| } else { | ||
| None | ||
| } | ||
| } else { | ||
| None | ||
| } | ||
| } | ||
| _ => None, | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the complexity of the circle here a bit high?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Simplify this please.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems that there are no modifications here
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jokemanfire my bad! Sorry. Done in 43f08bf |
||
| }; | ||
|
|
||
| let resolved_tool_attr = ResolvedToolAttribute { | ||
| name: attribute.name.unwrap_or_else(|| fn_ident.to_string()), | ||
| description: attribute | ||
| .description | ||
| .or_else(|| fn_item.attrs.iter().fold(None, extract_doc_line)), | ||
| input_schema: input_schema_expr, | ||
| output_schema: output_schema_expr, | ||
| annotations: annotations_expr, | ||
| }; | ||
| let tool_attr_fn = resolved_tool_attr.into_fn(tool_attr_fn_ident)?; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,39 @@ | ||
| //! Tool handler traits and types for MCP servers. | ||
| //! | ||
| //! This module provides the infrastructure for implementing tools that can be called | ||
| //! by MCP clients. Tools can return either unstructured content (text, images) or | ||
| //! structured JSON data with schemas. | ||
| //! | ||
| //! # Structured Output | ||
| //! | ||
| //! Tools can return structured JSON data using the [`Structured`] wrapper type. | ||
| //! When using `Structured<T>`, the framework will: | ||
| //! - Automatically generate a JSON schema for the output type | ||
| //! - Validate the output against the schema | ||
| //! - Return the data in the `structured_content` field of [`CallToolResult`] | ||
| //! | ||
| //! # Example | ||
| //! | ||
| //! ```rust,ignore | ||
| //! use rmcp::{tool, Structured}; | ||
| //! use schemars::JsonSchema; | ||
| //! use serde::{Serialize, Deserialize}; | ||
| //! | ||
| //! #[derive(Serialize, Deserialize, JsonSchema)] | ||
| //! struct AnalysisResult { | ||
| //! score: f64, | ||
| //! summary: String, | ||
| //! } | ||
| //! | ||
| //! #[tool(name = "analyze")] | ||
| //! async fn analyze(&self, text: String) -> Result<Structured<AnalysisResult>, String> { | ||
| //! Ok(Structured(AnalysisResult { | ||
| //! score: 0.95, | ||
| //! summary: "Positive sentiment".to_string(), | ||
| //! })) | ||
| //! } | ||
| //! ``` | ||
|
|
||
| use std::{ | ||
| any::TypeId, borrow::Cow, collections::HashMap, future::Ready, marker::PhantomData, sync::Arc, | ||
| }; | ||
|
|
@@ -30,6 +66,48 @@ pub fn schema_for_type<T: JsonSchema>() -> JsonObject { | |
| } | ||
| } | ||
|
|
||
| /// Validate that a JSON value conforms to basic type constraints from a schema. | ||
| /// | ||
| /// Note: This is a basic validation that only checks type compatibility. | ||
| /// For full JSON Schema validation, a dedicated validation library would be needed. | ||
| pub fn validate_against_schema( | ||
|
jokemanfire marked this conversation as resolved.
|
||
| value: &serde_json::Value, | ||
| schema: &JsonObject, | ||
| ) -> Result<(), crate::ErrorData> { | ||
| // Basic type validation | ||
| if let Some(schema_type) = schema.get("type").and_then(|t| t.as_str()) { | ||
| let is_valid = matches!( | ||
| (schema_type, value), | ||
| ("null", serde_json::Value::Null) | ||
| | ("boolean", serde_json::Value::Bool(_)) | ||
| | ("number", serde_json::Value::Number(_)) | ||
| | ("string", serde_json::Value::String(_)) | ||
| | ("array", serde_json::Value::Array(_)) | ||
| | ("object", serde_json::Value::Object(_)) | ||
| ); | ||
|
|
||
| if !is_valid { | ||
| return Err(crate::ErrorData::invalid_params( | ||
| format!( | ||
| "Value type does not match schema. Expected '{}', got '{}'", | ||
| schema_type, | ||
| match value { | ||
| serde_json::Value::Null => "null", | ||
| serde_json::Value::Bool(_) => "boolean", | ||
| serde_json::Value::Number(_) => "number", | ||
| serde_json::Value::String(_) => "string", | ||
| serde_json::Value::Array(_) => "array", | ||
| serde_json::Value::Object(_) => "object", | ||
| } | ||
| ), | ||
| None, | ||
| )); | ||
| } | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| /// Call [`schema_for_type`] with a cache | ||
| pub fn cached_schema_for_type<T: JsonSchema + std::any::Any>() -> Arc<JsonObject> { | ||
| thread_local! { | ||
|
|
@@ -97,6 +175,63 @@ pub trait FromToolCallContextPart<S>: Sized { | |
| ) -> Result<Self, crate::ErrorData>; | ||
| } | ||
|
|
||
| /// Marker wrapper to indicate that a type should be serialized as structured content | ||
| /// | ||
| /// When a tool returns `Structured<T>`, the MCP framework will: | ||
| /// 1. Serialize `T` to JSON and place it in `CallToolResult.structured_content` | ||
| /// 2. Leave `CallToolResult.content` as `None` | ||
| /// 3. Validate the serialized JSON against the tool's output schema (if present) | ||
| /// | ||
| /// # Example | ||
| /// | ||
| /// ```rust,ignore | ||
| /// use rmcp::{tool, Structured}; | ||
| /// use schemars::JsonSchema; | ||
| /// use serde::{Serialize, Deserialize}; | ||
| /// | ||
| /// #[derive(Serialize, Deserialize, JsonSchema)] | ||
| /// struct WeatherData { | ||
| /// temperature: f64, | ||
| /// description: String, | ||
| /// } | ||
| /// | ||
| /// #[tool(name = "get_weather")] | ||
| /// async fn get_weather(&self) -> Result<Structured<WeatherData>, String> { | ||
| /// Ok(Structured(WeatherData { | ||
| /// temperature: 22.5, | ||
| /// description: "Sunny".to_string(), | ||
| /// })) | ||
| /// } | ||
| /// ``` | ||
| pub struct Structured<T>(pub T); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We already have one:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| impl<T> Structured<T> { | ||
| pub fn new(value: T) -> Self { | ||
| Structured(value) | ||
| } | ||
| } | ||
|
|
||
| // Implement JsonSchema for Structured<T> to delegate to T's schema | ||
| impl<T: JsonSchema> JsonSchema for Structured<T> { | ||
| fn schema_name() -> Cow<'static, str> { | ||
| T::schema_name() | ||
| } | ||
|
|
||
| fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { | ||
| T::json_schema(generator) | ||
| } | ||
| } | ||
|
|
||
| /// Trait for converting tool return values into [`CallToolResult`]. | ||
| /// | ||
| /// This trait is automatically implemented for: | ||
| /// - Types implementing [`IntoContents`] (returns unstructured content) | ||
| /// - `Result<T, E>` where both `T` and `E` implement [`IntoContents`] | ||
| /// - [`Structured<T>`] where `T` implements [`Serialize`] (returns structured content) | ||
| /// - `Result<Structured<T>, E>` for structured results with errors | ||
| /// | ||
| /// The `#[tool]` macro uses this trait to convert tool function return values | ||
| /// into the appropriate [`CallToolResult`] format. | ||
| pub trait IntoCallToolResult { | ||
| fn into_call_tool_result(self) -> Result<CallToolResult, crate::ErrorData>; | ||
| } | ||
|
|
@@ -125,6 +260,35 @@ impl<T: IntoCallToolResult> IntoCallToolResult for Result<T, crate::ErrorData> { | |
| } | ||
| } | ||
|
|
||
| // Implementation for Structured<T> to create structured content | ||
| impl<T: Serialize> IntoCallToolResult for Structured<T> { | ||
| fn into_call_tool_result(self) -> Result<CallToolResult, crate::ErrorData> { | ||
| let value = serde_json::to_value(self.0).map_err(|e| { | ||
| crate::ErrorData::internal_error( | ||
| format!("Failed to serialize structured content: {}", e), | ||
| None, | ||
| ) | ||
| })?; | ||
|
|
||
| // Note: Full JSON Schema validation would require a validation library like `jsonschema`. | ||
| // For now, we ensure the value is properly serialized to JSON. | ||
| // The actual schema validation should be performed by the tool handler | ||
| // when it has access to the tool's output_schema. | ||
|
|
||
| Ok(CallToolResult::structured(value)) | ||
| } | ||
| } | ||
|
|
||
| // Implementation for Result<Structured<T>, E> | ||
| impl<T: Serialize, E: IntoContents> IntoCallToolResult for Result<Structured<T>, E> { | ||
| fn into_call_tool_result(self) -> Result<CallToolResult, crate::ErrorData> { | ||
| match self { | ||
| Ok(value) => value.into_call_tool_result(), | ||
| Err(error) => Ok(CallToolResult::error(error.into_contents())), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| pin_project_lite::pin_project! { | ||
| #[project = IntoCallToolResultFutProj] | ||
| pub enum IntoCallToolResultFut<F, R> { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the output schema should can be provided by trait
IntoCallToolResultAnd we should implement this for
Json<T>especially.It's a bad idea to check the return type here, which could be a lot of work.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@4t145 Done in 3ed064d