Skip to content

Commit b174b63

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 d82056f commit b174b63

File tree

9 files changed

+2196
-11
lines changed

9 files changed

+2196
-11
lines changed

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,

crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -299,12 +299,15 @@
299299
}
300300
},
301301
"CallToolResult": {
302-
"description": "The result of a tool call operation.\n\nContains the content returned by the tool execution and an optional\nflag indicating whether the operation resulted in an error.",
302+
"description": "The result of a tool call operation.\n\nContains the content returned by the tool execution and an optional\nflag indicating whether the operation resulted in an error.\n\nNote: `content` and `structured_content` are mutually exclusive - exactly one must be provided.",
303303
"type": "object",
304304
"properties": {
305305
"content": {
306306
"description": "The content returned by the tool (text, images, etc.)",
307-
"type": "array",
307+
"type": [
308+
"array",
309+
"null"
310+
],
308311
"items": {
309312
"$ref": "#/definitions/Annotated"
310313
}
@@ -315,11 +318,11 @@
315318
"boolean",
316319
"null"
317320
]
321+
},
322+
"structuredContent": {
323+
"description": "An optional JSON object that represents the structured result of the tool call"
318324
}
319-
},
320-
"required": [
321-
"content"
322-
]
325+
}
323326
},
324327
"CancelledNotificationMethod": {
325328
"type": "string",
@@ -1580,6 +1583,14 @@
15801583
"name": {
15811584
"description": "The name of the tool",
15821585
"type": "string"
1586+
},
1587+
"outputSchema": {
1588+
"description": "An optional JSON Schema object defining the structure of the tool's output",
1589+
"type": [
1590+
"object",
1591+
"null"
1592+
],
1593+
"additionalProperties": true
15831594
}
15841595
},
15851596
"required": [

0 commit comments

Comments
 (0)