Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 6 additions & 9 deletions crates/rmcp-macros/src/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ use syn::{Expr, Ident, ImplItemFn, LitStr, ReturnType, parse_quote};

use crate::common::extract_doc_line;

/// Check if a type is Json<T> and extract the inner type T
fn extract_json_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
/// Extract `T` from a `Json<T>` or `StructuredOnly<T>` return type.
fn extract_structured_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
if let syn::Type::Path(type_path) = ty
&& let Some(last_segment) = type_path.path.segments.last()
&& last_segment.ident == "Json"
&& (last_segment.ident == "Json" || last_segment.ident == "StructuredOnly")
&& let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments
&& let Some(syn::GenericArgument::Type(inner_type)) = args.args.first()
{
Expand All @@ -18,18 +18,15 @@ fn extract_json_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
None
}

/// Extract schema expression from a function's return type
/// Handles patterns like Json<T> and Result<Json<T>, E>
/// Extract a schema expression from a structured function return type.
fn extract_schema_from_return_type(ret_type: &syn::Type) -> Option<Expr> {
// First, try direct Json<T>
if let Some(inner_type) = extract_json_inner_type(ret_type) {
if let Some(inner_type) = extract_structured_inner_type(ret_type) {
return syn::parse2::<Expr>(quote! {
rmcp::handler::server::tool::schema_for_output::<#inner_type>()
})
.ok();
}

// Then, try Result<Json<T>, E>
let type_path = match ret_type {
syn::Type::Path(path) => path,
_ => return None,
Expand All @@ -51,7 +48,7 @@ fn extract_schema_from_return_type(ret_type: &syn::Type) -> Option<Expr> {
_ => return None,
};

let inner_type = extract_json_inner_type(ok_type)?;
let inner_type = extract_structured_inner_type(ok_type)?;

syn::parse2::<Expr>(quote! {
rmcp::handler::server::tool::schema_for_output::<#inner_type>()
Expand Down
2 changes: 2 additions & 0 deletions crates/rmcp/src/handler/server/wrapper.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
mod json;
mod parameters;
mod structured_only;
pub use json::*;
pub use parameters::*;
pub use structured_only::*;
5 changes: 4 additions & 1 deletion crates/rmcp/src/handler/server/wrapper/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ use crate::{
/// 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.
/// of the tool result, and also mirror it into the regular `content` field
/// as serialized text for clients that do not read `structured_content`.
/// For object-shaped results, use [`StructuredOnly`](crate::StructuredOnly)
/// to skip the optional text mirror.
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct Json<T>(pub T);

Expand Down
45 changes: 45 additions & 0 deletions crates/rmcp/src/handler/server/wrapper/structured_only.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
use std::borrow::Cow;

use schemars::JsonSchema;
use serde::Serialize;

use crate::{
handler::server::tool::IntoCallToolResult,
model::{CallToolResponse, CallToolResult},
};

/// JSON wrapper that omits the optional serialized text mirror.
///
/// Like [`Json`](crate::Json), this wrapper serializes the value into the
/// `structured_content` field of the tool result and derives the tool's
/// output schema from `T`. Object-shaped values are not mirrored into
/// `content`, so large results are not sent twice.
///
/// Per SEP-2106, arrays, primitives, and `null` retain a serialized JSON
/// [`TextContent`](crate::model::TextContent) fallback for older clients.
/// See [`CallToolResult::structured_only`] for details.
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct StructuredOnly<T>(pub T);

impl<T: JsonSchema> JsonSchema for StructuredOnly<T> {
fn schema_name() -> Cow<'static, str> {
T::schema_name()
}

fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
T::json_schema(generator)
}
}

impl<T: Serialize + JsonSchema + 'static> IntoCallToolResult for StructuredOnly<T> {
fn into_call_tool_result(self) -> Result<CallToolResponse, 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_only(value).into())
}
}
2 changes: 1 addition & 1 deletion crates/rmcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ pub use handler::client::ClientHandler;
#[cfg(feature = "server")]
pub use handler::server::ServerHandler;
#[cfg(feature = "server")]
pub use handler::server::wrapper::Json;
pub use handler::server::wrapper::{Json, StructuredOnly};
#[cfg(feature = "client")]
pub use service::{
ClientCacheConfig, ClientLifecycleMode, ClientServiceExt, MAX_CLIENT_CACHE_TTL, RoleClient,
Expand Down
83 changes: 83 additions & 0 deletions crates/rmcp/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3996,6 +3996,89 @@ impl CallToolResult {
meta: None,
}
}
/// Create a successful tool result without the optional serialized text mirror.
///
/// [`CallToolResult::structured`] duplicates the value as a text content
/// block so that clients which do not read `structuredContent` still see
/// the result. For object-shaped values, skipping that mirror halves the
/// payload, but those clients receive an empty `content` array — only opt
/// out when you know your callers consume `structuredContent`.
///
/// SEP-2106 requires arrays, primitives, and `null` to retain a serialized
/// JSON [`TextContent`] fallback for older clients. For those values this
/// constructor behaves like [`CallToolResult::structured`].
///
/// To send a custom rendering instead of none (for example a short text
/// summary of a large value), chain [`CallToolResult::with_content`].
///
/// # Example
///
/// ```rust,ignore
/// use rmcp::model::CallToolResult;
/// use serde_json::json;
///
/// let result = CallToolResult::structured_only(json!({
/// "temperature": 22.5,
/// "humidity": 65,
/// "description": "Partly cloudy"
/// }));
/// assert!(result.content.is_empty());
/// ```
pub fn structured_only(value: Value) -> Self {
let content = Self::required_non_object_fallback(&value);
CallToolResult {
result_type: Some(ResultType::COMPLETE),
content,
structured_content: Some(value),
is_error: Some(false),
meta: None,
}
}
/// Create an error tool result without the optional serialized text mirror.
///
/// The same compatibility behavior as [`CallToolResult::structured_only`]
/// applies, including the required fallback for non-object values.
pub fn structured_error_only(value: Value) -> Self {
let content = Self::required_non_object_fallback(&value);
CallToolResult {
result_type: Some(ResultType::COMPLETE),
content,
structured_content: Some(value),
is_error: Some(true),
meta: None,
}
}

fn required_non_object_fallback(value: &Value) -> Vec<ContentBlock> {
if value.is_object() {
Vec::new()
} else {
vec![ContentBlock::text(value.to_string())]
}
}

/// Replace the `content` blocks on this result.
///
/// The `content` and `structuredContent` fields need not carry the same
/// data: pairing a hand-written rendering with a structured value is
/// valid, e.g. a short text summary in `content` while
/// `structured_content` holds the full value.
/// For non-object structured values, retain the serialized JSON text block
/// required by SEP-2106 when replacing content.
///
/// # Example
///
/// ```rust,ignore
/// use rmcp::model::{CallToolResult, ContentBlock};
/// use serde_json::json;
///
/// let result = CallToolResult::structured_only(json!({"rows": [1, 2, 3]}))
/// .with_content(vec![ContentBlock::text("3 rows matched")]);
/// ```
pub fn with_content(mut self, content: Vec<ContentBlock>) -> Self {
self.content = content;
self
}

/// Set the metadata on this result
pub fn with_meta(mut self, meta: Option<MetaObject>) -> Self {
Expand Down
135 changes: 134 additions & 1 deletion crates/rmcp/tests/test_structured_output.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#![allow(clippy::exhaustive_structs)]
//cargo test --test test_structured_output --features "client server macros"
use rmcp::{
Json, ServerHandler,
Json, ServerHandler, StructuredOnly,
handler::server::{router::tool::ToolRouter, tool::IntoCallToolResult, wrapper::Parameters},
model::{CallToolResponse, CallToolResult, ContentBlock, ServerResult, Tool},
tool, tool_handler, tool_router,
Expand Down Expand Up @@ -114,6 +114,21 @@ impl TestServer {
pub async fn get_count(&self) -> Result<Json<i32>, String> {
Ok(Json(42))
}

/// Tool that returns structured output without the text mirror
#[tool(
name = "calculate-structured-only",
description = "Perform calculations, returning structured content only"
)]
pub async fn calculate_structured_only(
&self,
params: Parameters<CalculationRequest>,
) -> Result<StructuredOnly<CalculationResult>, String> {
Ok(StructuredOnly(CalculationResult {
sum: params.0.a + params.0.b,
product: params.0.a * params.0.b,
}))
}
}

#[tokio::test]
Expand Down Expand Up @@ -203,6 +218,69 @@ async fn test_structured_error_in_call_result() {
assert_eq!(result.is_error, Some(true));
}

#[tokio::test]
async fn test_structured_only_in_call_result() {
let structured_data = json!({
"sum": 7,
"product": 12
});

let result = CallToolResult::structured_only(structured_data.clone());

assert!(result.content.is_empty());
assert_eq!(result.structured_content, Some(structured_data));
assert_eq!(result.is_error, Some(false));

// The empty content array still serializes explicitly on the wire
let serialized = serde_json::to_value(&result).unwrap();
assert_eq!(serialized["content"], json!([]));
assert_eq!(serialized["structuredContent"]["sum"], 7);
}

#[tokio::test]
async fn test_structured_only_retains_required_fallback_for_non_object_value() {
let structured_data = json!([1, 2, 3]);

let result = CallToolResult::structured_only(structured_data.clone());

assert_eq!(
result.content.first().and_then(ContentBlock::as_text),
Some(&rmcp::model::TextContent::new(structured_data.to_string()))
);
}

#[tokio::test]
async fn test_structured_only_with_divergent_content() {
// with_content pairs structured_content with a custom rendering instead of
// the serialized mirror
let structured_data = json!({"rows": [1, 2, 3]});

let result = CallToolResult::structured_only(structured_data.clone())
.with_content(vec![ContentBlock::text("3 rows matched")]);

assert_eq!(result.content.len(), 1);
assert_eq!(
result.content.first().unwrap().as_text().unwrap().text,
"3 rows matched"
);
assert_eq!(result.structured_content, Some(structured_data));
assert_eq!(result.is_error, Some(false));
}

#[tokio::test]
async fn test_structured_error_only_in_call_result() {
let error_data = json!({
"error_code": "NOT_FOUND",
"message": "User not found"
});

let result = CallToolResult::structured_error_only(error_data.clone());

assert!(result.content.is_empty());
assert_eq!(result.structured_content, Some(error_data));
assert_eq!(result.is_error, Some(true));
}

#[tokio::test]
async fn test_mutual_exclusivity_validation() {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -275,6 +353,61 @@ async fn test_structured_return_conversion() {
assert_eq!(structured_value["product"], 12);
}

#[tokio::test]
async fn test_structured_only_return_conversion() {
let calc_result = CalculationResult {
sum: 7,
product: 12,
};

let structured = StructuredOnly(calc_result);
let result: Result<CallToolResponse, rmcp::ErrorData> =
rmcp::handler::server::tool::IntoCallToolResult::into_call_tool_result(structured);

assert!(result.is_ok());
let CallToolResponse::Complete(call_result) = result.unwrap() else {
panic!("expected complete CallToolResult");
};

assert!(call_result.content.is_empty());

let structured_value = call_result.structured_content.unwrap();
assert_eq!(structured_value["sum"], 7);
assert_eq!(structured_value["product"], 12);
}

#[tokio::test]
async fn test_structured_only_wrapper_retains_required_fallback_for_primitive() {
let result = StructuredOnly(42).into_call_tool_result();

let CallToolResponse::Complete(call_result) = result.unwrap() else {
panic!("expected complete CallToolResult");
};

assert_eq!(
call_result.content.first().and_then(ContentBlock::as_text),
Some(&rmcp::model::TextContent::new("42"))
);
}

#[tokio::test]
async fn test_structured_only_tool_has_output_schema() {
// The #[tool] macro derives outputSchema from StructuredOnly<T> just like Json<T>
let server = TestServer::new();
let tools = server.tool_router.list_all();

let tool = tools
.iter()
.find(|t| t.name == "calculate-structured-only")
.unwrap();

assert!(tool.output_schema.is_some());

let schema_str = serde_json::to_string(tool.output_schema.as_ref().unwrap()).unwrap();
assert!(schema_str.contains("sum"));
assert!(schema_str.contains("product"));
}

#[tokio::test]
async fn test_tool_serialization_with_output_schema() {
let server = TestServer::new();
Expand Down