From 943a367f609fdea77d75afd8796aa66510fd8359 Mon Sep 17 00:00:00 2001 From: Eren Atas Date: Tue, 30 Jun 2026 22:52:02 +0200 Subject: [PATCH] feat: add SEP-2575 discovery and SEP-2322 MRTR models Introduce the first scoped 2026-07-28 stateless MCP protocol surface in rmcp. Existing 2025 stateful flows and the default protocol version remain unchanged. Changes: - Add server/discover request and DiscoverResult models for SEP-2575. - Wire discover through ServerHandler with a default get_info()-based implementation. - Add 2026-07-28 protocol-version awareness while keeping LATEST at 2025-11-25. - Add SEP-2322 MRTR models: ResultType, InputRequest, and InputRequiredResult. - Add InputResponse for typed client-to-server MRTR input responses. - Add inputResponses and requestState to CallToolRequestParams. - Route server/discover through CustomRequest to preserve public enum semver. - Serialize typed new results through CustomResult to preserve public enum semver. - Allow CallToolResult to carry optional resultType for wire compatibility. - Register new model types in serde, meta, and schema plumbing. - Preserve PartialEq on existing task payload params. - Document why MRTR still exposes SEP-2577-deprecated sampling variants. - Add deserialization, schema, and round-trip coverage for the new wire shapes. Signed-off-by: Eren Atas --- crates/rmcp/src/handler/server.rs | 39 +- crates/rmcp/src/handler/server/router/tool.rs | 4 + crates/rmcp/src/handler/server/tool.rs | 2 + crates/rmcp/src/model.rs | 423 +++++++++++++++--- crates/rmcp/src/model/serde_impl.rs | 12 + crates/rmcp/tests/test_deserialization.rs | 144 +++++- .../client_json_rpc_message_schema.json | 36 ++ ...lient_json_rpc_message_schema_current.json | 36 ++ .../server_json_rpc_message_schema.json | 15 + ...erver_json_rpc_message_schema_current.json | 15 + 10 files changed, 661 insertions(+), 65 deletions(-) diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 3cec563e8..dd9d779f7 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -112,10 +112,18 @@ impl Service for H { .list_tools(request.params, context) .await .map(ServerResult::ListToolsResult), - ClientRequest::CustomRequest(request) => self - .on_custom_request(request, context) - .await - .map(ServerResult::CustomResult), + ClientRequest::CustomRequest(request) => { + if request.method == DiscoverRequestMethod.as_str() { + let params = request + .params_as::() + .map_err(|error| McpError::invalid_params(error.to_string(), None))?; + self.discover(params, context).await.map(ServerResult::from) + } else { + self.on_custom_request(request, context) + .await + .map(ServerResult::CustomResult) + } + } ClientRequest::ListTasksRequest(request) => self .list_tasks(request.params, context) .await @@ -211,6 +219,21 @@ macro_rules! server_handler_methods { ); std::future::ready(Ok(info)) } + fn discover( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + let info = self.get_info(); + std::future::ready(Ok(DiscoverResult { + supported_versions: ProtocolVersion::KNOWN_VERSIONS.to_vec(), + capabilities: info.capabilities, + server_info: info.server_info, + instructions: info.instructions, + meta: info.meta, + })) + } fn complete( &self, request: CompleteRequestParams, @@ -465,6 +488,14 @@ macro_rules! impl_server_handler_for_wrapper { (**self).initialize(request, context) } + fn discover( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).discover(request, context) + } + fn complete( &self, request: CompleteRequestParams, diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index ae096c00c..e2fbf8613 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -668,6 +668,8 @@ mod tests { meta: None, name: Cow::Borrowed("requires_params"), arguments: Some(Default::default()), + input_responses: None, + request_state: None, task: None, }, RequestContext::new(NumberOrString::Number(1), peer), @@ -707,6 +709,8 @@ mod tests { meta: None, name: Cow::Borrowed("test_tool"), arguments: None, + input_responses: None, + request_state: None, task: None, }, RequestContext::new(NumberOrString::Number(1), peer), diff --git a/crates/rmcp/src/handler/server/tool.rs b/crates/rmcp/src/handler/server/tool.rs index 9edf5ff8a..bbe57b491 100644 --- a/crates/rmcp/src/handler/server/tool.rs +++ b/crates/rmcp/src/handler/server/tool.rs @@ -45,6 +45,8 @@ impl<'s, S> ToolCallContext<'s, S> { meta: _, name, arguments, + input_responses: _, + request_state: _, task, }: CallToolRequestParams, request_context: RequestContext, diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index fc45f1efa..95fd8fe03 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -3,6 +3,7 @@ #![expect(deprecated)] use std::{ borrow::Cow, + collections::BTreeMap, ops::{Deref, DerefMut}, sync::Arc, }; @@ -30,6 +31,15 @@ use serde_json::Value; pub use task::*; pub use tool::*; +#[deprecated(since = "2.0.0", note = "Renamed to ContentBlock")] +pub type Content = ContentBlock; + +#[deprecated(since = "2.0.0", note = "Renamed to ContentBlock")] +pub type PromptMessageContent = ContentBlock; + +#[deprecated(since = "2.0.0", note = "Renamed to Role")] +pub type PromptMessageRole = Role; + /// A JSON object type alias for convenient handling of JSON data. /// /// You can use [`crate::object!`] or [`crate::model::object`] to create a json object quickly. @@ -867,6 +877,10 @@ impl RequestParamsMeta for InitializeRequestParams { #[deprecated(since = "0.13.0", note = "Use InitializeRequestParams instead")] pub type InitializeRequestParam = InitializeRequestParams; +const_string!(DiscoverRequestMethod = "server/discover"); +/// Request to discover a server's supported protocol versions and capabilities. +pub type DiscoverRequest = RequestOptionalParam; + /// The server's response to an initialization request. /// /// Contains the server's protocol version, capabilities, and implementation @@ -923,6 +937,59 @@ impl InitializeResult { pub type ServerInfo = InitializeResult; pub type ClientInfo = InitializeRequestParams; +/// Result of a `server/discover` request. +/// +/// This result advertises the protocol versions and capabilities that a server +/// supports without requiring an initialization handshake. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct DiscoverResult { + /// The MCP protocol versions this server supports. + pub supported_versions: Vec, + /// The capabilities this server provides. + pub capabilities: ServerCapabilities, + /// Information about the server implementation. + pub server_info: Implementation, + /// Optional human-readable instructions about using this server. + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl DiscoverResult { + /// Create a new `DiscoverResult` with supported versions and capabilities. + pub fn new(supported_versions: Vec, capabilities: ServerCapabilities) -> Self { + Self { + supported_versions, + capabilities, + server_info: Implementation::from_build_env(), + instructions: None, + meta: None, + } + } + + /// Set the server info on this result. + pub fn with_server_info(mut self, server_info: Implementation) -> Self { + self.server_info = server_info; + self + } + + /// Set instructions on this result. + pub fn with_instructions(mut self, instructions: impl Into) -> Self { + self.instructions = Some(instructions.into()); + self + } + + /// Set the metadata on this result. + pub fn with_meta(mut self, meta: Option) -> Self { + self.meta = meta; + self + } +} + #[allow(clippy::derivable_impls)] impl Default for ServerInfo { fn default() -> Self { @@ -2920,6 +2987,94 @@ pub type ElicitationCompletionNotification = ElicitationCompleteNotification; // TOOL EXECUTION RESULTS // ============================================================================= +/// Indicates the type of a protocol result. +/// +/// If `resultType` is absent on the wire, clients should treat the result as +/// [`ResultType::COMPLETE`] for backward compatibility. +#[derive(Debug, Clone, Eq, PartialEq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ResultType(Cow<'static, str>); + +impl ResultType { + pub const COMPLETE: Self = Self(Cow::Borrowed("complete")); + pub const INPUT_REQUIRED: Self = Self(Cow::Borrowed("input_required")); + + /// Returns the string representation of this result type. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Default for ResultType { + fn default() -> Self { + Self::COMPLETE + } +} + +impl std::fmt::Display for ResultType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl Serialize for ResultType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ResultType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: String = Deserialize::deserialize(deserializer)?; + match s.as_str() { + "complete" => Ok(ResultType::COMPLETE), + "input_required" => Ok(ResultType::INPUT_REQUIRED), + _ => Ok(ResultType(Cow::Owned(s))), + } + } +} + +/// Server-to-client requests that can be embedded in an MRTR +/// `InputRequiredResult`. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +#[allow(clippy::large_enum_variant)] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum InputRequest { + /// Sampling is deprecated by SEP-2577, but MRTR can still carry legacy + /// sampling requests while the protocol retains the feature. + CreateMessageRequest(Box), + ListRootsRequest(ListRootsRequest), + ElicitRequest(ElicitRequest), +} + +impl From for InputRequest { + fn from(value: CreateMessageRequest) -> Self { + Self::CreateMessageRequest(Box::new(value)) + } +} + +impl From for InputRequest { + fn from(value: ListRootsRequest) -> Self { + Self::ListRootsRequest(value) + } +} + +impl From for InputRequest { + fn from(value: ElicitRequest) -> Self { + Self::ElicitRequest(value) + } +} + +pub type InputRequests = BTreeMap; + /// The result of a tool call operation. /// /// Contains the content returned by the tool execution and an optional @@ -2929,6 +3084,9 @@ pub type ElicitationCompletionNotification = ElicitationCompleteNotification; #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct CallToolResult { + /// The protocol result type. If absent, clients should treat it as `complete`. + #[serde(rename = "resultType", skip_serializing_if = "Option::is_none")] + pub result_type: Option, /// The content returned by the tool (text, images, etc.) #[serde(default)] pub content: Vec, @@ -2956,6 +3114,8 @@ impl<'de> Deserialize<'de> for CallToolResult { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct Helper { + #[serde(rename = "resultType")] + result_type: Option, content: Option>, structured_content: Option, is_error: Option, @@ -2977,6 +3137,7 @@ impl<'de> Deserialize<'de> for CallToolResult { } Ok(CallToolResult { + result_type: helper.result_type, content: helper.content.unwrap_or_default(), structured_content: helper.structured_content, is_error: helper.is_error, @@ -2989,6 +3150,7 @@ impl CallToolResult { /// Create a successful tool result with unstructured content pub fn success(content: Vec) -> Self { CallToolResult { + result_type: None, content, structured_content: None, is_error: Some(false), @@ -3046,6 +3208,7 @@ impl CallToolResult { /// ``` pub fn error(content: Vec) -> Self { CallToolResult { + result_type: None, content, structured_content: None, is_error: Some(true), @@ -3068,6 +3231,7 @@ impl CallToolResult { /// ``` pub fn structured(value: Value) -> Self { CallToolResult { + result_type: None, content: vec![ContentBlock::text(value.to_string())], structured_content: Some(value), is_error: Some(false), @@ -3094,6 +3258,7 @@ impl CallToolResult { /// ``` pub fn structured_error(value: Value) -> Self { CallToolResult { + result_type: None, content: vec![ContentBlock::text(value.to_string())], structured_content: Some(value), is_error: Some(true), @@ -3147,6 +3312,168 @@ paginated_result!( ); const_string!(CallToolRequestMethod = "tools/call"); + +/// Result of sampling/createMessage (SEP-1577). +/// The result of a sampling/createMessage request containing the generated response. +/// +/// This structure contains the generated message along with metadata about +/// how the generation was performed and why it stopped. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub struct CreateMessageResult { + /// The identifier of the model that generated the response + pub model: String, + /// The reason why generation stopped (e.g., "endTurn", "maxTokens") + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_reason: Option, + /// The generated message with role and content + #[serde(flatten)] + pub message: SamplingMessage, +} + +impl CreateMessageResult { + /// Create a new CreateMessageResult with required fields. + pub fn new(message: SamplingMessage, model: String) -> Self { + Self { + message, + model, + stop_reason: None, + } + } + + pub const STOP_REASON_END_TURN: &str = "endTurn"; + pub const STOP_REASON_END_SEQUENCE: &str = "stopSequence"; + pub const STOP_REASON_END_MAX_TOKEN: &str = "maxTokens"; + pub const STOP_REASON_TOOL_USE: &str = "toolUse"; + + /// Set the stop reason. + pub fn with_stop_reason(mut self, stop_reason: impl Into) -> Self { + self.stop_reason = Some(stop_reason.into()); + self + } + + /// Set the model identifier. + pub fn with_model(mut self, model: impl Into) -> Self { + self.model = model.into(); + self + } + + /// Validate the result per SEP-1577: role must be "assistant". + pub fn validate(&self) -> Result<(), String> { + if self.message.role != Role::Assistant { + return Err("CreateMessageResult role must be 'assistant'".into()); + } + Ok(()) + } +} + +/// Client-to-server responses to MRTR input requests. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(untagged)] +#[allow(clippy::large_enum_variant)] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum InputResponse { + /// Sampling is deprecated by SEP-2577, but MRTR can still carry legacy + /// sampling responses while the protocol retains the feature. + CreateMessageResult(Box), + ListRootsResult(ListRootsResult), + ElicitResult(ElicitResult), +} + +impl From for InputResponse { + fn from(value: CreateMessageResult) -> Self { + Self::CreateMessageResult(Box::new(value)) + } +} + +impl From for InputResponse { + fn from(value: ListRootsResult) -> Self { + Self::ListRootsResult(value) + } +} + +impl From for InputResponse { + fn from(value: ElicitResult) -> Self { + Self::ElicitResult(value) + } +} + +pub type InputResponses = BTreeMap; + +/// Result returned when a request needs additional client input before it can +/// complete. +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct InputRequiredResult { + #[serde(rename = "resultType")] + pub result_type: ResultType, + #[serde(skip_serializing_if = "Option::is_none")] + pub input_requests: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_state: Option, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl<'de> Deserialize<'de> for InputRequiredResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Helper { + #[serde(rename = "resultType")] + result_type: ResultType, + input_requests: Option, + request_state: Option, + #[serde(rename = "_meta")] + meta: Option, + } + + let helper = Helper::deserialize(deserializer)?; + if helper.result_type != ResultType::INPUT_REQUIRED { + return Err(serde::de::Error::custom( + "expected resultType to be input_required", + )); + } + + Ok(InputRequiredResult { + result_type: helper.result_type, + input_requests: helper.input_requests, + request_state: helper.request_state, + meta: helper.meta, + }) + } +} + +impl InputRequiredResult { + /// Create a new `InputRequiredResult`. + pub fn new(input_requests: Option, request_state: Option) -> Self { + Self { + result_type: ResultType::INPUT_REQUIRED, + input_requests, + request_state, + meta: None, + } + } + + /// Set the metadata on this result. + pub fn with_meta(mut self, meta: Option) -> Self { + self.meta = meta; + self + } +} + /// Parameters for calling a tool provided by an MCP server. /// /// Contains the tool name and optional arguments needed to execute @@ -3167,6 +3494,12 @@ pub struct CallToolRequestParams { /// Arguments to pass to the tool (must match the tool's input schema) #[serde(skip_serializing_if = "Option::is_none")] pub arguments: Option, + /// Responses to input requests from a previous `InputRequiredResult`. + #[serde(skip_serializing_if = "Option::is_none")] + pub input_responses: Option, + /// Opaque request state echoed back from a previous `InputRequiredResult`. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_state: Option, /// Task metadata for async task management (SEP-1319) #[serde(skip_serializing_if = "Option::is_none")] pub task: Option, @@ -3179,6 +3512,8 @@ impl CallToolRequestParams { meta: None, name: name.into(), arguments: None, + input_responses: None, + request_state: None, task: None, } } @@ -3189,6 +3524,18 @@ impl CallToolRequestParams { self } + /// Sets the input responses for this tool call. + pub fn with_input_responses(mut self, input_responses: InputResponses) -> Self { + self.input_responses = Some(input_responses); + self + } + + /// Sets the request state for this tool call. + pub fn with_request_state(mut self, request_state: impl Into) -> Self { + self.request_state = Some(request_state.into()); + self + } + /// Sets the task metadata for this tool call. pub fn with_task(mut self, task: TaskMetadata) -> Self { self.task = Some(task); @@ -3221,66 +3568,6 @@ pub type CallToolRequestParam = CallToolRequestParams; /// Request to call a specific tool pub type CallToolRequest = Request; -/// Result of sampling/createMessage (SEP-1577). -/// The result of a sampling/createMessage request containing the generated response. -/// -/// This structure contains the generated message along with metadata about -/// how the generation was performed and why it stopped. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -#[deprecated( - since = "2.0.0", - note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub struct CreateMessageResult { - /// The identifier of the model that generated the response - pub model: String, - /// The reason why generation stopped (e.g., "endTurn", "maxTokens") - #[serde(skip_serializing_if = "Option::is_none")] - pub stop_reason: Option, - /// The generated message with role and content - #[serde(flatten)] - pub message: SamplingMessage, -} - -impl CreateMessageResult { - /// Create a new CreateMessageResult with required fields. - pub fn new(message: SamplingMessage, model: String) -> Self { - Self { - message, - model, - stop_reason: None, - } - } - - pub const STOP_REASON_END_TURN: &str = "endTurn"; - pub const STOP_REASON_END_SEQUENCE: &str = "stopSequence"; - pub const STOP_REASON_END_MAX_TOKEN: &str = "maxTokens"; - pub const STOP_REASON_TOOL_USE: &str = "toolUse"; - - /// Set the stop reason. - pub fn with_stop_reason(mut self, stop_reason: impl Into) -> Self { - self.stop_reason = Some(stop_reason.into()); - self - } - - /// Set the model identifier. - pub fn with_model(mut self, model: impl Into) -> Self { - self.model = model.into(); - self - } - - /// Validate the result per SEP-1577: role must be "assistant". - pub fn validate(&self) -> Result<(), String> { - if self.message.role != Role::Assistant { - return Err("CreateMessageResult role must be 'assistant'".into()); - } - Ok(()) - } -} - #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -3695,6 +3982,22 @@ impl ServerResult { } } +impl From for ServerResult { + fn from(value: DiscoverResult) -> Self { + ServerResult::CustomResult(CustomResult::new( + serde_json::to_value(value).expect("DiscoverResult serialization should not fail"), + )) + } +} + +impl From for ServerResult { + fn from(value: InputRequiredResult) -> Self { + ServerResult::CustomResult(CustomResult::new( + serde_json::to_value(value).expect("InputRequiredResult serialization should not fail"), + )) + } +} + pub type ServerJsonRpcMessage = JsonRpcMessage; impl TryInto for ServerNotification { diff --git a/crates/rmcp/src/model/serde_impl.rs b/crates/rmcp/src/model/serde_impl.rs index f8996f318..322d5cf45 100644 --- a/crates/rmcp/src/model/serde_impl.rs +++ b/crates/rmcp/src/model/serde_impl.rs @@ -450,6 +450,8 @@ mod test { meta: Some(params_meta), name: "my_tool".into(), arguments: None, + input_responses: None, + request_state: None, task: None, }, }; @@ -488,6 +490,8 @@ mod test { meta: None, name: "my_tool".into(), arguments: None, + input_responses: None, + request_state: None, task: None, }, }; @@ -509,6 +513,8 @@ mod test { meta: Some(params_meta), name: "my_tool".into(), arguments: None, + input_responses: None, + request_state: None, task: None, }, }; @@ -527,6 +533,8 @@ mod test { meta: None, name: "my_tool".into(), arguments: None, + input_responses: None, + request_state: None, task: None, }, }; @@ -563,6 +571,8 @@ mod test { meta: Some(params_meta), name: "my_tool".into(), arguments: None, + input_responses: None, + request_state: None, task: None, }, }; @@ -589,6 +599,8 @@ mod test { meta: None, name: "my_tool".into(), arguments: Some(serde_json::Map::from_iter([("x".to_string(), json!(1))])), + input_responses: None, + request_state: None, task: None, }, }; diff --git a/crates/rmcp/tests/test_deserialization.rs b/crates/rmcp/tests/test_deserialization.rs index 58e9a58af..0bdccf18d 100644 --- a/crates/rmcp/tests/test_deserialization.rs +++ b/crates/rmcp/tests/test_deserialization.rs @@ -22,7 +22,13 @@ fn test_tool_list_result() { /// ordering changes or the custom impl is removed, these tests will catch the /// regression. mod untagged_server_result { - use rmcp::model::{CallToolResult, JsonRpcResponse, ServerJsonRpcMessage, ServerResult}; + use std::collections::BTreeMap; + + use rmcp::model::{ + CallToolRequest, CallToolRequestParams, CallToolResult, ClientRequest, DiscoverResult, + ElicitationAction, InputRequiredResult, InputResponse, JsonRpcRequest, JsonRpcResponse, + ProtocolVersion, ServerJsonRpcMessage, ServerResult, + }; use serde_json::json; /// Helper: wrap a result value in a JSON-RPC response envelope. @@ -72,6 +78,76 @@ mod untagged_server_result { ); } + #[test] + fn call_tool_result_with_complete_result_type_deserializes_to_correct_variant() { + let result = parse_result(wrap_response(json!({ + "resultType": "complete", + "content": [ + { "type": "text", "text": "hello" } + ], + "isError": false + }))); + assert!( + matches!(result, ServerResult::CallToolResult(_)), + "expected CallToolResult, got {result:?}" + ); + } + + #[test] + fn input_required_result_deserializes_to_custom_result() { + let result = parse_result(wrap_response(json!({ + "resultType": "input_required", + "inputRequests": { + "confirm": { + "method": "elicitation/create", + "params": { + "mode": "form", + "message": "Delete 3 files?", + "requestedSchema": { + "type": "object", + "properties": { + "confirmed": { "type": "boolean" } + }, + "required": ["confirmed"] + } + } + } + }, + "requestState": "opaque-state" + }))); + let ServerResult::CustomResult(result) = result else { + panic!("expected CustomResult, got {result:?}"); + }; + let input_required: InputRequiredResult = result.result_as().unwrap(); + assert_eq!(input_required.result_type.as_str(), "input_required"); + assert_eq!( + input_required.request_state.as_deref(), + Some("opaque-state") + ); + } + + #[test] + fn discover_result_deserializes_to_custom_result() { + let result = parse_result(wrap_response(json!({ + "supportedVersions": ["2025-11-25", "2026-07-28"], + "capabilities": {}, + "serverInfo": { + "name": "test-server", + "version": "1.0.0" + }, + "instructions": "Use the tools carefully." + }))); + let ServerResult::CustomResult(result) = result else { + panic!("expected CustomResult, got {result:?}"); + }; + let discover: DiscoverResult = result.result_as().unwrap(); + assert_eq!( + discover.supported_versions, + vec![ProtocolVersion::V_2025_11_25, ProtocolVersion::V_2026_07_28] + ); + assert_eq!(discover.server_info.name, "test-server"); + } + #[test] fn empty_object_deserializes_to_empty_result() { let result = parse_result(wrap_response(json!({}))); @@ -132,4 +208,70 @@ mod untagged_server_result { let result = parse_result(wrap_response(json)); assert!(matches!(result, ServerResult::CallToolResult(_))); } + + #[test] + fn discover_request_deserializes_to_custom_request() { + let request: JsonRpcRequest = serde_json::from_value(json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "server/discover", + "params": {} + })) + .unwrap(); + + let ClientRequest::CustomRequest(custom) = request.request else { + panic!("expected CustomRequest, got {:?}", request.request); + }; + assert_eq!(custom.method, "server/discover"); + } + + #[test] + fn call_tool_request_round_trips_input_responses_and_request_state() { + let mut input_responses = BTreeMap::new(); + input_responses.insert( + "confirm".to_string(), + InputResponse::ElicitResult( + rmcp::model::ElicitResult::new(ElicitationAction::Accept) + .with_content(json!({ "confirmed": true })), + ), + ); + + let request = CallToolRequest::new( + CallToolRequestParams::new("delete_files") + .with_arguments(serde_json::Map::from_iter([( + "path".to_string(), + json!("/tmp/example"), + )])) + .with_input_responses(input_responses) + .with_request_state("opaque-state"), + ); + + let value = serde_json::to_value(&request).unwrap(); + assert_eq!(value["method"], "tools/call"); + assert_eq!( + value["params"]["inputResponses"]["confirm"]["action"], + "accept" + ); + assert_eq!(value["params"]["requestState"], "opaque-state"); + + let deserialized: CallToolRequest = serde_json::from_value(value).unwrap(); + assert_eq!(deserialized.params.name, "delete_files"); + assert_eq!( + deserialized.params.request_state.as_deref(), + Some("opaque-state") + ); + assert!( + deserialized + .params + .input_responses + .as_ref() + .is_some_and(|responses| responses.contains_key("confirm")) + ); + } + + #[test] + fn protocol_version_includes_2026_07_28_but_latest_remains_2025_11_25() { + assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2026_07_28)); + assert_eq!(ProtocolVersion::LATEST, ProtocolVersion::V_2025_11_25); + } } diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 3bfdb7c43..8bc9eb26c 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -143,10 +143,27 @@ ], "additionalProperties": true }, + "inputResponses": { + "description": "Responses to input requests from a previous `InputRequiredResult`.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputResponse" + } + }, "name": { "description": "The name of the tool to call", "type": "string" }, + "requestState": { + "description": "Opaque request state echoed back from a previous `InputRequiredResult`.", + "type": [ + "string", + "null" + ] + }, "task": { "description": "Task metadata for async task management (SEP-1319)", "anyOf": [ @@ -970,6 +987,25 @@ "format": "const", "const": "notifications/initialized" }, + "InputResponse": { + "description": "Client-to-server responses to MRTR input requests.", + "anyOf": [ + { + "description": "Sampling is deprecated by SEP-2577, but MRTR can still carry legacy\nsampling responses while the protocol retains the feature.", + "allOf": [ + { + "$ref": "#/definitions/CreateMessageResult" + } + ] + }, + { + "$ref": "#/definitions/ListRootsResult" + }, + { + "$ref": "#/definitions/ElicitResult" + } + ] + }, "JsonRpcError": { "type": "object", "properties": { diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 3bfdb7c43..8bc9eb26c 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -143,10 +143,27 @@ ], "additionalProperties": true }, + "inputResponses": { + "description": "Responses to input requests from a previous `InputRequiredResult`.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputResponse" + } + }, "name": { "description": "The name of the tool to call", "type": "string" }, + "requestState": { + "description": "Opaque request state echoed back from a previous `InputRequiredResult`.", + "type": [ + "string", + "null" + ] + }, "task": { "description": "Task metadata for async task management (SEP-1319)", "anyOf": [ @@ -970,6 +987,25 @@ "format": "const", "const": "notifications/initialized" }, + "InputResponse": { + "description": "Client-to-server responses to MRTR input requests.", + "anyOf": [ + { + "description": "Sampling is deprecated by SEP-2577, but MRTR can still carry legacy\nsampling responses while the protocol retains the feature.", + "allOf": [ + { + "$ref": "#/definitions/CreateMessageResult" + } + ] + }, + { + "$ref": "#/definitions/ListRootsResult" + }, + { + "$ref": "#/definitions/ElicitResult" + } + ] + }, "JsonRpcError": { "type": "object", "properties": { diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index fcf821f52..0c525d35e 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -178,6 +178,17 @@ "null" ] }, + "resultType": { + "description": "The protocol result type. If absent, clients should treat it as `complete`.", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, "structuredContent": { "description": "An optional JSON object that represents the structured result of the tool call" } @@ -2442,6 +2453,10 @@ } } }, + "ResultType": { + "description": "Indicates the type of a protocol result.\n\nIf `resultType` is absent on the wire, clients should treat the result as\n[`ResultType::COMPLETE`] for backward compatibility.", + "type": "string" + }, "Role": { "description": "Represents the role of a participant in a conversation or message exchange.\n\nUsed in sampling and chat contexts to distinguish between different\ntypes of message senders in the conversation flow.", "oneOf": [ diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index fcf821f52..0c525d35e 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -178,6 +178,17 @@ "null" ] }, + "resultType": { + "description": "The protocol result type. If absent, clients should treat it as `complete`.", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, "structuredContent": { "description": "An optional JSON object that represents the structured result of the tool call" } @@ -2442,6 +2453,10 @@ } } }, + "ResultType": { + "description": "Indicates the type of a protocol result.\n\nIf `resultType` is absent on the wire, clients should treat the result as\n[`ResultType::COMPLETE`] for backward compatibility.", + "type": "string" + }, "Role": { "description": "Represents the role of a participant in a conversation or message exchange.\n\nUsed in sampling and chat contexts to distinguish between different\ntypes of message senders in the conversation flow.", "oneOf": [