diff --git a/crates/rmcp-macros/src/tool.rs b/crates/rmcp-macros/src/tool.rs index 69f82e9d2..66aea3ba9 100644 --- a/crates/rmcp-macros/src/tool.rs +++ b/crates/rmcp-macros/src/tool.rs @@ -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 and extract the inner type T +/// Check if a type is Json or StructuredOnly 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() { @@ -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 and Result, E> +/// Handles patterns like Json and Result, E>, and the same +/// shapes with StructuredOnly fn extract_schema_from_return_type(ret_type: &syn::Type) -> Option { // First, try direct Json if let Some(inner_type) = extract_json_inner_type(ret_type) { diff --git a/crates/rmcp/src/handler/server/wrapper.rs b/crates/rmcp/src/handler/server/wrapper.rs index d9f2c86d9..2a4b35360 100644 --- a/crates/rmcp/src/handler/server/wrapper.rs +++ b/crates/rmcp/src/handler/server/wrapper.rs @@ -1,4 +1,6 @@ mod json; mod parameters; +mod structured_only; pub use json::*; pub use parameters::*; +pub use structured_only::*; diff --git a/crates/rmcp/src/handler/server/wrapper/json.rs b/crates/rmcp/src/handler/server/wrapper/json.rs index 7c5297963..56f464163 100644 --- a/crates/rmcp/src/handler/server/wrapper/json.rs +++ b/crates/rmcp/src/handler/server/wrapper/json.rs @@ -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(pub T); diff --git a/crates/rmcp/src/handler/server/wrapper/structured_only.rs b/crates/rmcp/src/handler/server/wrapper/structured_only.rs new file mode 100644 index 000000000..0833b4997 --- /dev/null +++ b/crates/rmcp/src/handler/server/wrapper/structured_only.rs @@ -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(pub T); + +// Implement JsonSchema for StructuredOnly to delegate to T's schema +impl JsonSchema for StructuredOnly { + 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 to create structured content without a text mirror +impl IntoCallToolResult for StructuredOnly { + fn into_call_tool_result(self) -> Result { + 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()) + } +} diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index 7c9b7b195..9205db6a0 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -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, diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 6531e6ee5..619551d4e 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -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) -> Self { + self.content = content; + self + } /// Set the metadata on this result pub fn with_meta(mut self, meta: Option) -> Self { diff --git a/crates/rmcp/tests/test_structured_output.rs b/crates/rmcp/tests/test_structured_output.rs index f3416e519..c313d832d 100644 --- a/crates/rmcp/tests/test_structured_output.rs +++ b/crates/rmcp/tests/test_structured_output.rs @@ -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, @@ -114,6 +114,21 @@ impl TestServer { pub async fn get_count(&self) -> Result, 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, + ) -> Result, String> { + Ok(StructuredOnly(CalculationResult { + sum: params.0.a + params.0.b, + product: params.0.a * params.0.b, + })) + } } #[tokio::test] @@ -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)] @@ -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 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 = + 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 just like Json + 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();