diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 4abe9ccb4..1fffcdad9 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -251,11 +251,13 @@ impl Service for H { )) } else { // 2026-07-28 requires caching hints on cacheable results; older - // peers keep the legacy shape without `resultType: "complete"`. + // peers keep the legacy shape without `resultType: "complete"` + // and without non-object tool output (SEP-2106). if is_2026_07_28_or_later { result.fill_missing_cache_hints(); } else { result.strip_result_type_for_legacy_peer(); + result.strip_non_object_output_for_legacy_peer(); } Ok(result) } diff --git a/crates/rmcp/src/handler/server/wrapper/json.rs b/crates/rmcp/src/handler/server/wrapper/json.rs index 7c5297963..cc99d8a5d 100644 --- a/crates/rmcp/src/handler/server/wrapper/json.rs +++ b/crates/rmcp/src/handler/server/wrapper/json.rs @@ -13,7 +13,12 @@ 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, with a serialized copy in `content` as a text block. +/// +/// A `T` whose schema root is not `"type": "object"` (`Vec`, `i32`, +/// `Option`, tagged enums) is listed without `outputSchema` for peers +/// before `2026-07-28`. Non-object values also lose `structuredContent` and +/// reach those peers through the text block. #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct Json(pub T); diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 275d86dd5..ce90b6d7f 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -4800,6 +4800,55 @@ impl ServerResult { result_type.take_if(|result_type| result_type.is_complete()); } + /// Drop a non-object tool `outputSchema` or `structuredContent` so the + /// result keeps the wire shape that predates protocol version `2026-07-28`. + /// + /// SEP-2106 lets tools on `2026-07-28` declare any JSON Schema as their + /// output and return any JSON value as structured content. Older versions + /// require both to be objects, so the server handler calls this before + /// responding to a peer that negotiated one of them. + /// + /// A tool's `outputSchema` is dropped when its root `type` is not the + /// string `"object"`, including `oneOf`/`anyOf`/`$ref` roots. + /// `structuredContent` is dropped when it is not a JSON object. The + /// serialized text block that accompanies non-object structured content is + /// left in place; if the result has no content at all, the dropped value is + /// added as a text block so the peer still gets the result. + /// + /// # Examples + /// + /// ``` + /// use rmcp::model::{CallToolResult, ServerResult}; + /// use serde_json::json; + /// + /// let names = CallToolResult::structured(json!(["a", "b"])); + /// let mut result = ServerResult::CallToolResult(names); + /// result.strip_non_object_output_for_legacy_peer(); + /// + /// let json = serde_json::to_value(&result).unwrap(); + /// assert!(json.get("structuredContent").is_none()); + /// assert_eq!(json["content"][0]["text"], r#"["a","b"]"#); + /// ``` + pub fn strip_non_object_output_for_legacy_peer(&mut self) { + match self { + ServerResult::ListToolsResult(r) => { + for tool in &mut r.tools { + tool.output_schema.take_if(|schema| { + schema.get("type").and_then(Value::as_str) != Some("object") + }); + } + } + ServerResult::CallToolResult(r) => { + if let Some(value) = r.structured_content.take_if(|value| !value.is_object()) + && r.content.is_empty() + { + r.content.push(ContentBlock::text(value.to_string())); + } + } + _ => {} + } + } + /// Fill in the SEP-2549 caching hints a cacheable result is missing. /// /// Protocol version `2026-07-28` requires `ttlMs` and `cacheScope` on every @@ -5605,4 +5654,97 @@ mod tests { result ); } + + fn tool_with_output_schema(name: &'static str, schema: Value) -> Tool { + let Value::Object(schema) = schema else { + panic!("output schema should be a JSON object"); + }; + Tool::new(name, "", JsonObject::new()).with_raw_output_schema(Arc::new(schema)) + } + + #[test] + fn strip_non_object_output_drops_schemas_without_an_object_root() { + let object = json!({ + "type": "object", + "properties": { "sum": { "type": "integer" } }, + "required": ["sum"] + }); + // `oneOf` and `anyOf` + `$ref` roots are what schemars emits for an + // internally tagged enum and for `Option`. + let mut result = ServerResult::ListToolsResult(ListToolsResult::with_all_items(vec![ + tool_with_output_schema("array", json!({ "type": "array", "items": {} })), + tool_with_output_schema( + "tagged_enum", + json!({ + "oneOf": [ + { "type": "object", "properties": { "kind": { "const": "A" } } }, + { "type": "object", "properties": { "kind": { "const": "B" } } } + ] + }), + ), + tool_with_output_schema( + "optional", + json!({ + "$defs": { "S": object.clone() }, + "anyOf": [{ "$ref": "#/$defs/S" }, { "type": "null" }] + }), + ), + tool_with_output_schema("object", object.clone()), + ])); + result.strip_non_object_output_for_legacy_peer(); + + let ServerResult::ListToolsResult(result) = result else { + unreachable!() + }; + let schemas: Vec<_> = result + .tools + .iter() + .map(|tool| { + let schema = tool.output_schema.as_deref().cloned().map(Value::Object); + (tool.name.as_ref(), schema) + }) + .collect(); + assert_eq!( + schemas, + vec![ + ("array", None), + ("tagged_enum", None), + ("optional", None), + ("object", Some(object)), + ] + ); + } + + #[test] + fn strip_non_object_output_moves_value_into_empty_content() { + let mut result = ServerResult::CallToolResult(CallToolResult { + structured_content: Some(json!(["a", "b"])), + ..Default::default() + }); + result.strip_non_object_output_for_legacy_peer(); + + let ServerResult::CallToolResult(result) = result else { + unreachable!() + }; + assert_eq!(result.structured_content, None); + assert_eq!( + serde_json::to_value(&result.content).expect("serialize content"), + json!([{ "type": "text", "text": r#"["a","b"]"# }]) + ); + } + + #[test] + fn strip_non_object_output_keeps_an_existing_text_block() { + let mut result = ServerResult::CallToolResult(CallToolResult::structured(json!([1, 2]))); + result.strip_non_object_output_for_legacy_peer(); + + let ServerResult::CallToolResult(result) = result else { + unreachable!() + }; + assert_eq!(result.structured_content, None); + assert_eq!( + serde_json::to_value(&result.content).expect("serialize content"), + json!([{ "type": "text", "text": "[1,2]" }]) + ); + } } diff --git a/crates/rmcp/tests/test_structured_output_version.rs b/crates/rmcp/tests/test_structured_output_version.rs new file mode 100644 index 000000000..26cd359a3 --- /dev/null +++ b/crates/rmcp/tests/test_structured_output_version.rs @@ -0,0 +1,185 @@ +//! SEP-2106: non-object tool output follows the negotiated protocol version. +//! +//! Peers negotiating `2026-07-28` or newer receive any JSON Schema as a tool's +//! `outputSchema` and any JSON value as `structuredContent`. Older versions +//! require both to be objects, so non-object ones are omitted and the result +//! reaches the peer through its text content. +#![cfg(not(feature = "local"))] +#![cfg(feature = "client")] + +use rmcp::{ + ClientHandler, Json, RoleClient, RoleServer, ServerHandler, + handler::server::router::tool::ToolRouter, + model::{CallToolRequestParams, ClientConfig, InitializeResult, ProtocolVersion, Tool}, + service::{RunningService, serve_directly}, + tool, tool_handler, tool_router, +}; +use schemars::JsonSchema; +use serde::Serialize; +use serde_json::json; +use tokio::task::JoinHandle; + +const LEGACY_VERSIONS: &[ProtocolVersion] = + &[ProtocolVersion::V_2025_06_18, ProtocolVersion::V_2025_11_25]; + +#[derive(Serialize, JsonSchema)] +struct Total { + sum: i32, +} + +#[derive(Debug, Clone)] +struct OutputServer { + tool_router: ToolRouter, +} + +#[tool_router] +impl OutputServer { + fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } + + #[tool(description = "Returns a list of names")] + async fn names(&self) -> Json> { + Json(vec!["a".to_string(), "b".to_string()]) + } + + #[tool(description = "Returns an object")] + async fn total(&self) -> Json { + Json(Total { sum: 3 }) + } +} + +#[tool_handler(router = self.tool_router)] +impl ServerHandler for OutputServer {} + +#[derive(Debug, Clone)] +struct VersionedClient { + protocol_version: ProtocolVersion, +} + +impl ClientHandler for VersionedClient { + fn get_info(&self) -> ClientConfig { + let mut info = ClientConfig::default(); + info.protocol_version = self.protocol_version.clone(); + info + } +} + +type ServerTask = JoinHandle>; + +/// Wires the pair up directly on `protocol_version`. `2026-07-28` removed the +/// `initialize` handshake, so a peer on that revision is reached the way the +/// discover lifecycle leaves one: with the version already agreed. +fn connect( + protocol_version: ProtocolVersion, +) -> (RunningService, ServerTask) { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let client_handler = VersionedClient { + protocol_version: protocol_version.clone(), + }; + let mut server_peer_info = InitializeResult::default(); + server_peer_info.protocol_version = protocol_version; + + let server = serve_directly::( + OutputServer::new(), + server_transport, + Some(client_handler.get_info()), + ); + let server_task = tokio::spawn(async move { + server.waiting().await?; + anyhow::Ok(()) + }); + + let client = serve_directly::( + client_handler, + client_transport, + Some(server_peer_info.into()), + ); + (client, server_task) +} + +async fn disconnect(client: RunningService, server_task: ServerTask) { + client.cancel().await.expect("client should cancel"); + server_task.await.expect("server task").expect("server"); +} + +fn tool_named<'a>(tools: &'a [Tool], name: &str) -> &'a Tool { + tools + .iter() + .find(|tool| tool.name == name) + .expect("tool should be listed") +} + +#[tokio::test] +async fn legacy_versions_omit_non_object_output_schema() { + for version in LEGACY_VERSIONS { + let (client, server_task) = connect(version.clone()); + let tools = client.list_tools(None).await.expect("tools/list").tools; + disconnect(client, server_task).await; + + assert_eq!( + tool_named(&tools, "names").output_schema, + None, + "{version} requires an object outputSchema" + ); + let total_schema = tool_named(&tools, "total").output_schema.as_ref(); + assert_eq!( + total_schema.and_then(|schema| schema.get("type")), + Some(&json!("object")), + "{version} keeps object outputSchema" + ); + } +} + +#[tokio::test] +async fn legacy_versions_omit_non_object_structured_content() { + for version in LEGACY_VERSIONS { + let (client, server_task) = connect(version.clone()); + let names = client + .call_tool(CallToolRequestParams::new("names")) + .await + .expect("names call should succeed"); + let total = client + .call_tool(CallToolRequestParams::new("total")) + .await + .expect("total call should succeed"); + disconnect(client, server_task).await; + + assert_eq!( + names.structured_content, None, + "{version} requires object structuredContent" + ); + assert_eq!( + names + .into_typed::>() + .expect("text content should carry the result"), + ["a", "b"], + ); + assert_eq!( + total.structured_content, + Some(json!({ "sum": 3 })), + "{version} keeps object structuredContent" + ); + } +} + +#[tokio::test] +async fn sep_2106_version_keeps_non_object_output() { + let (client, server_task) = connect(ProtocolVersion::V_2026_07_28); + let tools = client.list_tools(None).await.expect("tools/list").tools; + let names = client + .call_tool(CallToolRequestParams::new("names")) + .await + .expect("names call should succeed"); + disconnect(client, server_task).await; + + let names_schema = tool_named(&tools, "names").output_schema.as_ref(); + assert_eq!( + names_schema.and_then(|schema| schema.get("type")), + Some(&json!("array")) + ); + assert_eq!(names.structured_content, Some(json!(["a", "b"]))); +}