Skip to content

Commit d9b8bfc

Browse files
committed
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
1 parent 8812e81 commit d9b8bfc

File tree

16 files changed

+2332
-31
lines changed

16 files changed

+2332
-31
lines changed

STRUCTURED_OUTPUT_SUMMARY.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Structured Output Implementation Summary
2+
3+
This document summarizes the implementation of Tool.outputSchema and CallToolResult.structuredContent support in the Rust MCP SDK.
4+
5+
## What Was Implemented
6+
7+
### 1. Core Data Structures
8+
9+
- **Tool.outputSchema**: Added optional `output_schema: Option<Arc<JsonObject>>` field to the Tool struct
10+
- **CallToolResult.structuredContent**: Added optional `structured_content: Option<Value>` field to CallToolResult
11+
- **Validation**: Implemented mutual exclusivity validation between `content` and `structured_content` fields
12+
13+
### 2. Macro Support
14+
15+
The `#[tool]` macro now automatically generates output schemas from function return types:
16+
17+
```rust
18+
#[tool(name = "calculate")]
19+
pub async fn calculate(&self, params: Parameters<Request>) -> Result<Structured<CalculationResult>, String> {
20+
// Tool implementation
21+
}
22+
```
23+
24+
This automatically generates a JSON Schema for `CalculationResult` and includes it in the tool's metadata.
25+
26+
### 3. Type Conversion Infrastructure
27+
28+
- **Structured<T> Wrapper**: Created a wrapper type to indicate structured output
29+
- **IntoCallToolResult Trait**: Extended to handle structured content conversion
30+
- **JsonSchema Implementation**: Implemented JsonSchema for Structured<T> to delegate to T's schema
31+
32+
### 4. Tool Handler Updates
33+
34+
- **Schema Validation**: Tool invocation now validates structured outputs against their schemas
35+
- **Error Handling**: Proper error propagation for schema validation failures
36+
37+
### 5. Testing
38+
39+
Added comprehensive tests in `test_structured_output.rs` covering:
40+
- Tool schema generation
41+
- Structured content creation
42+
- Mutual exclusivity validation
43+
- Schema serialization/deserialization
44+
45+
### 6. Documentation and Examples
46+
47+
Created `structured_output.rs` example demonstrating:
48+
- Tools returning structured JSON data
49+
- Automatic output schema generation
50+
- Mixed structured and unstructured outputs
51+
52+
## Usage Example
53+
54+
```rust
55+
use rmcp::{Structured, tool};
56+
use schemars::JsonSchema;
57+
use serde::{Deserialize, Serialize};
58+
59+
#[derive(Serialize, Deserialize, JsonSchema)]
60+
pub struct WeatherResponse {
61+
pub temperature: f64,
62+
pub description: String,
63+
}
64+
65+
#[tool(name = "get_weather")]
66+
pub async fn get_weather(&self, city: String) -> Result<Structured<WeatherResponse>, String> {
67+
Ok(Structured(WeatherResponse {
68+
temperature: 22.5,
69+
description: "Partly cloudy".to_string(),
70+
}))
71+
}
72+
```
73+
74+
## Breaking Changes
75+
76+
- `CallToolResult.content` is now `Option<Vec<Content>>` instead of `Vec<Content>`
77+
- This change was necessary to support mutual exclusivity with `structured_content`
78+
- All examples and tests have been updated to handle this change
79+
80+
## Future Considerations
81+
82+
- Full JSON Schema validation could be enhanced with a dedicated validation library
83+
- Additional schema features (like references, definitions) could be supported
84+
- Schema caching is already implemented for performance
85+
86+
## Files Modified
87+
88+
- `/crates/rmcp/src/model/tool.rs` - Added output_schema field
89+
- `/crates/rmcp/src/model.rs` - Added structured_content and validation
90+
- `/crates/rmcp-macros/src/tool.rs` - Added output schema generation
91+
- `/crates/rmcp/src/handler/server/tool.rs` - Added Structured<T> and validation
92+
- `/crates/rmcp/src/handler/server/router/tool.rs` - Added schema validation on invocation
93+
- Plus tests and examples
94+
95+
## Testing
96+
97+
All tests pass with `cargo test --all-features`

TODO.md

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Implementation Plan: Tool.outputSchema and CallToolResult.structuredContent
22

3+
**Status: ✅ COMPLETED (except for migration guide and API documentation)**
4+
35
This document outlines the implementation plan for adding support for Tool.outputSchema and CallToolResult.structuredContent to the Rust MCP SDK, as specified in [MCP PR #371](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/371) and [Issue #312](https://github.com/modelcontextprotocol/rust-sdk/issues/312).
46

57
## Overview
@@ -34,32 +36,32 @@ The goal is to enable tools to:
3436
- [x] Implement schema validation in conversion logic
3537

3638
### Tool Handler Updates
37-
- [ ] Update tool invocation to check for output_schema
38-
- [ ] Implement validation of structured output against schema
39-
- [ ] Handle conversion between Rust types and JSON values
40-
- [ ] Update error propagation for validation failures
41-
- [ ] Cache output schemas similar to input schemas
42-
- [ ] Update tool listing to include output schemas
39+
- [x] Update tool invocation to check for output_schema
40+
- [x] Implement validation of structured output against schema
41+
- [x] Handle conversion between Rust types and JSON values
42+
- [x] Update error propagation for validation failures
43+
- [x] Cache output schemas similar to input schemas
44+
- [x] Update tool listing to include output schemas
4345

4446
### Testing
45-
- [ ] Test Tool serialization/deserialization with output_schema
46-
- [ ] Test CallToolResult with structured_content
47-
- [ ] Test mutual exclusivity validation
48-
- [ ] Test schema validation for structured outputs
49-
- [ ] Test #[tool] macro with various return types
50-
- [ ] Test error cases (schema violations, invalid types)
51-
- [ ] Test backward compatibility with existing tools
52-
- [ ] Add integration tests for end-to-end scenarios
47+
- [x] Test Tool serialization/deserialization with output_schema
48+
- [x] Test CallToolResult with structured_content
49+
- [x] Test mutual exclusivity validation
50+
- [x] Test schema validation for structured outputs
51+
- [x] Test #[tool] macro with various return types
52+
- [x] Test error cases (schema violations, invalid types)
53+
- [x] Test backward compatibility with existing tools
54+
- [x] Add integration tests for end-to-end scenarios
5355

5456
### Documentation and Examples
55-
- [ ] Document Tool.outputSchema field usage
56-
- [ ] Document CallToolResult.structuredContent usage
57-
- [ ] Create example: simple tool with structured output
58-
- [ ] Create example: complex nested structures
59-
- [ ] Create example: error handling with structured content
57+
- [x] Document Tool.outputSchema field usage
58+
- [x] Document CallToolResult.structuredContent usage
59+
- [x] Create example: simple tool with structured output
60+
- [x] Create example: complex nested structures
61+
- [x] Create example: error handling with structured content
6062
- [ ] Write migration guide for existing tools
6163
- [ ] Update API documentation
62-
- [ ] Add inline code documentation
64+
- [x] Add inline code documentation
6365

6466
## Technical Considerations
6567

crates/rmcp/src/handler/server/router/tool.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use schemars::JsonSchema;
55

66
use crate::{
77
handler::server::tool::{
8-
CallToolHandler, DynCallToolHandler, ToolCallContext, schema_for_type,
8+
CallToolHandler, DynCallToolHandler, ToolCallContext, schema_for_type, validate_against_schema,
99
},
1010
model::{CallToolResult, Tool, ToolAnnotations},
1111
};
@@ -242,7 +242,17 @@ where
242242
.map
243243
.get(context.name())
244244
.ok_or_else(|| crate::ErrorData::invalid_params("tool not found", None))?;
245-
(item.call)(context).await
245+
246+
let result = (item.call)(context).await?;
247+
248+
// Validate structured content against output schema if present
249+
if let Some(ref output_schema) = item.attr.output_schema {
250+
if let Some(ref structured_content) = result.structured_content {
251+
validate_against_schema(structured_content, output_schema)?;
252+
}
253+
}
254+
255+
Ok(result)
246256
}
247257

248258
pub fn list_all(&self) -> Vec<crate::model::Tool> {

crates/rmcp/src/handler/server/tool.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,39 @@
1+
//! Tool handler traits and types for MCP servers.
2+
//!
3+
//! This module provides the infrastructure for implementing tools that can be called
4+
//! by MCP clients. Tools can return either unstructured content (text, images) or
5+
//! structured JSON data with schemas.
6+
//!
7+
//! # Structured Output
8+
//!
9+
//! Tools can return structured JSON data using the [`Structured`] wrapper type.
10+
//! When using `Structured<T>`, the framework will:
11+
//! - Automatically generate a JSON schema for the output type
12+
//! - Validate the output against the schema
13+
//! - Return the data in the `structured_content` field of [`CallToolResult`]
14+
//!
15+
//! # Example
16+
//!
17+
//! ```rust,ignore
18+
//! use rmcp::{tool, Structured};
19+
//! use schemars::JsonSchema;
20+
//! use serde::{Serialize, Deserialize};
21+
//!
22+
//! #[derive(Serialize, Deserialize, JsonSchema)]
23+
//! struct AnalysisResult {
24+
//! score: f64,
25+
//! summary: String,
26+
//! }
27+
//!
28+
//! #[tool(name = "analyze")]
29+
//! async fn analyze(&self, text: String) -> Result<Structured<AnalysisResult>, String> {
30+
//! Ok(Structured(AnalysisResult {
31+
//! score: 0.95,
32+
//! summary: "Positive sentiment".to_string(),
33+
//! }))
34+
//! }
35+
//! ```
36+
137
use std::{
238
any::TypeId, borrow::Cow, collections::HashMap, future::Ready, marker::PhantomData, sync::Arc,
339
};
@@ -140,6 +176,33 @@ pub trait FromToolCallContextPart<S>: Sized {
140176
}
141177

142178
/// Marker wrapper to indicate that a type should be serialized as structured content
179+
///
180+
/// When a tool returns `Structured<T>`, the MCP framework will:
181+
/// 1. Serialize `T` to JSON and place it in `CallToolResult.structured_content`
182+
/// 2. Leave `CallToolResult.content` as `None`
183+
/// 3. Validate the serialized JSON against the tool's output schema (if present)
184+
///
185+
/// # Example
186+
///
187+
/// ```rust,ignore
188+
/// use rmcp::{tool, Structured};
189+
/// use schemars::JsonSchema;
190+
/// use serde::{Serialize, Deserialize};
191+
///
192+
/// #[derive(Serialize, Deserialize, JsonSchema)]
193+
/// struct WeatherData {
194+
/// temperature: f64,
195+
/// description: String,
196+
/// }
197+
///
198+
/// #[tool(name = "get_weather")]
199+
/// async fn get_weather(&self) -> Result<Structured<WeatherData>, String> {
200+
/// Ok(Structured(WeatherData {
201+
/// temperature: 22.5,
202+
/// description: "Sunny".to_string(),
203+
/// }))
204+
/// }
205+
/// ```
143206
pub struct Structured<T>(pub T);
144207

145208
impl<T> Structured<T> {
@@ -148,6 +211,27 @@ impl<T> Structured<T> {
148211
}
149212
}
150213

214+
// Implement JsonSchema for Structured<T> to delegate to T's schema
215+
impl<T: JsonSchema> JsonSchema for Structured<T> {
216+
fn schema_name() -> Cow<'static, str> {
217+
T::schema_name()
218+
}
219+
220+
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
221+
T::json_schema(generator)
222+
}
223+
}
224+
225+
/// Trait for converting tool return values into [`CallToolResult`].
226+
///
227+
/// This trait is automatically implemented for:
228+
/// - Types implementing [`IntoContents`] (returns unstructured content)
229+
/// - `Result<T, E>` where both `T` and `E` implement [`IntoContents`]
230+
/// - [`Structured<T>`] where `T` implements [`Serialize`] (returns structured content)
231+
/// - `Result<Structured<T>, E>` for structured results with errors
232+
///
233+
/// The `#[tool]` macro uses this trait to convert tool function return values
234+
/// into the appropriate [`CallToolResult`] format.
151235
pub trait IntoCallToolResult {
152236
fn into_call_tool_result(self) -> Result<CallToolResult, crate::ErrorData>;
153237
}

crates/rmcp/src/lib.rs

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,45 @@
4848
//! }
4949
//! ```
5050
//!
51-
//! Next also implement [ServerHandler] for `Counter` and start the server inside
52-
//! `main` by calling `Counter::new().serve(...)`. See the examples directory in the repository for more information.
51+
//! ### Structured Output
52+
//!
53+
//! Tools can also return structured JSON data with schemas. Use the [`handler::server::tool::Structured`] wrapper:
54+
//!
55+
//! ```rust
56+
//! # use rmcp::{tool, tool_router, ErrorData as McpError, handler::server::tool::{ToolRouter, Structured}};
57+
//! # use schemars::JsonSchema;
58+
//! # use serde::{Serialize, Deserialize};
59+
//! #
60+
//! #[derive(Serialize, Deserialize, JsonSchema)]
61+
//! struct CalculationResult {
62+
//! result: i32,
63+
//! operation: String,
64+
//! }
65+
//!
66+
//! # #[derive(Clone)]
67+
//! # struct Calculator {
68+
//! # tool_router: ToolRouter<Self>,
69+
//! # }
70+
//! #
71+
//! # #[tool_router]
72+
//! # impl Calculator {
73+
//! #[tool(name = "calculate")]
74+
//! async fn calculate(&self, a: i32, b: i32, op: String) -> Result<Structured<CalculationResult>, McpError> {
75+
//! let result = match op.as_str() {
76+
//! "add" => a + b,
77+
//! "multiply" => a * b,
78+
//! _ => return Err(McpError::invalid_params("Unknown operation", None)),
79+
//! };
80+
//!
81+
//! Ok(Structured(CalculationResult { result, operation: op }))
82+
//! }
83+
//! # }
84+
//! ```
85+
//!
86+
//! The `#[tool]` macro automatically generates an output schema from the `CalculationResult` type.
87+
//!
88+
//! Next also implement [ServerHandler] for your server type and start the server inside
89+
//! `main` by calling `.serve(...)`. See the examples directory in the repository for more information.
5390
//!
5491
//! ## Client
5592
//!
@@ -104,6 +141,9 @@ pub use handler::client::ClientHandler;
104141
#[cfg(feature = "server")]
105142
#[cfg_attr(docsrs, doc(cfg(feature = "server")))]
106143
pub use handler::server::ServerHandler;
144+
#[cfg(feature = "server")]
145+
#[cfg_attr(docsrs, doc(cfg(feature = "server")))]
146+
pub use handler::server::tool::Structured;
107147
#[cfg(any(feature = "client", feature = "server"))]
108148
#[cfg_attr(docsrs, doc(cfg(any(feature = "client", feature = "server"))))]
109149
pub use service::{Peer, Service, ServiceError, ServiceExt};

crates/rmcp/src/model.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,6 +1216,19 @@ impl CallToolResult {
12161216
}
12171217
}
12181218
/// Create a successful tool result with structured content
1219+
///
1220+
/// # Example
1221+
///
1222+
/// ```rust,ignore
1223+
/// use rmcp::model::CallToolResult;
1224+
/// use serde_json::json;
1225+
///
1226+
/// let result = CallToolResult::structured(json!({
1227+
/// "temperature": 22.5,
1228+
/// "humidity": 65,
1229+
/// "description": "Partly cloudy"
1230+
/// }));
1231+
/// ```
12191232
pub fn structured(value: Value) -> Self {
12201233
CallToolResult {
12211234
content: None,
@@ -1224,6 +1237,23 @@ impl CallToolResult {
12241237
}
12251238
}
12261239
/// Create an error tool result with structured content
1240+
///
1241+
/// # Example
1242+
///
1243+
/// ```rust,ignore
1244+
/// use rmcp::model::CallToolResult;
1245+
/// use serde_json::json;
1246+
///
1247+
/// let result = CallToolResult::structured_error(json!({
1248+
/// "error_code": "INVALID_INPUT",
1249+
/// "message": "Temperature value out of range",
1250+
/// "details": {
1251+
/// "min": -50,
1252+
/// "max": 50,
1253+
/// "provided": 100
1254+
/// }
1255+
/// }));
1256+
/// ```
12271257
pub fn structured_error(value: Value) -> Self {
12281258
CallToolResult {
12291259
content: None,

0 commit comments

Comments
 (0)