diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index b0b0d635c..fda1ab938 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -204,9 +204,8 @@ impl ServerHandler for ConformanceServer { ), ]; Ok(ListToolsResult { - meta: None, tools, - next_cursor: None, + ..Default::default() }) } @@ -540,7 +539,6 @@ impl ServerHandler for ConformanceServer { _cx: RequestContext, ) -> Result { Ok(ListResourcesResult { - meta: None, resources: vec![ Resource::new("test://static-text", "Static Text Resource") .with_description("A static text resource for testing") @@ -549,7 +547,7 @@ impl ServerHandler for ConformanceServer { .with_description("A static binary/blob resource for testing") .with_mime_type("image/png"), ], - next_cursor: None, + ..Default::default() }) } @@ -609,13 +607,12 @@ impl ServerHandler for ConformanceServer { _cx: RequestContext, ) -> Result { Ok(ListResourceTemplatesResult { - meta: None, resource_templates: vec![ ResourceTemplate::new("test://template/{id}/data", "Dynamic Resource") .with_description("A dynamic resource with parameter substitution") .with_mime_type("application/json"), ], - next_cursor: None, + ..Default::default() }) } @@ -645,7 +642,6 @@ impl ServerHandler for ConformanceServer { _cx: RequestContext, ) -> Result { Ok(ListPromptsResult { - meta: None, prompts: vec![ Prompt::new( "test_simple_prompt", @@ -675,7 +671,7 @@ impl ServerHandler for ConformanceServer { None, ), ], - next_cursor: None, + ..Default::default() }) } diff --git a/crates/rmcp-macros/src/prompt_handler.rs b/crates/rmcp-macros/src/prompt_handler.rs index 19ba4388e..af4f24bd8 100644 --- a/crates/rmcp-macros/src/prompt_handler.rs +++ b/crates/rmcp-macros/src/prompt_handler.rs @@ -61,6 +61,7 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result Result { let prompts = #router_expr.list_all(); Ok(rmcp::model::ListPromptsResult { + result_type: Default::default(), prompts, meta: #meta, next_cursor: None, diff --git a/crates/rmcp-macros/src/tool_handler.rs b/crates/rmcp-macros/src/tool_handler.rs index 0cb323b5a..dc935828d 100644 --- a/crates/rmcp-macros/src/tool_handler.rs +++ b/crates/rmcp-macros/src/tool_handler.rs @@ -69,6 +69,7 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result, ) -> Result { Ok(rmcp::model::ListToolsResult{ + result_type: Default::default(), tools: #router.list_all(), meta: #result_meta, next_cursor: None, diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index 90f7ed4a9..c9097e241 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -66,10 +66,6 @@ impl Service for H { ServerNotification::PromptListChangedNotification(_notification_no_param) => { self.on_prompt_list_changed(context).await } - ServerNotification::ElicitationCompleteNotification(notification) => { - self.on_url_elicitation_notification_complete(notification.params, context) - .await - } ServerNotification::TaskStatusNotification(notification) => { self.on_task_status(notification.params, context).await } @@ -242,13 +238,6 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { std::future::ready(()) } - fn on_url_elicitation_notification_complete( - &self, - params: ElicitationResponseNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } fn on_task_status( &self, params: TaskStatusNotificationParam, diff --git a/crates/rmcp/src/handler/server/prompt.rs b/crates/rmcp/src/handler/server/prompt.rs index c291ef70b..ffce6b2e0 100644 --- a/crates/rmcp/src/handler/server/prompt.rs +++ b/crates/rmcp/src/handler/server/prompt.rs @@ -103,6 +103,7 @@ impl IntoGetPromptResult for GetPromptResult { impl IntoGetPromptResult for Vec { fn into_get_prompt_result(self) -> Result { Ok(GetPromptResult { + result_type: Default::default(), description: None, messages: self, meta: None, diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index ae096c00c..dece66d95 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -669,6 +669,8 @@ mod tests { name: Cow::Borrowed("requires_params"), arguments: Some(Default::default()), task: None, + input_responses: None, + request_state: None, }, RequestContext::new(NumberOrString::Number(1), peer), ); @@ -708,6 +710,8 @@ mod tests { name: Cow::Borrowed("test_tool"), arguments: None, task: None, + input_responses: None, + request_state: 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..bf350797d 100644 --- a/crates/rmcp/src/handler/server/tool.rs +++ b/crates/rmcp/src/handler/server/tool.rs @@ -46,6 +46,7 @@ impl<'s, S> ToolCallContext<'s, S> { name, arguments, task, + .. }: CallToolRequestParams, request_context: RequestContext, ) -> Self { diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index fc45f1efa..f6c74c133 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -12,6 +12,7 @@ mod content; mod elicitation_schema; mod extension; mod meta; +mod mrtr; mod prompt; mod resource; mod serde_impl; @@ -23,6 +24,7 @@ pub use content::*; pub use elicitation_schema::*; pub use extension::*; pub use meta::*; +pub use mrtr::*; pub use prompt::*; pub use resource::*; use serde::{Deserialize, Serialize, de::DeserializeOwned}; @@ -516,7 +518,6 @@ impl ErrorCode { pub const INVALID_PARAMS: Self = Self(-32602); pub const INTERNAL_ERROR: Self = Self(-32603); pub const PARSE_ERROR: Self = Self(-32700); - pub const URL_ELICITATION_REQUIRED: Self = Self(-32042); } /// Error information for JSON-RPC error responses. @@ -572,12 +573,6 @@ impl ErrorData { pub fn internal_error(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::INTERNAL_ERROR, message, data) } - pub fn url_elicitation_required( - message: impl Into>, - data: Option, - ) -> Self { - Self::new(ErrorCode::URL_ELICITATION_REQUIRED, message, data) - } } /// Represents any JSON-RPC message that can be sent or received. @@ -684,6 +679,71 @@ impl From for () { fn from(_value: EmptyResult) {} } +/// Indicates the type of a result object, allowing the client to +/// determine how to parse the response. +/// +/// The spec defines this as an open string (`"complete" | "input_required" | string`), +/// so unknown values are preserved rather than rejected. Servers implementing this +/// protocol version MUST include `resultType` in every result. For backward +/// compatibility, clients MUST treat an absent field as `"complete"`. +#[derive(Debug, Clone, PartialEq, Eq)] +#[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")); + + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Returns `true` if this is `"input_required"`. + pub fn is_input_required(&self) -> bool { + self.0 == "input_required" + } + + /// Returns `true` if this is `"complete"`. + pub fn is_complete(&self) -> bool { + self.0 == "complete" + } +} + +impl Default for ResultType { + fn default() -> Self { + Self::COMPLETE + } +} + +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(Self::COMPLETE), + "input_required" => Ok(Self::INPUT_REQUIRED), + _ => Ok(Self(Cow::Owned(s))), + } + } +} + +impl std::fmt::Display for ResultType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + /// A catch-all response either side can use for custom requests. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(transparent)] @@ -1185,6 +1245,9 @@ macro_rules! paginated_result { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct $t { + /// Result type discriminator. Absent values deserialize as `"complete"`. + #[serde(default)] + pub result_type: ResultType, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1197,6 +1260,7 @@ macro_rules! paginated_result { items: $t_item, ) -> Self { Self { + result_type: ResultType::default(), meta: None, next_cursor: None, $i_item: items, @@ -1240,6 +1304,13 @@ pub struct ReadResourceRequestParams { pub meta: Option, /// The URI of the resource to read pub uri: String, + /// Client responses to server-initiated 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, } impl ReadResourceRequestParams { @@ -1248,6 +1319,8 @@ impl ReadResourceRequestParams { Self { meta: None, uri: uri.into(), + input_responses: None, + request_state: None, } } @@ -1256,6 +1329,18 @@ impl ReadResourceRequestParams { self.meta = Some(meta); self } + + /// Sets the input responses for an MRTR retry. + pub fn with_input_responses(mut self, input_responses: InputResponses) -> Self { + self.input_responses = Some(input_responses); + self + } + + /// Sets the request state for an MRTR retry. + pub fn with_request_state(mut self, request_state: impl Into) -> Self { + self.request_state = Some(request_state.into()); + self + } } impl RequestParamsMeta for ReadResourceRequestParams { @@ -1276,6 +1361,9 @@ pub type ReadResourceRequestParam = ReadResourceRequestParams; #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct ReadResourceResult { + /// Result type discriminator. Absent values deserialize as `"complete"`. + #[serde(default)] + pub result_type: ResultType, /// The actual content of the resource pub contents: Vec, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] @@ -1286,6 +1374,7 @@ impl ReadResourceResult { /// Create a new ReadResourceResult with the given contents. pub fn new(contents: Vec) -> Self { Self { + result_type: ResultType::default(), contents, meta: None, } @@ -1433,6 +1522,13 @@ pub struct GetPromptRequestParams { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub arguments: Option, + /// Client responses to server-initiated 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, } impl GetPromptRequestParams { @@ -1442,6 +1538,8 @@ impl GetPromptRequestParams { meta: None, name: name.into(), arguments: None, + input_responses: None, + request_state: None, } } @@ -1456,6 +1554,18 @@ impl GetPromptRequestParams { self.meta = Some(meta); self } + + /// Sets the input responses for an MRTR retry. + pub fn with_input_responses(mut self, input_responses: InputResponses) -> Self { + self.input_responses = Some(input_responses); + self + } + + /// Sets the request state for an MRTR retry. + pub fn with_request_state(mut self, request_state: impl Into) -> Self { + self.request_state = Some(request_state.into()); + self + } } impl RequestParamsMeta for GetPromptRequestParams { @@ -2438,6 +2548,9 @@ impl CompletionInfo { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct CompleteResult { + /// Result type discriminator. Absent values deserialize as `"complete"`. + #[serde(default)] + pub result_type: ResultType, pub completion: CompletionInfo, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, @@ -2447,6 +2560,7 @@ impl CompleteResult { /// Create a new CompleteResult with the given completion info. pub fn new(completion: CompletionInfo) -> Self { Self { + result_type: ResultType::default(), completion, meta: None, } @@ -2653,7 +2767,6 @@ pub type RootsListChangedNotification = NotificationNoParam, -} - -impl ElicitationResponseNotificationParam { - /// Create a new ElicitationResponseNotificationParam. - pub fn new(elicitation_id: impl Into) -> Self { - Self { - elicitation_id: elicitation_id.into(), - meta: None, - } - } -} - -/// Notification sent when an url elicitation process is completed. -pub type ElicitationCompleteNotification = - Notification; - -#[deprecated(since = "2.0.0", note = "Renamed to ElicitationCompleteNotification")] -pub type ElicitationCompletionNotification = ElicitationCompleteNotification; - // ============================================================================= // TOOL EXECUTION RESULTS // ============================================================================= @@ -2929,6 +3014,9 @@ pub type ElicitationCompletionNotification = ElicitationCompleteNotification; #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct CallToolResult { + /// Result type discriminator. Absent values deserialize as `"complete"`. + #[serde(default)] + pub result_type: ResultType, /// The content returned by the tool (text, images, etc.) #[serde(default)] pub content: Vec, @@ -2956,6 +3044,8 @@ impl<'de> Deserialize<'de> for CallToolResult { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct Helper { + #[serde(default)] + result_type: ResultType, content: Option>, structured_content: Option, is_error: Option, @@ -2977,6 +3067,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 +3080,7 @@ impl CallToolResult { /// Create a successful tool result with unstructured content pub fn success(content: Vec) -> Self { CallToolResult { + result_type: ResultType::default(), content, structured_content: None, is_error: Some(false), @@ -3046,6 +3138,7 @@ impl CallToolResult { /// ``` pub fn error(content: Vec) -> Self { CallToolResult { + result_type: ResultType::default(), content, structured_content: None, is_error: Some(true), @@ -3068,6 +3161,7 @@ impl CallToolResult { /// ``` pub fn structured(value: Value) -> Self { CallToolResult { + result_type: ResultType::default(), content: vec![ContentBlock::text(value.to_string())], structured_content: Some(value), is_error: Some(false), @@ -3094,6 +3188,7 @@ impl CallToolResult { /// ``` pub fn structured_error(value: Value) -> Self { CallToolResult { + result_type: ResultType::default(), content: vec![ContentBlock::text(value.to_string())], structured_content: Some(value), is_error: Some(true), @@ -3170,6 +3265,14 @@ pub struct CallToolRequestParams { /// Task metadata for async task management (SEP-1319) #[serde(skip_serializing_if = "Option::is_none")] pub task: Option, + /// Client responses to server-initiated input requests from a previous + /// [`InputRequiredResult`]. Present only when retrying after an incomplete result. + #[serde(skip_serializing_if = "Option::is_none")] + pub input_responses: Option, + /// Opaque request state echoed back from a previous [`InputRequiredResult`]. + /// Clients MUST return this value exactly as received. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_state: Option, } impl CallToolRequestParams { @@ -3180,6 +3283,8 @@ impl CallToolRequestParams { name: name.into(), arguments: None, task: None, + input_responses: None, + request_state: None, } } @@ -3194,6 +3299,18 @@ impl CallToolRequestParams { self.task = Some(task); self } + + /// Sets the input responses for an MRTR retry. + pub fn with_input_responses(mut self, input_responses: InputResponses) -> Self { + self.input_responses = Some(input_responses); + self + } + + /// Sets the request state for an MRTR retry. + pub fn with_request_state(mut self, request_state: impl Into) -> Self { + self.request_state = Some(request_state.into()); + self + } } impl RequestParamsMeta for CallToolRequestParams { @@ -3286,6 +3403,9 @@ impl CreateMessageResult { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct GetPromptResult { + /// Result type discriminator. Absent values deserialize as `"complete"`. + #[serde(default)] + pub result_type: ResultType, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub messages: Vec, @@ -3297,6 +3417,7 @@ impl GetPromptResult { /// Create a new GetPromptResult with required fields. pub fn new(messages: Vec) -> Self { Self { + result_type: ResultType::default(), description: None, messages, meta: None, @@ -3662,7 +3783,6 @@ ts_union!( | ResourceListChangedNotification | ToolListChangedNotification | PromptListChangedNotification - | ElicitationCompleteNotification | TaskStatusNotification | CustomNotification; ); @@ -3683,6 +3803,7 @@ ts_union!( | GetTaskResult | CancelTaskResult | CallToolResult + | InputRequiredResult | GetTaskPayloadResult | EmptyResult | CustomResult diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index 712a439d9..bc1b94b48 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -218,7 +218,6 @@ variant_extension! { ResourceListChangedNotification ToolListChangedNotification PromptListChangedNotification - ElicitationCompleteNotification TaskStatusNotification CustomNotification } diff --git a/crates/rmcp/src/model/mrtr.rs b/crates/rmcp/src/model/mrtr.rs new file mode 100644 index 000000000..e4a5b3fb8 --- /dev/null +++ b/crates/rmcp/src/model/mrtr.rs @@ -0,0 +1,388 @@ +//! Multi Round-Trip Request (MRTR) types for SEP-2322. +//! +//! Provides [`InputRequiredResult`], [`InputRequests`], and [`InputResponses`] +//! for the stateless multi round-trip request pattern defined in the MCP spec. +//! [`ResultType`] lives in the parent [`super`] module alongside other base result types. +//! +//! # Overview +//! +//! A server may respond to `tools/call`, `prompts/get`, or `resources/read` with an +//! [`InputRequiredResult`] instead of the normal result. The client fulfills the +//! [`InputRequests`], then retries the original request with [`InputResponses`] and +//! the echoed `requestState`. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::{CreateMessageRequest, ElicitRequest, ListRootsRequest, Meta, ResultType}; + +/// A server-initiated request that can appear inside [`InputRequests`]. +/// +/// Per the MCP spec, only `CreateMessageRequest` (sampling), +/// `ElicitRequest` (elicitation), and `ListRootsRequest` (roots) +/// are allowed. This is modeled as an untagged enum rather than a +/// `ServerRequest` alias to prevent `PingRequest` or `CustomRequest` from +/// being included. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub enum InputRequest { + /// A `sampling/createMessage` request. + CreateMessage(CreateMessageRequest), + /// An `elicitation/create` request. + Elicitation(ElicitRequest), + /// A `roots/list` request. + ListRoots(ListRootsRequest), +} + +/// A map of server-initiated requests that the client must fulfill. +/// +/// Keys are server-assigned string identifiers; values are request objects +/// (`ElicitRequest`, `CreateMessageRequest`, or `ListRootsRequest`). +pub type InputRequests = BTreeMap; + +/// A map of client responses to server-initiated requests. +/// +/// Keys correspond to the keys in the [`InputRequests`] map; values are the +/// client's result for each request (`ElicitResult`, `CreateMessageResult`, +/// or `ListRootsResult`), represented as opaque JSON because the +/// heterogeneous `ClientResult` union does not derive the traits required +/// for use as a `BTreeMap` value. +pub type InputResponses = BTreeMap; + +/// A result indicating that additional input is needed before the request +/// can be completed. +/// +/// At least one of [`input_requests`](Self::input_requests) or +/// [`request_state`](Self::request_state) MUST be present. +/// +/// Servers MAY send this in response to `tools/call`, `prompts/get`, or +/// `resources/read`. Servers MUST NOT send this for any other request. +/// +/// # Examples +/// +/// ``` +/// use rmcp::model::InputRequiredResult; +/// +/// let result = InputRequiredResult::from_request_state("opaque-server-state"); +/// assert!(result.input_requests.is_none()); +/// assert_eq!(result.request_state.as_deref(), Some("opaque-server-state")); +/// ``` +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct InputRequiredResult { + /// Always `"input_required"` for this result type. + pub result_type: ResultType, + + /// Server-initiated requests that the client must fulfill before retrying. + #[serde(skip_serializing_if = "Option::is_none")] + pub input_requests: Option, + + /// Opaque request state to be echoed back by the client on retry. + /// Clients MUST NOT inspect, parse, modify, or make any assumptions + /// about the contents. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_state: Option, + + /// Optional protocol-level metadata. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// Custom deserializer that requires `resultType: "input_required"` to prevent +/// greedy matching in the untagged `ServerResult` enum (which would otherwise +/// swallow empty objects or unknown shapes). +impl<'de> Deserialize<'de> for InputRequiredResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Helper { + result_type: Option, + input_requests: Option, + request_state: Option, + #[serde(rename = "_meta")] + meta: Option, + } + + let helper = Helper::deserialize(deserializer)?; + + match &helper.result_type { + Some(rt) if rt.is_input_required() => {} + _ => { + return Err(serde::de::Error::custom( + "InputRequiredResult requires resultType to be \"input_required\"", + )); + } + } + + Ok(InputRequiredResult { + result_type: ResultType::INPUT_REQUIRED, + input_requests: helper.input_requests, + request_state: helper.request_state, + meta: helper.meta, + }) + } +} + +impl InputRequiredResult { + /// Creates a new `InputRequiredResult` with both input requests and request state. + pub fn new(input_requests: Option, request_state: Option) -> Self { + Self { + result_type: ResultType::INPUT_REQUIRED, + input_requests, + request_state, + meta: None, + } + } + + /// Creates from input requests only. + pub fn from_input_requests(input_requests: InputRequests) -> Self { + Self::new(Some(input_requests), None) + } + + /// Creates from request state only (e.g. for load shedding). + pub fn from_request_state(request_state: impl Into) -> Self { + Self::new(None, Some(request_state.into())) + } + + /// Sets optional metadata. + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod result_type { + use super::*; + + #[test] + fn default_is_complete() { + assert_eq!(ResultType::default(), ResultType::COMPLETE); + } + + #[test] + fn serializes_complete() { + assert_eq!( + serde_json::to_value(&ResultType::COMPLETE).unwrap(), + serde_json::json!("complete") + ); + } + + #[test] + fn serializes_input_required() { + assert_eq!( + serde_json::to_value(&ResultType::INPUT_REQUIRED).unwrap(), + serde_json::json!("input_required") + ); + } + + #[test] + fn deserializes_known_values() { + let complete: ResultType = + serde_json::from_value(serde_json::json!("complete")).unwrap(); + assert_eq!(complete, ResultType::COMPLETE); + + let input_required: ResultType = + serde_json::from_value(serde_json::json!("input_required")).unwrap(); + assert_eq!(input_required, ResultType::INPUT_REQUIRED); + } + + #[test] + fn preserves_unknown_extension_values() { + let custom: ResultType = + serde_json::from_value(serde_json::json!("streaming")).unwrap(); + assert_eq!(custom.as_str(), "streaming"); + assert!(!custom.is_complete()); + assert!(!custom.is_input_required()); + + let reserialized = serde_json::to_value(&custom).unwrap(); + assert_eq!(reserialized, serde_json::json!("streaming")); + } + } + + mod input_required_result { + use super::*; + + #[test] + fn deserializes_with_requests_and_state() { + let json = serde_json::json!({ + "resultType": "input_required", + "inputRequests": { + "github_login": { + "method": "elicitation/create", + "params": { + "message": "Please provide your GitHub username", + "requestedSchema": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + } + } + }, + "capital_of_france": { + "method": "sampling/createMessage", + "params": { + "messages": [{ + "role": "user", + "content": { "type": "text", "text": "What is the capital of France?" } + }], + "maxTokens": 100 + } + } + }, + "requestState": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0" + }); + + let result: InputRequiredResult = serde_json::from_value(json).unwrap(); + + let requests = result + .input_requests + .as_ref() + .expect("should have input_requests"); + assert_eq!(requests.len(), 2); + assert!(requests.contains_key("github_login")); + assert!(requests.contains_key("capital_of_france")); + assert_eq!( + result.request_state.as_deref(), + Some("eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0") + ); + } + + #[test] + fn roundtrip_preserves_all_fields() { + let json = serde_json::json!({ + "resultType": "input_required", + "inputRequests": { + "key": { + "method": "elicitation/create", + "params": { + "message": "test", + "requestedSchema": { "type": "object", "properties": {} } + } + } + }, + "requestState": "abc123" + }); + + let result: InputRequiredResult = serde_json::from_value(json).unwrap(); + let reserialized = serde_json::to_value(&result).unwrap(); + + assert_eq!(reserialized["resultType"], "input_required"); + assert!(reserialized["inputRequests"].is_object()); + assert_eq!(reserialized["requestState"], "abc123"); + } + + #[test] + fn deserializes_with_request_state_only() { + let json = serde_json::json!({ + "resultType": "input_required", + "requestState": "eyJwcm9ncmVzcyI6IjUwJSJ9" + }); + + let result: InputRequiredResult = serde_json::from_value(json).unwrap(); + + assert!(result.input_requests.is_none()); + assert_eq!( + result.request_state.as_deref(), + Some("eyJwcm9ncmVzcyI6IjUwJSJ9") + ); + } + + #[test] + fn rejects_missing_result_type() { + let json = serde_json::json!({ + "requestState": "some-state" + }); + let err = serde_json::from_value::(json).unwrap_err(); + assert!( + err.to_string().contains("input_required"), + "error should mention the required resultType, got: {err}" + ); + } + + #[test] + fn rejects_wrong_result_type() { + let json = serde_json::json!({ + "resultType": "complete", + "requestState": "some-state" + }); + let err = serde_json::from_value::(json).unwrap_err(); + assert!( + err.to_string().contains("input_required"), + "error should mention the required resultType, got: {err}" + ); + } + } + + mod input_responses { + use super::*; + + #[test] + fn deserializes_heterogeneous_results() { + let json = serde_json::json!({ + "github_login": { + "action": "accept", + "content": { "name": "octocat" } + }, + "capital_of_france": { + "role": "assistant", + "content": { "type": "text", "text": "Paris." }, + "model": "claude-3-sonnet-20240307", + "stopReason": "endTurn" + } + }); + + let responses: InputResponses = serde_json::from_value(json).unwrap(); + + assert_eq!(responses.len(), 2); + assert!(responses.contains_key("github_login")); + assert!(responses.contains_key("capital_of_france")); + } + } + + mod constructors { + use super::*; + + #[test] + fn from_request_state_sets_state_only() { + let result = InputRequiredResult::from_request_state("opaque"); + + assert_eq!(result.result_type, ResultType::INPUT_REQUIRED); + assert!(result.input_requests.is_none()); + assert_eq!(result.request_state.as_deref(), Some("opaque")); + } + + #[test] + fn from_input_requests_sets_requests_only() { + let mut requests = InputRequests::new(); + requests.insert( + "key".to_string(), + serde_json::from_value(serde_json::json!({ + "method": "elicitation/create", + "params": { + "message": "test", + "requestedSchema": { "type": "object", "properties": {} } + } + })) + .unwrap(), + ); + + let result = InputRequiredResult::from_input_requests(requests); + + assert!(result.input_requests.is_some()); + assert!(result.request_state.is_none()); + } + } +} diff --git a/crates/rmcp/src/model/serde_impl.rs b/crates/rmcp/src/model/serde_impl.rs index f8996f318..7ff91099f 100644 --- a/crates/rmcp/src/model/serde_impl.rs +++ b/crates/rmcp/src/model/serde_impl.rs @@ -451,6 +451,8 @@ mod test { name: "my_tool".into(), arguments: None, task: None, + input_responses: None, + request_state: None, }, }; @@ -489,6 +491,8 @@ mod test { name: "my_tool".into(), arguments: None, task: None, + input_responses: None, + request_state: None, }, }; @@ -510,6 +514,8 @@ mod test { name: "my_tool".into(), arguments: None, task: None, + input_responses: None, + request_state: None, }, }; @@ -528,6 +534,8 @@ mod test { name: "my_tool".into(), arguments: None, task: None, + input_responses: None, + request_state: None, }, }; @@ -564,6 +572,8 @@ mod test { name: "my_tool".into(), arguments: None, task: None, + input_responses: None, + request_state: None, }, }; @@ -590,6 +600,8 @@ mod test { name: "my_tool".into(), arguments: Some(serde_json::Map::from_iter([("x".to_string(), json!(1))])), task: None, + input_responses: None, + request_state: None, }, }; diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 4f479b9e8..8c7a87dda 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -10,10 +10,7 @@ use url::Url; use super::*; #[cfg(feature = "elicitation")] -use crate::model::{ - ElicitRequest, ElicitRequestParams, ElicitResult, ElicitationAction, - ElicitationCompleteNotification, ElicitationResponseNotificationParam, -}; +use crate::model::{ElicitRequest, ElicitRequestParams, ElicitResult, ElicitationAction}; use crate::{ model::{ CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, @@ -474,8 +471,6 @@ impl Peer { method!(peer_req create_elicitation ElicitRequest(ElicitRequestParams) => ElicitResult); #[cfg(feature = "elicitation")] method!(peer_req_with_timeout create_elicitation_with_timeout ElicitRequest(ElicitRequestParams) => ElicitResult); - #[cfg(feature = "elicitation")] - method!(peer_not notify_url_elicitation_completed ElicitationCompleteNotification(ElicitationResponseNotificationParam)); method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam)); method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam)); diff --git a/crates/rmcp/tests/test_elicitation.rs b/crates/rmcp/tests/test_elicitation.rs index b4a163380..0c294aa49 100644 --- a/crates/rmcp/tests/test_elicitation.rs +++ b/crates/rmcp/tests/test_elicitation.rs @@ -1882,31 +1882,6 @@ async fn test_url_elicitation_json_rpc_protocol() { } } -/// Test ElicitationCompleteNotification serialization/deserialization -#[tokio::test] -async fn test_elicitation_completion_notification() { - let notification_params = ElicitationResponseNotificationParam::new("elicit-789"); - - // Test serialization - let json = serde_json::to_value(¬ification_params).unwrap(); - let expected = json!({ - "elicitationId": "elicit-789" - }); - assert_eq!(json, expected); - - // Test deserialization - let deserialized: ElicitationResponseNotificationParam = - serde_json::from_value(expected).unwrap(); - assert_eq!(deserialized.elicitation_id, "elicit-789"); - - // Test complete notification structure - let notification = ElicitationCompleteNotification::new(notification_params); - - let json = serde_json::to_value(¬ification).unwrap(); - assert_eq!(json["method"], "notifications/elicitation/complete"); - assert_eq!(json["params"]["elicitationId"], "elicit-789"); -} - /// Test UrlElicitationCapability structure and serialization #[tokio::test] async fn test_url_elicitation_capability() { @@ -2020,39 +1995,6 @@ async fn test_elicitation_both_modes() { assert!(url_json.get("requestedSchema").is_none()); } -/// Test URL_ELICITATION_REQUIRED error code -#[tokio::test] -async fn test_url_elicitation_required_error_code() { - // Test the error code constant - assert_eq!(ErrorCode::URL_ELICITATION_REQUIRED.0, -32042); - - // Test creating error data with URL_ELICITATION_REQUIRED - let error_data = ErrorData::url_elicitation_required( - "URL elicitation is required for this operation", - Some(json!({ - "url": "https://example.com/complete", - "elicitationId": "elicit-999" - })), - ); - - assert_eq!(error_data.code, ErrorCode::URL_ELICITATION_REQUIRED); - assert_eq!( - error_data.message, - "URL elicitation is required for this operation" - ); - assert!(error_data.data.is_some()); - - // Test serialization - let json = serde_json::to_value(&error_data).unwrap(); - assert_eq!(json["code"], -32042); - assert_eq!( - json["message"], - "URL elicitation is required for this operation" - ); - assert_eq!(json["data"]["url"], "https://example.com/complete"); - assert_eq!(json["data"]["elicitationId"], "elicit-999"); -} - /// Test ClientCapabilities with different elicitation mode combinations #[tokio::test] async fn test_client_capabilities_elicitation_modes() { @@ -2102,32 +2044,6 @@ async fn test_client_capabilities_elicitation_modes() { assert!(json["elicitation"]["url"].is_object()); } -/// Test ElicitationCompleteNotification in ServerNotification enum -#[tokio::test] -async fn test_elicitation_completion_in_server_notification() { - let notification_param = ElicitationResponseNotificationParam::new("notify-123"); - - let completion_notification = ElicitationCompleteNotification::new(notification_param.clone()); - - // Test that it's part of ServerNotification - let server_notification = - ServerNotification::ElicitationCompleteNotification(completion_notification); - - // Test serialization - let json = serde_json::to_value(&server_notification).unwrap(); - assert_eq!(json["method"], "notifications/elicitation/complete"); - assert_eq!(json["params"]["elicitationId"], "notify-123"); - - // Test deserialization - let deserialized: ServerNotification = serde_json::from_value(json).unwrap(); - match deserialized { - ServerNotification::ElicitationCompleteNotification(notif) => { - assert_eq!(notif.params.elicitation_id, "notify-123"); - } - _ => panic!("Expected ElicitationCompleteNotification variant"), - } -} - /// Test ElicitationAction with URL elicitation workflow #[tokio::test] async fn test_url_elicitation_action_workflow() { @@ -2161,10 +2077,4 @@ async fn test_elicitation_method_constants() { ElicitationResponseNotificationMethod::VALUE, "notifications/elicitation/response" ); - - // Test new completion notification method - assert_eq!( - ElicitationCompletionNotificationMethod::VALUE, - "notifications/elicitation/complete" - ); } diff --git a/crates/rmcp/tests/test_list_tools_result/list_tools_result.json b/crates/rmcp/tests/test_list_tools_result/list_tools_result.json index 15325e8fa..c6616b596 100644 --- a/crates/rmcp/tests/test_list_tools_result/list_tools_result.json +++ b/crates/rmcp/tests/test_list_tools_result/list_tools_result.json @@ -1,5 +1,6 @@ { "result": { + "resultType": "complete", "tools": [ { "name": "add", 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..ef19901f0 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,25 @@ ], "additionalProperties": true }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`]. Present only when retrying after an incomplete result.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "name": { "description": "The name of the tool to call", "type": "string" }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].\nClients MUST return this value exactly as received.", + "type": [ + "string", + "null" + ] + }, "task": { "description": "Task metadata for async task management (SEP-1319)", "anyOf": [ @@ -726,8 +741,23 @@ ], "additionalProperties": true }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "name": { "type": "string" + }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].", + "type": [ + "string", + "null" + ] } }, "required": [ @@ -1374,6 +1404,21 @@ ], "additionalProperties": true }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].", + "type": [ + "string", + "null" + ] + }, "uri": { "description": "The URI of the resource to read", "type": "string" 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..ef19901f0 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,25 @@ ], "additionalProperties": true }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`]. Present only when retrying after an incomplete result.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "name": { "description": "The name of the tool to call", "type": "string" }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].\nClients MUST return this value exactly as received.", + "type": [ + "string", + "null" + ] + }, "task": { "description": "Task metadata for async task management (SEP-1319)", "anyOf": [ @@ -726,8 +741,23 @@ ], "additionalProperties": true }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "name": { "type": "string" + }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].", + "type": [ + "string", + "null" + ] } }, "required": [ @@ -1374,6 +1404,21 @@ ], "additionalProperties": true }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].", + "type": [ + "string", + "null" + ] + }, "uri": { "description": "The URI of the resource to read", "type": "string" 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..e5cfc7ce5 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,15 @@ "null" ] }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, "structuredContent": { "description": "An optional JSON object that represents the structured result of the tool call" } @@ -292,6 +301,15 @@ }, "completion": { "$ref": "#/definitions/CompletionInfo" + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -752,35 +770,11 @@ } ] }, - "ElicitationCompletionNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/elicitation/complete" - }, "ElicitationCreateRequestMethod": { "type": "string", "format": "const", "const": "elicitation/create" }, - "ElicitationResponseNotificationParam": { - "description": "Notification parameters for an url elicitation completion notification.", - "type": "object", - "properties": { - "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "elicitationId": { - "type": "string" - } - }, - "required": [ - "elicitationId" - ] - }, "ElicitationSchema": { "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis enforces the MCP 2025-06-18 specification that elicitation schemas\nmust be objects with primitive-typed properties.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet schema = ElicitationSchema::builder()\n .required_email(\"email\")\n .required_integer(\"age\", 0, 150)\n .optional_bool(\"newsletter\", false)\n .build();\n```", "type": "object", @@ -948,6 +942,15 @@ "items": { "$ref": "#/definitions/PromptMessage" } + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -1205,6 +1208,77 @@ "serverInfo" ] }, + "InputRequest": { + "description": "A server-initiated request that can appear inside [`InputRequests`].\n\nPer the MCP spec, only `CreateMessageRequest` (sampling),\n`ElicitRequest` (elicitation), and `ListRootsRequest` (roots)\nare allowed. This is modeled as an untagged enum rather than a\n`ServerRequest` alias to prevent `PingRequest` or `CustomRequest` from\nbeing included.", + "anyOf": [ + { + "description": "A `sampling/createMessage` request.", + "allOf": [ + { + "$ref": "#/definitions/Request" + } + ] + }, + { + "description": "An `elicitation/create` request.", + "allOf": [ + { + "$ref": "#/definitions/Request2" + } + ] + }, + { + "description": "A `roots/list` request.", + "allOf": [ + { + "$ref": "#/definitions/RequestNoParam2" + } + ] + } + ] + }, + "InputRequiredResult": { + "description": "A result indicating that additional input is needed before the request\ncan be completed.\n\nAt least one of [`input_requests`](Self::input_requests) or\n[`request_state`](Self::request_state) MUST be present.\n\nServers MAY send this in response to `tools/call`, `prompts/get`, or\n`resources/read`. Servers MUST NOT send this for any other request.\n\n# Examples\n\n```\nuse rmcp::model::InputRequiredResult;\n\nlet result = InputRequiredResult::from_request_state(\"opaque-server-state\");\nassert!(result.input_requests.is_none());\nassert_eq!(result.request_state.as_deref(), Some(\"opaque-server-state\"));\n```", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "description": "Server-initiated requests that the client must fulfill before retrying.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, + "requestState": { + "description": "Opaque request state to be echoed back by the client on retry.\nClients MUST NOT inspect, parse, modify, or make any assumptions\nabout the contents.", + "type": [ + "string", + "null" + ] + }, + "resultType": { + "description": "Always `\"input_required\"` for this result type.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + } + }, + "required": [ + "resultType" + ] + }, "IntegerSchema": { "description": "Schema definition for integer properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", "type": "object", @@ -1322,9 +1396,6 @@ { "$ref": "#/definitions/Notification5" }, - { - "$ref": "#/definitions/Notification6" - }, { "$ref": "#/definitions/CustomNotification" } @@ -1456,6 +1527,15 @@ "items": { "$ref": "#/definitions/Prompt" } + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -1483,6 +1563,15 @@ "items": { "$ref": "#/definitions/ResourceTemplate" } + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -1510,6 +1599,15 @@ "items": { "$ref": "#/definitions/Resource" } + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -1564,6 +1662,15 @@ "null" ] }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, "tools": { "type": "array", "items": { @@ -1758,21 +1865,6 @@ ] }, "Notification5": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ElicitationCompletionNotificationMethod" - }, - "params": { - "$ref": "#/definitions/ElicitationResponseNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification6": { "type": "object", "properties": { "method": { @@ -2129,6 +2221,15 @@ "items": { "$ref": "#/definitions/ResourceContents" } + }, + "result_type": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -2442,6 +2543,10 @@ } } }, + "ResultType": { + "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.", + "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": [ @@ -2738,6 +2843,9 @@ { "$ref": "#/definitions/CallToolResult" }, + { + "$ref": "#/definitions/InputRequiredResult" + }, { "$ref": "#/definitions/GetTaskPayloadResult" }, 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..e5cfc7ce5 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,15 @@ "null" ] }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, "structuredContent": { "description": "An optional JSON object that represents the structured result of the tool call" } @@ -292,6 +301,15 @@ }, "completion": { "$ref": "#/definitions/CompletionInfo" + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -752,35 +770,11 @@ } ] }, - "ElicitationCompletionNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/elicitation/complete" - }, "ElicitationCreateRequestMethod": { "type": "string", "format": "const", "const": "elicitation/create" }, - "ElicitationResponseNotificationParam": { - "description": "Notification parameters for an url elicitation completion notification.", - "type": "object", - "properties": { - "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "elicitationId": { - "type": "string" - } - }, - "required": [ - "elicitationId" - ] - }, "ElicitationSchema": { "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis enforces the MCP 2025-06-18 specification that elicitation schemas\nmust be objects with primitive-typed properties.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet schema = ElicitationSchema::builder()\n .required_email(\"email\")\n .required_integer(\"age\", 0, 150)\n .optional_bool(\"newsletter\", false)\n .build();\n```", "type": "object", @@ -948,6 +942,15 @@ "items": { "$ref": "#/definitions/PromptMessage" } + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -1205,6 +1208,77 @@ "serverInfo" ] }, + "InputRequest": { + "description": "A server-initiated request that can appear inside [`InputRequests`].\n\nPer the MCP spec, only `CreateMessageRequest` (sampling),\n`ElicitRequest` (elicitation), and `ListRootsRequest` (roots)\nare allowed. This is modeled as an untagged enum rather than a\n`ServerRequest` alias to prevent `PingRequest` or `CustomRequest` from\nbeing included.", + "anyOf": [ + { + "description": "A `sampling/createMessage` request.", + "allOf": [ + { + "$ref": "#/definitions/Request" + } + ] + }, + { + "description": "An `elicitation/create` request.", + "allOf": [ + { + "$ref": "#/definitions/Request2" + } + ] + }, + { + "description": "A `roots/list` request.", + "allOf": [ + { + "$ref": "#/definitions/RequestNoParam2" + } + ] + } + ] + }, + "InputRequiredResult": { + "description": "A result indicating that additional input is needed before the request\ncan be completed.\n\nAt least one of [`input_requests`](Self::input_requests) or\n[`request_state`](Self::request_state) MUST be present.\n\nServers MAY send this in response to `tools/call`, `prompts/get`, or\n`resources/read`. Servers MUST NOT send this for any other request.\n\n# Examples\n\n```\nuse rmcp::model::InputRequiredResult;\n\nlet result = InputRequiredResult::from_request_state(\"opaque-server-state\");\nassert!(result.input_requests.is_none());\nassert_eq!(result.request_state.as_deref(), Some(\"opaque-server-state\"));\n```", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "description": "Server-initiated requests that the client must fulfill before retrying.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, + "requestState": { + "description": "Opaque request state to be echoed back by the client on retry.\nClients MUST NOT inspect, parse, modify, or make any assumptions\nabout the contents.", + "type": [ + "string", + "null" + ] + }, + "resultType": { + "description": "Always `\"input_required\"` for this result type.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + } + }, + "required": [ + "resultType" + ] + }, "IntegerSchema": { "description": "Schema definition for integer properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", "type": "object", @@ -1322,9 +1396,6 @@ { "$ref": "#/definitions/Notification5" }, - { - "$ref": "#/definitions/Notification6" - }, { "$ref": "#/definitions/CustomNotification" } @@ -1456,6 +1527,15 @@ "items": { "$ref": "#/definitions/Prompt" } + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -1483,6 +1563,15 @@ "items": { "$ref": "#/definitions/ResourceTemplate" } + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -1510,6 +1599,15 @@ "items": { "$ref": "#/definitions/Resource" } + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -1564,6 +1662,15 @@ "null" ] }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, "tools": { "type": "array", "items": { @@ -1758,21 +1865,6 @@ ] }, "Notification5": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ElicitationCompletionNotificationMethod" - }, - "params": { - "$ref": "#/definitions/ElicitationResponseNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification6": { "type": "object", "properties": { "method": { @@ -2129,6 +2221,15 @@ "items": { "$ref": "#/definitions/ResourceContents" } + }, + "result_type": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -2442,6 +2543,10 @@ } } }, + "ResultType": { + "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.", + "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": [ @@ -2738,6 +2843,9 @@ { "$ref": "#/definitions/CallToolResult" }, + { + "$ref": "#/definitions/InputRequiredResult" + }, { "$ref": "#/definitions/GetTaskPayloadResult" }, diff --git a/crates/rmcp/tests/test_tool_result_meta.rs b/crates/rmcp/tests/test_tool_result_meta.rs index a1d3d3af3..d164e843e 100644 --- a/crates/rmcp/tests/test_tool_result_meta.rs +++ b/crates/rmcp/tests/test_tool_result_meta.rs @@ -9,6 +9,7 @@ fn serialize_tool_result_with_meta() { let result = CallToolResult::success(content).with_meta(Some(meta)); let v = serde_json::to_value(&result).unwrap(); let expected = json!({ + "resultType": "complete", "content": [{"type":"text","text":"ok"}], "isError": false, "_meta": {"foo":"bar"} diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index f618fee0d..09e52f3e6 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -252,8 +252,7 @@ impl ServerHandler for Counter { self._create_resource_text("str:////Users/to/some/path/", "cwd"), self._create_resource_text("memo://insights", "memo-name"), ], - next_cursor: None, - meta: None, + ..Default::default() }) } @@ -293,9 +292,8 @@ impl ServerHandler for Counter { _: RequestContext, ) -> Result { Ok(ListResourceTemplatesResult { - next_cursor: None, resource_templates: Vec::new(), - meta: None, + ..Default::default() }) } diff --git a/examples/servers/src/elicitation_stdio.rs b/examples/servers/src/elicitation_stdio.rs index e465e4b4d..d506a9c7f 100644 --- a/examples/servers/src/elicitation_stdio.rs +++ b/examples/servers/src/elicitation_stdio.rs @@ -129,18 +129,9 @@ impl ElicitationServer { ) })?; match elicit_result { - ElicitationAction::Accept => { - // Mock notifying completion - let _ = context - .peer - .notify_url_elicitation_completed(ElicitationResponseNotificationParam::new( - "elicit_123", - )) - .await; - Ok(CallToolResult::success(vec![ContentBlock::text( - "Elicitation via URL successful".to_string(), - )])) - } + ElicitationAction::Accept => Ok(CallToolResult::success(vec![ContentBlock::text( + "Elicitation via URL successful".to_string(), + )])), ElicitationAction::Cancel => Ok(CallToolResult::success(vec![ContentBlock::text( "Elicitation via URL cancelled by user".to_string(), )])), diff --git a/examples/servers/src/sampling_stdio.rs b/examples/servers/src/sampling_stdio.rs index f75c445f6..2690a28f2 100644 --- a/examples/servers/src/sampling_stdio.rs +++ b/examples/servers/src/sampling_stdio.rs @@ -113,8 +113,7 @@ impl ServerHandler for SamplingDemoServer { .unwrap(), ), )], - meta: None, - next_cursor: None, + ..Default::default() }) } }