feat: Add support for Tool.outputSchema and CallToolResult.structuredContent (#316)

* feat: add output_schema field to Tool struct

- Add optional output_schema field to Tool struct for defining tool
output structure
- Update Tool::new() to initialize output_schema as None

* feat: add structured_content field to CallToolResult

- Add optional structured_content field for JSON object results
- Make content field optional to support either structured or
unstructured results
- Add CallToolResult::structured() and structured_error() constructor
methods

* feat: implement validation for mutually exclusive content/structuredContent

- Add validate() method to ensure content and structured_content are
mutually exclusive
- Implement custom Deserialize to enforce validation during
deserialization
- Update documentation to clarify the mutual exclusivity requirement

* feat: add output_schema support to #[tool] macro

- Add output_schema field to ToolAttribute and ResolvedToolAttribute
structs
- Implement automatic output schema generation from return types
- Support explicit output_schema attribute for manual specification
- Generate schemas for Result<T, E> where T is not CallToolResult
- Update tool generation to include output_schema in Tool struct

* feat: implement IntoCallToolResult for structured content

- Add Structured<T> wrapper type for explicit structured content
- Implement IntoCallToolResult for Structured<T> with JSON serialization
- Add support for Result<Structured<T>, E> conversions
- Enable tools to return structured content through the trait system

* fix: update simple-chat-client example for optional content field

- Handle Option<Vec<Content>> in CallToolResult.content
- Add proper unwrapping for the optional content field
- Fix compilation error in chat.rs

* fix: update examples and tests for optional content field

- Add output_schema field to Tool initialization in sampling_stdio
example
- Update test_tool_macros tests to handle Option<Vec<Content>>
- Use as_ref() before calling first() on optional content field

* feat: implement basic schema validation in conversion logic

- Add validate_against_schema function for basic type validation
- Add note that full JSON Schema validation requires dedicated library
- Document that actual validation should happen in tool handler

* feat: add structured output support for tools

- Add output_schema field to Tool struct for defining output JSON schemas
- Add structured_content field to CallToolResult (mutually exclusive with content)
- Implement Structured<T> wrapper for type-safe structured outputs
- Update #[tool] macro to automatically generate output schemas from return types
- Add validation of structured outputs against their schemas
- Update all examples and tests for breaking change (CallToolResult.content now Option)
- Add comprehensive documentation and rustdoc
- Add structured_output example demonstrating the feature

BREAKING CHANGE: CallToolResult.content is now Option<Vec<Content>> instead of Vec<Content>

Closes #312

* fix: correct structured output doctest to use Parameters wrapper

The #[tool] macro requires Parameters<T> wrapper for tool inputs.
This fixes the pre-existing broken doctest in the structured output
documentation example.

* feat: replace Structured<T> with Json<T> for structured output

- Remove Structured<T> type definition and implementations
- Reuse existing Json<T> wrapper for structured content
- Update IntoCallToolResult implementations to use Json<T>
- Add JsonSchema implementation for Json<T> delegating to T
- Update all examples and tests to use Json<T> instead of Structured<T>
- Update documentation and exports

BREAKING CHANGE: Structured<T> has been replaced with Json<T>. Users must update their code to use Json<T> for structured tool outputs.

* feat: add output_schema() method to IntoCallToolResult trait

- Add output_schema() method with default None implementation
- Implement output_schema() for Json<T> to return cached schema
- Implement output_schema() for Result<Json<T>, E> delegating to Json<T>
- Enable trait-based schema generation for structured outputs

* feat: update macro to detect Json<T> wrapper for output schemas

- Add extract_json_inner_type() helper to detect Json<T> types
- Update schema generation to only occur for Json<T> wrapped types
- Remove generic Result<T, E> detection in favor of specific Json<T> detection
- Add comprehensive tests to verify schema generation behavior

* feat: add builder methods to Tool struct for setting schemas

- Add with_output_schema<T>() method to set output schema from type
- Add with_input_schema<T>() method to set input schema from type
- Both methods use cached_schema_for_type internally
- Add comprehensive tests for builder methods

* fix: address clippy warnings

- Add Default implementation for StructuredOutputServer
- Fix collapsible else-if in simple-chat-client
- No functional changes

* style: apply cargo fmt

Apply automatic formatting changes to:
- examples/simple-chat-client/src/chat.rs - fix line wrapping
- crates/rmcp-macros/src/tool.rs - format method chaining
- examples/servers/src/structured_output.rs - reorder imports and format function signatures

* chore: fix formatting

* chore: fix rustdoc redundant link warning

* refactor: validate_against_schema

* feat: enforce structured_content usage when output_schema is defined

This commit implements strict validation to ensure tools with output_schema
consistently use structured_content for both success and error responses.

Changes:
- Enhanced ToolRouter::call() validation to require structured_content when output_schema is present
- Added validation that tools with output_schema cannot use regular content field
- Added comprehensive tests covering the new strict validation behavior
- Created example demonstrating proper structured output usage
- Updated TODO.md to track validation improvements

This ensures consistent response format and better type safety for MCP clients.

* chore: remove TODO.md

* refactor: simplify output schema extraction logic in tool macro

- Extract complex nested logic into dedicated helper function
- Replace deeply nested if-else chains with functional approach
- Use early returns and ? operator for cleaner code flow
- Reduce 46 lines to 7 lines in main logic while improving readability

* chore: run cargo fmt

* fix: enforce structured_content usage when output_schema is defined

Structured content is returned as a JSON object in the structuredContent
field of a result.For backwards compatibility, a tool that returns
structured content SHOULD also return the serialized JSON in a
TextContent block.

https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content

Tools may also provide an output schema for validation of structured
results. If an output schema is provided:

- Servers MUST provide structured results that conform to this schema.
- Clients SHOULD validate structured results against this schema.

https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema

* chore: cargo fmt
This commit is contained in:
Jean-Marc Le Roux 2025-07-31 10:32:24 +02:00 • committed by GitHub
commit fbc7ab70ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 2658 additions and 41 deletions

View file

@ -9,6 +9,7 @@ publish = false
[dependencies]
rmcp = { workspace = true, features = [
"server",
"macros",
"transport-sse-server",
"transport-io",
"transport-streamable-http-server",
@ -33,7 +34,7 @@ tracing-subscriber = { version = "0.3", features = [
futures = "0.3"
rand = { version = "0.9", features = ["std"] }
axum = { version = "0.8", features = ["macros"] }
schemars = { version = "1.0", optional = true }
schemars = { version = "1.0" }
reqwest = { version = "0.12", features = ["json"] }
chrono = "0.4"
uuid = { version = "1.6", features = ["v4", "serde"] }
@ -82,3 +83,7 @@ path = "src/counter_hyper_streamable_http.rs"
[[example]]
name = "servers_sampling_stdio"
path = "src/sampling_stdio.rs"
[[example]]
name = "servers_structured_output"
path = "src/structured_output.rs"

View file

@ -119,6 +119,7 @@ impl ServerHandler for SamplingDemoServer {
}))
.unwrap(),
),
output_schema: None,
annotations: None,
}],
next_cursor: None,

View file

@ -0,0 +1,158 @@
//! Example demonstrating structured output from tools
//!
//! This example shows how to:
//! - Return structured data from tools using the Json<T> wrapper
//! - Automatically generate output schemas from Rust types
//! - Handle both structured and unstructured tool outputs
use rmcp::{
Json, ServiceExt,
handler::server::{router::tool::ToolRouter, tool::Parameters},
tool, tool_handler, tool_router,
transport::stdio,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct WeatherRequest {
pub city: String,
pub units: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct WeatherResponse {
pub temperature: f64,
pub description: String,
pub humidity: u8,
pub wind_speed: f64,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct CalculationRequest {
pub numbers: Vec<i32>,
pub operation: String,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct CalculationResult {
pub result: f64,
pub operation: String,
pub input_count: usize,
}
#[derive(Clone)]
pub struct StructuredOutputServer {
tool_router: ToolRouter<Self>,
}
#[tool_handler(router = self.tool_router)]
impl rmcp::ServerHandler for StructuredOutputServer {}
impl Default for StructuredOutputServer {
fn default() -> Self {
Self::new()
}
}
#[tool_router(router = tool_router)]
impl StructuredOutputServer {
pub fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
/// Get weather information for a city (returns structured data)
#[tool(name = "get_weather", description = "Get current weather for a city")]
pub async fn get_weather(
&self,
params: Parameters<WeatherRequest>,
) -> Result<Json<WeatherResponse>, String> {
// Simulate weather API call
let weather = WeatherResponse {
temperature: match params.0.units.as_deref() {
Some("fahrenheit") => 72.5,
_ => 22.5, // celsius by default
},
description: "Partly cloudy".to_string(),
humidity: 65,
wind_speed: 12.5,
};
Ok(Json(weather))
}
/// Perform calculations on a list of numbers (returns structured data)
#[tool(name = "calculate", description = "Perform calculations on numbers")]
pub async fn calculate(
&self,
params: Parameters<CalculationRequest>,
) -> Result<Json<CalculationResult>, String> {
let numbers = &params.0.numbers;
if numbers.is_empty() {
return Err("No numbers provided".to_string());
}
let result = match params.0.operation.as_str() {
"sum" => numbers.iter().sum::<i32>() as f64,
"average" => numbers.iter().sum::<i32>() as f64 / numbers.len() as f64,
"product" => numbers.iter().product::<i32>() as f64,
_ => return Err(format!("Unknown operation: {}", params.0.operation)),
};
Ok(Json(CalculationResult {
result,
operation: params.0.operation,
input_count: numbers.len(),
}))
}
/// Get server info (returns unstructured text)
#[tool(name = "get_info", description = "Get server information")]
pub async fn get_info(&self) -> String {
"Structured Output Example Server v1.0".to_string()
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
eprintln!("Starting structured output example server...");
eprintln!();
eprintln!("This server demonstrates:");
eprintln!("- Tools that return structured JSON data");
eprintln!("- Automatic output schema generation");
eprintln!("- Mixed structured and unstructured outputs");
eprintln!();
eprintln!("Tools available:");
eprintln!("- get_weather: Returns structured weather data");
eprintln!("- calculate: Returns structured calculation results");
eprintln!("- get_info: Returns plain text");
eprintln!();
let server = StructuredOutputServer::new();
// Print the tools with their schemas for demonstration
eprintln!("Tool schemas:");
for tool in server.tool_router.list_all() {
eprintln!("\n{}: {}", tool.name, tool.description.unwrap_or_default());
if let Some(output_schema) = &tool.output_schema {
eprintln!(
" Output schema: {}",
serde_json::to_string_pretty(output_schema).unwrap()
);
} else {
eprintln!(" Output: Unstructured text");
}
}
eprintln!();
// Start the server
eprintln!("Starting server. Connect with an MCP client to test the tools.");
eprintln!("Press Ctrl+C to stop.");
let service = server.serve(stdio()).await?;
service.waiting().await?;
Ok(())
}

View file

@ -85,8 +85,8 @@ impl ChatSession {
if result.is_error.is_some_and(|b| b) {
self.messages
.push(Message::user("tool call failed, mcp call error"));
} else {
result.content.iter().for_each(|content| {
} else if let Some(contents) = &result.content {
contents.iter().for_each(|content| {
if let Some(content_text) = content.as_text() {
let json_result = serde_json::from_str::<serde_json::Value>(
&content_text.text,