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
7 changes: 4 additions & 3 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
/// Check if a type is Json<T> or StructuredOnly<T> and extract the inner type T
fn extract_json_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 @@ -19,7 +19,8 @@ fn extract_json_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
}

/// Extract schema expression from a function's return type
/// Handles patterns like Json<T> and Result<Json<T>, E>
/// Handles patterns like Json<T> and Result<Json<T>, E>, and the same
/// shapes with StructuredOnly<T>
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) {
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`.
/// To skip that text mirror, use [`StructuredOnly`](crate::StructuredOnly)
/// instead.
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct Json<T>(pub T);

Expand Down
46 changes: 46 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,46 @@
use std::borrow::Cow;

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

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

/// Json wrapper for structured output without the serialized text fallback
///
/// 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`, but it does not mirror the value into `content`
/// as a text block, so large results are not sent twice.
///
/// Clients that do not read `structured_content` will see an empty `content`
/// array; see [`CallToolResult::structured_only`] for when that is safe.
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct StructuredOnly<T>(pub T);

// Implement JsonSchema for StructuredOnly<T> to delegate to T's schema
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)
}
}

// Implementation for StructuredOnly<T> to create structured content without a text mirror
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 @@ -15,7 +15,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
69 changes: 69 additions & 0 deletions crates/rmcp/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3860,6 +3860,75 @@ impl CallToolResult {
meta: None,
}
}
/// Create a successful tool result with structured content only, without
/// mirroring it into `content` as serialized text.
///
/// [`CallToolResult::structured`] duplicates the value as a text content
/// block so that clients which do not read `structuredContent` still see
/// the result. Skipping that mirror halves the payload for large values,
/// but such clients will receive an empty `content` array — only opt out
/// when you know your callers consume `structuredContent`.
///
/// 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 {
CallToolResult {
result_type: ResultType::default(),
content: vec![],
structured_content: Some(value),
is_error: Some(false),
meta: None,
}
}
/// Create an error tool result with structured content only, without
/// mirroring it into `content` as serialized text.
///
/// The same caveat as [`CallToolResult::structured_only`] applies: clients
/// that do not read `structuredContent` will see an empty error result.
pub fn structured_error_only(value: Value) -> Self {
CallToolResult {
result_type: ResultType::default(),
content: vec![],
structured_content: Some(value),
is_error: Some(true),
meta: None,
}
}

/// 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.
///
/// # 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
112 changes: 111 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,58 @@ 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() {
// structured_only skips the text mirror: structured content, empty content
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_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 +342,49 @@ async fn test_structured_return_conversion() {
assert_eq!(structured_value["product"], 12);
}

#[tokio::test]
async fn test_structured_only_return_conversion() {
// StructuredOnly<T> converts to CallToolResult with structured_content but
// no text mirror in content
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_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