Skip to content

Commit 9b7b3c4

Browse files
olaservoclaude
andcommitted
feat: add structured-only tool results that skip the text mirror
CallToolResult::structured and structured_error always mirror the structured value into content as serialized text, doubling large payloads. Add explicit opt-outs, never the default since the mirror is the only compatibility path for clients that do not read structuredContent: - CallToolResult::structured_only / structured_error_only constructors that leave content empty - StructuredOnly<T> return-type wrapper, a sibling of Json<T>, for use with #[tool] methods; the tool macro derives outputSchema from it the same way it does for Json<T> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7698802 commit 9b7b3c4

7 files changed

Lines changed: 195 additions & 6 deletions

File tree

crates/rmcp-macros/src/tool.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ use syn::{Expr, Ident, ImplItemFn, LitStr, ReturnType, parse_quote};
55

66
use crate::common::extract_doc_line;
77

8-
/// Check if a type is Json<T> and extract the inner type T
8+
/// Check if a type is Json<T> or StructuredOnly<T> and extract the inner type T
99
fn extract_json_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
1010
if let syn::Type::Path(type_path) = ty
1111
&& let Some(last_segment) = type_path.path.segments.last()
12-
&& last_segment.ident == "Json"
12+
&& (last_segment.ident == "Json" || last_segment.ident == "StructuredOnly")
1313
&& let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments
1414
&& let Some(syn::GenericArgument::Type(inner_type)) = args.args.first()
1515
{
@@ -19,7 +19,8 @@ fn extract_json_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
1919
}
2020

2121
/// Extract schema expression from a function's return type
22-
/// Handles patterns like Json<T> and Result<Json<T>, E>
22+
/// Handles patterns like Json<T> and Result<Json<T>, E>, and the same
23+
/// shapes with StructuredOnly<T>
2324
fn extract_schema_from_return_type(ret_type: &syn::Type) -> Option<Expr> {
2425
// First, try direct Json<T>
2526
if let Some(inner_type) = extract_json_inner_type(ret_type) {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
mod json;
22
mod parameters;
3+
mod structured_only;
34
pub use json::*;
45
pub use parameters::*;
6+
pub use structured_only::*;

crates/rmcp/src/handler/server/wrapper/json.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ use crate::{
1313
/// When used with tools, this wrapper indicates that the value should be
1414
/// serialized as structured JSON content with an associated schema.
1515
/// The framework will place the JSON in the `structured_content` field
16-
/// of the tool result rather than the regular `content` field.
16+
/// of the tool result, and also mirror it into the regular `content` field
17+
/// as serialized text for clients that do not read `structured_content`.
18+
/// To skip that text mirror, use [`StructuredOnly`](crate::StructuredOnly)
19+
/// instead.
1720
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
1821
pub struct Json<T>(pub T);
1922

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
use std::borrow::Cow;
2+
3+
use schemars::JsonSchema;
4+
use serde::Serialize;
5+
6+
use crate::{
7+
handler::server::tool::IntoCallToolResult,
8+
model::{CallToolResponse, CallToolResult},
9+
};
10+
11+
/// Json wrapper for structured output without the serialized text fallback
12+
///
13+
/// Like [`Json`](crate::Json), this wrapper serializes the value into the
14+
/// `structured_content` field of the tool result and derives the tool's
15+
/// output schema from `T`, but it does not mirror the value into `content`
16+
/// as a text block, so large results are not sent twice.
17+
///
18+
/// Clients that do not read `structured_content` will see an empty `content`
19+
/// array; see [`CallToolResult::structured_only`] for when that is safe.
20+
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
21+
pub struct StructuredOnly<T>(pub T);
22+
23+
// Implement JsonSchema for StructuredOnly<T> to delegate to T's schema
24+
impl<T: JsonSchema> JsonSchema for StructuredOnly<T> {
25+
fn schema_name() -> Cow<'static, str> {
26+
T::schema_name()
27+
}
28+
29+
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
30+
T::json_schema(generator)
31+
}
32+
}
33+
34+
// Implementation for StructuredOnly<T> to create structured content without a text mirror
35+
impl<T: Serialize + JsonSchema + 'static> IntoCallToolResult for StructuredOnly<T> {
36+
fn into_call_tool_result(self) -> Result<CallToolResponse, crate::ErrorData> {
37+
let value = serde_json::to_value(self.0).map_err(|e| {
38+
crate::ErrorData::internal_error(
39+
format!("Failed to serialize structured content: {}", e),
40+
None,
41+
)
42+
})?;
43+
44+
Ok(CallToolResult::structured_only(value).into())
45+
}
46+
}

crates/rmcp/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pub use handler::client::ClientHandler;
1515
#[cfg(feature = "server")]
1616
pub use handler::server::ServerHandler;
1717
#[cfg(feature = "server")]
18-
pub use handler::server::wrapper::Json;
18+
pub use handler::server::wrapper::{Json, StructuredOnly};
1919
#[cfg(feature = "client")]
2020
pub use service::{
2121
ClientCacheConfig, ClientLifecycleMode, ClientServiceExt, MAX_CLIENT_CACHE_TTL, RoleClient,

crates/rmcp/src/model.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3860,6 +3860,51 @@ impl CallToolResult {
38603860
meta: None,
38613861
}
38623862
}
3863+
/// Create a successful tool result with structured content only, without
3864+
/// mirroring it into `content` as serialized text.
3865+
///
3866+
/// [`CallToolResult::structured`] duplicates the value as a text content
3867+
/// block so that clients which do not read `structuredContent` still see
3868+
/// the result. Skipping that mirror halves the payload for large values,
3869+
/// but such clients will receive an empty `content` array — only opt out
3870+
/// when you know your callers consume `structuredContent`.
3871+
///
3872+
/// # Example
3873+
///
3874+
/// ```rust,ignore
3875+
/// use rmcp::model::CallToolResult;
3876+
/// use serde_json::json;
3877+
///
3878+
/// let result = CallToolResult::structured_only(json!({
3879+
/// "temperature": 22.5,
3880+
/// "humidity": 65,
3881+
/// "description": "Partly cloudy"
3882+
/// }));
3883+
/// assert!(result.content.is_empty());
3884+
/// ```
3885+
pub fn structured_only(value: Value) -> Self {
3886+
CallToolResult {
3887+
result_type: ResultType::default(),
3888+
content: vec![],
3889+
structured_content: Some(value),
3890+
is_error: Some(false),
3891+
meta: None,
3892+
}
3893+
}
3894+
/// Create an error tool result with structured content only, without
3895+
/// mirroring it into `content` as serialized text.
3896+
///
3897+
/// The same caveat as [`CallToolResult::structured_only`] applies: clients
3898+
/// that do not read `structuredContent` will see an empty error result.
3899+
pub fn structured_error_only(value: Value) -> Self {
3900+
CallToolResult {
3901+
result_type: ResultType::default(),
3902+
content: vec![],
3903+
structured_content: Some(value),
3904+
is_error: Some(true),
3905+
meta: None,
3906+
}
3907+
}
38633908

38643909
/// Set the metadata on this result
38653910
pub fn with_meta(mut self, meta: Option<MetaObject>) -> Self {

crates/rmcp/tests/test_structured_output.rs

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#![allow(clippy::exhaustive_structs)]
22
//cargo test --test test_structured_output --features "client server macros"
33
use rmcp::{
4-
Json, ServerHandler,
4+
Json, ServerHandler, StructuredOnly,
55
handler::server::{router::tool::ToolRouter, tool::IntoCallToolResult, wrapper::Parameters},
66
model::{CallToolResponse, CallToolResult, ContentBlock, ServerResult, Tool},
77
tool, tool_handler, tool_router,
@@ -114,6 +114,21 @@ impl TestServer {
114114
pub async fn get_count(&self) -> Result<Json<i32>, String> {
115115
Ok(Json(42))
116116
}
117+
118+
/// Tool that returns structured output without the text mirror
119+
#[tool(
120+
name = "calculate-structured-only",
121+
description = "Perform calculations, returning structured content only"
122+
)]
123+
pub async fn calculate_structured_only(
124+
&self,
125+
params: Parameters<CalculationRequest>,
126+
) -> Result<StructuredOnly<CalculationResult>, String> {
127+
Ok(StructuredOnly(CalculationResult {
128+
sum: params.0.a + params.0.b,
129+
product: params.0.a * params.0.b,
130+
}))
131+
}
117132
}
118133

119134
#[tokio::test]
@@ -203,6 +218,40 @@ async fn test_structured_error_in_call_result() {
203218
assert_eq!(result.is_error, Some(true));
204219
}
205220

221+
#[tokio::test]
222+
async fn test_structured_only_in_call_result() {
223+
// structured_only skips the text mirror: structured content, empty content
224+
let structured_data = json!({
225+
"sum": 7,
226+
"product": 12
227+
});
228+
229+
let result = CallToolResult::structured_only(structured_data.clone());
230+
231+
assert!(result.content.is_empty());
232+
assert_eq!(result.structured_content, Some(structured_data));
233+
assert_eq!(result.is_error, Some(false));
234+
235+
// The empty content array still serializes explicitly on the wire
236+
let serialized = serde_json::to_value(&result).unwrap();
237+
assert_eq!(serialized["content"], json!([]));
238+
assert_eq!(serialized["structuredContent"]["sum"], 7);
239+
}
240+
241+
#[tokio::test]
242+
async fn test_structured_error_only_in_call_result() {
243+
let error_data = json!({
244+
"error_code": "NOT_FOUND",
245+
"message": "User not found"
246+
});
247+
248+
let result = CallToolResult::structured_error_only(error_data.clone());
249+
250+
assert!(result.content.is_empty());
251+
assert_eq!(result.structured_content, Some(error_data));
252+
assert_eq!(result.is_error, Some(true));
253+
}
254+
206255
#[tokio::test]
207256
async fn test_mutual_exclusivity_validation() {
208257
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
@@ -275,6 +324,49 @@ async fn test_structured_return_conversion() {
275324
assert_eq!(structured_value["product"], 12);
276325
}
277326

327+
#[tokio::test]
328+
async fn test_structured_only_return_conversion() {
329+
// StructuredOnly<T> converts to CallToolResult with structured_content but
330+
// no text mirror in content
331+
let calc_result = CalculationResult {
332+
sum: 7,
333+
product: 12,
334+
};
335+
336+
let structured = StructuredOnly(calc_result);
337+
let result: Result<CallToolResponse, rmcp::ErrorData> =
338+
rmcp::handler::server::tool::IntoCallToolResult::into_call_tool_result(structured);
339+
340+
assert!(result.is_ok());
341+
let CallToolResponse::Complete(call_result) = result.unwrap() else {
342+
panic!("expected complete CallToolResult");
343+
};
344+
345+
assert!(call_result.content.is_empty());
346+
347+
let structured_value = call_result.structured_content.unwrap();
348+
assert_eq!(structured_value["sum"], 7);
349+
assert_eq!(structured_value["product"], 12);
350+
}
351+
352+
#[tokio::test]
353+
async fn test_structured_only_tool_has_output_schema() {
354+
// The #[tool] macro derives outputSchema from StructuredOnly<T> just like Json<T>
355+
let server = TestServer::new();
356+
let tools = server.tool_router.list_all();
357+
358+
let tool = tools
359+
.iter()
360+
.find(|t| t.name == "calculate-structured-only")
361+
.unwrap();
362+
363+
assert!(tool.output_schema.is_some());
364+
365+
let schema_str = serde_json::to_string(tool.output_schema.as_ref().unwrap()).unwrap();
366+
assert!(schema_str.contains("sum"));
367+
assert!(schema_str.contains("product"));
368+
}
369+
278370
#[tokio::test]
279371
async fn test_tool_serialization_with_output_schema() {
280372
let server = TestServer::new();

0 commit comments

Comments
 (0)