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
5 changed files with 2121 additions and 127 deletions
|
|
@ -13,6 +13,7 @@ rmcp = { workspace = true, features = [
|
|||
"transport-streamable-http-server",
|
||||
"auth",
|
||||
"elicitation",
|
||||
"request-state",
|
||||
"schemars",
|
||||
] }
|
||||
tokio = { version = "1", features = [
|
||||
|
|
@ -113,3 +114,7 @@ path = "src/elicitation_enum_inference.rs"
|
|||
[[example]]
|
||||
name = "servers_task_stdio"
|
||||
path = "src/task_stdio.rs"
|
||||
|
||||
[[example]]
|
||||
name = "servers_mrtr"
|
||||
path = "src/mrtr.rs"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,17 @@ A minimal stdio server demonstrating task-based tool invocation per
|
|||
- Wires up `enqueue_task` / `tasks/get` / `tasks/result` / `tasks/cancel` via `#[task_handler]`
|
||||
- Pair with `examples/clients/src/task_stdio.rs` to see the full lifecycle (create → poll → fetch result)
|
||||
|
||||
### MRTR Demo (`mrtr.rs`)
|
||||
|
||||
An end-to-end walkthrough of SEP-2322 Multi Round-Trip Requests, running a
|
||||
server and client in one process over an in-memory stream.
|
||||
|
||||
- Server answers `tools/call` with an `InputRequiredResult` asking the client to elicit a value
|
||||
- Client uses `call_tool` to auto-fulfil the elicitation and retry, then `call_tool_once` for manual control
|
||||
- Seals/opens the untrusted `requestState` with `RequestStateCodec` (HMAC integrity)
|
||||
- Both sides negotiate `2026-07-28`, the minimum version for MRTR
|
||||
- Run with `cargo run -p mcp-server-examples --example servers_mrtr`
|
||||
|
||||
### Progress Demo Server (`progress_demo.rs`)
|
||||
|
||||
A server that demonstrates progress notifications during long-running operations.
|
||||
|
|
|
|||
|
|
@ -260,22 +260,16 @@ impl ServerHandler for Counter {
|
|||
&self,
|
||||
request: ReadResourceRequestParams,
|
||||
_: RequestContext<RoleServer>,
|
||||
) -> Result<ReadResourceResult, McpError> {
|
||||
) -> Result<ReadResourceResponse, McpError> {
|
||||
let uri = &request.uri;
|
||||
match uri.as_str() {
|
||||
"str:////Users/to/some/path/" => {
|
||||
let cwd = "/Users/to/some/path/";
|
||||
Ok(ReadResourceResult::new(vec![ResourceContents::text(
|
||||
cwd,
|
||||
uri.clone(),
|
||||
)]))
|
||||
Ok(ReadResourceResult::new(vec![ResourceContents::text(cwd, uri.clone())]).into())
|
||||
}
|
||||
"memo://insights" => {
|
||||
let memo = "Business Intelligence Memo\n\nAnalysis has revealed 5 key insights ...";
|
||||
Ok(ReadResourceResult::new(vec![ResourceContents::text(
|
||||
memo,
|
||||
uri.clone(),
|
||||
)]))
|
||||
Ok(ReadResourceResult::new(vec![ResourceContents::text(memo, uri.clone())]).into())
|
||||
}
|
||||
_ => Err(McpError::resource_not_found(
|
||||
"resource_not_found",
|
||||
|
|
|
|||
199
examples/servers/src/mrtr.rs
Normal file
199
examples/servers/src/mrtr.rs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
//! SEP-2322 Multi Round-Trip Request (MRTR) end-to-end example.
|
||||
//!
|
||||
//! This runs a server and a client in the same process, connected over an
|
||||
//! in-memory duplex stream, to show the full MRTR flow:
|
||||
//!
|
||||
//! * The **server** answers `tools/call` with an [`InputRequiredResult`] instead
|
||||
//! of a final result, asking the client to elicit a value first. It stores its
|
||||
//! progress in an opaque, integrity-protected `requestState` produced by a
|
||||
//! [`RequestStateCodec`], and verifies that state when the client retries.
|
||||
//! * The **client** uses the high-level [`RunningService::call_tool`] helper,
|
||||
//! which automatically fulfils the elicitation through the local
|
||||
//! [`ClientHandler`] and retries the original request. The example then repeats
|
||||
//! the call with [`RunningService::call_tool_once`] to show the manual escape
|
||||
//! hatch that returns the intermediate result without retrying.
|
||||
//!
|
||||
//! ## Version gating
|
||||
//!
|
||||
//! `InputRequiredResult` is only valid once the peers have negotiated protocol
|
||||
//! version `2026-07-28` or newer. Both sides advertise that version below. If a
|
||||
//! server emits an `InputRequiredResult` to an older client, the SDK turns it
|
||||
//! into a protocol error instead of sending it on the wire.
|
||||
//!
|
||||
//! ## `requestState` is untrusted input
|
||||
//!
|
||||
//! The client echoes `requestState` back verbatim, so from the server's point of
|
||||
//! view it is attacker-controlled. A stateless server that puts meaningful data
|
||||
//! in `requestState` MUST verify it. This example uses [`RequestStateCodec`] to
|
||||
//! seal and open it with an HMAC tag; tampered values are rejected.
|
||||
//!
|
||||
//! Run with:
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p mcp-server-examples --example servers_mrtr
|
||||
//! ```
|
||||
|
||||
use rmcp::{
|
||||
ClientHandler, ServerHandler, ServiceExt,
|
||||
model::*,
|
||||
service::{RequestContext, RoleClient, RoleServer},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
/// A stable, high-entropy secret. In a real deployment, load this from your
|
||||
/// secret manager and keep it out of clients' reach. It must stay constant for
|
||||
/// the lifetime of any in-flight MRTR exchange.
|
||||
const REQUEST_STATE_KEY: &[u8] = b"example-request-state-signing-key-32b!";
|
||||
|
||||
/// A server that needs a city name before it can answer a weather query.
|
||||
#[derive(Clone)]
|
||||
struct WeatherServer {
|
||||
codec: RequestStateCodec,
|
||||
}
|
||||
|
||||
impl Default for WeatherServer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
codec: RequestStateCodec::new(REQUEST_STATE_KEY),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerHandler for WeatherServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build());
|
||||
// MRTR requires 2026-07-28 or newer.
|
||||
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 {
|
||||
// First round: ask the client to provide a city, and remember where
|
||||
// we are by sealing our progress into `requestState`.
|
||||
None => {
|
||||
let sealed = self
|
||||
.codec
|
||||
.seal_json(&json!({ "awaiting": "city" }))
|
||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||
|
||||
let mut input_requests = InputRequests::new();
|
||||
input_requests.insert(
|
||||
"city".to_string(),
|
||||
InputRequest::Elicitation(ElicitRequest::new(
|
||||
ElicitRequestParams::FormElicitationParams {
|
||||
meta: None,
|
||||
message: "Which city do you want the weather for?".into(),
|
||||
requested_schema: serde_json::from_value(json!({
|
||||
"type": "object",
|
||||
"properties": { "city": { "type": "string" } },
|
||||
"required": ["city"]
|
||||
}))
|
||||
.expect("valid schema"),
|
||||
},
|
||||
)),
|
||||
);
|
||||
|
||||
Ok(InputRequiredResult::new(Some(input_requests), Some(sealed)).into())
|
||||
}
|
||||
// Retry round: verify the echoed state before trusting it, read the
|
||||
// elicited city, and return the final result.
|
||||
Some(sealed) => {
|
||||
let _state: serde_json::Value = self.codec.open_json(&sealed).map_err(|_| {
|
||||
ErrorData::invalid_params("tampered or unknown request state", None)
|
||||
})?;
|
||||
|
||||
let city = request
|
||||
.input_responses
|
||||
.as_ref()
|
||||
.and_then(|r| r.get("city"))
|
||||
.and_then(|v| v["content"]["city"].as_str())
|
||||
.unwrap_or("your area");
|
||||
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text(format!(
|
||||
"It is sunny in {city}."
|
||||
))])
|
||||
.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A client that fulfils elicitation requests. A real client would prompt a user.
|
||||
#[derive(Clone, Default)]
|
||||
struct InteractiveClient;
|
||||
|
||||
impl ClientHandler for InteractiveClient {
|
||||
fn get_info(&self) -> ClientInfo {
|
||||
ClientInfo::new(
|
||||
ClientCapabilities::builder().enable_elicitation().build(),
|
||||
Implementation::new("mrtr-example-client", env!("CARGO_PKG_VERSION")),
|
||||
)
|
||||
.with_protocol_version(ProtocolVersion::V_2026_07_28)
|
||||
}
|
||||
|
||||
async fn create_elicitation(
|
||||
&self,
|
||||
request: ElicitRequestParams,
|
||||
_context: RequestContext<RoleClient>,
|
||||
) -> Result<ElicitResult, ErrorData> {
|
||||
if let ElicitRequestParams::FormElicitationParams { message, .. } = &request {
|
||||
println!(" [client] server asked: {message}");
|
||||
}
|
||||
// Pretend the user typed "Paris".
|
||||
Ok(ElicitResult::new(ElicitationAction::Accept).with_content(json!({ "city": "Paris" })))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let (server_transport, client_transport) = tokio::io::duplex(8192);
|
||||
|
||||
// Spin up the server side.
|
||||
tokio::spawn(async move {
|
||||
let server = WeatherServer::default()
|
||||
.serve(server_transport)
|
||||
.await
|
||||
.expect("server should start");
|
||||
let _ = server.waiting().await;
|
||||
});
|
||||
|
||||
// Connect the client (this performs the initialize handshake).
|
||||
let client = InteractiveClient::default().serve(client_transport).await?;
|
||||
|
||||
// 1. High-level auto mode: the SDK fulfils the elicitation and retries for us.
|
||||
println!("== auto mode (call_tool) ==");
|
||||
let result = client
|
||||
.call_tool(CallToolRequestParams::new("weather"))
|
||||
.await?;
|
||||
println!(
|
||||
" [client] final result: {}\n",
|
||||
result.content[0].as_text().unwrap().text
|
||||
);
|
||||
|
||||
// 2. Manual mode: get the intermediate InputRequiredResult without retrying.
|
||||
println!("== manual mode (call_tool_once) ==");
|
||||
match client
|
||||
.call_tool_once(CallToolRequestParams::new("weather"))
|
||||
.await?
|
||||
{
|
||||
CallToolResponse::InputRequired(input_required) => {
|
||||
let requests = input_required.input_requests.unwrap_or_default();
|
||||
println!(
|
||||
" [client] server requested {} input(s); handling them yourself is up to you.",
|
||||
requests.len()
|
||||
);
|
||||
}
|
||||
CallToolResponse::Complete(result) => {
|
||||
println!(" [client] completed immediately: {result:?}");
|
||||
}
|
||||
_ => println!(" [client] unhandled response variant"),
|
||||
}
|
||||
|
||||
client.cancel().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ impl ServerHandler for SamplingDemoServer {
|
|||
&self,
|
||||
request: CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
) -> Result<CallToolResponse, ErrorData> {
|
||||
match request.name.as_ref() {
|
||||
"ask_llm" => {
|
||||
// Get the question from arguments
|
||||
|
|
@ -79,7 +79,8 @@ impl ServerHandler for SamplingDemoServer {
|
|||
.and_then(|c| c.as_text())
|
||||
.map(|t| &t.text)
|
||||
.unwrap_or(&"No text response".to_string())
|
||||
))]))
|
||||
))])
|
||||
.into())
|
||||
}
|
||||
|
||||
_ => Err(ErrorData::new(
|
||||
|
|
|
|||
Loading…
Reference in a new issue