Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c2f65cc
feat: add output_schema field to Tool struct
JMLX42 Jul 14, 2025
5d24acc
feat: add structured_content field to CallToolResult
JMLX42 Jul 14, 2025
efdb55f
feat: implement validation for mutually exclusive content/structuredC…
JMLX42 Jul 14, 2025
c3a9ba7
feat: add output_schema support to #[tool] macro
JMLX42 Jul 14, 2025
6cad6c5
feat: implement IntoCallToolResult for structured content
JMLX42 Jul 14, 2025
366d0af
fix: update simple-chat-client example for optional content field
JMLX42 Jul 14, 2025
b16fd38
fix: update examples and tests for optional content field
JMLX42 Jul 14, 2025
d82056f
feat: implement basic schema validation in conversion logic
JMLX42 Jul 14, 2025
b174b63
feat: add structured output support for tools
JMLX42 Jul 14, 2025
cb28342
fix: correct structured output doctest to use Parameters wrapper
JMLX42 Jul 14, 2025
1b03666
feat: replace Structured<T> with Json<T> for structured output
JMLX42 Jul 15, 2025
3ed064d
feat: add output_schema() method to IntoCallToolResult trait
JMLX42 Jul 15, 2025
70bf2b1
feat: update macro to detect Json<T> wrapper for output schemas
JMLX42 Jul 15, 2025
43a72da
feat: add builder methods to Tool struct for setting schemas
JMLX42 Jul 15, 2025
4001d65
fix: address clippy warnings
JMLX42 Jul 15, 2025
cff51c4
style: apply cargo fmt
JMLX42 Jul 15, 2025
33b4d59
chore: fix formatting
JMLX42 Jul 16, 2025
1274857
chore: fix rustdoc redundant link warning
JMLX42 Jul 16, 2025
a1ef39e
refactor: validate_against_schema
JMLX42 Jul 17, 2025
f752f66
Merge branch 'modelcontextprotocol:main' into feature/output-schema
JMLX42 Jul 21, 2025
767d3ae
feat: enforce structured_content usage when output_schema is defined
JMLX42 Jul 22, 2025
cf52be9
chore: remove TODO.md
JMLX42 Jul 23, 2025
43f08bf
refactor: simplify output schema extraction logic in tool macro
JMLX42 Jul 23, 2025
5109fcb
chore: run cargo fmt
JMLX42 Jul 23, 2025
906812e
fix: enforce structured_content usage when output_schema is defined
JMLX42 Jul 25, 2025
d6807ce
chore: cargo fmt
JMLX42 Jul 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions crates/rmcp-macros/src/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
}
Expand All @@ -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,
}

Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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 {

@4t145 4t145 Jul 15, 2025

Copy link
Copy Markdown
Contributor

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 IntoCallToolResult

pub trait IntoCallToolResult {
    fn into_call_tool_result(self) -> Result<CallToolResult, crate::ErrorData>;
    // some interface like this
    fn output_schema() -> Option<Value> {
        None
    }
}

And 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@4t145 Done in 3ed064d

// 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,
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the complexity of the circle here a bit high?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplify this please.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@4t145 done in 70bf2b1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that there are no modifications here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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)?;
Expand Down
14 changes: 12 additions & 2 deletions crates/rmcp/src/handler/server/router/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use schemars::JsonSchema;

use crate::{
handler::server::tool::{
CallToolHandler, DynCallToolHandler, ToolCallContext, schema_for_type,
CallToolHandler, DynCallToolHandler, ToolCallContext, schema_for_type, validate_against_schema,
},
model::{CallToolResult, Tool, ToolAnnotations},
};
Expand Down Expand Up @@ -242,7 +242,17 @@ where
.map
.get(context.name())
.ok_or_else(|| crate::ErrorData::invalid_params("tool not found", None))?;
(item.call)(context).await

let result = (item.call)(context).await?;

// Validate structured content against output schema if present
if let Some(ref output_schema) = item.attr.output_schema {
if let Some(ref structured_content) = result.structured_content {
validate_against_schema(structured_content, output_schema)?;
}
}

Ok(result)
}

pub fn list_all(&self) -> Vec<crate::model::Tool> {
Expand Down
164 changes: 164 additions & 0 deletions crates/rmcp/src/handler/server/tool.rs
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,
};
Expand Down Expand Up @@ -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(
Comment thread
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! {
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have one: rmcp::handler::server::wrapper::Json

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@4t145 done in 1b03666


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>;
}
Expand Down Expand Up @@ -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> {
Expand Down
51 changes: 49 additions & 2 deletions crates/rmcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,52 @@
//! }
//! ```
//!
//! Next also implement [ServerHandler] for `Counter` and start the server inside
//! `main` by calling `Counter::new().serve(...)`. See the examples directory in the repository for more information.
//! ### Structured Output
//!
//! Tools can also return structured JSON data with schemas. Use the [`handler::server::tool::Structured`] wrapper:
//!
//! ```rust
//! # use rmcp::{tool, tool_router, handler::server::tool::{ToolRouter, Structured, Parameters}};
//! # use schemars::JsonSchema;
//! # use serde::{Serialize, Deserialize};
//! #
//! #[derive(Serialize, Deserialize, JsonSchema)]
//! struct CalculationRequest {
//! a: i32,
//! b: i32,
//! operation: String,
//! }
//!
//! #[derive(Serialize, Deserialize, JsonSchema)]
//! struct CalculationResult {
//! result: i32,
//! operation: String,
//! }
//!
//! # #[derive(Clone)]
//! # struct Calculator {
//! # tool_router: ToolRouter<Self>,
//! # }
//! #
//! # #[tool_router]
//! # impl Calculator {
//! #[tool(name = "calculate", description = "Perform a calculation")]
//! async fn calculate(&self, params: Parameters<CalculationRequest>) -> Result<Structured<CalculationResult>, String> {
//! let result = match params.0.operation.as_str() {
//! "add" => params.0.a + params.0.b,
//! "multiply" => params.0.a * params.0.b,
//! _ => return Err("Unknown operation".to_string()),
//! };
//!
//! Ok(Structured(CalculationResult { result, operation: params.0.operation }))
//! }
//! # }
//! ```
//!
//! The `#[tool]` macro automatically generates an output schema from the `CalculationResult` type.
//!
//! Next also implement [ServerHandler] for your server type and start the server inside
//! `main` by calling `.serve(...)`. See the examples directory in the repository for more information.
//!
//! ## Client
//!
Expand Down Expand Up @@ -104,6 +148,9 @@ pub use handler::client::ClientHandler;
#[cfg(feature = "server")]
#[cfg_attr(docsrs, doc(cfg(feature = "server")))]
pub use handler::server::ServerHandler;
#[cfg(feature = "server")]
#[cfg_attr(docsrs, doc(cfg(feature = "server")))]
pub use handler::server::tool::Structured;
#[cfg(any(feature = "client", feature = "server"))]
#[cfg_attr(docsrs, doc(cfg(any(feature = "client", feature = "server"))))]
pub use service::{Peer, Service, ServiceError, ServiceExt};
Expand Down
Loading
Loading