Skip to content

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

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
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
77 changes: 77 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 @@ -89,6 +99,63 @@ fn none_expr() -> Expr {
syn::parse2::<Expr>(quote! { None }).unwrap()
}

/// Check if a type is Json<T> and extract the inner type T
fn extract_json_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
if let syn::Type::Path(type_path) = ty {
if let Some(last_segment) = type_path.path.segments.last() {
if last_segment.ident == "Json" {
if let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments {
if let Some(syn::GenericArgument::Type(inner_type)) = args.args.first() {
return Some(inner_type);
}
}
}
}
}
None
}

/// Extract schema expression from a function's return type
/// Handles patterns like Json<T> and Result<Json<T>, E>
fn extract_schema_from_return_type(ret_type: &syn::Type) -> Option<Expr> {
// First, try direct Json<T>
if let Some(inner_type) = extract_json_inner_type(ret_type) {
return syn::parse2::<Expr>(quote! {
rmcp::handler::server::tool::cached_schema_for_type::<#inner_type>()
})
.ok();
}

// Then, try Result<Json<T>, E>
let type_path = match ret_type {
syn::Type::Path(path) => path,
_ => return None,
};

let last_segment = type_path.path.segments.last()?;

if last_segment.ident != "Result" {
return None;
}

let args = match &last_segment.arguments {
syn::PathArguments::AngleBracketed(args) => args,
_ => return None,
};

let ok_type = match args.args.first()? {
syn::GenericArgument::Type(ty) => ty,
_ => return None,
};

let inner_type = extract_json_inner_type(ok_type)?;

syn::parse2::<Expr>(quote! {
rmcp::handler::server::tool::cached_schema_for_type::<#inner_type>()
})
.ok()
}

// extract doc line from attribute
fn extract_doc_line(existing_docs: Option<String>, attr: &syn::Attribute) -> Option<String> {
if !attr.path().is_ident("doc") {
Expand Down Expand Up @@ -192,12 +259,22 @@ 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 = attribute.output_schema.or_else(|| {
// Try to generate schema from return type
match &fn_item.sig.output {
syn::ReturnType::Type(_, ret_type) => extract_schema_from_return_type(ret_type),
_ => None,
}
Copy link
Collaborator

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
Collaborator

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
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
Collaborator

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
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
19 changes: 18 additions & 1 deletion crates/rmcp/src/handler/server/router/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use schemars::JsonSchema;
use crate::{
handler::server::tool::{
CallToolHandler, DynCallToolHandler, ToolCallContext, schema_for_type,
validate_against_schema,
},
model::{CallToolResult, Tool, ToolAnnotations},
};
Expand Down Expand Up @@ -242,7 +243,23 @@ 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 {
// When output_schema is defined, structured_content is required
if result.structured_content.is_none() {
return Err(crate::ErrorData::invalid_params(
"Tool with output_schema must return structured_content",
None,
));
}
// Validate the structured content against the schema
validate_against_schema(result.structured_content.as_ref().unwrap(), output_schema)?;
}

Ok(result)
}

pub fn list_all(&self) -> Vec<crate::model::Tool> {
Expand Down
126 changes: 126 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 [`Json`] wrapper type.
//! When using `Json<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, Json};
//! 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<Json<AnalysisResult>, String> {
//! Ok(Json(AnalysisResult {
//! score: 0.95,
//! summary: "Positive sentiment".to_string(),
//! }))
//! }
//! ```

use std::{
any::TypeId, borrow::Cow, collections::HashMap, future::Ready, marker::PhantomData, sync::Arc,
};
Expand All @@ -10,6 +46,7 @@ use tokio_util::sync::CancellationToken;
pub use super::router::tool::{ToolRoute, ToolRouter};
use crate::{
RoleServer,
handler::server::wrapper::Json,
model::{CallToolRequestParam, CallToolResult, IntoContents, JsonObject},
schemars::generate::SchemaSettings,
service::RequestContext,
Expand All @@ -30,6 +67,43 @@ 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(
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 value_type = get_json_value_type(value);

if schema_type != value_type {
return Err(crate::ErrorData::invalid_params(
format!(
"Value type does not match schema. Expected '{}', got '{}'",
schema_type, value_type
),
None,
));
}
}

Ok(())
}

fn get_json_value_type(value: &serde_json::Value) -> &'static str {
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",
}
}

/// 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,8 +171,26 @@ pub trait FromToolCallContextPart<S>: Sized {
) -> Result<Self, crate::ErrorData>;
}

/// 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`]
/// - [`Json<T>`](crate::handler::server::wrapper::Json) where `T` implements [`Serialize`] (returns structured content)
/// - `Result<Json<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>;

/// Returns the output schema for this type, if any.
///
/// This is used by the macro to automatically generate output schemas
/// for tool functions that return structured data.
fn output_schema() -> Option<Arc<JsonObject>> {
None
}
}

impl<T: IntoContents> IntoCallToolResult for T {
Expand All @@ -125,6 +217,40 @@ impl<T: IntoCallToolResult> IntoCallToolResult for Result<T, crate::ErrorData> {
}
}

// Implementation for Json<T> to create structured content
impl<T: Serialize + JsonSchema + 'static> IntoCallToolResult for Json<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,
)
})?;

Ok(CallToolResult::structured(value))
}

fn output_schema() -> Option<Arc<JsonObject>> {
Some(cached_schema_for_type::<T>())
}
}

// Implementation for Result<Json<T>, E>
impl<T: Serialize + JsonSchema + 'static, E: IntoContents> IntoCallToolResult
for Result<Json<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())),
}
}

fn output_schema() -> Option<Arc<JsonObject>> {
Json::<T>::output_schema()
}
}

pin_project_lite::pin_project! {
#[project = IntoCallToolResultFutProj]
pub enum IntoCallToolResultFut<F, R> {
Expand Down
36 changes: 15 additions & 21 deletions crates/rmcp/src/handler/server/wrapper/json.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,22 @@
use serde::Serialize;
use std::borrow::Cow;

use crate::model::IntoContents;
use schemars::JsonSchema;

/// Json wrapper
/// Json wrapper for structured output
///
/// This is used to tell the SDK to serialize the inner value into json
/// When used with tools, this wrapper indicates that the value should be
/// serialized as structured JSON content with an associated schema.
/// The framework will place the JSON in the `structured_content` field
/// of the tool result rather than the regular `content` field.
pub struct Json<T>(pub T);

impl<T> IntoContents for Json<T>
where
T: Serialize,
{
fn into_contents(self) -> Vec<crate::model::Content> {
let result = crate::model::Content::json(self.0);
debug_assert!(
result.is_ok(),
"Json wrapped content should be able to serialized into json"
);
match result {
Ok(content) => vec![content],
Err(e) => {
tracing::error!("failed to convert json content: {e}");
vec![]
}
}
// Implement JsonSchema for Json<T> to delegate to T's schema
impl<T: JsonSchema> JsonSchema for Json<T> {
fn schema_name() -> Cow<'static, str> {
T::schema_name()
}

fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
T::json_schema(generator)
}
}
Loading