feat!: add MRTR behavior support (SEP-2322) (#929)
* feat!: add MRTR behavior support * feat: harden SEP-2322 MRTR support * ci: diff public API only on features common to base and head cargo public-api builds both revisions with the same feature set, so a feature introduced (or removed) by a PR broke the build of the other revision. Restrict the all-features diff to features present in both the base and head revisions.
This commit is contained in:
parent
a7194590ff
commit
dd30a70f84
7 changed files with 2121 additions and 127 deletions
|
|
@ -7,7 +7,10 @@ use std::{collections::BTreeSet, process::Stdio, time::Duration};
|
|||
|
||||
use rmcp::{
|
||||
ErrorData as McpError, RoleServer, ServerHandler, ServiceExt,
|
||||
model::{CallToolRequestParams, CallToolResult, ContentBlock, ServerCapabilities, ServerInfo},
|
||||
model::{
|
||||
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities,
|
||||
ServerInfo,
|
||||
},
|
||||
service::RequestContext,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -96,11 +99,9 @@ impl ServerHandler for WaitForCancelServer {
|
|||
&self,
|
||||
_request: CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
) -> Result<CallToolResponse, McpError> {
|
||||
context.ct.cancelled().await;
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||
"late response",
|
||||
)]))
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text("late response")]).into())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
594
crates/rmcp/tests/test_mrtr_behavior.rs
Normal file
594
crates/rmcp/tests/test_mrtr_behavior.rs
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
//! Behavior and edge-case coverage for SEP-2322 multi round-trip requests (MRTR).
|
||||
//!
|
||||
//! These tests drive a real client/server pair over an in-memory duplex stream
|
||||
//! and exercise the auto fulfill/retry loop, the manual `*_once` escape hatch,
|
||||
//! and the server-side version gating.
|
||||
|
||||
// Sampling/Roots are SEP-2577-deprecated but still used to model MRTR input requests.
|
||||
#![allow(deprecated)]
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
use rmcp::{
|
||||
ClientHandler, ServerHandler,
|
||||
model::*,
|
||||
service::{RequestContext, RoleClient, RoleServer, ServiceError, serve_directly},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
/// A `requestState` value with characters that must survive a byte-exact echo:
|
||||
/// dots (the codec delimiter), base64 punctuation, whitespace, and quotes.
|
||||
const TRICKY_STATE: &str = "st.ate/with+special=chars and spaces \"quotes\"\n\ttab";
|
||||
|
||||
// =============================================================================
|
||||
// Test handlers
|
||||
// =============================================================================
|
||||
|
||||
/// A stateless MRTR server whose behavior is selected by the tool/prompt/resource
|
||||
/// name. Round progression is derived entirely from `request_state` and
|
||||
/// `input_responses`, as required by the stateless MRTR pattern.
|
||||
#[derive(Clone, Default)]
|
||||
struct MrtrServer {
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
fn elicitation_request(message: &str) -> InputRequest {
|
||||
InputRequest::Elicitation(ElicitRequest::new(
|
||||
ElicitRequestParams::FormElicitationParams {
|
||||
meta: None,
|
||||
message: message.into(),
|
||||
requested_schema: serde_json::from_value(json!({
|
||||
"type": "object",
|
||||
"properties": { "name": { "type": "string" } },
|
||||
"required": ["name"]
|
||||
}))
|
||||
.unwrap(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn sampling_request() -> InputRequest {
|
||||
InputRequest::CreateMessage(CreateMessageRequest::new(CreateMessageRequestParams::new(
|
||||
vec![SamplingMessage::user_text("What is the capital of France?")],
|
||||
100,
|
||||
)))
|
||||
}
|
||||
|
||||
fn roots_request() -> InputRequest {
|
||||
InputRequest::ListRoots(ListRootsRequest::default())
|
||||
}
|
||||
|
||||
fn single_elicitation(state: &str) -> InputRequiredResult {
|
||||
let mut requests = InputRequests::new();
|
||||
requests.insert("answer".to_string(), elicitation_request("Name?"));
|
||||
InputRequiredResult::new(Some(requests), Some(state.into()))
|
||||
}
|
||||
|
||||
impl MrtrServer {
|
||||
fn call_tool_impl(
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
) -> Result<CallToolResponse, ErrorData> {
|
||||
let responses = request.input_responses.as_ref();
|
||||
let state = request.request_state.as_deref();
|
||||
match request.name.as_ref() {
|
||||
// Single round: one elicitation, then complete.
|
||||
"single" => match responses {
|
||||
None => Ok(single_elicitation("state-single").into()),
|
||||
Some(map) => {
|
||||
if state != Some("state-single") {
|
||||
return Err(ErrorData::internal_error("request_state not echoed", None));
|
||||
}
|
||||
let answer = map
|
||||
.get("answer")
|
||||
.ok_or_else(|| ErrorData::internal_error("missing answer", None))?;
|
||||
if answer["action"] != "accept" || answer["content"]["name"] != "Ferris" {
|
||||
return Err(ErrorData::internal_error("unexpected elicit result", None));
|
||||
}
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into())
|
||||
}
|
||||
},
|
||||
// Two elicitation rounds before completing.
|
||||
"multi_round" => match state {
|
||||
None => Ok(single_elicitation("round-1").into()),
|
||||
Some("round-1") => Ok(single_elicitation("round-2").into()),
|
||||
Some("round-2") => {
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text("multi-done")]).into())
|
||||
}
|
||||
Some(other) => Err(ErrorData::internal_error(
|
||||
format!("unexpected round state {other:?}"),
|
||||
None,
|
||||
)),
|
||||
},
|
||||
// Several input requests fulfilled concurrently in a single round.
|
||||
"multi_request" => match responses {
|
||||
None => {
|
||||
let mut requests = InputRequests::new();
|
||||
requests.insert("form".to_string(), elicitation_request("Name?"));
|
||||
requests.insert("sample".to_string(), sampling_request());
|
||||
requests.insert("roots".to_string(), roots_request());
|
||||
Ok(InputRequiredResult::new(Some(requests), Some("multi-req".into())).into())
|
||||
}
|
||||
Some(map) => {
|
||||
for key in ["form", "sample", "roots"] {
|
||||
if !map.contains_key(key) {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!("missing response for {key}"),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text("multi-req-done")]).into())
|
||||
}
|
||||
},
|
||||
// State-only load shedding: two state-only rounds, then complete.
|
||||
"state_only" => match state {
|
||||
None => Ok(InputRequiredResult::from_request_state("so-1").into()),
|
||||
Some("so-1") => Ok(InputRequiredResult::from_request_state("so-2").into()),
|
||||
Some("so-2") => {
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text("state-done")]).into())
|
||||
}
|
||||
Some(other) => Err(ErrorData::internal_error(
|
||||
format!("unexpected state {other:?}"),
|
||||
None,
|
||||
)),
|
||||
},
|
||||
// Never completes: used to exercise the max-rounds cap.
|
||||
"loops" => Ok(single_elicitation("loop").into()),
|
||||
// Triggers a failure inside the client's elicitation handler.
|
||||
"handler_error" => {
|
||||
let mut requests = InputRequests::new();
|
||||
requests.insert("answer".to_string(), elicitation_request("FAIL"));
|
||||
Ok(InputRequiredResult::new(Some(requests), Some("state".into())).into())
|
||||
}
|
||||
// Verifies the client echoes `request_state` byte-for-byte.
|
||||
"echo_state" => match responses {
|
||||
None => {
|
||||
let mut requests = InputRequests::new();
|
||||
requests.insert("answer".to_string(), elicitation_request("Name?"));
|
||||
Ok(InputRequiredResult::new(Some(requests), Some(TRICKY_STATE.into())).into())
|
||||
}
|
||||
Some(_) => {
|
||||
if state != Some(TRICKY_STATE) {
|
||||
return Err(ErrorData::internal_error(
|
||||
"request_state was not echoed byte-exact",
|
||||
None,
|
||||
));
|
||||
}
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text("echo-ok")]).into())
|
||||
}
|
||||
},
|
||||
_ => Ok(CallToolResult::success(vec![ContentBlock::text("noop")]).into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerHandler for MrtrServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
let mut info = ServerInfo::new(
|
||||
ServerCapabilities::builder()
|
||||
.enable_tools()
|
||||
.enable_prompts()
|
||||
.build(),
|
||||
);
|
||||
info.protocol_version = ProtocolVersion::V_2026_07_28;
|
||||
info
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResponse, ErrorData> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.call_tool_impl(request)
|
||||
}
|
||||
|
||||
async fn get_prompt(
|
||||
&self,
|
||||
request: GetPromptRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<GetPromptResponse, ErrorData> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
match request.request_state.as_deref() {
|
||||
None => Ok(single_elicitation("prompt-1").into()),
|
||||
Some("prompt-1") => Ok(GetPromptResult::new(vec![PromptMessage::new_text(
|
||||
Role::Assistant,
|
||||
"prompt-done",
|
||||
)])
|
||||
.into()),
|
||||
Some(other) => Err(ErrorData::internal_error(
|
||||
format!("unexpected prompt state {other:?}"),
|
||||
None,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_resource(
|
||||
&self,
|
||||
request: ReadResourceRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ReadResourceResponse, ErrorData> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
match request.request_state.as_deref() {
|
||||
None => Ok(single_elicitation("res-1").into()),
|
||||
Some("res-1") => Ok(ReadResourceResult::new(vec![ResourceContents::text(
|
||||
"resource-done",
|
||||
request.uri,
|
||||
)])
|
||||
.into()),
|
||||
Some(other) => Err(ErrorData::internal_error(
|
||||
format!("unexpected resource state {other:?}"),
|
||||
None,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A client that fulfills every kind of MRTR input request. Elicitation fails
|
||||
/// deliberately when the prompt message is `"FAIL"`.
|
||||
#[derive(Clone, Default)]
|
||||
struct MrtrClient;
|
||||
|
||||
impl ClientHandler for MrtrClient {
|
||||
async fn create_elicitation(
|
||||
&self,
|
||||
request: ElicitRequestParams,
|
||||
_context: RequestContext<RoleClient>,
|
||||
) -> Result<ElicitResult, ErrorData> {
|
||||
if let ElicitRequestParams::FormElicitationParams { message, .. } = &request {
|
||||
if message == "FAIL" {
|
||||
return Err(ErrorData::internal_error(
|
||||
"elicitation handler failed",
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(ElicitResult::new(ElicitationAction::Accept).with_content(json!({ "name": "Ferris" })))
|
||||
}
|
||||
|
||||
async fn create_message(
|
||||
&self,
|
||||
_request: CreateMessageRequestParams,
|
||||
_context: RequestContext<RoleClient>,
|
||||
) -> Result<CreateMessageResult, ErrorData> {
|
||||
Ok(CreateMessageResult::new(
|
||||
SamplingMessage::assistant_text("Paris."),
|
||||
"test-model".into(),
|
||||
)
|
||||
.with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN))
|
||||
}
|
||||
|
||||
async fn list_roots(
|
||||
&self,
|
||||
_context: RequestContext<RoleClient>,
|
||||
) -> Result<ListRootsResult, ErrorData> {
|
||||
Ok(ListRootsResult::new(vec![Root::new("file:///workspace")]))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Harness
|
||||
// =============================================================================
|
||||
|
||||
fn client_info(protocol_version: ProtocolVersion) -> ClientInfo {
|
||||
ClientInfo::new(
|
||||
ClientCapabilities::builder().enable_elicitation().build(),
|
||||
Implementation::new("mrtr-test-client", "0.0.0"),
|
||||
)
|
||||
.with_protocol_version(protocol_version)
|
||||
}
|
||||
|
||||
fn server_info_2026() -> ServerInfo {
|
||||
let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build());
|
||||
info.protocol_version = ProtocolVersion::V_2026_07_28;
|
||||
info
|
||||
}
|
||||
|
||||
/// Runs `body` inside a `LocalSet` so `spawn_local` (used when the `local`
|
||||
/// feature is active) is available, wiring up a connected client/server pair.
|
||||
async fn with_pair<F, Fut>(
|
||||
server: MrtrServer,
|
||||
client_protocol: ProtocolVersion,
|
||||
body: F,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
F: FnOnce(rmcp::service::RunningService<RoleClient, MrtrClient>) -> Fut,
|
||||
Fut: std::future::Future<Output = anyhow::Result<()>>,
|
||||
{
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async move {
|
||||
let (server_transport, client_transport) = tokio::io::duplex(8192);
|
||||
let server_peer_info = client_info(client_protocol);
|
||||
let server_task = tokio::task::spawn_local(async move {
|
||||
let running = serve_directly::<RoleServer, _, _, _, _>(
|
||||
server,
|
||||
server_transport,
|
||||
Some(server_peer_info),
|
||||
);
|
||||
running.waiting().await?;
|
||||
anyhow::Ok(())
|
||||
});
|
||||
|
||||
let client = serve_directly::<RoleClient, _, _, _, _>(
|
||||
MrtrClient,
|
||||
client_transport,
|
||||
Some(server_info_2026()),
|
||||
);
|
||||
|
||||
let result = body(client).await;
|
||||
|
||||
server_task.abort();
|
||||
result
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn client_auto_fulfills_input_required_tool_call() -> anyhow::Result<()> {
|
||||
let server = MrtrServer::default();
|
||||
let calls = server.calls.clone();
|
||||
with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move {
|
||||
let result = client
|
||||
.call_tool(CallToolRequestParams::new("single"))
|
||||
.await?;
|
||||
assert_eq!(result.content[0].as_text().unwrap().text, "done");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn manual_once_returns_input_required_without_retry() -> anyhow::Result<()> {
|
||||
let server = MrtrServer::default();
|
||||
let calls = server.calls.clone();
|
||||
with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move {
|
||||
let result = client
|
||||
.call_tool_once(CallToolRequestParams::new("single"))
|
||||
.await?;
|
||||
assert!(matches!(result, CallToolResponse::InputRequired(_)));
|
||||
// A manual round makes exactly one server call and never retries.
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn multi_round_input_required_completes() -> anyhow::Result<()> {
|
||||
let server = MrtrServer::default();
|
||||
let calls = server.calls.clone();
|
||||
with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move {
|
||||
let result = client
|
||||
.call_tool(CallToolRequestParams::new("multi_round"))
|
||||
.await?;
|
||||
assert_eq!(result.content[0].as_text().unwrap().text, "multi-done");
|
||||
// round 0 + two retries = 3 server calls.
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 3);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn multiple_input_requests_fulfilled_in_one_round() -> anyhow::Result<()> {
|
||||
let server = MrtrServer::default();
|
||||
let calls = server.calls.clone();
|
||||
with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move {
|
||||
let result = client
|
||||
.call_tool(CallToolRequestParams::new("multi_request"))
|
||||
.await?;
|
||||
assert_eq!(result.content[0].as_text().unwrap().text, "multi-req-done");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn state_only_input_required_completes() -> anyhow::Result<()> {
|
||||
let server = MrtrServer::default();
|
||||
let calls = server.calls.clone();
|
||||
with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move {
|
||||
let result = client
|
||||
.call_tool(CallToolRequestParams::new("state_only"))
|
||||
.await?;
|
||||
assert_eq!(result.content[0].as_text().unwrap().text, "state-done");
|
||||
// round 0 + two state-only retries = 3 server calls.
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 3);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn max_rounds_exceeded_returns_error() -> anyhow::Result<()> {
|
||||
let server = MrtrServer::default();
|
||||
let calls = server.calls.clone();
|
||||
with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move {
|
||||
let err = client
|
||||
.call_tool_with_mrtr_max_rounds(CallToolRequestParams::new("loops"), 3)
|
||||
.await
|
||||
.expect_err("a tool that never completes must exhaust the round cap");
|
||||
assert!(matches!(
|
||||
err,
|
||||
ServiceError::InputRequiredRoundsExceeded { max_rounds: 3 }
|
||||
));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 3);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn client_handler_error_propagates() -> anyhow::Result<()> {
|
||||
let server = MrtrServer::default();
|
||||
with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move {
|
||||
let err = client
|
||||
.call_tool(CallToolRequestParams::new("handler_error"))
|
||||
.await
|
||||
.expect_err("a failing input handler must fail the whole call");
|
||||
assert!(matches!(err, ServiceError::McpError(_)));
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn request_state_is_echoed_byte_exact() -> anyhow::Result<()> {
|
||||
let server = MrtrServer::default();
|
||||
with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move {
|
||||
// The server returns an error result unless it sees TRICKY_STATE echoed
|
||||
// back unchanged, so a successful completion proves the byte-exact echo.
|
||||
let result = client
|
||||
.call_tool(CallToolRequestParams::new("echo_state"))
|
||||
.await?;
|
||||
assert_eq!(result.content[0].as_text().unwrap().text, "echo-ok");
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn get_prompt_auto_fulfills_input_required() -> anyhow::Result<()> {
|
||||
let server = MrtrServer::default();
|
||||
with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move {
|
||||
let result = client.get_prompt(GetPromptRequestParams::new("p")).await?;
|
||||
assert_eq!(
|
||||
result.messages[0].content.as_text().unwrap().text,
|
||||
"prompt-done"
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn read_resource_auto_fulfills_input_required() -> anyhow::Result<()> {
|
||||
let server = MrtrServer::default();
|
||||
with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move {
|
||||
let result = client
|
||||
.read_resource(ReadResourceRequestParams::new("res://x"))
|
||||
.await?;
|
||||
let text = match &result.contents[0] {
|
||||
ResourceContents::TextResourceContents { text, .. } => text.clone(),
|
||||
_ => panic!("expected text resource"),
|
||||
};
|
||||
assert_eq!(text, "resource-done");
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn old_protocol_rejects_input_required() -> anyhow::Result<()> {
|
||||
let server = MrtrServer::default();
|
||||
// The client negotiated 2025-11-25, so the server must refuse to emit an
|
||||
// InputRequiredResult and return a protocol error instead.
|
||||
with_pair(server, ProtocolVersion::V_2025_11_25, |client| async move {
|
||||
let err = client
|
||||
.call_tool_once(CallToolRequestParams::new("single"))
|
||||
.await
|
||||
.expect_err("MRTR must be rejected for pre-2026 peers");
|
||||
match err {
|
||||
ServiceError::McpError(error) => {
|
||||
assert!(
|
||||
error.message.contains("2026-07-28"),
|
||||
"unexpected error message: {}",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
other => panic!("expected an McpError, got {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(feature = "request-state")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn request_state_codec_seals_and_verifies_through_the_loop() -> anyhow::Result<()> {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use rmcp::model::RequestStateCodec;
|
||||
|
||||
// A shared per-process signing key, mirroring how a real server would derive one.
|
||||
static KEY: &[u8] = b"integration-signing-key-32-bytes!";
|
||||
|
||||
fn codec() -> &'static RequestStateCodec {
|
||||
static CODEC: OnceLock<RequestStateCodec> = OnceLock::new();
|
||||
CODEC.get_or_init(|| RequestStateCodec::new(KEY))
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SealingServer;
|
||||
|
||||
impl ServerHandler for SealingServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build());
|
||||
info.protocol_version = ProtocolVersion::V_2026_07_28;
|
||||
info
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResponse, ErrorData> {
|
||||
match request.request_state {
|
||||
None => {
|
||||
let sealed = codec()
|
||||
.seal_json(&json!({ "step": 1, "tool": request.name }))
|
||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||
let mut requests = InputRequests::new();
|
||||
requests.insert("answer".to_string(), elicitation_request("Name?"));
|
||||
Ok(InputRequiredResult::new(Some(requests), Some(sealed)).into())
|
||||
}
|
||||
Some(sealed) => {
|
||||
// The echoed state is untrusted; verify it before use.
|
||||
let state: serde_json::Value = codec()
|
||||
.open_json(&sealed)
|
||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||
assert_eq!(state["step"], 1);
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text("sealed-done")]).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async move {
|
||||
let (server_transport, client_transport) = tokio::io::duplex(8192);
|
||||
let server_task = tokio::task::spawn_local(async move {
|
||||
let running = serve_directly::<RoleServer, _, _, _, _>(
|
||||
SealingServer,
|
||||
server_transport,
|
||||
Some(client_info(ProtocolVersion::V_2026_07_28)),
|
||||
);
|
||||
running.waiting().await?;
|
||||
anyhow::Ok(())
|
||||
});
|
||||
|
||||
let client = serve_directly::<RoleClient, _, _, _, _>(
|
||||
MrtrClient,
|
||||
client_transport,
|
||||
Some(server_info_2026()),
|
||||
);
|
||||
|
||||
let result = client
|
||||
.call_tool(CallToolRequestParams::new("sealed"))
|
||||
.await?;
|
||||
assert_eq!(result.content[0].as_text().unwrap().text, "sealed-done");
|
||||
|
||||
server_task.abort();
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ use rmcp::{
|
|||
ClientHandler, RoleServer, ServerHandler, ServiceError, ServiceExt,
|
||||
model::{
|
||||
ClientInfo, ErrorCode, ErrorData, ProtocolVersion, ReadResourceRequestParams,
|
||||
ReadResourceResult,
|
||||
ReadResourceResponse,
|
||||
},
|
||||
service::RequestContext,
|
||||
};
|
||||
|
|
@ -22,7 +22,7 @@ impl ServerHandler for ResourceServer {
|
|||
&self,
|
||||
_request: ReadResourceRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ReadResourceResult, ErrorData> {
|
||||
) -> Result<ReadResourceResponse, ErrorData> {
|
||||
Err(ErrorData::resource_not_found("resource not found", None))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
crates/rmcp/tests/test_result_type_wire.rs
Normal file
29
crates/rmcp/tests/test_result_type_wire.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
//! Wire-shape regression guards for the SEP-2322 `resultType` discriminator.
|
||||
//!
|
||||
//! These pin the behavior that keeps older/strict peers working:
|
||||
//! - `EmptyResult` stays a bare `{}` (some peers strict-validate empty results
|
||||
//! and reject extra keys), and
|
||||
//! - ordinary results carry `resultType: "complete"`.
|
||||
|
||||
use rmcp::model::{CallToolResult, ContentBlock, EmptyResult, ListToolsResult};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn empty_result_serializes_without_result_type() {
|
||||
let value = serde_json::to_value(EmptyResult {}).expect("serialize EmptyResult");
|
||||
assert_eq!(value, json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_tool_result_serializes_complete_result_type() {
|
||||
let value = serde_json::to_value(CallToolResult::success(vec![ContentBlock::text("ok")]))
|
||||
.expect("serialize CallToolResult");
|
||||
assert_eq!(value["resultType"], "complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginated_result_serializes_complete_result_type() {
|
||||
let value =
|
||||
serde_json::to_value(ListToolsResult::default()).expect("serialize ListToolsResult");
|
||||
assert_eq!(value["resultType"], "complete");
|
||||
}
|
||||
|
|
@ -4,7 +4,10 @@ use std::{collections::BTreeSet, process::Stdio, time::Duration};
|
|||
|
||||
use rmcp::{
|
||||
ErrorData as McpError, ServerHandler, ServiceExt,
|
||||
model::{CallToolRequestParams, CallToolResult, ContentBlock, ServerCapabilities, ServerInfo},
|
||||
model::{
|
||||
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities,
|
||||
ServerInfo,
|
||||
},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::{
|
||||
|
|
@ -87,11 +90,9 @@ impl ServerHandler for LargeResponseServer {
|
|||
&self,
|
||||
request: CallToolRequestParams,
|
||||
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
) -> Result<CallToolResponse, McpError> {
|
||||
assert_eq!("large-response", request.name.as_ref());
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||
"x".repeat(RESPONSE_BYTES),
|
||||
)]))
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text("x".repeat(RESPONSE_BYTES))]).into())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
use rmcp::{
|
||||
Json, ServerHandler,
|
||||
handler::server::{router::tool::ToolRouter, tool::IntoCallToolResult, wrapper::Parameters},
|
||||
model::{CallToolResult, ContentBlock, ServerResult, Tool},
|
||||
model::{CallToolResponse, CallToolResult, ContentBlock, ServerResult, Tool},
|
||||
tool, tool_handler, tool_router,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
|
|
@ -245,11 +245,13 @@ async fn test_structured_return_conversion() {
|
|||
};
|
||||
|
||||
let structured = Json(calc_result);
|
||||
let result: Result<CallToolResult, rmcp::ErrorData> =
|
||||
let result: Result<CallToolResponse, rmcp::ErrorData> =
|
||||
rmcp::handler::server::tool::IntoCallToolResult::into_call_tool_result(structured);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let call_result = result.unwrap();
|
||||
let CallToolResponse::Complete(call_result) = result.unwrap() else {
|
||||
panic!("expected complete CallToolResult");
|
||||
};
|
||||
|
||||
// Tools which return structured content should also return a serialized version as
|
||||
// Content::text for backwards compatibility.
|
||||
|
|
@ -306,11 +308,13 @@ async fn test_output_schema_requires_structured_content() {
|
|||
let result = server.calculate(params).await.unwrap();
|
||||
|
||||
// Convert the Json<CalculationResult> to CallToolResult
|
||||
let call_result: Result<CallToolResult, rmcp::ErrorData> =
|
||||
let call_result: Result<CallToolResponse, rmcp::ErrorData> =
|
||||
IntoCallToolResult::into_call_tool_result(result);
|
||||
|
||||
assert!(call_result.is_ok());
|
||||
let call_result = call_result.unwrap();
|
||||
let CallToolResponse::Complete(call_result) = call_result.unwrap() else {
|
||||
panic!("expected complete CallToolResult");
|
||||
};
|
||||
|
||||
// Verify it has structured_content and content
|
||||
assert!(call_result.structured_content.is_some());
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use std::sync::{
|
|||
use rmcp::{
|
||||
ClientHandler, RoleClient, RoleServer, ServerHandler, ServiceExt,
|
||||
handler::server::{router::tool::ToolRoute, tool::ToolCallContext},
|
||||
model::{CallToolResult, ServerCapabilities, ServerInfo, Tool},
|
||||
model::{CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool},
|
||||
service::{MaybeSendFuture, NotificationContext},
|
||||
};
|
||||
use tokio::sync::{Notify, RwLock};
|
||||
|
|
@ -26,11 +26,11 @@ impl TestToolServer {
|
|||
let mut tool_router = rmcp::handler::server::router::tool::ToolRouter::<Self>::new();
|
||||
tool_router.add_route(ToolRoute::new_dyn(
|
||||
Tool::new("tool_a", "Tool A", Arc::new(Default::default())),
|
||||
|_ctx| Box::pin(async { Ok(CallToolResult::default()) }),
|
||||
|_ctx| Box::pin(async { Ok(CallToolResult::default().into()) }),
|
||||
));
|
||||
tool_router.add_route(ToolRoute::new_dyn(
|
||||
Tool::new("tool_b", "Tool B", Arc::new(Default::default())),
|
||||
|_ctx| Box::pin(async { Ok(CallToolResult::default()) }),
|
||||
|_ctx| Box::pin(async { Ok(CallToolResult::default().into()) }),
|
||||
));
|
||||
Self {
|
||||
router: Arc::new(RwLock::new(tool_router)),
|
||||
|
|
@ -49,7 +49,7 @@ impl ServerHandler for TestToolServer {
|
|||
&self,
|
||||
request: rmcp::model::CallToolRequestParams,
|
||||
context: rmcp::service::RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
) -> Result<CallToolResponse, rmcp::ErrorData> {
|
||||
let router = self.router.read().await;
|
||||
let tcc = ToolCallContext::new(self, request, context);
|
||||
router.call(tcc).await
|
||||
|
|
|
|||
Loading…
Reference in a new issue