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
5 changes: 5 additions & 0 deletions crates/rmcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
32 changes: 30 additions & 2 deletions crates/rmcp/src/service/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<CallToolResult> {
#[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<RunningService<RoleClient, S>, ClientError>`
Expand Down Expand Up @@ -1490,7 +1516,9 @@ impl Peer<RoleClient> {
}
// 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),
}
}

Expand Down
88 changes: 88 additions & 0 deletions crates/rmcp/tests/test_call_tool_contentless_reply.rs
Original file line number Diff line number Diff line change
@@ -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<CallToolResult, ServiceError> {
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::<RoleClient, _, _, _, _>(
(),
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)));
}
Loading