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 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
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,22 @@ 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 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 +218,63 @@ 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 Json<T> or Result<Json<T>, E>
match &fn_item.sig.output {
syn::ReturnType::Type(_, ret_type) => {
// Check if it's directly Json<T>
if let Some(inner_type) = extract_json_inner_type(ret_type) {
syn::parse2::<Expr>(quote! {
rmcp::handler::server::tool::cached_schema_for_type::<#inner_type>()
})
.ok()
} else 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 Ok type is Json<T>
if let Some(inner_type) = extract_json_inner_type(ok_type) {
syn::parse2::<Expr>(quote! {
rmcp::handler::server::tool::cached_schema_for_type::<#inner_type>()
}).ok()
} else {
None
}
} else {
None
}
} else {
None
}
} else {
None
}
} else {
None
}
} else {
None
}
}
_ => 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

};

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
13 changes: 12 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,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
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(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we extract it like this ,For better scalability, And there seems to be some repetition here, How about this

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

Copy link
Author

@JMLX42 JMLX42 Jul 17, 2025

Choose a reason for hiding this comment

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

@jokemanfire I'm on it!

Copy link
Author

Choose a reason for hiding this comment

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

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