diff --git a/crates/rmcp-macros/src/task_handler.rs b/crates/rmcp-macros/src/task_handler.rs index ba8df4b9..a2077e63 100644 --- a/crates/rmcp-macros/src/task_handler.rs +++ b/crates/rmcp-macros/src/task_handler.rs @@ -223,38 +223,24 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result(get_result_fn)?); } - if !has_method("cancel_task", &item_impl) { - let cancel_fn = quote! { - async fn cancel_task( + if !has_method("delete_task", &item_impl) { + let delete_fn = quote! { + async fn delete_task( &self, - request: rmcp::model::CancelTaskParams, + request: rmcp::model::DeleteTaskParams, _context: rmcp::service::RequestContext, - ) -> Result { - use rmcp::task_manager::current_timestamp; + ) -> Result { let task_id = request.task_id; let mut processor = (#processor).lock().await; - if processor.cancel_task(&task_id) { - let timestamp = current_timestamp(); - let task = rmcp::model::Task::new( - task_id, - rmcp::model::TaskStatus::Cancelled, - timestamp.clone(), - timestamp, - ); - return Ok(rmcp::model::CancelTaskResult { meta: None, task }); - } - - // If already completed, signal it's not cancellable - let exists_completed = processor.peek_completed().iter().any(|r| r.descriptor.operation_id == task_id); - if exists_completed { - return Err(McpError::invalid_request(format!("task already completed: {}", task_id), None)); + if processor.delete_task(&task_id) { + return Ok(rmcp::model::DeleteTaskResult { meta: None }); } Err(McpError::resource_not_found(format!("task not found: {}", task_id), None)) } }; - item_impl.items.push(syn::parse2::(cancel_fn)?); + item_impl.items.push(syn::parse2::(delete_fn)?); } // Auto-generate get_info() if not already provided and no sibling tool/prompt handler diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 5006681d..a4a36806 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -283,6 +283,11 @@ name = "test_custom_request" required-features = ["server", "client"] path = "tests/test_custom_request.rs" +[[test]] +name = "test_task_client_receiver" +required-features = ["server", "client"] +path = "tests/test_task_client_receiver.rs" + [[test]] name = "test_prompt_macros" required-features = ["server", "client"] diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index 926aafcb..b6296ee4 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -30,6 +30,22 @@ impl Service for H { .create_elicitation(request.params, context) .await .map(ClientResult::CreateElicitationResult), + ServerRequest::ListTasksRequest(request) => self + .list_tasks(request.params, context) + .await + .map(ClientResult::ListTasksResult), + ServerRequest::GetTaskInfoRequest(request) => self + .get_task_info(request.params, context) + .await + .map(ClientResult::GetTaskResult), + ServerRequest::GetTaskResultRequest(request) => self + .get_task_result(request.params, context) + .await + .map(ClientResult::GetTaskPayloadResult), + ServerRequest::DeleteTaskRequest(request) => self + .delete_task(request.params, context) + .await + .map(ClientResult::DeleteTaskResult), ServerRequest::CustomRequest(request) => self .on_custom_request(request, context) .await @@ -191,6 +207,68 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { ))) } + /// Handle a `tasks/list` request from a server. Only relevant when the + /// client is also a task *receiver* (e.g. it accepted a task-augmented + /// `sampling/createMessage` or `elicitation/create` request). + /// + /// # Default Behavior + /// Returns `-32601` (Method not found). Clients that advertise + /// `capabilities.tasks.list` must override this. + fn list_tasks( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::())) + } + + /// Handle a `tasks/get` request from a server. Only relevant when the + /// client is also a task *receiver* (e.g. it accepted a task-augmented + /// `sampling/createMessage` or `elicitation/create` request). + /// + /// # Default Behavior + /// Returns `-32601` (Method not found). Clients that advertise + /// `capabilities.tasks.requests.sampling.createMessage` or + /// `capabilities.tasks.requests.elicitation.create` must override this. + fn get_task_info( + &self, + request: GetTaskInfoParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::())) + } + + /// Handle a `tasks/result` request from a server. Only relevant when + /// the client is also a task *receiver*. + /// + /// # Default Behavior + /// Returns `-32601` (Method not found). + fn get_task_result( + &self, + request: GetTaskResultParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::())) + } + + /// Handle a `tasks/delete` request from a server (SEP-1686 §3.6). + /// Only relevant when the client is also a task *receiver*. + /// + /// # Default Behavior + /// Returns `-32601` (Method not found). Task receivers must override this + /// to delete the task and any associated stored result. + fn delete_task( + &self, + request: DeleteTaskParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::())) + } + fn on_cancelled( &self, params: CancelledNotificationParam, @@ -310,6 +388,38 @@ macro_rules! impl_client_handler_for_wrapper { (**self).on_custom_request(request, context) } + fn list_tasks( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).list_tasks(request, context) + } + + fn get_task_info( + &self, + request: GetTaskInfoParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).get_task_info(request, context) + } + + fn get_task_result( + &self, + request: GetTaskResultParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).get_task_result(request, context) + } + + fn delete_task( + &self, + request: DeleteTaskParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).delete_task(request, context) + } + fn on_cancelled( &self, params: CancelledNotificationParam, diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 8673a8bf..8e838e68 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -123,10 +123,10 @@ impl Service for H { .get_task_result(request.params, context) .await .map(ServerResult::GetTaskPayloadResult), - ClientRequest::CancelTaskRequest(request) => self - .cancel_task(request.params, context) + ClientRequest::DeleteTaskRequest(request) => self + .delete_task(request.params, context) .await - .map(ServerResult::CancelTaskResult), + .map(ServerResult::DeleteTaskResult), } } @@ -359,13 +359,13 @@ macro_rules! server_handler_methods { std::future::ready(Err(McpError::method_not_found::())) } - fn cancel_task( + fn delete_task( &self, - request: CancelTaskParams, + request: DeleteTaskParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { let _ = (request, context); - std::future::ready(Err(McpError::method_not_found::())) + std::future::ready(Err(McpError::method_not_found::())) } }; } @@ -575,12 +575,12 @@ macro_rules! impl_server_handler_for_wrapper { (**self).get_task_result(request, context) } - fn cancel_task( + fn delete_task( &self, - request: CancelTaskParams, + request: DeleteTaskParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).cancel_task(request, context) + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).delete_task(request, context) } } }; diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index b473e9ac..b62508ba 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -3155,21 +3155,21 @@ impl RequestParamsMeta for GetTaskResultParams { #[deprecated(since = "0.13.0", note = "Use GetTaskResultParams instead")] pub type GetTaskResultParam = GetTaskResultParams; -const_string!(CancelTaskMethod = "tasks/cancel"); -pub type CancelTaskRequest = Request; +const_string!(DeleteTaskMethod = "tasks/delete"); +pub type DeleteTaskRequest = Request; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct CancelTaskParams { +pub struct DeleteTaskParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, pub task_id: String, } -impl RequestParamsMeta for CancelTaskParams { +impl RequestParamsMeta for DeleteTaskParams { fn meta(&self) -> Option<&Meta> { self.meta.as_ref() } @@ -3177,10 +3177,6 @@ impl RequestParamsMeta for CancelTaskParams { &mut self.meta } } - -/// Deprecated: Use [`CancelTaskParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use CancelTaskParams instead")] -pub type CancelTaskParam = CancelTaskParams; /// Deprecated: Use [`GetTaskResult`] instead (spec alignment). #[deprecated(since = "0.15.0", note = "Use GetTaskResult instead")] pub type GetTaskInfoResult = GetTaskResult; @@ -3280,7 +3276,7 @@ ts_union!( | GetTaskInfoRequest | ListTasksRequest | GetTaskResultRequest - | CancelTaskRequest + | DeleteTaskRequest | CustomRequest; ); @@ -3303,7 +3299,7 @@ impl ClientRequest { ClientRequest::GetTaskInfoRequest(r) => r.method.as_str(), ClientRequest::ListTasksRequest(r) => r.method.as_str(), ClientRequest::GetTaskResultRequest(r) => r.method.as_str(), - ClientRequest::CancelTaskRequest(r) => r.method.as_str(), + ClientRequest::DeleteTaskRequest(r) => r.method.as_str(), ClientRequest::CustomRequest(r) => r.method.as_str(), } } @@ -3323,6 +3319,10 @@ ts_union!( box CreateMessageResult | ListRootsResult | CreateElicitationResult + | ListTasksResult + | GetTaskResult + | GetTaskPayloadResult + | DeleteTaskResult | EmptyResult | CustomResult; ); @@ -3341,9 +3341,29 @@ ts_union!( | CreateMessageRequest | ListRootsRequest | CreateElicitationRequest + | GetTaskInfoRequest + | ListTasksRequest + | GetTaskResultRequest + | DeleteTaskRequest | CustomRequest; ); +impl ServerRequest { + pub fn method(&self) -> &str { + match &self { + ServerRequest::PingRequest(r) => r.method.as_str(), + ServerRequest::CreateMessageRequest(r) => r.method.as_str(), + ServerRequest::ListRootsRequest(r) => r.method.as_str(), + ServerRequest::CreateElicitationRequest(r) => r.method.as_str(), + ServerRequest::GetTaskInfoRequest(r) => r.method.as_str(), + ServerRequest::ListTasksRequest(r) => r.method.as_str(), + ServerRequest::GetTaskResultRequest(r) => r.method.as_str(), + ServerRequest::DeleteTaskRequest(r) => r.method.as_str(), + ServerRequest::CustomRequest(r) => r.method.as_str(), + } + } +} + ts_union!( export type ServerNotification = | CancelledNotification @@ -3371,7 +3391,7 @@ ts_union!( | CreateTaskResult | ListTasksResult | GetTaskResult - | CancelTaskResult + | DeleteTaskResult | CallToolResult | GetTaskPayloadResult | EmptyResult diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index 186db6a2..2e01de3f 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -155,7 +155,7 @@ variant_extension! { GetTaskInfoRequest ListTasksRequest GetTaskResultRequest - CancelTaskRequest + DeleteTaskRequest } } @@ -166,6 +166,10 @@ variant_extension! { ListRootsRequest CreateElicitationRequest CustomRequest + GetTaskInfoRequest + ListTasksRequest + GetTaskResultRequest + DeleteTaskRequest } } diff --git a/crates/rmcp/src/model/task.rs b/crates/rmcp/src/model/task.rs index dbdc3406..50aba27a 100644 --- a/crates/rmcp/src/model/task.rs +++ b/crates/rmcp/src/model/task.rs @@ -156,18 +156,35 @@ impl<'de> serde::Deserialize<'de> for GetTaskPayloadResult { } } -/// Response to a `tasks/cancel` request. +/// Response to a `tasks/delete` request. /// -/// Per spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +/// Per SEP-1686 §3.6, the result carries only protocol-level metadata. +#[derive(Debug, Clone, PartialEq, Serialize, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct CancelTaskResult { +pub struct DeleteTaskResult { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, - #[serde(flatten)] - pub task: Task, +} + +// Custom Deserialize that always fails, so that `DeleteTaskResult` is skipped +// during `#[serde(untagged)]` enum deserialization (e.g. `ClientResult` / +// `ServerResult`). The on-wire shape (`{ "_meta"?: ... }`) is +// indistinguishable from many other results — `EmptyResult` / `CustomResult` +// act as the catch-all. `DeleteTaskResult` should be constructed +// programmatically (e.g. via `::default()`). +impl<'de> serde::Deserialize<'de> for DeleteTaskResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + serde::de::IgnoredAny::deserialize(deserializer)?; + Err(serde::de::Error::custom( + "DeleteTaskResult cannot be deserialized directly; \ + use EmptyResult / CustomResult as the catch-all", + )) + } } /// Paginated list of tasks diff --git a/crates/rmcp/src/task_manager.rs b/crates/rmcp/src/task_manager.rs index 32bcf8f0..e709fd96 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -286,6 +286,26 @@ impl OperationProcessor { false } + /// Delete a task and its associated stored result (SEP-1686 §3.6). + /// + /// Aborts the task if it is still running and removes any stored + /// completed result. Returns `true` if anything was deleted. + pub fn delete_task(&mut self, task_id: &str) -> bool { + self.collect_completed_results(); + let mut deleted = false; + if let Some(task) = self.running_tasks.remove(task_id) { + task.task_handle.abort(); + deleted = true; + } + let before = self.completed_results.len(); + self.completed_results + .retain(|r| r.descriptor.operation_id != task_id); + if self.completed_results.len() != before { + deleted = true; + } + deleted + } + /// Retrieve a completed task result if available. pub fn take_completed_result(&mut self, task_id: &str) -> Option { self.collect_completed_results(); 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 8e082db9..6b7a0ee6 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 @@ -248,6 +248,70 @@ "taskId" ] }, + "CancelTaskResult": { + "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, "CancelledNotificationMethod": { "type": "string", "format": "const", @@ -351,6 +415,18 @@ { "$ref": "#/definitions/CreateElicitationResult" }, + { + "$ref": "#/definitions/ListTasksResult" + }, + { + "$ref": "#/definitions/GetTaskResult" + }, + { + "$ref": "#/definitions/GetTaskPayloadResult" + }, + { + "$ref": "#/definitions/CancelTaskResult" + }, { "$ref": "#/definitions/EmptyObject" }, @@ -680,6 +756,73 @@ "taskId" ] }, + "GetTaskPayloadResult": { + "description": "Response to a `tasks/result` request.\n\nPer spec, the result structure matches the original request type\n(e.g., `CallToolResult` for `tools/call`). This is represented as\nan open object. The payload is the original request's result\nserialized as a JSON value." + }, + "GetTaskResult": { + "description": "Response to a `tasks/get` request.\n\nPer spec, `GetTaskResult = allOf[Result, Task]` — the Task fields are\nflattened at the top level, not nested under a `task` key.", + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, "GetTaskResultMethod": { "type": "string", "format": "const", @@ -1031,6 +1174,34 @@ "format": "const", "const": "tasks/list" }, + "ListTasksResult": { + "type": "object", + "properties": { + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/definitions/Task" + } + }, + "total": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "tasks" + ] + }, "ListToolsRequestMethod": { "type": "string", "format": "const", @@ -2016,6 +2187,63 @@ "uri" ] }, + "Task": { + "description": "Primary Task object that surfaces metadata during the task lifecycle.\n\nPer spec, `lastUpdatedAt` and `ttl` are required fields.\n`ttl` is nullable (`null` means unlimited retention).", + "type": "object", + "properties": { + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, "TaskRequestsCapability": { "description": "Request types that support task-augmented execution.", "type": "object", @@ -2052,6 +2280,36 @@ } } }, + "TaskStatus": { + "description": "Canonical task lifecycle status as defined by SEP-1686.", + "oneOf": [ + { + "description": "The receiver accepted the request and is currently working on it.", + "type": "string", + "const": "working" + }, + { + "description": "The receiver requires additional input before work can continue.", + "type": "string", + "const": "input_required" + }, + { + "description": "The underlying operation completed successfully and the result is ready.", + "type": "string", + "const": "completed" + }, + { + "description": "The underlying operation failed and will not continue.", + "type": "string", + "const": "failed" + }, + { + "description": "The task was cancelled and will not continue processing.", + "type": "string", + "const": "cancelled" + } + ] + }, "TasksCapability": { "description": "Task capabilities shared by client and server.", "type": "object", 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 8e082db9..6b7a0ee6 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 @@ -248,6 +248,70 @@ "taskId" ] }, + "CancelTaskResult": { + "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, "CancelledNotificationMethod": { "type": "string", "format": "const", @@ -351,6 +415,18 @@ { "$ref": "#/definitions/CreateElicitationResult" }, + { + "$ref": "#/definitions/ListTasksResult" + }, + { + "$ref": "#/definitions/GetTaskResult" + }, + { + "$ref": "#/definitions/GetTaskPayloadResult" + }, + { + "$ref": "#/definitions/CancelTaskResult" + }, { "$ref": "#/definitions/EmptyObject" }, @@ -680,6 +756,73 @@ "taskId" ] }, + "GetTaskPayloadResult": { + "description": "Response to a `tasks/result` request.\n\nPer spec, the result structure matches the original request type\n(e.g., `CallToolResult` for `tools/call`). This is represented as\nan open object. The payload is the original request's result\nserialized as a JSON value." + }, + "GetTaskResult": { + "description": "Response to a `tasks/get` request.\n\nPer spec, `GetTaskResult = allOf[Result, Task]` — the Task fields are\nflattened at the top level, not nested under a `task` key.", + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, "GetTaskResultMethod": { "type": "string", "format": "const", @@ -1031,6 +1174,34 @@ "format": "const", "const": "tasks/list" }, + "ListTasksResult": { + "type": "object", + "properties": { + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/definitions/Task" + } + }, + "total": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "tasks" + ] + }, "ListToolsRequestMethod": { "type": "string", "format": "const", @@ -2016,6 +2187,63 @@ "uri" ] }, + "Task": { + "description": "Primary Task object that surfaces metadata during the task lifecycle.\n\nPer spec, `lastUpdatedAt` and `ttl` are required fields.\n`ttl` is nullable (`null` means unlimited retention).", + "type": "object", + "properties": { + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, "TaskRequestsCapability": { "description": "Request types that support task-augmented execution.", "type": "object", @@ -2052,6 +2280,36 @@ } } }, + "TaskStatus": { + "description": "Canonical task lifecycle status as defined by SEP-1686.", + "oneOf": [ + { + "description": "The receiver accepted the request and is currently working on it.", + "type": "string", + "const": "working" + }, + { + "description": "The receiver requires additional input before work can continue.", + "type": "string", + "const": "input_required" + }, + { + "description": "The underlying operation completed successfully and the result is ready.", + "type": "string", + "const": "completed" + }, + { + "description": "The underlying operation failed and will not continue.", + "type": "string", + "const": "failed" + }, + { + "description": "The task was cancelled and will not continue processing.", + "type": "string", + "const": "cancelled" + } + ] + }, "TasksCapability": { "description": "Task capabilities shared by client and server.", "type": "object", 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 405b3e02..4982a975 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 @@ -405,6 +405,30 @@ } } }, + "CancelTaskMethod": { + "type": "string", + "format": "const", + "const": "tasks/cancel" + }, + "CancelTaskParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, "CancelTaskResult": { "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", "type": "object", @@ -1007,6 +1031,30 @@ "messages" ] }, + "GetTaskInfoMethod": { + "type": "string", + "format": "const", + "const": "tasks/get" + }, + "GetTaskInfoParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, "GetTaskPayloadResult": { "description": "Response to a `tasks/result` request.\n\nPer spec, the result structure matches the original request type\n(e.g., `CallToolResult` for `tools/call`). This is represented as\nan open object. The payload is the original request's result\nserialized as a JSON value." }, @@ -1074,6 +1122,30 @@ "lastUpdatedAt" ] }, + "GetTaskResultMethod": { + "type": "string", + "format": "const", + "const": "tasks/result" + }, + "GetTaskResultParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, "Icon": { "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", "type": "object", @@ -1356,6 +1428,18 @@ { "$ref": "#/definitions/Request2" }, + { + "$ref": "#/definitions/Request3" + }, + { + "$ref": "#/definitions/RequestOptionalParam" + }, + { + "$ref": "#/definitions/Request4" + }, + { + "$ref": "#/definitions/Request5" + }, { "$ref": "#/definitions/CustomRequest" } @@ -1515,6 +1599,11 @@ "format": "const", "const": "roots/list" }, + "ListTasksMethod": { + "type": "string", + "format": "const", + "const": "tasks/list" + }, "ListTasksResult": { "type": "object", "properties": { @@ -1864,6 +1953,25 @@ "format": "const", "const": "object" }, + "PaginatedRequestParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "cursor": { + "type": [ + "string", + "null" + ] + } + } + }, "PingRequestMethod": { "type": "string", "format": "const", @@ -2452,6 +2560,54 @@ "params" ] }, + "Request3": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/GetTaskInfoMethod" + }, + "params": { + "$ref": "#/definitions/GetTaskInfoParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request4": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/GetTaskResultMethod" + }, + "params": { + "$ref": "#/definitions/GetTaskResultParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request5": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CancelTaskMethod" + }, + "params": { + "$ref": "#/definitions/CancelTaskParams" + } + }, + "required": [ + "method", + "params" + ] + }, "RequestNoParam": { "type": "object", "properties": { @@ -2474,6 +2630,27 @@ "method" ] }, + "RequestOptionalParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListTasksMethod" + }, + "params": { + "anyOf": [ + { + "$ref": "#/definitions/PaginatedRequestParams" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "method" + ] + }, "ResourceContents": { "anyOf": [ { 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 405b3e02..4982a975 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 @@ -405,6 +405,30 @@ } } }, + "CancelTaskMethod": { + "type": "string", + "format": "const", + "const": "tasks/cancel" + }, + "CancelTaskParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, "CancelTaskResult": { "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", "type": "object", @@ -1007,6 +1031,30 @@ "messages" ] }, + "GetTaskInfoMethod": { + "type": "string", + "format": "const", + "const": "tasks/get" + }, + "GetTaskInfoParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, "GetTaskPayloadResult": { "description": "Response to a `tasks/result` request.\n\nPer spec, the result structure matches the original request type\n(e.g., `CallToolResult` for `tools/call`). This is represented as\nan open object. The payload is the original request's result\nserialized as a JSON value." }, @@ -1074,6 +1122,30 @@ "lastUpdatedAt" ] }, + "GetTaskResultMethod": { + "type": "string", + "format": "const", + "const": "tasks/result" + }, + "GetTaskResultParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, "Icon": { "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", "type": "object", @@ -1356,6 +1428,18 @@ { "$ref": "#/definitions/Request2" }, + { + "$ref": "#/definitions/Request3" + }, + { + "$ref": "#/definitions/RequestOptionalParam" + }, + { + "$ref": "#/definitions/Request4" + }, + { + "$ref": "#/definitions/Request5" + }, { "$ref": "#/definitions/CustomRequest" } @@ -1515,6 +1599,11 @@ "format": "const", "const": "roots/list" }, + "ListTasksMethod": { + "type": "string", + "format": "const", + "const": "tasks/list" + }, "ListTasksResult": { "type": "object", "properties": { @@ -1864,6 +1953,25 @@ "format": "const", "const": "object" }, + "PaginatedRequestParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "cursor": { + "type": [ + "string", + "null" + ] + } + } + }, "PingRequestMethod": { "type": "string", "format": "const", @@ -2452,6 +2560,54 @@ "params" ] }, + "Request3": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/GetTaskInfoMethod" + }, + "params": { + "$ref": "#/definitions/GetTaskInfoParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request4": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/GetTaskResultMethod" + }, + "params": { + "$ref": "#/definitions/GetTaskResultParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request5": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CancelTaskMethod" + }, + "params": { + "$ref": "#/definitions/CancelTaskParams" + } + }, + "required": [ + "method", + "params" + ] + }, "RequestNoParam": { "type": "object", "properties": { @@ -2474,6 +2630,27 @@ "method" ] }, + "RequestOptionalParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListTasksMethod" + }, + "params": { + "anyOf": [ + { + "$ref": "#/definitions/PaginatedRequestParams" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "method" + ] + }, "ResourceContents": { "anyOf": [ { diff --git a/crates/rmcp/tests/test_task_client_receiver.rs b/crates/rmcp/tests/test_task_client_receiver.rs new file mode 100644 index 00000000..0e3a7a18 --- /dev/null +++ b/crates/rmcp/tests/test_task_client_receiver.rs @@ -0,0 +1,322 @@ +//! End-to-end tests proving that task-management RPCs (`tasks/get`, +//! `tasks/list`, `tasks/result`, `tasks/delete`) sent from a server to a +//! client are dispatched to the appropriate `ClientHandler` methods and +//! return results that the server receives as the correct `ClientResult` +//! variant. +//! +//! Companion tests for the server-as-receiver path already exist; these +//! round out the bidirectional coverage required by SEP-1686. + +#![cfg(not(feature = "local"))] +use std::sync::Arc; + +use rmcp::{ + ClientHandler, ServerHandler, ServiceExt, + model::{ + ClientResult, DeleteTaskParams, DeleteTaskRequest, DeleteTaskResult, GetTaskInfoParams, + GetTaskInfoRequest, GetTaskPayloadResult, GetTaskResult, GetTaskResultParams, + GetTaskResultRequest, ListTasksRequest, ListTasksResult, PaginatedRequestParams, + ServerRequest, Task, TaskStatus, + }, +}; +use serde_json::json; +use tokio::sync::{Mutex, Notify}; + +/// Shared bookkeeping for the client handler: records which method was +/// invoked with which task id, so each test can assert on it. +#[derive(Default)] +struct ClientState { + last_get_task_id: Option, + last_result_task_id: Option, + last_delete_task_id: Option, + list_called: bool, +} + +struct TaskClient { + state: Arc>, + received: Arc, +} + +impl ClientHandler for TaskClient { + async fn get_task_info( + &self, + request: GetTaskInfoParams, + _context: rmcp::service::RequestContext, + ) -> Result { + self.state.lock().await.last_get_task_id = Some(request.task_id.clone()); + self.received.notify_one(); + Ok(GetTaskResult { + meta: None, + task: Task::new( + request.task_id, + TaskStatus::Working, + "2025-11-25T10:30:00Z".into(), + "2025-11-25T10:30:00Z".into(), + ), + }) + } + + async fn list_tasks( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { + self.state.lock().await.list_called = true; + self.received.notify_one(); + Ok(ListTasksResult::new(vec![Task::new( + "task-42".to_string(), + TaskStatus::Working, + "2025-11-25T10:30:00Z".into(), + "2025-11-25T10:30:00Z".into(), + )])) + } + + async fn get_task_result( + &self, + request: GetTaskResultParams, + _context: rmcp::service::RequestContext, + ) -> Result { + self.state.lock().await.last_result_task_id = Some(request.task_id); + self.received.notify_one(); + Ok(GetTaskPayloadResult::new(json!({ "ok": true }))) + } + + async fn delete_task( + &self, + request: DeleteTaskParams, + _context: rmcp::service::RequestContext, + ) -> Result { + self.state.lock().await.last_delete_task_id = Some(request.task_id); + self.received.notify_one(); + Ok(DeleteTaskResult::default()) + } +} + +/// Signal we fire when the server finishes its outbound RPC so the test +/// can assert on the response shape. +struct ServerCompletion { + done: Arc, + last_response: Arc>>>, +} + +/// A server that, on initialize, fires a single `ServerRequest` (parameterised +/// externally via a closure) at the client and stashes the response. +struct RequestingServer { + request: Arc>>, + completion: ServerCompletion, +} + +impl ServerHandler for RequestingServer { + async fn on_initialized(&self, context: rmcp::service::NotificationContext) { + let peer = context.peer.clone(); + let request = self.request.lock().await.take(); + let done = self.completion.done.clone(); + let last_response = self.completion.last_response.clone(); + tokio::spawn(async move { + let Some(req) = request else { + *last_response.lock().await = Some(Err("no request".into())); + done.notify_one(); + return; + }; + let outcome = peer + .send_request(req) + .await + .map_err(|e| format!("send_request failed: {e}")); + *last_response.lock().await = Some(outcome); + done.notify_one(); + }); + } +} + +async fn run_server_request(request: ServerRequest) -> (Arc>, ClientResult) { + let _ = tracing_subscriber::fmt::try_init(); + + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let completion = ServerCompletion { + done: Arc::new(Notify::new()), + last_response: Arc::new(Mutex::new(None)), + }; + let server_done = completion.done.clone(); + let server_response = completion.last_response.clone(); + tokio::spawn({ + let request_slot = Arc::new(Mutex::new(Some(request))); + async move { + let server = RequestingServer { + request: request_slot, + completion, + } + .serve(server_transport) + .await?; + server.waiting().await?; + anyhow::Ok(()) + } + }); + + let state = Arc::new(Mutex::new(ClientState::default())); + let received = Arc::new(Notify::new()); + let client = TaskClient { + state: state.clone(), + received: received.clone(), + } + .serve(client_transport) + .await + .expect("client serve"); + + tokio::time::timeout(std::time::Duration::from_secs(5), received.notified()) + .await + .expect("client handler fired"); + tokio::time::timeout(std::time::Duration::from_secs(5), server_done.notified()) + .await + .expect("server got response"); + + let outcome = server_response + .lock() + .await + .take() + .expect("server outcome set"); + let response = outcome.expect("server request succeeded"); + + client.cancel().await.ok(); + (state, response) +} + +#[tokio::test] +async fn tasks_get_reaches_client_handler() { + let request = ServerRequest::GetTaskInfoRequest(GetTaskInfoRequest::new(GetTaskInfoParams { + meta: None, + task_id: "task-abc".into(), + })); + let (state, response) = run_server_request(request).await; + + assert_eq!( + state.lock().await.last_get_task_id.as_deref(), + Some("task-abc") + ); + match response { + ClientResult::GetTaskResult(r) => { + assert_eq!(r.task.task_id, "task-abc"); + assert_eq!(r.task.status, TaskStatus::Working); + } + other => panic!("unexpected variant: {other:?}"), + } +} + +#[tokio::test] +async fn tasks_list_reaches_client_handler() { + let request = ServerRequest::ListTasksRequest(ListTasksRequest::default()); + let (state, response) = run_server_request(request).await; + + assert!(state.lock().await.list_called); + match response { + ClientResult::ListTasksResult(r) => { + assert_eq!(r.tasks.len(), 1); + assert_eq!(r.tasks[0].task_id, "task-42"); + } + other => panic!("unexpected variant: {other:?}"), + } +} + +#[tokio::test] +async fn tasks_result_reaches_client_handler() { + let request = + ServerRequest::GetTaskResultRequest(GetTaskResultRequest::new(GetTaskResultParams { + meta: None, + task_id: "task-xyz".into(), + })); + let (state, response) = run_server_request(request).await; + + assert_eq!( + state.lock().await.last_result_task_id.as_deref(), + Some("task-xyz") + ); + // GetTaskPayloadResult has a custom Deserialize that always fails (see + // crates/rmcp/src/model/task.rs) so the payload surfaces as the + // catch-all CustomResult on the wire. This matches the existing design + // on the server-as-receiver path. + match response { + ClientResult::CustomResult(r) => { + assert_eq!(r.0, json!({ "ok": true })); + } + other => panic!("unexpected variant: {other:?}"), + } +} + +#[tokio::test] +async fn tasks_delete_reaches_client_handler() { + let request = ServerRequest::DeleteTaskRequest(DeleteTaskRequest::new(DeleteTaskParams { + meta: None, + task_id: "task-deleteme".into(), + })); + let (state, response) = run_server_request(request).await; + + assert_eq!( + state.lock().await.last_delete_task_id.as_deref(), + Some("task-deleteme") + ); + // `DeleteTaskResult` has a custom Deserialize that always fails (see + // crates/rmcp/src/model/task.rs) so its on-wire shape (`{_meta?}`) + // surfaces as `EmptyResult` — the first matching variant in the + // untagged `ClientResult` enum. Callers distinguish by knowing which + // request they sent rather than by inspecting the response variant. + // This mirrors the design used for `GetTaskPayloadResult`. + match response { + ClientResult::EmptyResult(_) => {} + other => panic!("unexpected variant: {other:?}"), + } +} + +#[tokio::test] +async fn default_handler_returns_method_not_found() { + use rmcp::model::ErrorCode; + let _ = tracing_subscriber::fmt::try_init(); + + let (server_transport, client_transport) = tokio::io::duplex(4096); + let completion = ServerCompletion { + done: Arc::new(Notify::new()), + last_response: Arc::new(Mutex::new(None)), + }; + let done = completion.done.clone(); + let response = completion.last_response.clone(); + tokio::spawn({ + let request_slot = Arc::new(Mutex::new(Some(ServerRequest::GetTaskInfoRequest( + GetTaskInfoRequest::new(GetTaskInfoParams { + meta: None, + task_id: "whatever".into(), + }), + )))); + async move { + let server = RequestingServer { + request: request_slot, + completion, + } + .serve(server_transport) + .await?; + server.waiting().await?; + anyhow::Ok(()) + } + }); + + // Use the empty-unit client, which relies on the default trait impls. + let client = ().serve(client_transport).await.expect("client"); + + tokio::time::timeout(std::time::Duration::from_secs(5), done.notified()) + .await + .expect("server got response"); + + let outcome = response.lock().await.take().expect("response set"); + let err_msg = match outcome { + Ok(other) => panic!("expected method-not-found error, got: {other:?}"), + Err(s) => s, + }; + // The ServiceError Display surfaces the inner McpError, which carries + // the METHOD_NOT_FOUND code. + assert!( + err_msg.contains(&ErrorCode::METHOD_NOT_FOUND.0.to_string()) + || err_msg.to_lowercase().contains("method not found") + || err_msg.to_lowercase().contains("tasks/get"), + "expected method-not-found style error, got: {err_msg}" + ); + + client.cancel().await.ok(); +}