From cb3699a08b566fed34abcb8a1cb9c53f0e3298b5 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:47:13 -0500 Subject: [PATCH] fix(client): accept tool results without content --- crates/rmcp/Cargo.toml | 5 ++ crates/rmcp/src/service/client.rs | 32 ++++++- .../tests/test_call_tool_contentless_reply.rs | 88 +++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 crates/rmcp/tests/test_call_tool_contentless_reply.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 5042f45a4..cb8553720 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -387,6 +387,11 @@ name = "test_trace_context" required-features = ["server", "client"] path = "tests/test_trace_context.rs" +[[test]] +name = "test_call_tool_contentless_reply" +required-features = ["client"] +path = "tests/test_call_tool_contentless_reply.rs" + [[test]] name = "test_abandoned_request_cancel" required-features = ["server", "client"] diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 21d68be89..e9d4515ad 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -23,7 +23,7 @@ use crate::{ InitializedNotification, InputRequest, InputRequiredResult, InputResponses, JsonRpcResponse, ListPromptsRequest, ListPromptsResult, ListResourceTemplatesRequest, ListResourceTemplatesResult, ListResourcesRequest, ListResourcesResult, ListSkillsRequest, - ListSkillsRequestMethod, ListSkillsResult, ListToolsRequest, ListToolsResult, + ListSkillsRequestMethod, ListSkillsResult, ListToolsRequest, ListToolsResult, MetaObject, NumberOrString, PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, ProtocolVersion, ReadResourceDirectoryRequest, ReadResourceDirectoryRequestMethod, ReadResourceDirectoryRequestParams, ReadResourceDirectoryResult, ReadResourceRequest, @@ -37,6 +37,32 @@ use crate::{ transport::DynamicTransportError, }; +/// Recovers a `tools/call` reply that omits `content`, which the untagged +/// [`ServerResult`] decodes as one of these other variants. +fn contentless_call_tool_result(result: ServerResult) -> Option { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct MetaOnly { + #[serde(rename = "_meta")] + meta: MetaObject, + } + + let (result_type, meta) = match result { + ServerResult::EmptyResult(_) => (None, None), + ServerResult::TaskAckResult(ack) => (Some(ack.result_type), ack.meta), + ServerResult::CustomResult(custom) => { + let MetaOnly { meta } = serde_json::from_value(custom.0).ok()?; + (None, Some(meta)) + } + _ => return None, + }; + Some(CallToolResult { + result_type, + meta, + ..Default::default() + }) +} + /// It represents the error that may occur when serving the client. /// /// if you want to handle the error, you can use `serve_client_with_ct` or `serve_client` with `Result, ClientError>` @@ -1490,7 +1516,9 @@ impl Peer { } // SEP-2663 Tasks extension: the server materialized a task. ServerResult::CreateTaskResult(result) => Ok(CallToolResponse::Task(result)), - _ => Err(ServiceError::UnexpectedResponse), + other => contentless_call_tool_result(other) + .map(CallToolResponse::Complete) + .ok_or(ServiceError::UnexpectedResponse), } } diff --git a/crates/rmcp/tests/test_call_tool_contentless_reply.rs b/crates/rmcp/tests/test_call_tool_contentless_reply.rs new file mode 100644 index 000000000..9281fb838 --- /dev/null +++ b/crates/rmcp/tests/test_call_tool_contentless_reply.rs @@ -0,0 +1,88 @@ +//! A `tools/call` reply that omits `content` decodes as a non-tool +//! `ServerResult` variant; `call_tool` still returns it as an empty result. + +use rmcp::{ + model::{CallToolRequestParams, CallToolResult, InitializeResult}, + service::{RoleClient, ServiceError, serve_directly}, +}; +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +/// Calls a tool against a raw peer that answers the request with `reply`. +async fn call_tool_with_reply(reply: Value) -> Result { + tokio::task::LocalSet::new() + .run_until(async move { + let (client_transport, server_transport) = tokio::io::duplex(4096); + let (read, mut write) = tokio::io::split(server_transport); + let client = serve_directly::( + (), + client_transport, + Some(InitializeResult::default().into()), + ); + + let respond = async { + let mut lines = BufReader::new(read).lines(); + let line = lines.next_line().await.unwrap().unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + let response = json!({ "jsonrpc": "2.0", "id": request["id"], "result": reply }); + write + .write_all(format!("{response}\n").as_bytes()) + .await + .unwrap(); + // Keep the transport open until the client has read the reply. + (lines, write) + }; + let (_peer, result) = tokio::join!( + respond, + client.call_tool(CallToolRequestParams::new("probe")) + ); + result + }) + .await +} + +fn call_tool_result(value: Value) -> CallToolResult { + serde_json::from_value(value).unwrap() +} + +#[tokio::test] +async fn call_tool_should_accept_meta_only_reply() { + let result = call_tool_with_reply(json!({ "_meta": { "example.com/replica": "r1" } })).await; + + assert_eq!( + result.unwrap(), + call_tool_result(json!({ "content": [], "_meta": { "example.com/replica": "r1" } })) + ); +} + +#[tokio::test] +async fn call_tool_should_accept_result_type_and_meta_reply() { + let result = call_tool_with_reply(json!({ + "resultType": "complete", + "_meta": { "example.com/replica": "r1" } + })) + .await; + + assert_eq!( + result.unwrap(), + call_tool_result(json!({ + "resultType": "complete", + "content": [], + "_meta": { "example.com/replica": "r1" } + })) + ); +} + +#[tokio::test] +async fn call_tool_should_accept_empty_reply() { + let result = call_tool_with_reply(json!({})).await; + + assert_eq!(result.unwrap(), call_tool_result(json!({ "content": [] }))); +} + +#[tokio::test] +async fn call_tool_should_reject_unrelated_reply() { + let result = call_tool_with_reply(json!({ "skills": [], "_meta": {} })).await; + + assert!(matches!(result, Err(ServiceError::UnexpectedResponse))); +}