From 3e5ef3093c96f3bf5803182187582b7f96890cc3 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 21 Jul 2026 11:53:42 -0400 Subject: [PATCH 01/13] feat!: implement SEP-2663 Tasks extension, removing the experimental 2025-11-25 tasks design Reshape tasks from the experimental SEP-1319/1686 core-protocol feature into the official io.modelcontextprotocol/tasks extension (SEP-2663): Model: - Re-model Task (statusMessage, ttlMs nullable, pollIntervalMs) and add DetailedTask with status-discriminated payloads (inputRequests/result/error inlined per spec) - CreateTaskResult now flattens Task with resultType: "task"; add ResultType::TASK and CallToolResponse::Task - Add tasks/update (UpdateTaskParams with MRTR InputResponses); tasks/get and tasks/cancel reworked; remove tasks/list, tasks/result - notifications/tasks now carries a full DetailedTask; removed from client notifications (SEP-2260) Capabilities: - Remove core TasksCapability et al; tasks are declared via the extensions map (enable_tasks() builders, supports_tasks() accessors) - Remove tool-level execution.taskSupport and the _meta.task / TaskMetadata / with_task() opt-in: task creation is server-directed and gated on the per-request client capability Runtime: - Replace OperationProcessor with TaskManager: durable-before-response task creation, input_required round-trips via tasks/update, cooperative cancellation, TTL expiry - Client peer helpers get_task/update_task/cancel_task - Emit Mcp-Name routing header from params.taskId for tasks/* methods (SEP-2243/2663) Macros: - Remove #[task_handler] and the tool execution() attribute Tests/examples/docs updated; schema snapshots regenerated. BREAKING CHANGE: the 2025-11-25 experimental tasks API is removed without a compatibility shim. Clients that do not declare the tasks extension always receive synchronous results. --- README.md | 39 +- crates/rmcp-macros/README.md | 2 - crates/rmcp-macros/src/lib.rs | 19 +- crates/rmcp-macros/src/task_handler.rs | 286 ------- crates/rmcp-macros/src/tool.rs | 48 -- crates/rmcp-macros/src/tool_handler.rs | 9 +- crates/rmcp/Cargo.toml | 2 +- crates/rmcp/src/handler/client.rs | 4 +- crates/rmcp/src/handler/server.rs | 140 +--- crates/rmcp/src/handler/server/router/tool.rs | 2 - .../handler/server/router/tool/tool_traits.rs | 6 +- crates/rmcp/src/handler/server/tool.rs | 7 +- crates/rmcp/src/model.rs | 161 +--- crates/rmcp/src/model/capabilities.rs | 284 ++----- crates/rmcp/src/model/meta.rs | 21 +- crates/rmcp/src/model/mrtr.rs | 26 +- crates/rmcp/src/model/serde_impl.rs | 8 - crates/rmcp/src/model/task.rs | 479 ++++++++---- crates/rmcp/src/model/tool.rs | 77 -- crates/rmcp/src/service/client.rs | 80 +- crates/rmcp/src/task_manager.rs | 708 ++++++++++++------ .../rmcp/src/transport/common/mcp_headers.rs | 6 + crates/rmcp/tests/test_deserialization.rs | 14 +- .../client_json_rpc_message_schema.json | 356 +-------- ...lient_json_rpc_message_schema_current.json | 356 +-------- .../server_json_rpc_message_schema.json | 482 +++--------- ...erver_json_rpc_message_schema_current.json | 482 +++--------- crates/rmcp/tests/test_task.rs | 318 +++++--- .../tests/test_task_support_validation.rs | 251 ------- crates/rmcp/tests/test_tool_macros.rs | 4 +- examples/clients/README.md | 7 +- examples/clients/src/task_stdio.rs | 115 +-- examples/servers/README.md | 10 +- examples/servers/src/common/counter.rs | 72 +- examples/servers/src/common/task_demo.rs | 133 +++- 35 files changed, 1744 insertions(+), 3270 deletions(-) delete mode 100644 crates/rmcp-macros/src/task_handler.rs delete mode 100644 crates/rmcp/tests/test_task_support_validation.rs diff --git a/README.md b/README.md index 7586d5c58..b84cc2b76 100644 --- a/README.md +++ b/README.md @@ -971,21 +971,34 @@ and [client](examples/clients/src/subscriptions_streamhttp.rs) examples. ## Tasks (long-running tool invocations) -`rmcp` supports the [task-based tool invocation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks) -flow defined in SEP-1319. Annotate a tool with `execution(task_support = "required" | "optional")` -and add `#[task_handler]` to your `ServerHandler` impl — `enqueue_task`, `tasks/list`, `tasks/get`, -`tasks/result`, and `tasks/cancel` are generated for you on top of an `OperationProcessor`. +`rmcp` implements the [MCP Tasks extension](https://modelcontextprotocol.io/extensions/tasks/overview) +(SEP-2663, `io.modelcontextprotocol/tasks`). A client declares the extension in its +capabilities; the server then decides per request whether to materialize a `tools/call` +as a task, returning a `CreateTaskResult` (`resultType: "task"`). The client polls +`tasks/get`, answers in-task input requests via `tasks/update`, and may request +cooperative cancellation via `tasks/cancel`. Use `rmcp::task_manager::TaskManager` +to manage task lifecycles server-side. ```rust, ignore -#[tool( - description = "Sum two numbers after a 2-second delay", - execution(task_support = "required") -)] -async fn slow_sum(/* ... */) -> Result { /* ... */ } - -#[tool_handler] -#[task_handler] -impl ServerHandler for TaskDemo {} +// Client: declare the tasks extension capability. +let caps = ClientCapabilities::builder().enable_tasks().build(); + +// Server: decide per request whether to materialize a task. +async fn call_tool(&self, request: CallToolRequestParams, context: RequestContext) + -> Result +{ + let client_supports_tasks = context + .meta + .client_capabilities() + .is_some_and(|caps| caps.supports_tasks()); + if client_supports_tasks { + let task = self.tasks.spawn(TaskOptions::default(), move |_ctx| { + Box::pin(async move { /* long-running work -> Ok(CallToolResult) */ }) + }); + return Ok(CallToolResponse::Task(CreateTaskResult::new(task))); + } + // ... fall back to synchronous execution +} ``` See [`servers_task_stdio`](examples/servers/src/task_stdio.rs) and the matching diff --git a/crates/rmcp-macros/README.md b/crates/rmcp-macros/README.md index cd9262edc..adc838874 100644 --- a/crates/rmcp-macros/README.md +++ b/crates/rmcp-macros/README.md @@ -25,7 +25,6 @@ For **getting started** and **full MCP feature documentation**, see the [main RE | [`#[prompt]`][prompt] | Mark a function as an MCP prompt handler | | [`#[prompt_router]`][prompt_router] | Generate a prompt router from an impl block | | [`#[prompt_handler]`][prompt_handler] | Generate `get_prompt` and `list_prompts` handler methods | -| [`#[task_handler]`][task_handler] | Wire up the task lifecycle on top of an `OperationProcessor` | [tool]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.tool.html [tool_router]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.tool_router.html @@ -33,7 +32,6 @@ For **getting started** and **full MCP feature documentation**, see the [main RE [prompt]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.prompt.html [prompt_router]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.prompt_router.html [prompt_handler]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.prompt_handler.html -[task_handler]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.task_handler.html ## Quick Example diff --git a/crates/rmcp-macros/src/lib.rs b/crates/rmcp-macros/src/lib.rs index e721338d9..156e53b4a 100644 --- a/crates/rmcp-macros/src/lib.rs +++ b/crates/rmcp-macros/src/lib.rs @@ -7,7 +7,6 @@ mod common; mod prompt; mod prompt_handler; mod prompt_router; -mod task_handler; mod tool; mod tool_handler; mod tool_router; @@ -100,7 +99,7 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> TokenStream { /// impl MyToolHandler { /// #[tool] /// fn my_tool_a() { -/// +/// /// } /// } /// } @@ -110,7 +109,7 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> TokenStream { /// impl MyToolHandler { /// #[tool] /// fn my_tool_b() { -/// +/// /// } /// } /// } @@ -299,17 +298,3 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> TokenStream { .unwrap_or_else(|err| err.to_compile_error()) .into() } - -/// # task_handler -/// -/// Generates basic task-handling methods (`enqueue_task` and `list_tasks`) for a server handler -/// using a shared \[`OperationProcessor`\]. The default processor expression assumes a -/// `self.processor` field holding an `Arc>`, but it can be customized -/// via `#[task_handler(processor = ...)]`. Because the macro captures `self` inside spawned -/// futures, the handler type must implement [`Clone`]. -#[proc_macro_attribute] -pub fn task_handler(attr: TokenStream, input: TokenStream) -> TokenStream { - task_handler::task_handler(attr.into(), input.into()) - .unwrap_or_else(|err| err.to_compile_error()) - .into() -} diff --git a/crates/rmcp-macros/src/task_handler.rs b/crates/rmcp-macros/src/task_handler.rs deleted file mode 100644 index c743463cb..000000000 --- a/crates/rmcp-macros/src/task_handler.rs +++ /dev/null @@ -1,286 +0,0 @@ -use darling::{FromMeta, ast::NestedMeta}; -use proc_macro2::TokenStream; -use quote::{ToTokens, quote}; -use syn::{Expr, ImplItem, ItemImpl}; - -use crate::common::{has_method, has_sibling_handler}; - -#[derive(FromMeta)] -#[darling(default)] -struct TaskHandlerAttribute { - processor: Expr, -} - -impl Default for TaskHandlerAttribute { - fn default() -> Self { - Self { - processor: syn::parse2(quote! { self.processor }).expect("default processor expr"), - } - } -} - -pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result { - let attr_args = NestedMeta::parse_meta_list(attr)?; - let TaskHandlerAttribute { processor } = TaskHandlerAttribute::from_list(&attr_args)?; - let mut item_impl = syn::parse2::(input)?; - - if !has_method("list_tasks", &item_impl) { - let list_fn = quote! { - async fn list_tasks( - &self, - _request: Option, - _: rmcp::service::RequestContext, - ) -> Result { - let running_ids = (#processor).lock().await.list_running(); - let total = running_ids.len() as u64; - let tasks = running_ids - .into_iter() - .map(|task_id| { - let timestamp = rmcp::task_manager::current_timestamp(); - rmcp::model::Task::new( - task_id, - rmcp::model::TaskStatus::Working, - timestamp.clone(), - timestamp, - ) - }) - .collect::>(); - - Ok(rmcp::model::ListTasksResult::new(tasks)) - } - }; - item_impl.items.push(syn::parse2::(list_fn)?); - } - - if !has_method("enqueue_task", &item_impl) { - let enqueue_fn = quote! { - async fn enqueue_task( - &self, - request: rmcp::model::CallToolRequestParams, - context: rmcp::service::RequestContext, - ) -> Result { - use rmcp::task_manager::{ - current_timestamp, OperationDescriptor, OperationMessage, OperationResultTransport, - ToolCallTaskResult, - }; - let task_id = context.id.to_string(); - let operation_name = request.name.to_string(); - let future_request = request.clone(); - let future_context = context.clone(); - let server = self.clone(); - - let descriptor = OperationDescriptor::new(task_id.clone(), operation_name) - .with_context(context) - .with_client_request(rmcp::model::ClientRequest::CallToolRequest( - rmcp::model::Request::new(request), - )); - - let task_result_id = task_id.clone(); - let future = Box::pin(async move { - let result = server - .call_tool(future_request, future_context) - .await - .and_then(|response| match response { - rmcp::model::CallToolResponse::Complete(result) => Ok(result), - _ => Err(rmcp::ErrorData::internal_error( - "input_required is not supported for task-based tool calls", - None, - )), - }); - Ok( - Box::new(ToolCallTaskResult::new(task_result_id, result)) - as Box, - ) - }); - - (#processor) - .lock() - .await - .submit_operation(OperationMessage::new(descriptor, future)) - .map_err(|err| rmcp::ErrorData::internal_error( - format!("failed to enqueue task: {err}"), - None, - ))?; - - let timestamp = current_timestamp(); - let task = rmcp::model::Task::new( - task_id, - rmcp::model::TaskStatus::Working, - timestamp.clone(), - timestamp, - ).with_status_message("Task accepted"); - - Ok(rmcp::model::CreateTaskResult::new(task)) - } - }; - item_impl.items.push(syn::parse2::(enqueue_fn)?); - } - - if !has_method("get_task_info", &item_impl) { - let get_info_fn = quote! { - async fn get_task_info( - &self, - request: rmcp::model::GetTaskParams, - _context: rmcp::service::RequestContext, - ) -> Result { - use rmcp::task_manager::current_timestamp; - let task_id = request.task_id.clone(); - let mut processor = (#processor).lock().await; - - // Check completed results first - let completed = processor.peek_completed().iter().rev().find(|r| r.descriptor.operation_id == task_id); - if let Some(completed_result) = completed { - // Determine Finished vs Failed - let status = match &completed_result.result { - Ok(boxed) => { - if let Some(tool) = boxed.as_any().downcast_ref::() { - match &tool.result { - Ok(_) => rmcp::model::TaskStatus::Completed, - Err(_) => rmcp::model::TaskStatus::Failed, - } - } else { - rmcp::model::TaskStatus::Completed - } - } - Err(_) => rmcp::model::TaskStatus::Failed, - }; - let timestamp = current_timestamp(); - let mut task = rmcp::model::Task::new( - task_id, - status, - timestamp.clone(), - timestamp, - ); - if let Some(ttl) = completed_result.descriptor.ttl { - task = task.with_ttl(ttl); - } - return Ok(rmcp::model::GetTaskResult::new(task)); - } - - // If not completed, check running - let running = processor.list_running(); - if running.into_iter().any(|id| id == task_id) { - let timestamp = current_timestamp(); - let task = rmcp::model::Task::new( - task_id, - rmcp::model::TaskStatus::Working, - timestamp.clone(), - timestamp, - ); - return Ok(rmcp::model::GetTaskResult::new(task)); - } - - Err(McpError::resource_not_found(format!("task not found: {}", task_id), None)) - } - }; - item_impl.items.push(syn::parse2::(get_info_fn)?); - } - - if !has_method("get_task_result", &item_impl) { - let get_result_fn = quote! { - async fn get_task_result( - &self, - request: rmcp::model::GetTaskPayloadParams, - _context: rmcp::service::RequestContext, - ) -> Result { - use std::time::Duration; - let task_id = request.task_id.clone(); - - loop { - // Scope the lock so we can await outside if needed - { - let mut processor = (#processor).lock().await; - - if let Some(task_result) = processor.take_completed_result(&task_id) { - match task_result.result { - Ok(boxed) => { - if let Some(tool) = boxed.as_any().downcast_ref::() { - match &tool.result { - Ok(call_tool) => { - let value = ::rmcp::serde_json::to_value(call_tool).unwrap_or_default(); - return Ok(rmcp::model::GetTaskPayloadResult::new(value)); - } - Err(err) => return Err(McpError::internal_error( - format!("task failed: {}", err), - None, - )), - } - } else { - return Err(McpError::internal_error("unsupported task result transport", None)); - } - } - Err(err) => return Err(McpError::internal_error( - format!("task execution error: {}", err), - None, - )), - } - } - - // Not completed yet: if not running, return not found - let running = processor.list_running(); - if !running.iter().any(|id| id == &task_id) { - return Err(McpError::resource_not_found(format!("task not found: {}", task_id), None)); - } - } - - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - }; - item_impl - .items - .push(syn::parse2::(get_result_fn)?); - } - - if !has_method("cancel_task", &item_impl) { - let cancel_fn = quote! { - async fn cancel_task( - &self, - request: rmcp::model::CancelTaskParams, - _context: rmcp::service::RequestContext, - ) -> Result { - use rmcp::task_manager::current_timestamp; - 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::new(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)); - } - - Err(McpError::resource_not_found(format!("task not found: {}", task_id), None)) - } - }; - item_impl.items.push(syn::parse2::(cancel_fn)?); - } - - // Auto-generate get_info() if not already provided and no sibling tool/prompt handler - // will generate it (they take priority since they run as outer attributes). - if !has_method("get_info", &item_impl) - && !has_sibling_handler(&item_impl, "tool_handler") - && !has_sibling_handler(&item_impl, "prompt_handler") - { - let get_info_fn = crate::tool_handler::build_get_info( - &item_impl, - None, - None, - None, - crate::tool_handler::CallerCapability::Tasks, - )?; - item_impl.items.push(get_info_fn); - } - - Ok(item_impl.into_token_stream()) -} diff --git a/crates/rmcp-macros/src/tool.rs b/crates/rmcp-macros/src/tool.rs index c289c32c8..cb11042eb 100644 --- a/crates/rmcp-macros/src/tool.rs +++ b/crates/rmcp-macros/src/tool.rs @@ -75,8 +75,6 @@ pub struct ToolAttribute { pub output_schema: Option, /// Optional additional tool information. pub annotations: Option, - /// Execution-related configuration including task support. - pub execution: Option, /// Optional icons for the tool pub icons: Option, /// Optional metadata for the tool @@ -86,13 +84,6 @@ pub struct ToolAttribute { pub local: bool, } -#[derive(FromMeta, Debug, Default)] -#[darling(default)] -pub struct ToolExecutionAttribute { - /// Task support mode: "forbidden", "optional", or "required" - pub task_support: Option, -} - pub struct ResolvedToolAttribute { pub name: String, pub title: Option, @@ -100,7 +91,6 @@ pub struct ResolvedToolAttribute { pub input_schema: Expr, pub output_schema: Option, pub annotations: Option, - pub execution: Option, pub icons: Option, pub meta: Option, } @@ -114,7 +104,6 @@ impl ResolvedToolAttribute { input_schema, output_schema, annotations, - execution, icons, meta, } = self; @@ -132,9 +121,6 @@ impl ResolvedToolAttribute { let annotations_call = annotations .map(|a| quote! { .with_annotations(#a) }) .unwrap_or_default(); - let execution_call = execution - .map(|e| quote! { .with_execution(#e) }) - .unwrap_or_default(); let icons_call = icons .map(|i| quote! { .with_icons(#i) }) .unwrap_or_default(); @@ -152,7 +138,6 @@ impl ResolvedToolAttribute { #title_call #output_schema_call #annotations_call - #execution_call #icons_call #meta_call } @@ -264,38 +249,6 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { } else { None }; - let execution_expr = if let Some(execution) = attribute.execution { - let ToolExecutionAttribute { task_support } = execution; - - let task_support_expr = if let Some(ts) = task_support { - let ts_ident = match ts.as_str() { - "forbidden" => quote! { rmcp::model::TaskSupport::Forbidden }, - "optional" => quote! { rmcp::model::TaskSupport::Optional }, - "required" => quote! { rmcp::model::TaskSupport::Required }, - _ => { - return Err(syn::Error::new( - Span::call_site(), - format!( - "Invalid task_support value '{}'. Expected 'forbidden', 'optional', or 'required'", - ts - ), - )); - } - }; - quote! { Some(#ts_ident) } - } else { - quote! { None } - }; - - let token_stream = quote! { - rmcp::model::ToolExecution::from_raw( - #task_support_expr, - ) - }; - Some(syn::parse2::(token_stream)?) - } else { - None - }; // Handle output_schema - either explicit or generated from return type let output_schema_expr = attribute.output_schema.or_else(|| { // Try to generate schema from return type @@ -319,7 +272,6 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { input_schema: input_schema_expr, output_schema: output_schema_expr, annotations: annotations_expr, - execution: execution_expr, title: attribute.title, icons: attribute.icons, meta: attribute.meta, diff --git a/crates/rmcp-macros/src/tool_handler.rs b/crates/rmcp-macros/src/tool_handler.rs index f09aec53e..7732687d0 100644 --- a/crates/rmcp-macros/src/tool_handler.rs +++ b/crates/rmcp-macros/src/tool_handler.rs @@ -110,13 +110,12 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result quote! { rmcp::model::Implementation::new(#n, #v) }, (Some(n), None) => { diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 280cfd376..60a4cb296 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -121,7 +121,7 @@ chrono = { version = "0.4.38", default-features = false, features = [ default = ["base64", "macros", "server"] local = ["rmcp-macros?/local"] client = ["dep:tokio-stream"] -server = ["transport-async-rw", "schemars", "dep:pastey"] +server = ["transport-async-rw", "schemars", "dep:pastey", "uuid"] macros = ["dep:rmcp-macros", "dep:pastey"] elicitation = ["dep:url"] diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index 99d099d65..d61070b61 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -251,7 +251,7 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { fn on_task_status( &self, - params: TaskStatusNotificationParam, + params: TaskStatusNotificationParams, context: NotificationContext, ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) @@ -386,7 +386,7 @@ macro_rules! impl_client_handler_for_wrapper { fn on_task_status( &self, - params: TaskStatusNotificationParam, + params: TaskStatusNotificationParams, context: NotificationContext, ) -> impl Future + MaybeSendFuture + '_ { (**self).on_task_status(params, context) diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 4c5f321c3..4529e8f8c 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -4,7 +4,7 @@ use std::{borrow::Cow, sync::Arc}; use crate::{ error::ErrorData as McpError, - model::{TaskSupport, *}, + model::*, service::{ MaybeSendFuture, NotificationContext, RequestContext, RoleServer, Service, ServiceRole, SubscriptionContext, negotiate_protocol_version, uses_legacy_lifecycle, @@ -169,43 +169,10 @@ impl Service for H { .map(ServerResult::empty) } } - ClientRequest::CallToolRequest(request) => { - let is_task = request.params.task.is_some(); - - // Validate task support mode per MCP specification - if let Some(tool) = self.get_tool(&request.params.name) { - match (tool.task_support(), is_task) { - // If taskSupport is "required", clients MUST invoke the tool as a task. - // Servers MUST return a -32601 (Method not found) error if they don't. - (TaskSupport::Required, false) => { - return Err(McpError::new( - ErrorCode::METHOD_NOT_FOUND, - "Tool requires task-based invocation", - None, - )); - } - // If taskSupport is "forbidden" (default), clients MUST NOT invoke as a task. - (TaskSupport::Forbidden, true) => { - return Err(McpError::invalid_params( - "Tool does not support task-based invocation", - None, - )); - } - _ => {} - } - } - - if is_task { - tracing::info!("Enqueueing task for tool call: {}", request.params.name); - self.enqueue_task(request.params, context.clone()) - .await - .map(ServerResult::CreateTaskResult) - } else { - self.call_tool(request.params, context) - .await - .map(ServerResult::from) - } - } + ClientRequest::CallToolRequest(request) => self + .call_tool(request.params, context) + .await + .map(ServerResult::from), ClientRequest::ListToolsRequest(request) => self .list_tools(request.params, context) .await @@ -214,22 +181,18 @@ impl Service for H { .on_custom_request(request, context) .await .map(ServerResult::CustomResult), - ClientRequest::ListTasksRequest(request) => self - .list_tasks(request.params, context) - .await - .map(ServerResult::ListTasksResult), ClientRequest::GetTaskRequest(request) => self - .get_task_info(request.params, context) + .get_task(request.params, context) .await .map(ServerResult::GetTaskResult), - ClientRequest::GetTaskPayloadRequest(request) => self - .get_task_result(request.params, context) + ClientRequest::UpdateTaskRequest(request) => self + .update_task(request.params, context) .await - .map(ServerResult::GetTaskPayloadResult), + .map(ServerResult::empty), ClientRequest::CancelTaskRequest(request) => self .cancel_task(request.params, context) .await - .map(ServerResult::CancelTaskResult), + .map(ServerResult::empty), }; let result = result.and_then(|result| { if matches!(result, ServerResult::InputRequiredResult(_)) && !mrtr_supported { @@ -273,9 +236,6 @@ impl Service for H { ClientNotification::RootsListChangedNotification(_notification) => { self.on_roots_list_changed(context).await } - ClientNotification::TaskStatusNotification(notification) => { - self.on_task_status(notification.params, context).await - } ClientNotification::CustomNotification(notification) => { self.on_custom_notification(notification, context).await } @@ -290,16 +250,6 @@ impl Service for H { macro_rules! server_handler_methods { () => { - fn enqueue_task( - &self, - _request: CallToolRequestParams, - _context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(McpError::internal_error( - "Task processing not implemented".to_string(), - None, - ))) - } fn ping( &self, context: RequestContext, @@ -526,13 +476,6 @@ macro_rules! server_handler_methods { ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) } - fn on_task_status( - &self, - params: TaskStatusNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } fn on_custom_notification( &self, notification: CustomNotification, @@ -546,15 +489,8 @@ macro_rules! server_handler_methods { ServerInfo::default() } - fn list_tasks( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(McpError::method_not_found::())) - } - - fn get_task_info( + /// SEP-2663 `tasks/get`: return the current [`DetailedTask`] state. + fn get_task( &self, request: GetTaskParams, context: RequestContext, @@ -563,20 +499,24 @@ macro_rules! server_handler_methods { std::future::ready(Err(McpError::method_not_found::())) } - fn get_task_result( + /// SEP-2663 `tasks/update`: accept responses to outstanding in-task + /// input requests. Returns an empty acknowledgement on success. + fn update_task( &self, - request: GetTaskPayloadParams, + request: UpdateTaskParams, 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::())) } + /// SEP-2663 `tasks/cancel`: cooperative cancellation. Returns an empty + /// acknowledgement; the task's observable status may lag. fn cancel_task( &self, request: CancelTaskParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { let _ = (request, context); std::future::ready(Err(McpError::method_not_found::())) } @@ -598,14 +538,6 @@ pub trait ServerHandler: Sized + 'static { macro_rules! impl_server_handler_for_wrapper { ($wrapper:ident) => { impl ServerHandler for $wrapper { - fn enqueue_task( - &self, - request: CallToolRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).enqueue_task(request, context) - } - fn ping( &self, context: RequestContext, @@ -777,14 +709,6 @@ macro_rules! impl_server_handler_for_wrapper { (**self).on_roots_list_changed(context) } - fn on_task_status( - &self, - params: TaskStatusNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_task_status(params, context) - } - fn on_custom_notification( &self, notification: CustomNotification, @@ -797,35 +721,27 @@ macro_rules! impl_server_handler_for_wrapper { (**self).get_info() } - fn list_tasks( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).list_tasks(request, context) - } - - fn get_task_info( + fn get_task( &self, request: GetTaskParams, context: RequestContext, ) -> impl Future> + MaybeSendFuture + '_ { - (**self).get_task_info(request, context) + (**self).get_task(request, context) } - fn get_task_result( + fn update_task( &self, - request: GetTaskPayloadParams, + request: UpdateTaskParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).get_task_result(request, context) + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).update_task(request, context) } fn cancel_task( &self, request: CancelTaskParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).cancel_task(request, context) } } diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index 215116250..31aa6d250 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -667,7 +667,6 @@ mod tests { meta: None, name: Cow::Borrowed("requires_params"), arguments: Some(Default::default()), - task: None, input_responses: None, request_state: None, }, @@ -711,7 +710,6 @@ mod tests { meta: None, name: Cow::Borrowed("test_tool"), arguments: None, - task: None, input_responses: None, request_state: None, }, diff --git a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs index 436c3df3b..bde92147d 100644 --- a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs +++ b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs @@ -9,7 +9,7 @@ use crate::{ tool::schema_for_output, wrapper::{Json, Parameters}, }, - model::{Icon, JsonObject, MetaObject, ToolAnnotations, ToolExecution}, + model::{Icon, JsonObject, MetaObject, ToolAnnotations}, schemars::JsonSchema, service::{MaybeSend, MaybeSendFuture}, }; @@ -71,9 +71,6 @@ pub trait ToolBase { fn annotations() -> Option { None } - fn execution() -> Option { - None - } fn icons() -> Option> { None } @@ -111,7 +108,6 @@ pub(crate) fn tool_attribute() -> crate::model::Tool { input_schema: T::input_schema().unwrap_or_else(schema_for_empty_input), output_schema: T::output_schema(), annotations: T::annotations(), - execution: T::execution(), icons: T::icons(), meta: T::meta(), } diff --git a/crates/rmcp/src/handler/server/tool.rs b/crates/rmcp/src/handler/server/tool.rs index cb4966df0..a90240660 100644 --- a/crates/rmcp/src/handler/server/tool.rs +++ b/crates/rmcp/src/handler/server/tool.rs @@ -38,7 +38,6 @@ pub struct ToolCallContext<'s, S> { pub service: &'s S, pub name: Cow<'static, str>, pub arguments: Option, - pub task: Option, } impl<'s, S> ToolCallContext<'s, S> { @@ -48,7 +47,6 @@ impl<'s, S> ToolCallContext<'s, S> { meta: _, name, arguments, - task, .. }: CallToolRequestParams, request_context: RequestContext, @@ -58,7 +56,6 @@ impl<'s, S> ToolCallContext<'s, S> { service, name, arguments, - task, } } pub fn name(&self) -> &str { @@ -120,6 +117,10 @@ impl IntoCallToolResult for Result "InputRequiredResult cannot be returned from a tool error branch", None, )), + Ok(CallToolResponse::Task(_)) => Err(crate::ErrorData::internal_error( + "CreateTaskResult cannot be returned from a tool error branch", + None, + )), Err(e) => Err(e), }, } diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 09d296e9a..865565d4f 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -783,6 +783,8 @@ 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")); + /// SEP-2663 Tasks extension: the result is a task handle ([`CreateTaskResult`]). + pub const TASK: Self = Self(Cow::Borrowed("task")); pub fn as_str(&self) -> &str { &self.0 @@ -797,6 +799,11 @@ impl ResultType { pub fn is_complete(&self) -> bool { self.0 == "complete" } + + /// Returns `true` if this is `"task"` (SEP-2663 Tasks extension). + pub fn is_task(&self) -> bool { + self.0 == "task" + } } impl Default for ResultType { @@ -2740,9 +2747,6 @@ pub struct CreateMessageRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, - /// Task metadata for async task management (SEP-1319) - #[serde(skip_serializing_if = "Option::is_none")] - pub task: Option, /// The conversation history and current messages pub messages: Vec, /// Preferences for model selection and behavior @@ -2782,21 +2786,11 @@ impl RequestParamsMeta for CreateMessageRequestParams { } } -impl TaskAugmentedRequestParamsMeta for CreateMessageRequestParams { - fn task(&self) -> Option<&TaskMetadata> { - self.task.as_ref() - } - fn task_mut(&mut self) -> &mut Option { - &mut self.task - } -} - impl CreateMessageRequestParams { /// Create a new CreateMessageRequestParams with required fields. pub fn new(messages: Vec, max_tokens: u32) -> Self { Self { meta: None, - task: None, messages, model_preferences: None, system_prompt: None, @@ -3917,9 +3911,6 @@ const_string!(CallToolRequestMethod = "tools/call"); /// /// Contains the tool name and optional arguments needed to execute /// the tool operation. -/// -/// This implements `TaskAugmentedRequestParamsMeta` as tool calls can be -/// long-running and may benefit from task-based execution. #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -3933,9 +3924,6 @@ pub struct CallToolRequestParams { /// Arguments to pass to the tool (must match the tool's input schema) #[serde(skip_serializing_if = "Option::is_none")] pub arguments: Option, - /// 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")] @@ -3953,7 +3941,6 @@ impl CallToolRequestParams { meta: None, name: name.into(), arguments: None, - task: None, input_responses: None, request_state: None, } @@ -3965,12 +3952,6 @@ impl CallToolRequestParams { self } - /// Sets the task metadata for this tool call. - pub fn with_task(mut self, task: TaskMetadata) -> Self { - 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); @@ -3993,15 +3974,6 @@ impl RequestParamsMeta for CallToolRequestParams { } } -impl TaskAugmentedRequestParamsMeta for CallToolRequestParams { - fn task(&self) -> Option<&TaskMetadata> { - self.task.as_ref() - } - fn task_mut(&mut self) -> &mut Option { - &mut self.task - } -} - /// Deprecated: Use [`CallToolRequestParams`] instead (SEP-1319 compliance). #[deprecated(since = "0.13.0", note = "Use CallToolRequestParams instead")] pub type CallToolRequestParam = CallToolRequestParams; @@ -4103,25 +4075,20 @@ impl GetPromptResult { } // ============================================================================= -// TASK MANAGEMENT +// TASK MANAGEMENT (SEP-2663 Tasks extension: `io.modelcontextprotocol/tasks`) // ============================================================================= const_string!(GetTaskMethod = "tasks/get"); pub type GetTaskRequest = Request; -#[deprecated(since = "2.0.0", note = "Renamed to GetTaskMethod")] -pub type GetTaskInfoMethod = GetTaskMethod; -#[deprecated(since = "2.0.0", note = "Renamed to GetTaskRequest")] -pub type GetTaskInfoRequest = GetTaskRequest; - #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct GetTaskParams { - /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, + /// Identifier of the task to query. pub task_id: String, } @@ -4143,44 +4110,37 @@ impl RequestParamsMeta for GetTaskParams { } } -#[deprecated(since = "2.0.0", note = "Renamed to GetTaskParams")] -pub type GetTaskInfoParams = GetTaskParams; - -#[deprecated(since = "0.13.0", note = "Use GetTaskParams instead")] -pub type GetTaskInfoParam = GetTaskParams; - -const_string!(ListTasksMethod = "tasks/list"); -pub type ListTasksRequest = RequestOptionalParam; - -const_string!(GetTaskPayloadMethod = "tasks/result"); -pub type GetTaskPayloadRequest = Request; - -#[deprecated(since = "2.0.0", note = "Renamed to GetTaskPayloadMethod")] -pub type GetTaskResultMethod = GetTaskPayloadMethod; -#[deprecated(since = "2.0.0", note = "Renamed to GetTaskPayloadRequest")] -pub type GetTaskResultRequest = GetTaskPayloadRequest; +const_string!(UpdateTaskMethod = "tasks/update"); +pub type UpdateTaskRequest = Request; +/// Parameters for `tasks/update` (SEP-2663): deliver responses to outstanding +/// in-task server-to-client requests surfaced via `tasks/get` `inputRequests`. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] -pub struct GetTaskPayloadParams { - /// Protocol-level metadata for this request (SEP-1319) +pub struct UpdateTaskParams { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, + /// Identifier of the task to update. pub task_id: String, + /// Responses to outstanding `inputRequests` previously surfaced by the + /// server. Each key MUST correspond to a currently-outstanding + /// `inputRequests` key. + pub input_responses: InputResponses, } -impl GetTaskPayloadParams { - pub fn new(task_id: impl Into) -> Self { +impl UpdateTaskParams { + pub fn new(task_id: impl Into, input_responses: InputResponses) -> Self { Self { meta: None, task_id: task_id.into(), + input_responses, } } } -impl RequestParamsMeta for GetTaskPayloadParams { +impl RequestParamsMeta for UpdateTaskParams { fn meta(&self) -> Option<&RequestMetaObject> { self.meta.as_ref() } @@ -4189,11 +4149,6 @@ impl RequestParamsMeta for GetTaskPayloadParams { } } -#[deprecated(since = "2.0.0", note = "Renamed to GetTaskPayloadParams")] -pub type GetTaskResultParams = GetTaskPayloadParams; -#[deprecated(since = "2.0.0", note = "Renamed to GetTaskPayloadParams")] -pub type GetTaskResultParam = GetTaskPayloadParams; - const_string!(CancelTaskMethod = "tasks/cancel"); pub type CancelTaskRequest = Request; @@ -4226,31 +4181,29 @@ impl RequestParamsMeta for CancelTaskParams { } } -/// Deprecated: Use [`CancelTaskParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use CancelTaskParams instead")] -pub type CancelTaskParam = CancelTaskParams; - // --------------------------------------------------------------------------- -// Task status notification (spec `notifications/tasks/status`) +// Task status notification (SEP-2663 `notifications/tasks`) // --------------------------------------------------------------------------- -const_string!(TaskStatusNotificationMethod = "notifications/tasks/status"); +const_string!(TaskStatusNotificationMethod = "notifications/tasks"); /// Parameters for a task status notification (spec `TaskStatusNotificationParams`). /// -/// The task fields are flattened at the top level: `NotificationParams & Task`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +/// Carries a complete [`DetailedTask`] for the current status, identical to +/// what `tasks/get` would have returned at that moment. The task fields are +/// flattened at the top level: `NotificationParams & Task`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] -pub struct TaskStatusNotificationParam { +pub struct TaskStatusNotificationParams { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, #[serde(flatten)] - pub task: crate::model::Task, + pub task: crate::model::DetailedTask, } -impl TaskStatusNotificationParam { - pub fn new(task: crate::model::Task) -> Self { +impl TaskStatusNotificationParams { + pub fn new(task: crate::model::DetailedTask) -> Self { Self { meta: None, task } } @@ -4260,53 +4213,28 @@ impl TaskStatusNotificationParam { } } -impl From for TaskStatusNotificationParam { - fn from(task: crate::model::Task) -> Self { +impl From for TaskStatusNotificationParams { + fn from(task: crate::model::DetailedTask) -> Self { Self::new(task) } } -impl Deref for TaskStatusNotificationParam { - type Target = crate::model::Task; +impl Deref for TaskStatusNotificationParams { + type Target = crate::model::DetailedTask; fn deref(&self) -> &Self::Target { &self.task } } -impl DerefMut for TaskStatusNotificationParam { +impl DerefMut for TaskStatusNotificationParams { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.task } } pub type TaskStatusNotification = - Notification; -/// Deprecated: Use [`GetTaskResult`] instead (spec alignment). -#[deprecated(since = "0.15.0", note = "Use GetTaskResult instead")] -pub type GetTaskInfoResult = GetTaskResult; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ListTasksResult { - pub tasks: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_cursor: Option, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -impl ListTasksResult { - pub fn new(tasks: Vec) -> Self { - Self { - tasks, - next_cursor: None, - meta: None, - } - } -} + Notification; // ============================================================================= // MESSAGE TYPE UNIONS @@ -4380,8 +4308,7 @@ ts_union!( | CallToolRequest | ListToolsRequest | GetTaskRequest - | ListTasksRequest - | GetTaskPayloadRequest + | UpdateTaskRequest | CancelTaskRequest | CustomRequest; ); @@ -4405,8 +4332,7 @@ impl ClientRequest { ClientRequest::CallToolRequest(r) => r.method.as_str(), ClientRequest::ListToolsRequest(r) => r.method.as_str(), ClientRequest::GetTaskRequest(r) => r.method.as_str(), - ClientRequest::ListTasksRequest(r) => r.method.as_str(), - ClientRequest::GetTaskPayloadRequest(r) => r.method.as_str(), + ClientRequest::UpdateTaskRequest(r) => r.method.as_str(), ClientRequest::CancelTaskRequest(r) => r.method.as_str(), ClientRequest::CustomRequest(r) => r.method.as_str(), } @@ -4419,7 +4345,6 @@ ts_union!( | ProgressNotification | InitializedNotification | RootsListChangedNotification - | TaskStatusNotification | CustomNotification; ); @@ -4477,12 +4402,9 @@ ts_union!( | ListToolsResult | ElicitResult | CreateTaskResult - | ListTasksResult | GetTaskResult - | CancelTaskResult | CallToolResult | InputRequiredResult - | GetTaskPayloadResult | EmptyResult | CustomResult ; @@ -4533,7 +4455,6 @@ mod tests { fn deprecated_aliases_still_resolve() { // 하위호환: 구 이름이 새 타입으로 여전히 resolve되는지 확인. let _: CreateElicitationResult = ElicitResult::new(ElicitationAction::Accept); - let _: GetTaskResultParams = GetTaskPayloadParams::new("task-1"); let _: ResourceReference = ResourceTemplateReference::new("res://x"); } diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index b42e40c67..f014f569f 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -72,130 +72,6 @@ pub struct RootsCapabilities { pub list_changed: Option, } -/// Task capabilities shared by client and server. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct TasksCapability { - #[serde(skip_serializing_if = "Option::is_none")] - pub requests: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub list: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cancel: Option, -} - -/// Request types that support task-augmented execution. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct TaskRequestsCapability { - #[serde(skip_serializing_if = "Option::is_none")] - pub sampling: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub elicitation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option, -} - -/// Sampling task capability. Deprecated by SEP-2577; remains functional and -/// will be removed in a future release. -/// See . -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct SamplingTaskCapability { - #[serde(skip_serializing_if = "Option::is_none")] - pub create_message: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ElicitationTaskCapability { - #[serde(skip_serializing_if = "Option::is_none")] - pub create: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ToolsTaskCapability { - #[serde(skip_serializing_if = "Option::is_none")] - pub call: Option, -} - -impl TasksCapability { - /// Default client tasks capability with sampling and elicitation support. - pub fn client_default() -> Self { - Self { - list: Some(JsonObject::new()), - cancel: Some(JsonObject::new()), - requests: Some(TaskRequestsCapability { - sampling: Some(SamplingTaskCapability { - create_message: Some(JsonObject::new()), - }), - elicitation: Some(ElicitationTaskCapability { - create: Some(JsonObject::new()), - }), - tools: None, - }), - } - } - - /// Default server tasks capability with tools/call support. - pub fn server_default() -> Self { - Self { - list: Some(JsonObject::new()), - cancel: Some(JsonObject::new()), - requests: Some(TaskRequestsCapability { - sampling: None, - elicitation: None, - tools: Some(ToolsTaskCapability { - call: Some(JsonObject::new()), - }), - }), - } - } - - pub fn supports_list(&self) -> bool { - self.list.is_some() - } - - pub fn supports_cancel(&self) -> bool { - self.cancel.is_some() - } - - pub fn supports_tools_call(&self) -> bool { - self.requests - .as_ref() - .and_then(|r| r.tools.as_ref()) - .and_then(|t| t.call.as_ref()) - .is_some() - } - - pub fn supports_sampling_create_message(&self) -> bool { - self.requests - .as_ref() - .and_then(|r| r.sampling.as_ref()) - .and_then(|s| s.create_message.as_ref()) - .is_some() - } - - pub fn supports_elicitation_create(&self) -> bool { - self.requests - .as_ref() - .and_then(|r| r.elicitation.as_ref()) - .and_then(|e| e.create.as_ref()) - .is_some() - } -} - /// Capability for handling elicitation requests from servers. /// Elicitation allows servers to request interactive input from users during tool execution. /// This capability indicates that a client can handle elicitation requests and present @@ -316,8 +192,16 @@ pub struct ClientCapabilities { /// Capability to handle elicitation requests from servers for interactive user input #[serde(skip_serializing_if = "Option::is_none")] pub elicitation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tasks: Option, +} + +impl ClientCapabilities { + /// Returns `true` if the `io.modelcontextprotocol/tasks` extension + /// (SEP-2663) is declared in [`Self::extensions`]. + pub fn supports_tasks(&self) -> bool { + self.extensions + .as_ref() + .is_some_and(|e| e.contains_key(super::TASKS_EXTENSION_ID)) + } } /// @@ -356,8 +240,16 @@ pub struct ServerCapabilities { pub resources: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tasks: Option, +} + +impl ServerCapabilities { + /// Returns `true` if the `io.modelcontextprotocol/tasks` extension + /// (SEP-2663) is declared in [`Self::extensions`]. + pub fn supports_tasks(&self) -> bool { + self.extensions + .as_ref() + .is_some_and(|e| e.contains_key(super::TASKS_EXTENSION_ID)) + } } #[cfg(any(feature = "server", feature = "macros"))] @@ -484,20 +376,12 @@ builder! { prompts: PromptsCapability, resources: ResourcesCapability, tools: ToolsCapability, - tasks: TasksCapability } } #[cfg(any(feature = "server", feature = "macros"))] -impl< - const E: bool, - const EXT: bool, - const L: bool, - const C: bool, - const P: bool, - const R: bool, - const TASKS: bool, -> ServerCapabilitiesBuilder> +impl + ServerCapabilitiesBuilder> { pub fn enable_tool_list_changed(mut self) -> Self { if let Some(c) = self.tools.as_mut() { @@ -508,15 +392,8 @@ impl< } #[cfg(any(feature = "server", feature = "macros"))] -impl< - const E: bool, - const EXT: bool, - const L: bool, - const C: bool, - const R: bool, - const T: bool, - const TASKS: bool, -> ServerCapabilitiesBuilder> +impl + ServerCapabilitiesBuilder> { pub fn enable_prompts_list_changed(mut self) -> Self { if let Some(c) = self.prompts.as_mut() { @@ -527,15 +404,8 @@ impl< } #[cfg(any(feature = "server", feature = "macros"))] -impl< - const E: bool, - const EXT: bool, - const L: bool, - const C: bool, - const P: bool, - const T: bool, - const TASKS: bool, -> ServerCapabilitiesBuilder> +impl + ServerCapabilitiesBuilder> { pub fn enable_resources_list_changed(mut self) -> Self { if let Some(c) = self.resources.as_mut() { @@ -552,6 +422,18 @@ impl< } } +#[cfg(any(feature = "server", feature = "macros"))] +impl ServerCapabilitiesBuilder { + /// Declare support for the `io.modelcontextprotocol/tasks` extension + /// (SEP-2663) in the `extensions` capability map. + pub fn enable_tasks(mut self) -> Self { + self.extensions + .get_or_insert_with(ExtensionCapabilities::new) + .insert(super::TASKS_EXTENSION_ID.to_string(), JsonObject::new()); + self + } +} + #[cfg(any(feature = "server", feature = "macros"))] builder! { ClientCapabilities{ @@ -568,13 +450,24 @@ builder! { )] sampling: SamplingCapability, elicitation: ElicitationCapability, - tasks: TasksCapability, } } #[cfg(any(feature = "server", feature = "macros"))] -impl - ClientCapabilitiesBuilder> +impl ClientCapabilitiesBuilder { + /// Declare support for the `io.modelcontextprotocol/tasks` extension + /// (SEP-2663) in the `extensions` capability map. + pub fn enable_tasks(mut self) -> Self { + self.extensions + .get_or_insert_with(ExtensionCapabilities::new) + .insert(super::TASKS_EXTENSION_ID.to_string(), JsonObject::new()); + self + } +} + +#[cfg(any(feature = "server", feature = "macros"))] +impl + ClientCapabilitiesBuilder> { #[deprecated( since = "1.8.0", @@ -589,8 +482,8 @@ impl - ClientCapabilitiesBuilder> +impl + ClientCapabilitiesBuilder> { /// Enable tool calling in sampling requests #[deprecated( @@ -618,8 +511,8 @@ impl - ClientCapabilitiesBuilder> +impl + ClientCapabilitiesBuilder> { /// Enable JSON Schema validation for elicitation responses in form mode. /// When enabled, the client will validate user input against the requested_schema @@ -679,68 +572,25 @@ mod test { } #[test] - fn test_task_capabilities_deserialization() { - // Test deserializing from the MCP spec format - let json = serde_json::json!({ - "list": {}, - "cancel": {}, - "requests": { - "tools": { "call": {} } - } - }); - - let tasks: TasksCapability = serde_json::from_value(json).unwrap(); - assert!(tasks.list.is_some()); - assert!(tasks.cancel.is_some()); - assert!(tasks.requests.is_some()); - let requests = tasks.requests.unwrap(); - assert!(requests.tools.is_some()); - assert!(requests.tools.unwrap().call.is_some()); - } - - #[test] - fn test_tasks_capability_client_default() { - let tasks = TasksCapability::client_default(); - - // Verify structure - assert!(tasks.supports_list()); - assert!(tasks.supports_cancel()); - assert!(tasks.supports_sampling_create_message()); - assert!(tasks.supports_elicitation_create()); - assert!(!tasks.supports_tools_call()); - - // Verify serialization matches expected format - let json = serde_json::to_value(&tasks).unwrap(); - assert_eq!(json["list"], serde_json::json!({})); - assert_eq!(json["cancel"], serde_json::json!({})); + fn test_tasks_extension_capability() { + // SEP-2663: tasks are declared via the extensions map. + let capabilities = ClientCapabilities::builder().enable_tasks().build(); + assert!(capabilities.supports_tasks()); + let json = serde_json::to_value(&capabilities).unwrap(); assert_eq!( - json["requests"]["sampling"]["createMessage"], + json["extensions"][crate::model::TASKS_EXTENSION_ID], serde_json::json!({}) ); + + let server = ServerCapabilities::builder().enable_tasks().build(); + assert!(server.supports_tasks()); + let json = serde_json::to_value(&server).unwrap(); assert_eq!( - json["requests"]["elicitation"]["create"], + json["extensions"][crate::model::TASKS_EXTENSION_ID], serde_json::json!({}) ); } - #[test] - fn test_tasks_capability_server_default() { - let tasks = TasksCapability::server_default(); - - // Verify structure - assert!(tasks.supports_list()); - assert!(tasks.supports_cancel()); - assert!(tasks.supports_tools_call()); - assert!(!tasks.supports_sampling_create_message()); - assert!(!tasks.supports_elicitation_create()); - - // Verify serialization matches expected format - let json = serde_json::to_value(&tasks).unwrap(); - assert_eq!(json["list"], serde_json::json!({})); - assert_eq!(json["cancel"], serde_json::json!({})); - assert_eq!(json["requests"]["tools"]["call"], serde_json::json!({})); - } - #[test] #[allow(deprecated)] fn test_client_extensions_capability() { diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index d86156aa9..0f7d90ce6 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -6,7 +6,7 @@ use serde_json::Value; use super::{ ClientCapabilities, ClientNotification, ClientRequest, CustomNotification, CustomRequest, Extensions, Implementation, JsonObject, JsonRpcMessage, LoggingLevel, ProgressToken, - ProtocolVersion, RequestId, ServerNotification, ServerRequest, TaskMetadata, + ProtocolVersion, RequestId, ServerNotification, ServerRequest, }; /// Access to the metadata carried by a message envelope's [`Extensions`]. @@ -94,21 +94,6 @@ pub trait RequestParamsMeta { } } -/// Trait for task-augmented request params that contain both `_meta` and `task` fields. -/// -/// Per the MCP 2025-11-25 spec, certain requests (like `tools/call` and `sampling/createMessage`) -/// can include a `task` field to signal that the caller wants task-augmented execution. -pub trait TaskAugmentedRequestParamsMeta: RequestParamsMeta { - /// Get a reference to the task field - fn task(&self) -> Option<&TaskMetadata>; - /// Get a mutable reference to the task field - fn task_mut(&mut self) -> &mut Option; - /// Set the task field - fn set_task(&mut self, task: TaskMetadata) { - *self.task_mut() = Some(task); - } -} - impl GetExtensions for CustomNotification { fn extensions(&self) -> &Extensions { &self.extensions @@ -204,8 +189,7 @@ variant_extension! { ListToolsRequest CustomRequest GetTaskRequest - ListTasksRequest - GetTaskPayloadRequest + UpdateTaskRequest CancelTaskRequest } } @@ -226,7 +210,6 @@ variant_extension! { ProgressNotification InitializedNotification RootsListChangedNotification - TaskStatusNotification CustomNotification } } diff --git a/crates/rmcp/src/model/mrtr.rs b/crates/rmcp/src/model/mrtr.rs index 7e08a5cc0..40621a7cd 100644 --- a/crates/rmcp/src/model/mrtr.rs +++ b/crates/rmcp/src/model/mrtr.rs @@ -42,8 +42,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use super::{ - CallToolResult, CreateMessageRequest, ElicitRequest, GetPromptResult, ListRootsRequest, - MetaObject, ReadResourceResult, ResultType, ServerResult, + CallToolResult, CreateMessageRequest, CreateTaskResult, ElicitRequest, GetPromptResult, + ListRootsRequest, MetaObject, ReadResourceResult, ResultType, ServerResult, }; /// Default maximum number of MRTR rounds a high-level client call will drive. @@ -72,6 +72,18 @@ pub enum InputRequest { ListRoots(ListRootsRequest), } +// Wire-level equality: two `InputRequest`s are equal if they serialize to the +// same JSON. The wrapped request envelopes do not implement `PartialEq` +// structurally (they carry `Extensions`). +impl PartialEq for InputRequest { + fn eq(&self, other: &Self) -> bool { + match (serde_json::to_value(self), serde_json::to_value(other)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } + } +} + /// A map of server-initiated requests that the client must fulfill. /// /// Keys are server-assigned string identifiers; values are request objects @@ -95,6 +107,9 @@ pub enum CallToolResponse { Complete(CallToolResult), /// The server requires client-side input before the tool call can complete. InputRequired(InputRequiredResult), + /// The server materialized a task for this call (SEP-2663 Tasks extension, + /// `resultType: "task"`). The client polls `tasks/get` for the result. + Task(CreateTaskResult), } impl From for CallToolResponse { @@ -114,10 +129,17 @@ impl From for ServerResult { match response { CallToolResponse::Complete(result) => ServerResult::CallToolResult(result), CallToolResponse::InputRequired(result) => ServerResult::InputRequiredResult(result), + CallToolResponse::Task(result) => ServerResult::CreateTaskResult(result), } } } +impl From for CallToolResponse { + fn from(result: CreateTaskResult) -> Self { + Self::Task(result) + } +} + /// Result of a `prompts/get` request, including the MRTR intermediate result. #[derive(Debug, Clone)] #[non_exhaustive] diff --git a/crates/rmcp/src/model/serde_impl.rs b/crates/rmcp/src/model/serde_impl.rs index 6a7197ae5..b974722af 100644 --- a/crates/rmcp/src/model/serde_impl.rs +++ b/crates/rmcp/src/model/serde_impl.rs @@ -497,7 +497,6 @@ mod test { meta: Some(params_meta), name: "my_tool".into(), arguments: None, - task: None, input_responses: None, request_state: None, }, @@ -537,7 +536,6 @@ mod test { meta: None, name: "my_tool".into(), arguments: None, - task: None, input_responses: None, request_state: None, }, @@ -560,7 +558,6 @@ mod test { meta: Some(params_meta), name: "my_tool".into(), arguments: None, - task: None, input_responses: None, request_state: None, }, @@ -580,7 +577,6 @@ mod test { meta: None, name: "my_tool".into(), arguments: None, - task: None, input_responses: None, request_state: None, }, @@ -612,7 +608,6 @@ mod test { meta: Some(params_meta), name: "my_tool".into(), arguments: None, - task: None, input_responses: None, request_state: None, }, @@ -638,7 +633,6 @@ mod test { meta: None, name: "my_tool".into(), arguments: Some(serde_json::Map::from_iter([("x".to_string(), json!(1))])), - task: None, input_responses: None, request_state: None, }, @@ -798,7 +792,6 @@ mod test { meta: None, name: "my_tool".into(), arguments: None, - task: None, input_responses: None, request_state: None, }, @@ -826,7 +819,6 @@ mod test { meta: None, name: "my_tool".into(), arguments: None, - task: None, input_responses: None, request_state: None, }, diff --git a/crates/rmcp/src/model/task.rs b/crates/rmcp/src/model/task.rs index e57fdd872..f52461669 100644 --- a/crates/rmcp/src/model/task.rs +++ b/crates/rmcp/src/model/task.rs @@ -1,114 +1,90 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use super::MetaObject; - -/// Metadata for augmenting a request with task execution (spec `TaskMetadata`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct TaskMetadata { - #[serde(skip_serializing_if = "Option::is_none")] - pub ttl: Option, -} - -impl TaskMetadata { - pub fn new() -> Self { - Self::default() - } - - pub fn with_ttl(mut self, ttl: u64) -> Self { - self.ttl = Some(ttl); - self - } -} +//! Task types for the MCP Tasks extension (SEP-2663). +//! +//! Tasks are defined by the official `io.modelcontextprotocol/tasks` extension. +//! A server may respond to a supported request (currently `tools/call`) with a +//! [`CreateTaskResult`] (`resultType: "task"`) instead of the standard result. +//! The client then polls `tasks/get`, answers in-task server-to-client requests +//! via `tasks/update`, and may signal cancellation via `tasks/cancel`. -/// Metadata for associating messages with a task (spec `RelatedTaskMetadata`). -/// -/// Carried in `_meta` under the key `"io.modelcontextprotocol/related-task"`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct RelatedTaskMetadata { - pub task_id: String, -} +use serde::{Deserialize, Serialize}; -impl RelatedTaskMetadata { - pub fn new(task_id: impl Into) -> Self { - Self { - task_id: task_id.into(), - } - } +use super::{InputRequests, JsonObject, MetaObject, ResultType}; - /// The well-known `_meta` key for related-task metadata. - pub const META_KEY: &str = "io.modelcontextprotocol/related-task"; -} +/// Extension identifier for the MCP Tasks extension (SEP-2663). +pub const TASKS_EXTENSION_ID: &str = "io.modelcontextprotocol/tasks"; -/// Canonical task lifecycle status as defined by SEP-1686. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +/// Canonical task lifecycle status (SEP-2663). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub enum TaskStatus { - /// The receiver accepted the request and is currently working on it. + /// The request is currently being processed. #[default] Working, - /// The receiver requires additional input before work can continue. + /// The server needs input from the client before the task can proceed. InputRequired, - /// The underlying operation completed successfully and the result is ready. + /// The request completed successfully and the result is available. + /// This includes tool calls that returned results with `isError: true`. Completed, - /// The underlying operation failed and will not continue. + /// The request failed due to a JSON-RPC error during execution. Failed, - /// The task was cancelled and will not continue processing. + /// The request was cancelled before completion. Cancelled, } -/// Primary Task object that surfaces metadata during the task lifecycle. -/// -/// Per spec, `lastUpdatedAt` and `ttl` are required fields. -/// `ttl` is nullable (`null` means unlimited retention). +impl TaskStatus { + /// Returns `true` for terminal statuses (`completed`, `failed`, `cancelled`). + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Cancelled) + } +} + +/// Operational metadata about ongoing work (spec `Task`, SEP-2663). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct Task { - /// Unique task identifier generated by the receiver. + /// Stable identifier for this task, generated by the server. pub task_id: String, - /// Current lifecycle status (see [`TaskStatus`]). + /// Current task status. pub status: TaskStatus, - /// Optional human-readable status message for UI surfaces. + /// Optional message describing the current task state. + /// This MAY be exposed to the end-user or model. #[serde(skip_serializing_if = "Option::is_none")] pub status_message: Option, - /// ISO-8601 creation timestamp. + /// ISO 8601 timestamp when the task was created. pub created_at: String, - /// ISO-8601 timestamp for the most recent status change. + /// ISO 8601 timestamp when the task was last updated. pub last_updated_at: String, - /// Retention window in milliseconds that the receiver agreed to honor. - /// `None` (serialized as `null`) means unlimited retention. - pub ttl: Option, - /// Suggested polling interval (milliseconds). + /// Time-to-live duration from creation in integer milliseconds; `None` + /// (serialized as `null`) means unlimited. The server may discard the task + /// after the TTL elapses. This value MAY change over the lifetime of a task. + pub ttl_ms: Option, + /// Suggested polling interval in integer milliseconds. Clients SHOULD honor + /// this value to avoid overwhelming the server. This value MAY change over + /// the lifetime of a task. #[serde(skip_serializing_if = "Option::is_none")] - pub poll_interval: Option, + pub poll_interval_ms: Option, } impl Task { - /// Create a new Task with required fields. + /// Create a new task with required fields. pub fn new( - task_id: String, + task_id: impl Into, status: TaskStatus, - created_at: String, - last_updated_at: String, + created_at: impl Into, + last_updated_at: impl Into, ) -> Self { Self { - task_id, + task_id: task_id.into(), status, status_message: None, - created_at, - last_updated_at, - ttl: None, - poll_interval: None, + created_at: created_at.into(), + last_updated_at: last_updated_at.into(), + ttl_ms: None, + poll_interval_ms: None, } } @@ -119,33 +95,206 @@ impl Task { } /// Set the TTL in milliseconds. `None` means unlimited retention. - pub fn with_ttl(mut self, ttl: u64) -> Self { - self.ttl = Some(ttl); + pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { + self.ttl_ms = Some(ttl_ms); self } - /// Set the poll interval in milliseconds. - pub fn with_poll_interval(mut self, poll_interval: u64) -> Self { - self.poll_interval = Some(poll_interval); + /// Set the suggested poll interval in milliseconds. + pub fn with_poll_interval_ms(mut self, poll_interval_ms: u64) -> Self { + self.poll_interval_ms = Some(poll_interval_ms); self } } -/// Wrapper returned by task-augmented requests (CreateTaskResult in SEP-1686). +/// Status-specific payload carried alongside the base [`Task`] fields in a +/// [`DetailedTask`]. +/// +/// Mirrors the spec's `WorkingTask` / `InputRequiredTask` / `CompletedTask` / +/// `FailedTask` / `CancelledTask` union: the variant is discriminated by the +/// `status` field on the wire, with the payload fields inlined at the top level. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum TaskPayload { + /// `status: "working"` — no additional payload. + Working, + /// `status: "input_required"` — outstanding server-to-client requests. + InputRequired { + /// Server-to-client requests that need to be fulfilled during task + /// execution. Keys are arbitrary identifiers for matching requests + /// to responses, unique over the lifetime of the task. + input_requests: InputRequests, + }, + /// `status: "completed"` — the final result of the task. The structure + /// matches the result type of the original request (e.g. `CallToolResult`). + Completed { + /// The final result of the original request. + result: JsonObject, + }, + /// `status: "failed"` — the JSON-RPC error that caused the task to fail. + Failed { + /// The JSON-RPC error object. + error: JsonObject, + }, + /// `status: "cancelled"` — no additional payload. + Cancelled, +} + +impl TaskPayload { + /// The [`TaskStatus`] this payload corresponds to. + pub fn status(&self) -> TaskStatus { + match self { + Self::Working => TaskStatus::Working, + Self::InputRequired { .. } => TaskStatus::InputRequired, + Self::Completed { .. } => TaskStatus::Completed, + Self::Failed { .. } => TaskStatus::Failed, + Self::Cancelled => TaskStatus::Cancelled, + } + } +} + +/// A task with its status-specific payload inlined (spec `DetailedTask`). +/// +/// Used by `tasks/get` responses ([`GetTaskResult`]) and `notifications/tasks` +/// ([`TaskStatusNotificationParams`](crate::model::TaskStatusNotificationParams)). +/// On the wire, the payload fields (`inputRequests` / `result` / `error`) are +/// flattened at the top level next to the base [`Task`] fields, and `status` +/// discriminates the variant. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct DetailedTask { + /// Base task metadata. Its `status` always agrees with the payload. + pub task: Task, + /// Status-specific payload. + pub payload: TaskPayload, +} + +impl DetailedTask { + /// Build a `DetailedTask`, forcing `task.status` to match the payload. + pub fn new(mut task: Task, payload: TaskPayload) -> Self { + task.status = payload.status(); + Self { task, payload } + } + + /// The current status. + pub fn status(&self) -> TaskStatus { + self.task.status + } +} + +// Wire shape helper: base Task fields + optional payload fields, all flattened. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DetailedTaskWire { + #[serde(flatten)] + task: Task, + #[serde(skip_serializing_if = "Option::is_none")] + input_requests: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl From for DetailedTaskWire { + fn from(value: DetailedTask) -> Self { + let DetailedTask { task, payload } = value; + let (input_requests, result, error) = match payload { + TaskPayload::Working | TaskPayload::Cancelled => (None, None, None), + TaskPayload::InputRequired { input_requests } => (Some(input_requests), None, None), + TaskPayload::Completed { result } => (None, Some(result), None), + TaskPayload::Failed { error } => (None, None, Some(error)), + }; + Self { + task, + input_requests, + result, + error, + } + } +} + +impl TryFrom for DetailedTask { + type Error = String; + fn try_from(wire: DetailedTaskWire) -> Result { + let payload = match wire.task.status { + TaskStatus::Working => TaskPayload::Working, + TaskStatus::Cancelled => TaskPayload::Cancelled, + TaskStatus::InputRequired => TaskPayload::InputRequired { + input_requests: wire.input_requests.ok_or_else(|| { + "task with status \"input_required\" is missing `inputRequests`".to_owned() + })?, + }, + TaskStatus::Completed => TaskPayload::Completed { + result: wire.result.ok_or_else(|| { + "task with status \"completed\" is missing `result`".to_owned() + })?, + }, + TaskStatus::Failed => TaskPayload::Failed { + error: wire + .error + .ok_or_else(|| "task with status \"failed\" is missing `error`".to_owned())?, + }, + }; + Ok(DetailedTask { + task: wire.task, + payload, + }) + } +} + +impl Serialize for DetailedTask { + fn serialize(&self, serializer: S) -> Result { + DetailedTaskWire::from(self.clone()).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for DetailedTask { + fn deserialize>(deserializer: D) -> Result { + let wire = DetailedTaskWire::deserialize(deserializer)?; + Self::try_from(wire).map_err(serde::de::Error::custom) + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for DetailedTask { + fn schema_name() -> std::borrow::Cow<'static, str> { + "DetailedTask".into() + } + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + // Approximate with the wire shape (base Task + optional payload fields). + ::json_schema(generator) + } +} + +/// Result returned in lieu of a standard result to indicate the request will +/// be processed asynchronously (spec `CreateTaskResult`, `resultType: "task"`). +/// +/// The embedded task is the seed state for the task; the client uses +/// `task.task_id` for all subsequent `tasks/get`, `tasks/update`, and +/// `tasks/cancel` calls. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct CreateTaskResult { + /// Always `"task"`. + pub result_type: ResultType, + /// Seed state of the newly created task, flattened at the top level. + #[serde(flatten)] pub task: Task, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, } impl CreateTaskResult { - /// Create a new CreateTaskResult. + /// Create a new `CreateTaskResult` from the seed task state. pub fn new(task: Task) -> Self { - Self { task, meta: None } + Self { + result_type: ResultType::TASK, + task, + meta: None, + } } /// Sets the protocol-level metadata for this result. @@ -155,10 +304,10 @@ impl CreateTaskResult { } } -/// Response to a `tasks/get` request. +/// Response to a `tasks/get` request (spec `GetTaskResult = Result & DetailedTask`). /// -/// Per spec, `GetTaskResult = allOf[Result, Task]` — the Task fields are -/// flattened at the top level, not nested under a `task` key. +/// `resultType` is `"complete"` — this is the standard result shape for +/// `tasks/get`, not a task handle. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -166,69 +315,127 @@ impl CreateTaskResult { pub struct GetTaskResult { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, + /// The task with status-specific payload inlined. #[serde(flatten)] - pub task: Task, + pub task: DetailedTask, } impl GetTaskResult { - pub fn new(task: Task) -> Self { + pub fn new(task: DetailedTask) -> Self { Self { meta: None, task } } } -/// Response to a `tasks/result` request. -/// -/// Per spec, the result structure matches the original request type -/// (e.g., `CallToolResult` for `tools/call`). This is represented as -/// an open object. The payload is the original request's result -/// serialized as a JSON value. -#[derive(Debug, Clone, PartialEq, Serialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct GetTaskPayloadResult(pub Value); +#[cfg(test)] +mod tests { + use serde_json::json; -impl GetTaskPayloadResult { - /// Create a new GetTaskPayloadResult with the given value. - pub fn new(value: Value) -> Self { - Self(value) + use super::*; + + fn base_task(status: TaskStatus) -> Task { + Task::new( + "task-1", + status, + "2025-11-25T10:30:00Z", + "2025-11-25T10:40:00Z", + ) + .with_ttl_ms(60000) + .with_poll_interval_ms(5000) } -} -// Custom Deserialize that always fails, so that `GetTaskPayloadResult` is skipped -// during `#[serde(untagged)]` enum deserialization (e.g. `ServerResult`). -// The payload has the same JSON shape as `CustomResult(Value)`, so they are -// indistinguishable. `CustomResult` acts as the catch-all instead. -// `GetTaskPayloadResult` should be constructed programmatically via `::new()`. -impl<'de> serde::Deserialize<'de> for GetTaskPayloadResult { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - // Consume the value so the deserializer state stays consistent. - serde::de::IgnoredAny::deserialize(deserializer)?; - Err(serde::de::Error::custom( - "GetTaskPayloadResult cannot be deserialized directly; \ - use CustomResult as the catch-all", - )) + #[test] + fn create_task_result_wire_shape() { + let result = CreateTaskResult::new(base_task(TaskStatus::Working)); + let value = serde_json::to_value(&result).unwrap(); + assert_eq!( + value, + json!({ + "resultType": "task", + "taskId": "task-1", + "status": "working", + "createdAt": "2025-11-25T10:30:00Z", + "lastUpdatedAt": "2025-11-25T10:40:00Z", + "ttlMs": 60000, + "pollIntervalMs": 5000 + }) + ); + let roundtrip: CreateTaskResult = serde_json::from_value(value).unwrap(); + assert_eq!(roundtrip, result); } -} -/// Response to a `tasks/cancel` request. -/// -/// Per spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct CancelTaskResult { - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - #[serde(flatten)] - pub task: Task, -} + #[test] + fn ttl_ms_null_means_unlimited() { + let mut task = base_task(TaskStatus::Working); + task.ttl_ms = None; + let value = serde_json::to_value(&task).unwrap(); + assert_eq!(value["ttlMs"], serde_json::Value::Null); + let roundtrip: Task = serde_json::from_value(value).unwrap(); + assert_eq!(roundtrip.ttl_ms, None); + } -impl CancelTaskResult { - pub fn new(task: Task) -> Self { - Self { meta: None, task } + #[test] + fn detailed_task_completed_roundtrip() { + let detailed = DetailedTask::new( + base_task(TaskStatus::Working), + TaskPayload::Completed { + result: serde_json::from_value(json!({ + "content": [{"type": "text", "text": "ok"}], + "isError": false + })) + .unwrap(), + }, + ); + // Status is forced to match the payload. + assert_eq!(detailed.status(), TaskStatus::Completed); + let value = serde_json::to_value(&detailed).unwrap(); + assert_eq!(value["status"], "completed"); + assert_eq!(value["result"]["isError"], false); + let roundtrip: DetailedTask = serde_json::from_value(value).unwrap(); + assert_eq!(roundtrip, detailed); + } + + #[test] + fn detailed_task_input_required_requires_input_requests() { + let err = serde_json::from_value::(json!({ + "taskId": "task-1", + "status": "input_required", + "createdAt": "2025-11-25T10:30:00Z", + "lastUpdatedAt": "2025-11-25T10:40:00Z", + "ttlMs": null + })) + .unwrap_err(); + assert!(err.to_string().contains("inputRequests")); + } + + #[test] + fn detailed_task_failed_roundtrip() { + let detailed = DetailedTask::new( + base_task(TaskStatus::Failed), + TaskPayload::Failed { + error: serde_json::from_value(json!({ + "code": -32603, + "message": "boom" + })) + .unwrap(), + }, + ); + let value = serde_json::to_value(&detailed).unwrap(); + assert_eq!(value["status"], "failed"); + assert_eq!(value["error"]["code"], -32603); + let roundtrip: DetailedTask = serde_json::from_value(value).unwrap(); + assert_eq!(roundtrip, detailed); + } + + #[test] + fn get_task_result_flattens_detailed_task() { + let result = GetTaskResult::new(DetailedTask::new( + base_task(TaskStatus::Working), + TaskPayload::Working, + )); + let value = serde_json::to_value(&result).unwrap(); + assert_eq!(value["taskId"], "task-1"); + assert_eq!(value["status"], "working"); + let roundtrip: GetTaskResult = serde_json::from_value(value).unwrap(); + assert_eq!(roundtrip, result); } } diff --git a/crates/rmcp/src/model/tool.rs b/crates/rmcp/src/model/tool.rs index ec2ad741a..be7cbbd0a 100644 --- a/crates/rmcp/src/model/tool.rs +++ b/crates/rmcp/src/model/tool.rs @@ -31,9 +31,6 @@ pub struct Tool { #[serde(skip_serializing_if = "Option::is_none")] /// Optional additional tool information. pub annotations: Option, - /// Execution-related configuration including task support mode. - #[serde(skip_serializing_if = "Option::is_none")] - pub execution: Option, /// Optional list of icons for the tool #[serde(skip_serializing_if = "Option::is_none")] pub icons: Option>, @@ -42,62 +39,6 @@ pub struct Tool { pub meta: Option, } -/// Per-tool task support mode as defined in the MCP specification. -/// -/// This enum indicates whether a tool supports task-based invocation, -/// allowing clients to know how to properly call the tool. -/// -/// See [Tool-Level Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] -pub enum TaskSupport { - /// Clients MUST NOT invoke this tool as a task (default behavior). - #[default] - Forbidden, - /// Clients MAY invoke this tool as either a task or a normal call. - Optional, - /// Clients MUST invoke this tool as a task. - Required, -} - -/// Execution-related configuration for a tool. -/// -/// This struct contains settings that control how a tool should be executed, -/// including task support configuration. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ToolExecution { - /// Indicates whether this tool supports task-based invocation. - /// - /// When not present or set to `Forbidden`, clients MUST NOT invoke this tool as a task. - /// When set to `Optional`, clients MAY invoke this tool as a task or normal call. - /// When set to `Required`, clients MUST invoke this tool as a task. - #[serde(skip_serializing_if = "Option::is_none")] - pub task_support: Option, -} - -impl ToolExecution { - /// Create a new empty ToolExecution configuration. - pub fn new() -> Self { - Self::default() - } - - /// Create a ToolExecution from raw optional fields. - pub fn from_raw(task_support: Option) -> Self { - Self { task_support } - } - - /// Set the task support mode. - pub fn with_task_support(mut self, task_support: TaskSupport) -> Self { - self.task_support = Some(task_support); - self - } -} - /// Additional properties describing a Tool to clients. /// /// NOTE: all properties in ToolAnnotations are **hints**. @@ -232,7 +173,6 @@ impl Tool { input_schema: input_schema.into(), output_schema: None, annotations: None, - execution: None, icons: None, meta: None, } @@ -255,7 +195,6 @@ impl Tool { input_schema: input_schema.into(), output_schema: None, annotations: None, - execution: None, icons: None, meta: None, } @@ -298,22 +237,6 @@ impl Tool { } } - /// Set the execution configuration for this tool. - pub fn with_execution(mut self, execution: ToolExecution) -> Self { - self.execution = Some(execution); - self - } - - /// Returns the task support mode for this tool. - /// - /// Returns `TaskSupport::Forbidden` if not explicitly set. - pub fn task_support(&self) -> TaskSupport { - self.execution - .as_ref() - .and_then(|e| e.task_support) - .unwrap_or_default() - } - /// Set the output schema using a type that implements JsonSchema #[cfg(feature = "server")] pub fn with_output_schema(mut self) -> Self { diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 093a537b1..3d9409109 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -8,22 +8,23 @@ use super::*; use crate::{ model::{ ArgumentInfo, CallToolRequest, CallToolRequestParams, CallToolResponse, CallToolResult, - CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, - ClientNotification, ClientRequest, ClientResult, CompleteRequest, CompleteRequestParams, - CompleteResult, CompletionContext, CompletionInfo, DEFAULT_MRTR_MAX_ROUNDS, - DiscoverRequest, DiscoverRequestParams, DiscoverResult, ErrorData, GetExtensions, GetMeta, - GetPromptRequest, GetPromptRequestParams, GetPromptResponse, GetPromptResult, - InitializeRequest, InitializedNotification, InputRequest, InputRequiredResult, - InputResponses, JsonRpcResponse, ListPromptsRequest, ListPromptsResult, - ListResourceTemplatesRequest, ListResourceTemplatesResult, ListResourcesRequest, - ListResourcesResult, ListToolsRequest, ListToolsResult, NumberOrString, - PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, ProtocolVersion, - ReadResourceRequest, ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, - Reference, RequestId, RequestMetaObject, RootsListChangedNotification, ServerInfo, - ServerJsonRpcMessage, ServerNotification, ServerRequest, ServerResult, SetLevelRequest, - SetLevelRequestParams, SubscribeRequest, SubscribeRequestParams, SubscriptionFilter, - SubscriptionsListenRequest, SubscriptionsListenRequestParams, SubscriptionsListenResult, - UnsubscribeRequest, UnsubscribeRequestParams, + CancelTaskParams, CancelTaskRequest, CancelledNotification, CancelledNotificationParam, + ClientInfo, ClientJsonRpcMessage, ClientNotification, ClientRequest, ClientResult, + CompleteRequest, CompleteRequestParams, CompleteResult, CompletionContext, CompletionInfo, + DEFAULT_MRTR_MAX_ROUNDS, DiscoverRequest, DiscoverRequestParams, DiscoverResult, ErrorData, + GetExtensions, GetMeta, GetPromptRequest, GetPromptRequestParams, GetPromptResponse, + GetPromptResult, GetTaskParams, GetTaskRequest, GetTaskResult, InitializeRequest, + InitializedNotification, InputRequest, InputRequiredResult, InputResponses, + JsonRpcResponse, ListPromptsRequest, ListPromptsResult, ListResourceTemplatesRequest, + ListResourceTemplatesResult, ListResourcesRequest, ListResourcesResult, ListToolsRequest, + ListToolsResult, NumberOrString, PaginatedRequestParams, ProgressNotification, + ProgressNotificationParam, ProtocolVersion, ReadResourceRequest, ReadResourceRequestParams, + ReadResourceResponse, ReadResourceResult, Reference, RequestId, RequestMetaObject, + RootsListChangedNotification, ServerInfo, ServerJsonRpcMessage, ServerNotification, + ServerRequest, ServerResult, SetLevelRequest, SetLevelRequestParams, SubscribeRequest, + SubscribeRequestParams, SubscriptionFilter, SubscriptionsListenRequest, + SubscriptionsListenRequestParams, SubscriptionsListenResult, UnsubscribeRequest, + UnsubscribeRequestParams, UpdateTaskParams, UpdateTaskRequest, }, transport::DynamicTransportError, }; @@ -1044,6 +1045,47 @@ impl Peer { ServerResult::InputRequiredResult(result) => { Ok(CallToolResponse::InputRequired(result)) } + // SEP-2663 Tasks extension: the server materialized a task. + ServerResult::CreateTaskResult(result) => Ok(CallToolResponse::Task(result)), + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// SEP-2663 `tasks/get`: poll the current state of a task. + pub async fn get_task(&self, params: GetTaskParams) -> Result { + let result = self + .send_request(ClientRequest::GetTaskRequest(GetTaskRequest::new(params))) + .await?; + match result { + ServerResult::GetTaskResult(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// SEP-2663 `tasks/update`: deliver responses to outstanding in-task + /// input requests. The acknowledgement is eventually consistent. + pub async fn update_task(&self, params: UpdateTaskParams) -> Result<(), ServiceError> { + let result = self + .send_request(ClientRequest::UpdateTaskRequest(UpdateTaskRequest::new( + params, + ))) + .await?; + match result { + ServerResult::EmptyResult(_) => Ok(()), + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// SEP-2663 `tasks/cancel`: signal intent to cancel a task. Cancellation + /// is cooperative; the ack does not guarantee the task stops. + pub async fn cancel_task(&self, params: CancelTaskParams) -> Result<(), ServiceError> { + let result = self + .send_request(ClientRequest::CancelTaskRequest(CancelTaskRequest::new( + params, + ))) + .await?; + match result { + ServerResult::EmptyResult(_) => Ok(()), _ => Err(ServiceError::UnexpectedResponse), } } @@ -1209,7 +1251,7 @@ impl Peer { /// /// # Arguments /// * `prompt_name` - Name of the prompt being completed - /// * `argument_name` - Name of the argument being completed + /// * `argument_name` - Name of the argument being completed /// * `current_value` - Current partial value of the argument /// * `context` - Optional context with previously resolved arguments /// @@ -1368,6 +1410,10 @@ where params.input_responses = input_responses; params.request_state = request_state; } + // SEP-2663: this helper does not drive the task polling + // lifecycle. Callers that declare the tasks extension + // capability should use `call_tool_once` and poll `tasks/get`. + CallToolResponse::Task(_) => return Err(ServiceError::UnexpectedResponse), } } Err(ServiceError::InputRequiredRoundsExceeded { max_rounds }) diff --git a/crates/rmcp/src/task_manager.rs b/crates/rmcp/src/task_manager.rs index 21adb38b3..fb4168536 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -1,307 +1,559 @@ -use std::{any::Any, collections::HashMap, pin::Pin}; +//! Server-side runtime for the MCP Tasks extension (SEP-2663, +//! `io.modelcontextprotocol/tasks`). +//! +//! [`TaskManager`] owns the durable state for tasks a server has materialized +//! in response to task-eligible requests (currently `tools/call`). It: +//! +//! - spawns the underlying operation and tracks its lifecycle as a +//! [`DetailedTask`] (`working` → terminal, optionally via `input_required`), +//! - answers `tasks/get` with the current state (including in-flight +//! `inputRequests` and terminal `result`/`error` payloads), +//! - accepts `tasks/update` `inputResponses` and routes them to the running +//! operation (ignoring unknown or already-answered keys per spec), +//! - handles cooperative `tasks/cancel`, +//! - enforces TTL-based expiry (`ttl_ms`), marking overdue tasks `failed`. +//! +//! Tasks are only durably observable once [`TaskManager::spawn`] returns, +//! satisfying the spec requirement that a server not return `CreateTaskResult` +//! before `tasks/get` for that id would resolve. + +use std::{ + collections::HashMap, + pin::Pin, + sync::{Arc, Mutex}, + time::Instant, +}; use futures::Future; -use tokio::{ - sync::mpsc, - time::{Duration, timeout}, -}; +use tokio::sync::oneshot; use crate::{ - RoleServer, - error::{ErrorData as McpError, RmcpError as Error}, - model::{CallToolResult, ClientRequest}, - service::RequestContext, + error::ErrorData as McpError, + model::{ + CallToolResult, DetailedTask, InputRequest, InputRequests, JsonObject, Task, TaskPayload, + TaskStatus, + }, }; -/// Boxed future that represents an asynchronous operation managed by the processor. -pub type OperationFuture = - Pin, Error>> + Send>>; +/// Default TTL (5 minutes, in milliseconds) applied when none is specified. +pub const DEFAULT_TASK_TTL_MS: u64 = 300_000; -/// Describes metadata associated with an enqueued task. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct OperationDescriptor { - pub operation_id: String, - pub name: String, - pub client_request: Option, - pub context: Option>, - pub ttl: Option, +/// Default suggested polling interval, in milliseconds. +pub const DEFAULT_POLL_INTERVAL_MS: u64 = 1_000; + +/// Helper to generate an ISO 8601 timestamp for task metadata. +pub fn current_timestamp() -> String { + chrono::Utc::now().to_rfc3339() } -impl OperationDescriptor { - pub fn new(operation_id: impl Into, name: impl Into) -> Self { - Self { - operation_id: operation_id.into(), - name: name.into(), - client_request: None, - context: None, - ttl: None, - } +/// Handle passed to a running task operation, allowing it to surface +/// server-to-client requests (elicitation, sampling, roots) mid-task and +/// await the client's `tasks/update` response. +#[derive(Clone)] +pub struct TaskContext { + task_id: String, + inner: Arc>, +} + +impl TaskContext { + /// The id of the task this context belongs to. + pub fn task_id(&self) -> &str { + &self.task_id } - pub fn with_client_request(mut self, request: ClientRequest) -> Self { - self.client_request = Some(request); - self + /// Surface a server-to-client request under `key` and wait for the + /// client's response delivered via `tasks/update`. + /// + /// While at least one request is outstanding the task reports + /// `input_required` from `tasks/get`, with all outstanding requests in + /// `inputRequests`. Keys must be unique over the lifetime of the task; + /// reusing a key returns an error. + pub async fn request_input( + &self, + key: impl Into, + request: InputRequest, + ) -> Result { + let key = key.into(); + let (tx, rx) = oneshot::channel(); + { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + let entry = inner.tasks.get_mut(&self.task_id).ok_or_else(|| { + McpError::internal_error("task no longer exists".to_string(), None) + })?; + if !entry.used_input_keys.insert(key.clone()) { + return Err(McpError::internal_error( + format!("inputRequests key {key:?} was already used for this task"), + None, + )); + } + entry.pending_inputs.insert(key.clone(), (request, tx)); + entry.touch(); + } + rx.await.map_err(|_| { + McpError::internal_error("task cancelled while awaiting input".to_string(), None) + }) } - pub fn with_context(mut self, context: RequestContext) -> Self { - self.context = Some(context); - self + /// Update the task's human-readable status message. + pub fn set_status_message(&self, message: impl Into) { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + if let Some(entry) = inner.tasks.get_mut(&self.task_id) { + entry.task.status_message = Some(message.into()); + entry.touch(); + } } - /// Time-to-live in milliseconds, matching `TaskMetadata.ttl` from the MCP spec. - pub fn with_ttl(mut self, ttl: u64) -> Self { - self.ttl = Some(ttl); - self + /// Returns `true` if `tasks/cancel` has been received for this task. + /// Cooperative: operations should check this and stop when set. + pub fn is_cancel_requested(&self) -> bool { + let inner = self.inner.lock().expect("task manager lock poisoned"); + inner + .tasks + .get(&self.task_id) + .is_some_and(|e| e.cancel_requested) } } -/// Operation message describing a unit of asynchronous work. -#[non_exhaustive] -pub struct OperationMessage { - pub descriptor: OperationDescriptor, - pub future: OperationFuture, +/// Boxed future representing the async operation backing a task. +pub type TaskFuture = Pin> + Send>>; + +struct TaskEntry { + task: Task, + /// Terminal payload, if the task has finished. + terminal: Option, + /// Outstanding input requests keyed by their unique identifier. + pending_inputs: HashMap)>, + /// Every key ever used, to enforce uniqueness across the task lifetime. + used_input_keys: std::collections::HashSet, + cancel_requested: bool, + created: Instant, + join_handle: Option>, } -impl OperationMessage { - pub fn new(descriptor: OperationDescriptor, future: OperationFuture) -> Self { - Self { descriptor, future } +impl TaskEntry { + fn touch(&mut self) { + self.task.last_updated_at = current_timestamp(); } -} -/// Trait for operation result transport -pub trait OperationResultTransport: Send + Sync + 'static { - fn operation_id(&self) -> &String; - fn as_any(&self) -> &dyn std::any::Any; -} + fn current_status(&self) -> TaskStatus { + match &self.terminal { + Some(payload) => payload.status(), + None if !self.pending_inputs.is_empty() => TaskStatus::InputRequired, + None => TaskStatus::Working, + } + } -// ===== Operation Processor ===== -#[deprecated(note = "use DEFAULT_TASK_TIMEOUT_MS; ttl values are milliseconds per the MCP spec")] -pub const DEFAULT_TASK_TIMEOUT_SECS: u64 = 300; -/// Default execution timeout (5 minutes), in milliseconds, applied when a -/// descriptor does not specify a `ttl`. -pub const DEFAULT_TASK_TIMEOUT_MS: u64 = 300_000; -/// Operation processor that coordinates extractors and handlers -pub struct OperationProcessor { - /// Currently running tasks keyed by id - running_tasks: HashMap, - /// Completed results waiting to be collected - completed_results: Vec, - task_result_receiver: mpsc::UnboundedReceiver, - task_result_sender: mpsc::UnboundedSender, + fn detailed(&self) -> DetailedTask { + let payload = match &self.terminal { + Some(p) => p.clone(), + None if !self.pending_inputs.is_empty() => TaskPayload::InputRequired { + input_requests: self + .pending_inputs + .iter() + .map(|(k, (req, _))| (k.clone(), req.clone())) + .collect::(), + }, + None => TaskPayload::Working, + }; + DetailedTask::new(self.task.clone(), payload) + } } -struct RunningTask { - task_handle: tokio::task::JoinHandle<()>, - started_at: std::time::Instant, - timeout: Option, - descriptor: OperationDescriptor, +#[derive(Default)] +struct TaskManagerInner { + tasks: HashMap, } +/// Options controlling a spawned task. +#[derive(Debug, Clone)] #[non_exhaustive] -pub struct TaskResult { - pub descriptor: OperationDescriptor, - pub result: Result, Error>, -} - -/// Helper to generate an ISO 8601 timestamp for task metadata. -pub fn current_timestamp() -> String { - chrono::Utc::now().to_rfc3339() -} - -/// Result transport for tool calls executed as tasks. -pub struct ToolCallTaskResult { - id: String, - pub result: Result, +pub struct TaskOptions { + /// TTL in milliseconds; `None` means unlimited retention. + pub ttl_ms: Option, + /// Suggested polling interval in milliseconds. + pub poll_interval_ms: Option, + /// Initial status message. + pub status_message: Option, } -impl ToolCallTaskResult { - pub fn new(id: impl Into, result: Result) -> Self { +impl Default for TaskOptions { + fn default() -> Self { Self { - id: id.into(), - result, + ttl_ms: Some(DEFAULT_TASK_TTL_MS), + poll_interval_ms: Some(DEFAULT_POLL_INTERVAL_MS), + status_message: None, } } } -impl OperationResultTransport for ToolCallTaskResult { - fn operation_id(&self) -> &String { - &self.id +impl TaskOptions { + pub fn new() -> Self { + Self::default() } - fn as_any(&self) -> &dyn Any { + /// Set the TTL in milliseconds. `None` means unlimited retention. + pub fn with_ttl_ms(mut self, ttl_ms: impl Into>) -> Self { + self.ttl_ms = ttl_ms.into(); self } -} -impl Default for OperationProcessor { - fn default() -> Self { - Self::new() + /// Set the suggested polling interval in milliseconds. + pub fn with_poll_interval_ms(mut self, poll_interval_ms: u64) -> Self { + self.poll_interval_ms = Some(poll_interval_ms); + self + } + + /// Set the initial status message. + pub fn with_status_message(mut self, message: impl Into) -> Self { + self.status_message = Some(message.into()); + self } } -impl OperationProcessor { +/// Server-side task store and executor for the SEP-2663 Tasks extension. +/// +/// Cheaply cloneable; all clones share the same state. +#[derive(Clone, Default)] +pub struct TaskManager { + inner: Arc>, +} + +impl TaskManager { pub fn new() -> Self { - let (task_result_sender, task_result_receiver) = mpsc::unbounded_channel(); - Self { - running_tasks: HashMap::new(), - completed_results: Vec::new(), - task_result_receiver, - task_result_sender, - } + Self::default() } - /// Submit an operation for asynchronous execution. - #[allow(clippy::result_large_err)] - pub fn submit_operation(&mut self, message: OperationMessage) -> Result<(), Error> { - if self - .running_tasks - .contains_key(&message.descriptor.operation_id) + /// Spawn an operation as a task and return its seed [`Task`] state for a + /// `CreateTaskResult`. The task is durably observable via + /// [`Self::get_task`] before this method returns. + /// + /// `make_future` receives a [`TaskContext`] for mid-task input requests, + /// status messages, and cooperative cancellation checks. + pub fn spawn(&self, options: TaskOptions, make_future: F) -> Task + where + F: FnOnce(TaskContext) -> TaskFuture, + { + let task_id = uuid::Uuid::new_v4().to_string(); + let now = current_timestamp(); + let mut task = Task::new(task_id.clone(), TaskStatus::Working, now.clone(), now); + task.ttl_ms = options.ttl_ms; + task.poll_interval_ms = options.poll_interval_ms; + task.status_message = options.status_message; + + let entry = TaskEntry { + task: task.clone(), + terminal: None, + pending_inputs: HashMap::new(), + used_input_keys: std::collections::HashSet::new(), + cancel_requested: false, + created: Instant::now(), + join_handle: None, + }; + self.inner + .lock() + .expect("task manager lock poisoned") + .tasks + .insert(task_id.clone(), entry); + + let context = TaskContext { + task_id: task_id.clone(), + inner: self.inner.clone(), + }; + let future = make_future(context); + let inner = self.inner.clone(); + let id_for_task = task_id.clone(); + let handle = tokio::spawn(async move { + let result = future.await; + let mut inner = inner.lock().expect("task manager lock poisoned"); + if let Some(entry) = inner.tasks.get_mut(&id_for_task) { + if entry.terminal.is_none() { + entry.terminal = Some(match result { + Ok(result) => TaskPayload::Completed { + result: result_to_object(&result), + }, + Err(error) => { + if entry.cancel_requested { + TaskPayload::Cancelled + } else { + TaskPayload::Failed { + error: error_to_object(&error), + } + } + } + }); + entry.pending_inputs.clear(); + entry.touch(); + entry.task.status = entry.current_status(); + } + } + }); + if let Some(entry) = self + .inner + .lock() + .expect("task manager lock poisoned") + .tasks + .get_mut(&task_id) { - return Err(Error::TaskError(format!( - "Operation with id {} is already running", - message.descriptor.operation_id - ))); + entry.join_handle = Some(handle); } - self.spawn_async_task(message); + task + } + + /// Handle `tasks/get`: return the current [`DetailedTask`] state. + pub fn get_task(&self, task_id: &str) -> Result { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + Self::expire_overdue(&mut inner); + let entry = inner + .tasks + .get_mut(task_id) + .ok_or_else(|| unknown_task(task_id))?; + entry.task.status = entry.current_status(); + Ok(entry.detailed()) + } + + /// Handle `tasks/update`: deliver `inputResponses` to the running + /// operation. Unknown, already-answered, or superseded keys are ignored + /// per spec; a partial set of responses is accepted. + pub fn update_task( + &self, + task_id: &str, + input_responses: impl IntoIterator, + ) -> Result<(), McpError> { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + let entry = inner + .tasks + .get_mut(task_id) + .ok_or_else(|| unknown_task(task_id))?; + for (key, value) in input_responses { + if let Some((_, tx)) = entry.pending_inputs.remove(&key) { + // Receiver dropped means the operation moved on; ignore. + let _ = tx.send(value); + } + } + entry.touch(); + entry.task.status = entry.current_status(); Ok(()) } - fn spawn_async_task(&mut self, message: OperationMessage) { - let OperationMessage { descriptor, future } = message; - let task_id = descriptor.operation_id.clone(); - let timeout_ms = descriptor.ttl.or(Some(DEFAULT_TASK_TIMEOUT_MS)); - let sender = self.task_result_sender.clone(); - let descriptor_for_result = descriptor.clone(); - - let timed_future = async move { - if let Some(ms) = timeout_ms { - match timeout(Duration::from_millis(ms), future).await { - Ok(result) => result, - Err(_) => Err(Error::TaskError("Operation timed out".to_string())), - } - } else { - future.await + /// Handle `tasks/cancel`: cooperative cancellation. Acknowledges + /// immediately; the operation is aborted and the task transitions to + /// `cancelled` unless it already reached a terminal state. + pub fn cancel_task(&self, task_id: &str) -> Result<(), McpError> { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + let entry = inner + .tasks + .get_mut(task_id) + .ok_or_else(|| unknown_task(task_id))?; + entry.cancel_requested = true; + if entry.terminal.is_none() { + if let Some(handle) = entry.join_handle.take() { + handle.abort(); } - }; + entry.terminal = Some(TaskPayload::Cancelled); + entry.pending_inputs.clear(); + entry.touch(); + entry.task.status = TaskStatus::Cancelled; + } + Ok(()) + } - let handle = tokio::spawn(async move { - let result = timed_future.await; - let task_result = TaskResult { - descriptor: descriptor_for_result, - result, - }; - let _ = sender.send(task_result); - }); - let running_task = RunningTask { - task_handle: handle, - started_at: std::time::Instant::now(), - timeout: timeout_ms, - descriptor, - }; - self.running_tasks.insert(task_id, running_task); + /// Number of tasks currently in a non-terminal state. + pub fn running_task_count(&self) -> usize { + let inner = self.inner.lock().expect("task manager lock poisoned"); + inner + .tasks + .values() + .filter(|e| e.terminal.is_none()) + .count() } - /// Collect completed results from running tasks and remove them from the running tasks map. - fn collect_completed_results(&mut self) { - while let Ok(result) = self.task_result_receiver.try_recv() { - self.running_tasks.remove(&result.descriptor.operation_id); - self.completed_results.push(result); + /// Abort all running tasks and clear all task state. + pub fn shutdown(&self) { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + for (_, mut entry) in inner.tasks.drain() { + if let Some(handle) = entry.join_handle.take() { + handle.abort(); + } } } - /// Check for tasks that have exceeded their timeout and handle them appropriately. - pub fn check_timeouts(&mut self) { - self.collect_completed_results(); - let now = std::time::Instant::now(); - let mut timed_out_tasks = Vec::new(); - - for (task_id, task) in &self.running_tasks { - if let Some(timeout_duration) = task.timeout { - if now.duration_since(task.started_at).as_millis() > u128::from(timeout_duration) { - task.task_handle.abort(); - timed_out_tasks.push(task_id.clone()); + /// Mark tasks whose TTL has elapsed as `failed` (spec: servers MAY fail + /// tasks any time after TTL expiry). + fn expire_overdue(inner: &mut TaskManagerInner) { + for entry in inner.tasks.values_mut() { + if entry.terminal.is_none() + && let Some(ttl_ms) = entry.task.ttl_ms + && entry.created.elapsed().as_millis() > u128::from(ttl_ms) + { + if let Some(handle) = entry.join_handle.take() { + handle.abort(); } + entry.terminal = Some(TaskPayload::Failed { + error: error_to_object(&McpError::internal_error( + "task expired: TTL elapsed before completion".to_string(), + None, + )), + }); + entry.pending_inputs.clear(); + entry.touch(); + entry.task.status = TaskStatus::Failed; } } + } +} - for task_id in timed_out_tasks { - if let Some(task) = self.running_tasks.remove(&task_id) { - let timeout_result = TaskResult { - descriptor: task.descriptor, - result: Err(Error::TaskError("Operation timed out".to_string())), - }; - self.completed_results.push(timeout_result); - } - } +fn unknown_task(task_id: &str) -> McpError { + McpError::invalid_params(format!("unknown task: {task_id}"), None) +} + +fn result_to_object(result: &CallToolResult) -> JsonObject { + match serde_json::to_value(result) { + Ok(serde_json::Value::Object(map)) => map, + _ => JsonObject::new(), } +} - /// Get the number of running tasks. - pub fn running_task_count(&mut self) -> usize { - self.collect_completed_results(); - self.running_tasks.len() +fn error_to_object(error: &McpError) -> JsonObject { + match serde_json::to_value(error) { + Ok(serde_json::Value::Object(map)) => map, + _ => JsonObject::new(), } +} - /// Cancel all running tasks. - pub fn cancel_all_tasks(&mut self) { - for (_, task) in self.running_tasks.drain() { - task.task_handle.abort(); - } - while self.task_result_receiver.try_recv().is_ok() {} - self.completed_results.clear(); +#[cfg(test)] +mod tests { + use super::*; + use crate::model::ContentBlock; + + fn ok_result(text: &str) -> CallToolResult { + CallToolResult::success(vec![ContentBlock::text(text.to_string())]) } - /// List running task ids. - pub fn list_running(&mut self) -> Vec { - self.collect_completed_results(); - self.running_tasks.keys().cloned().collect() + #[tokio::test] + async fn task_completes_and_result_is_inlined() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::default(), |_ctx| { + Box::pin(async { Ok(ok_result("42")) }) + }); + assert_eq!(task.status, TaskStatus::Working); + + // Durable immediately. + manager.get_task(&task.task_id).unwrap(); + + // Wait for completion. + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if detailed.status() == TaskStatus::Completed { + match detailed.payload { + TaskPayload::Completed { result } => { + assert!(result.contains_key("content")); + return; + } + other => panic!("unexpected payload: {other:?}"), + } + } + } + panic!("task did not complete"); } - /// Returns a snapshot of completed task results. - pub fn peek_completed(&mut self) -> &[TaskResult] { - self.collect_completed_results(); - &self.completed_results + #[tokio::test] + async fn cancel_marks_task_cancelled() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::default(), |_ctx| { + Box::pin(async { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + Ok(ok_result("never")) + }) + }); + manager.cancel_task(&task.task_id).unwrap(); + let detailed = manager.get_task(&task.task_id).unwrap(); + assert_eq!(detailed.status(), TaskStatus::Cancelled); } - /// Fetch the metadata for a running or recently completed task. - pub fn task_descriptor(&self, task_id: &str) -> Option<&OperationDescriptor> { - if let Some(task) = self.running_tasks.get(task_id) { - return Some(&task.descriptor); - } - self.completed_results - .iter() - .rev() - .find(|result| result.descriptor.operation_id == task_id) - .map(|result| &result.descriptor) + #[tokio::test] + async fn unknown_task_is_an_error() { + let manager = TaskManager::new(); + assert!(manager.get_task("nope").is_err()); + assert!(manager.cancel_task("nope").is_err()); + assert!(manager.update_task("nope", []).is_err()); } - /// Attempt to cancel a running task. - pub fn cancel_task(&mut self, task_id: &str) -> bool { - self.collect_completed_results(); - if let Some(task) = self.running_tasks.remove(task_id) { - task.task_handle.abort(); - // Insert a cancelled result so callers can observe the terminal state. - let cancel_result = TaskResult { - descriptor: task.descriptor, - result: Err(Error::TaskError("Operation cancelled".to_string())), - }; - self.completed_results.push(cancel_result); - return true; - } - false + #[tokio::test] + async fn ttl_expiry_fails_task() { + let manager = TaskManager::new(); + let task = manager.spawn( + TaskOptions { + ttl_ms: Some(10), + ..Default::default() + }, + |_ctx| { + Box::pin(async { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + Ok(ok_result("never")) + }) + }, + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + assert_eq!(detailed.status(), TaskStatus::Failed); } - /// Retrieve a completed task result if available. - pub fn take_completed_result(&mut self, task_id: &str) -> Option { - self.collect_completed_results(); - if let Some(position) = self - .completed_results - .iter() - .position(|result| result.descriptor.operation_id == task_id) - { - Some(self.completed_results.remove(position)) - } else { - None + #[tokio::test] + async fn input_required_roundtrip() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::default(), |ctx| { + Box::pin(async move { + let request: InputRequest = serde_json::from_value(serde_json::json!({ + "method": "elicitation/create", + "params": { + "message": "What is your name?", + "requestedSchema": {"type": "object", "properties": {}} + } + })) + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + let response = ctx.request_input("name-1", request).await?; + let name = response + .get("content") + .and_then(|c| c.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + Ok(ok_result(&format!("hello {name}"))) + }) + }); + + // Wait for the task to surface the input request. + let mut saw_input_required = false; + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if let TaskPayload::InputRequired { input_requests } = &detailed.payload { + assert!(input_requests.contains_key("name-1")); + saw_input_required = true; + break; + } + } + assert!(saw_input_required, "task never reached input_required"); + + // Respond via tasks/update. + manager + .update_task( + &task.task_id, + [( + "name-1".to_string(), + serde_json::json!({"action": "accept", "content": {"name": "Ada"}}), + )], + ) + .unwrap(); + + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if detailed.status() == TaskStatus::Completed { + return; + } } + panic!("task did not complete after input response"); } } diff --git a/crates/rmcp/src/transport/common/mcp_headers.rs b/crates/rmcp/src/transport/common/mcp_headers.rs index 0b38981a4..12f8594c2 100644 --- a/crates/rmcp/src/transport/common/mcp_headers.rs +++ b/crates/rmcp/src/transport/common/mcp_headers.rs @@ -25,6 +25,10 @@ const NAME_FROM_URI: &[&str] = &[ "resources/subscribe", "resources/unsubscribe", ]; +/// Methods whose `Mcp-Name` is sourced from `params.taskId` (SEP-2663 Tasks +/// extension): allows intermediaries to route task polling to the server +/// instance holding the task's state. +const NAME_FROM_TASK_ID: &[&str] = &["tasks/get", "tasks/update", "tasks/cancel"]; /// Returns the `Mcp-Name` value for a request, if the method carries one. fn extract_name(method: &str, params: Option<&Value>) -> Option { @@ -33,6 +37,8 @@ fn extract_name(method: &str, params: Option<&Value>) -> Option { "name" } else if NAME_FROM_URI.contains(&method) { "uri" + } else if NAME_FROM_TASK_ID.contains(&method) { + "taskId" } else { return None; }; diff --git a/crates/rmcp/tests/test_deserialization.rs b/crates/rmcp/tests/test_deserialization.rs index 58e9a58af..5346ab052 100644 --- a/crates/rmcp/tests/test_deserialization.rs +++ b/crates/rmcp/tests/test_deserialization.rs @@ -17,10 +17,8 @@ fn test_tool_list_result() { /// Regression tests for `#[serde(untagged)]` deserialization of `ServerResult`. /// /// `ServerResult` is an untagged enum, so serde tries each variant in declaration -/// order. `GetTaskPayloadResult` has a custom `Deserialize` impl that always fails -/// so it is skipped, and `CustomResult(Value)` acts as the catch-all. If variant -/// ordering changes or the custom impl is removed, these tests will catch the -/// regression. +/// order, with `CustomResult(Value)` acting as the catch-all. If variant ordering +/// changes, these tests will catch the regression. mod untagged_server_result { use rmcp::model::{CallToolResult, JsonRpcResponse, ServerJsonRpcMessage, ServerResult}; use serde_json::json; @@ -84,7 +82,7 @@ mod untagged_server_result { #[test] fn unknown_shape_falls_through_to_custom_result() { // A value that doesn't match any known result type should land in - // CustomResult, NOT GetTaskPayloadResult. + // CustomResult. let result = parse_result(wrap_response(json!({ "some_unknown_field": "some_value", "number": 42 @@ -96,10 +94,8 @@ mod untagged_server_result { } #[test] - fn arbitrary_json_value_does_not_deserialize_as_get_task_payload_result() { - // GetTaskPayloadResult wraps a bare Value, but its custom Deserialize - // always fails so serde skips it during untagged resolution. - // Any JSON value must fall through to CustomResult instead. + fn arbitrary_json_value_falls_through_to_custom_result() { + // Any bare JSON value must fall through to CustomResult. for value in [json!(42), json!("hello"), json!(null), json!([1, 2, 3])] { let result = parse_result(wrap_response(value.clone())); assert!( 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 922469c23..fe2eb5e70 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 @@ -126,7 +126,7 @@ "const": "tools/call" }, "CallToolRequestParams": { - "description": "Parameters for calling a tool provided by an MCP server.\n\nContains the tool name and optional arguments needed to execute\nthe tool operation.\n\nThis implements `TaskAugmentedRequestParamsMeta` as tool calls can be\nlong-running and may benefit from task-based execution.", + "description": "Parameters for calling a tool provided by an MCP server.\n\nContains the tool name and optional arguments needed to execute\nthe tool operation.", "type": "object", "properties": { "_meta": { @@ -166,17 +166,6 @@ "string", "null" ] - }, - "task": { - "description": "Task metadata for async task management (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/TaskMetadata" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -304,16 +293,6 @@ "type": "null" } ] - }, - "tasks": { - "anyOf": [ - { - "$ref": "#/definitions/TasksCapability" - }, - { - "type": "null" - } - ] } } }, @@ -657,18 +636,6 @@ } } }, - "ElicitationTaskCapability": { - "type": "object", - "properties": { - "create": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "EmbeddedResource": { "description": "Embedded resource content (spec `EmbeddedResource`).", "type": "object", @@ -815,34 +782,6 @@ "type": "object", "properties": { "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "taskId": { - "type": "string" - } - }, - "required": [ - "taskId" - ] - }, - "GetTaskPayloadMethod": { - "type": "string", - "format": "const", - "const": "tasks/result" - }, - "GetTaskPayloadParams": { - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", "anyOf": [ { "$ref": "#/definitions/RequestMetaObject" @@ -853,6 +792,7 @@ ] }, "taskId": { + "description": "Identifier of the task to query.", "type": "string" } }, @@ -1099,9 +1039,6 @@ { "$ref": "#/definitions/NotificationNoParam2" }, - { - "$ref": "#/definitions/Notification3" - }, { "$ref": "#/definitions/CustomNotification" } @@ -1169,9 +1106,6 @@ { "$ref": "#/definitions/Request11" }, - { - "$ref": "#/definitions/RequestOptionalParam5" - }, { "$ref": "#/definitions/Request12" }, @@ -1251,11 +1185,6 @@ "roots" ] }, - "ListTasksMethod": { - "type": "string", - "format": "const", - "const": "tasks/list" - }, "ListToolsRequestMethod": { "type": "string", "format": "const", @@ -1311,21 +1240,6 @@ "params" ] }, - "Notification3": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/TaskStatusNotificationMethod" - }, - "params": { - "$ref": "#/definitions/TaskStatusNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, "NotificationMetaObject": { "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", "type": "object", @@ -1606,10 +1520,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/GetTaskPayloadMethod" + "$ref": "#/definitions/UpdateTaskMethod" }, "params": { - "$ref": "#/definitions/GetTaskPayloadParams" + "$ref": "#/definitions/UpdateTaskParams" } }, "required": [ @@ -1878,27 +1792,6 @@ "method" ] }, - "RequestOptionalParam5": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ListTasksMethod" - }, - "params": { - "anyOf": [ - { - "$ref": "#/definitions/PaginatedRequestParams" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "method" - ] - }, "Resource": { "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", "type": "object", @@ -2246,19 +2139,6 @@ } ] }, - "SamplingTaskCapability": { - "description": "Sampling task capability. Deprecated by SEP-2577; remains functional and\nwill be removed in a future release.\nSee .", - "type": "object", - "properties": { - "createMessage": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "SetLevelRequestMethod": { "type": "string", "format": "const", @@ -2393,188 +2273,6 @@ "notifications" ] }, - "TaskMetadata": { - "description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).", - "type": "object", - "properties": { - "ttl": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - } - }, - "TaskRequestsCapability": { - "description": "Request types that support task-augmented execution.", - "type": "object", - "properties": { - "elicitation": { - "anyOf": [ - { - "$ref": "#/definitions/ElicitationTaskCapability" - }, - { - "type": "null" - } - ] - }, - "sampling": { - "anyOf": [ - { - "$ref": "#/definitions/SamplingTaskCapability" - }, - { - "type": "null" - } - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsTaskCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "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" - } - ] - }, - "TaskStatusNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/tasks/status" - }, - "TaskStatusNotificationParam": { - "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nThe task fields are flattened at the top level: `NotificationParams & Task`.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "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" - ] - }, - "TasksCapability": { - "description": "Task capabilities shared by client and server.", - "type": "object", - "properties": { - "cancel": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "list": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "requests": { - "anyOf": [ - { - "$ref": "#/definitions/TaskRequestsCapability" - }, - { - "type": "null" - } - ] - } - } - }, "TextContent": { "description": "Text content block (spec `TextContent`).", "type": "object", @@ -2679,18 +2377,6 @@ "input" ] }, - "ToolsTaskCapability": { - "type": "object", - "properties": { - "call": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "UnsubscribeRequestMethod": { "type": "string", "format": "const", @@ -2720,6 +2406,40 @@ "uri" ] }, + "UpdateTaskMethod": { + "type": "string", + "format": "const", + "const": "tasks/update" + }, + "UpdateTaskParams": { + "description": "Parameters for `tasks/update` (SEP-2663): deliver responses to outstanding\nin-task server-to-client requests surfaced via `tasks/get` `inputRequests`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "inputResponses": { + "description": "Responses to outstanding `inputRequests` previously surfaced by the\nserver. Each key MUST correspond to a currently-outstanding\n`inputRequests` key.", + "type": "object", + "additionalProperties": true + }, + "taskId": { + "description": "Identifier of the task to update.", + "type": "string" + } + }, + "required": [ + "taskId", + "inputResponses" + ] + }, "UrlElicitationCapability": { "description": "Capability for URL mode elicitation.", "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 922469c23..fe2eb5e70 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 @@ -126,7 +126,7 @@ "const": "tools/call" }, "CallToolRequestParams": { - "description": "Parameters for calling a tool provided by an MCP server.\n\nContains the tool name and optional arguments needed to execute\nthe tool operation.\n\nThis implements `TaskAugmentedRequestParamsMeta` as tool calls can be\nlong-running and may benefit from task-based execution.", + "description": "Parameters for calling a tool provided by an MCP server.\n\nContains the tool name and optional arguments needed to execute\nthe tool operation.", "type": "object", "properties": { "_meta": { @@ -166,17 +166,6 @@ "string", "null" ] - }, - "task": { - "description": "Task metadata for async task management (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/TaskMetadata" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -304,16 +293,6 @@ "type": "null" } ] - }, - "tasks": { - "anyOf": [ - { - "$ref": "#/definitions/TasksCapability" - }, - { - "type": "null" - } - ] } } }, @@ -657,18 +636,6 @@ } } }, - "ElicitationTaskCapability": { - "type": "object", - "properties": { - "create": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "EmbeddedResource": { "description": "Embedded resource content (spec `EmbeddedResource`).", "type": "object", @@ -815,34 +782,6 @@ "type": "object", "properties": { "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "taskId": { - "type": "string" - } - }, - "required": [ - "taskId" - ] - }, - "GetTaskPayloadMethod": { - "type": "string", - "format": "const", - "const": "tasks/result" - }, - "GetTaskPayloadParams": { - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", "anyOf": [ { "$ref": "#/definitions/RequestMetaObject" @@ -853,6 +792,7 @@ ] }, "taskId": { + "description": "Identifier of the task to query.", "type": "string" } }, @@ -1099,9 +1039,6 @@ { "$ref": "#/definitions/NotificationNoParam2" }, - { - "$ref": "#/definitions/Notification3" - }, { "$ref": "#/definitions/CustomNotification" } @@ -1169,9 +1106,6 @@ { "$ref": "#/definitions/Request11" }, - { - "$ref": "#/definitions/RequestOptionalParam5" - }, { "$ref": "#/definitions/Request12" }, @@ -1251,11 +1185,6 @@ "roots" ] }, - "ListTasksMethod": { - "type": "string", - "format": "const", - "const": "tasks/list" - }, "ListToolsRequestMethod": { "type": "string", "format": "const", @@ -1311,21 +1240,6 @@ "params" ] }, - "Notification3": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/TaskStatusNotificationMethod" - }, - "params": { - "$ref": "#/definitions/TaskStatusNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, "NotificationMetaObject": { "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", "type": "object", @@ -1606,10 +1520,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/GetTaskPayloadMethod" + "$ref": "#/definitions/UpdateTaskMethod" }, "params": { - "$ref": "#/definitions/GetTaskPayloadParams" + "$ref": "#/definitions/UpdateTaskParams" } }, "required": [ @@ -1878,27 +1792,6 @@ "method" ] }, - "RequestOptionalParam5": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ListTasksMethod" - }, - "params": { - "anyOf": [ - { - "$ref": "#/definitions/PaginatedRequestParams" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "method" - ] - }, "Resource": { "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", "type": "object", @@ -2246,19 +2139,6 @@ } ] }, - "SamplingTaskCapability": { - "description": "Sampling task capability. Deprecated by SEP-2577; remains functional and\nwill be removed in a future release.\nSee .", - "type": "object", - "properties": { - "createMessage": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "SetLevelRequestMethod": { "type": "string", "format": "const", @@ -2393,188 +2273,6 @@ "notifications" ] }, - "TaskMetadata": { - "description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).", - "type": "object", - "properties": { - "ttl": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - } - }, - "TaskRequestsCapability": { - "description": "Request types that support task-augmented execution.", - "type": "object", - "properties": { - "elicitation": { - "anyOf": [ - { - "$ref": "#/definitions/ElicitationTaskCapability" - }, - { - "type": "null" - } - ] - }, - "sampling": { - "anyOf": [ - { - "$ref": "#/definitions/SamplingTaskCapability" - }, - { - "type": "null" - } - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsTaskCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "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" - } - ] - }, - "TaskStatusNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/tasks/status" - }, - "TaskStatusNotificationParam": { - "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nThe task fields are flattened at the top level: `NotificationParams & Task`.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "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" - ] - }, - "TasksCapability": { - "description": "Task capabilities shared by client and server.", - "type": "object", - "properties": { - "cancel": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "list": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "requests": { - "anyOf": [ - { - "$ref": "#/definitions/TaskRequestsCapability" - }, - { - "type": "null" - } - ] - } - } - }, "TextContent": { "description": "Text content block (spec `TextContent`).", "type": "object", @@ -2679,18 +2377,6 @@ "input" ] }, - "ToolsTaskCapability": { - "type": "object", - "properties": { - "call": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "UnsubscribeRequestMethod": { "type": "string", "format": "const", @@ -2720,6 +2406,40 @@ "uri" ] }, + "UpdateTaskMethod": { + "type": "string", + "format": "const", + "const": "tasks/update" + }, + "UpdateTaskParams": { + "description": "Parameters for `tasks/update` (SEP-2663): deliver responses to outstanding\nin-task server-to-client requests surfaced via `tasks/get` `inputRequests`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "inputResponses": { + "description": "Responses to outstanding `inputRequests` previously surfaced by the\nserver. Each key MUST correspond to a currently-outstanding\n`inputRequests` key.", + "type": "object", + "additionalProperties": true + }, + "taskId": { + "description": "Identifier of the task to update.", + "type": "string" + } + }, + "required": [ + "taskId", + "inputResponses" + ] + }, "UrlElicitationCapability": { "description": "Capability for URL mode elicitation.", "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 520702420..2fba99a6a 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 @@ -212,73 +212,6 @@ } } }, - "CancelTaskResult": { - "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "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", @@ -373,16 +306,6 @@ "type": "null" } ] - }, - "tasks": { - "anyOf": [ - { - "$ref": "#/definitions/TasksCapability" - }, - { - "type": "null" - } - ] } } }, @@ -645,17 +568,6 @@ "null" ] }, - "task": { - "description": "Task metadata for async task management (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/TaskMetadata" - }, - { - "type": "null" - } - ] - }, "temperature": { "description": "Temperature for controlling randomness (0.0 to 1.0)", "type": [ @@ -693,7 +605,7 @@ ] }, "CreateTaskResult": { - "description": "Wrapper returned by task-augmented requests (CreateTaskResult in SEP-1686).", + "description": "Result returned in lieu of a standard result to indicate the request will\nbe processed asynchronously (spec `CreateTaskResult`, `resultType: \"task\"`).\n\nThe embedded task is the seed state for the task; the client uses\n`task.task_id` for all subsequent `tasks/get`, `tasks/update`, and\n`tasks/cancel` calls.", "type": "object", "properties": { "_meta": { @@ -706,12 +618,66 @@ } ] }, - "task": { - "$ref": "#/definitions/Task" + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "resultType": { + "description": "Always `\"task\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + }, + "status": { + "description": "Current task status.", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Stable identifier for this task, generated by the server.", + "type": "string" + }, + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ - "task" + "resultType", + "taskId", + "status", + "createdAt", + "lastUpdatedAt" ] }, "CustomNotification": { @@ -1047,18 +1013,6 @@ "properties" ] }, - "ElicitationTaskCapability": { - "type": "object", - "properties": { - "create": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "EmbeddedResource": { "description": "Embedded resource content (spec `EmbeddedResource`).", "type": "object", @@ -1199,11 +1153,8 @@ "messages" ] }, - "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.", + "description": "Response to a `tasks/get` request (spec `GetTaskResult = Result & DetailedTask`).\n\n`resultType` is `\"complete\"` — this is the standard result shape for\n`tasks/get`, not a task handle.", "type": "object", "properties": { "_meta": { @@ -1217,15 +1168,15 @@ ] }, "createdAt": { - "description": "ISO-8601 creation timestamp.", + "description": "ISO 8601 timestamp when the task was created.", "type": "string" }, "lastUpdatedAt": { - "description": "ISO-8601 timestamp for the most recent status change.", + "description": "ISO 8601 timestamp when the task was last updated.", "type": "string" }, - "pollInterval": { - "description": "Suggested polling interval (milliseconds).", + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", "type": [ "integer", "null" @@ -1234,7 +1185,7 @@ "minimum": 0 }, "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", + "description": "Current task status.", "allOf": [ { "$ref": "#/definitions/TaskStatus" @@ -1242,18 +1193,18 @@ ] }, "statusMessage": { - "description": "Optional human-readable status message for UI surfaces.", + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", "type": [ "string", "null" ] }, "taskId": { - "description": "Unique task identifier generated by the receiver.", + "description": "Stable identifier for this task, generated by the server.", "type": "string" }, - "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", "type": [ "integer", "null" @@ -1945,36 +1896,6 @@ "format": "const", "const": "roots/list" }, - "ListTasksResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - }, - "tasks": { - "type": "array", - "items": { - "$ref": "#/definitions/Task" - } - } - }, - "required": [ - "tasks" - ] - }, "ListToolsResult": { "type": "object", "properties": { @@ -2246,7 +2167,7 @@ "$ref": "#/definitions/TaskStatusNotificationMethod" }, "params": { - "$ref": "#/definitions/TaskStatusNotificationParam" + "$ref": "#/definitions/TaskStatusNotificationParams" } }, "required": [ @@ -3191,19 +3112,6 @@ } ] }, - "SamplingTaskCapability": { - "description": "Sampling task capability. Deprecated by SEP-2577; remains functional and\nwill be removed in a future release.\nSee .", - "type": "object", - "properties": { - "createMessage": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "ServerCapabilities": { "title": "Builder", "description": "```rust\n# use rmcp::model::ServerCapabilities;\nlet cap = ServerCapabilities::builder()\n .enable_experimental()\n .enable_prompts()\n .enable_resources()\n .enable_tools()\n .enable_tool_list_changed()\n .build();\n```", @@ -3265,16 +3173,6 @@ } ] }, - "tasks": { - "anyOf": [ - { - "$ref": "#/definitions/TasksCapability" - }, - { - "type": "null" - } - ] - }, "tools": { "anyOf": [ { @@ -3325,24 +3223,15 @@ { "$ref": "#/definitions/CreateTaskResult" }, - { - "$ref": "#/definitions/ListTasksResult" - }, { "$ref": "#/definitions/GetTaskResult" }, - { - "$ref": "#/definitions/CancelTaskResult" - }, { "$ref": "#/definitions/CallToolResult" }, { "$ref": "#/definitions/InputRequiredResult" }, - { - "$ref": "#/definitions/GetTaskPayloadResult" - }, { "$ref": "#/definitions/EmptyObject" }, @@ -3528,138 +3417,31 @@ "io.modelcontextprotocol/subscriptionId" ] }, - "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" - ] - }, - "TaskMetadata": { - "description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).", - "type": "object", - "properties": { - "ttl": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - } - }, - "TaskRequestsCapability": { - "description": "Request types that support task-augmented execution.", - "type": "object", - "properties": { - "elicitation": { - "anyOf": [ - { - "$ref": "#/definitions/ElicitationTaskCapability" - }, - { - "type": "null" - } - ] - }, - "sampling": { - "anyOf": [ - { - "$ref": "#/definitions/SamplingTaskCapability" - }, - { - "type": "null" - } - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsTaskCapability" - }, - { - "type": "null" - } - ] - } - } - }, "TaskStatus": { - "description": "Canonical task lifecycle status as defined by SEP-1686.", + "description": "Canonical task lifecycle status (SEP-2663).", "oneOf": [ { - "description": "The receiver accepted the request and is currently working on it.", + "description": "The request is currently being processed.", "type": "string", "const": "working" }, { - "description": "The receiver requires additional input before work can continue.", + "description": "The server needs input from the client before the task can proceed.", "type": "string", "const": "input_required" }, { - "description": "The underlying operation completed successfully and the result is ready.", + "description": "The request completed successfully and the result is available.\nThis includes tool calls that returned results with `isError: true`.", "type": "string", "const": "completed" }, { - "description": "The underlying operation failed and will not continue.", + "description": "The request failed due to a JSON-RPC error during execution.", "type": "string", "const": "failed" }, { - "description": "The task was cancelled and will not continue processing.", + "description": "The request was cancelled before completion.", "type": "string", "const": "cancelled" } @@ -3668,10 +3450,10 @@ "TaskStatusNotificationMethod": { "type": "string", "format": "const", - "const": "notifications/tasks/status" + "const": "notifications/tasks" }, - "TaskStatusNotificationParam": { - "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nThe task fields are flattened at the top level: `NotificationParams & Task`.", + "TaskStatusNotificationParams": { + "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nCarries a complete [`DetailedTask`] for the current status, identical to\nwhat `tasks/get` would have returned at that moment. The task fields are\nflattened at the top level: `NotificationParams & Task`.", "type": "object", "properties": { "_meta": { @@ -3685,15 +3467,15 @@ ] }, "createdAt": { - "description": "ISO-8601 creation timestamp.", + "description": "ISO 8601 timestamp when the task was created.", "type": "string" }, "lastUpdatedAt": { - "description": "ISO-8601 timestamp for the most recent status change.", + "description": "ISO 8601 timestamp when the task was last updated.", "type": "string" }, - "pollInterval": { - "description": "Suggested polling interval (milliseconds).", + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", "type": [ "integer", "null" @@ -3702,7 +3484,7 @@ "minimum": 0 }, "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", + "description": "Current task status.", "allOf": [ { "$ref": "#/definitions/TaskStatus" @@ -3710,18 +3492,18 @@ ] }, "statusMessage": { - "description": "Optional human-readable status message for UI surfaces.", + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", "type": [ "string", "null" ] }, "taskId": { - "description": "Unique task identifier generated by the receiver.", + "description": "Stable identifier for this task, generated by the server.", "type": "string" }, - "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", "type": [ "integer", "null" @@ -3737,56 +3519,6 @@ "lastUpdatedAt" ] }, - "TaskSupport": { - "description": "Per-tool task support mode as defined in the MCP specification.\n\nThis enum indicates whether a tool supports task-based invocation,\nallowing clients to know how to properly call the tool.\n\nSee [Tool-Level Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation).", - "oneOf": [ - { - "description": "Clients MUST NOT invoke this tool as a task (default behavior).", - "type": "string", - "const": "forbidden" - }, - { - "description": "Clients MAY invoke this tool as either a task or a normal call.", - "type": "string", - "const": "optional" - }, - { - "description": "Clients MUST invoke this tool as a task.", - "type": "string", - "const": "required" - } - ] - }, - "TasksCapability": { - "description": "Task capabilities shared by client and server.", - "type": "object", - "properties": { - "cancel": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "list": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "requests": { - "anyOf": [ - { - "$ref": "#/definitions/TaskRequestsCapability" - }, - { - "type": "null" - } - ] - } - } - }, "TextContent": { "description": "Text content block (spec `TextContent`).", "type": "object", @@ -3960,17 +3692,6 @@ "null" ] }, - "execution": { - "description": "Execution-related configuration including task support mode.", - "anyOf": [ - { - "$ref": "#/definitions/ToolExecution" - }, - { - "type": "null" - } - ] - }, "icons": { "description": "Optional list of icons for the tool", "type": [ @@ -4089,23 +3810,6 @@ } ] }, - "ToolExecution": { - "description": "Execution-related configuration for a tool.\n\nThis struct contains settings that control how a tool should be executed,\nincluding task support configuration.", - "type": "object", - "properties": { - "taskSupport": { - "description": "Indicates whether this tool supports task-based invocation.\n\nWhen not present or set to `Forbidden`, clients MUST NOT invoke this tool as a task.\nWhen set to `Optional`, clients MAY invoke this tool as a task or normal call.\nWhen set to `Required`, clients MUST invoke this tool as a task.", - "anyOf": [ - { - "$ref": "#/definitions/TaskSupport" - }, - { - "type": "null" - } - ] - } - } - }, "ToolListChangedNotificationMethod": { "type": "string", "format": "const", @@ -4191,18 +3895,6 @@ } } }, - "ToolsTaskCapability": { - "type": "object", - "properties": { - "call": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "UntitledItems": { "description": "Items for untitled multi-select options", "type": "object", 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 520702420..2fba99a6a 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 @@ -212,73 +212,6 @@ } } }, - "CancelTaskResult": { - "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "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", @@ -373,16 +306,6 @@ "type": "null" } ] - }, - "tasks": { - "anyOf": [ - { - "$ref": "#/definitions/TasksCapability" - }, - { - "type": "null" - } - ] } } }, @@ -645,17 +568,6 @@ "null" ] }, - "task": { - "description": "Task metadata for async task management (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/TaskMetadata" - }, - { - "type": "null" - } - ] - }, "temperature": { "description": "Temperature for controlling randomness (0.0 to 1.0)", "type": [ @@ -693,7 +605,7 @@ ] }, "CreateTaskResult": { - "description": "Wrapper returned by task-augmented requests (CreateTaskResult in SEP-1686).", + "description": "Result returned in lieu of a standard result to indicate the request will\nbe processed asynchronously (spec `CreateTaskResult`, `resultType: \"task\"`).\n\nThe embedded task is the seed state for the task; the client uses\n`task.task_id` for all subsequent `tasks/get`, `tasks/update`, and\n`tasks/cancel` calls.", "type": "object", "properties": { "_meta": { @@ -706,12 +618,66 @@ } ] }, - "task": { - "$ref": "#/definitions/Task" + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "resultType": { + "description": "Always `\"task\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + }, + "status": { + "description": "Current task status.", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Stable identifier for this task, generated by the server.", + "type": "string" + }, + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ - "task" + "resultType", + "taskId", + "status", + "createdAt", + "lastUpdatedAt" ] }, "CustomNotification": { @@ -1047,18 +1013,6 @@ "properties" ] }, - "ElicitationTaskCapability": { - "type": "object", - "properties": { - "create": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "EmbeddedResource": { "description": "Embedded resource content (spec `EmbeddedResource`).", "type": "object", @@ -1199,11 +1153,8 @@ "messages" ] }, - "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.", + "description": "Response to a `tasks/get` request (spec `GetTaskResult = Result & DetailedTask`).\n\n`resultType` is `\"complete\"` — this is the standard result shape for\n`tasks/get`, not a task handle.", "type": "object", "properties": { "_meta": { @@ -1217,15 +1168,15 @@ ] }, "createdAt": { - "description": "ISO-8601 creation timestamp.", + "description": "ISO 8601 timestamp when the task was created.", "type": "string" }, "lastUpdatedAt": { - "description": "ISO-8601 timestamp for the most recent status change.", + "description": "ISO 8601 timestamp when the task was last updated.", "type": "string" }, - "pollInterval": { - "description": "Suggested polling interval (milliseconds).", + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", "type": [ "integer", "null" @@ -1234,7 +1185,7 @@ "minimum": 0 }, "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", + "description": "Current task status.", "allOf": [ { "$ref": "#/definitions/TaskStatus" @@ -1242,18 +1193,18 @@ ] }, "statusMessage": { - "description": "Optional human-readable status message for UI surfaces.", + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", "type": [ "string", "null" ] }, "taskId": { - "description": "Unique task identifier generated by the receiver.", + "description": "Stable identifier for this task, generated by the server.", "type": "string" }, - "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", "type": [ "integer", "null" @@ -1945,36 +1896,6 @@ "format": "const", "const": "roots/list" }, - "ListTasksResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - }, - "tasks": { - "type": "array", - "items": { - "$ref": "#/definitions/Task" - } - } - }, - "required": [ - "tasks" - ] - }, "ListToolsResult": { "type": "object", "properties": { @@ -2246,7 +2167,7 @@ "$ref": "#/definitions/TaskStatusNotificationMethod" }, "params": { - "$ref": "#/definitions/TaskStatusNotificationParam" + "$ref": "#/definitions/TaskStatusNotificationParams" } }, "required": [ @@ -3191,19 +3112,6 @@ } ] }, - "SamplingTaskCapability": { - "description": "Sampling task capability. Deprecated by SEP-2577; remains functional and\nwill be removed in a future release.\nSee .", - "type": "object", - "properties": { - "createMessage": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "ServerCapabilities": { "title": "Builder", "description": "```rust\n# use rmcp::model::ServerCapabilities;\nlet cap = ServerCapabilities::builder()\n .enable_experimental()\n .enable_prompts()\n .enable_resources()\n .enable_tools()\n .enable_tool_list_changed()\n .build();\n```", @@ -3265,16 +3173,6 @@ } ] }, - "tasks": { - "anyOf": [ - { - "$ref": "#/definitions/TasksCapability" - }, - { - "type": "null" - } - ] - }, "tools": { "anyOf": [ { @@ -3325,24 +3223,15 @@ { "$ref": "#/definitions/CreateTaskResult" }, - { - "$ref": "#/definitions/ListTasksResult" - }, { "$ref": "#/definitions/GetTaskResult" }, - { - "$ref": "#/definitions/CancelTaskResult" - }, { "$ref": "#/definitions/CallToolResult" }, { "$ref": "#/definitions/InputRequiredResult" }, - { - "$ref": "#/definitions/GetTaskPayloadResult" - }, { "$ref": "#/definitions/EmptyObject" }, @@ -3528,138 +3417,31 @@ "io.modelcontextprotocol/subscriptionId" ] }, - "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" - ] - }, - "TaskMetadata": { - "description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).", - "type": "object", - "properties": { - "ttl": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - } - }, - "TaskRequestsCapability": { - "description": "Request types that support task-augmented execution.", - "type": "object", - "properties": { - "elicitation": { - "anyOf": [ - { - "$ref": "#/definitions/ElicitationTaskCapability" - }, - { - "type": "null" - } - ] - }, - "sampling": { - "anyOf": [ - { - "$ref": "#/definitions/SamplingTaskCapability" - }, - { - "type": "null" - } - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsTaskCapability" - }, - { - "type": "null" - } - ] - } - } - }, "TaskStatus": { - "description": "Canonical task lifecycle status as defined by SEP-1686.", + "description": "Canonical task lifecycle status (SEP-2663).", "oneOf": [ { - "description": "The receiver accepted the request and is currently working on it.", + "description": "The request is currently being processed.", "type": "string", "const": "working" }, { - "description": "The receiver requires additional input before work can continue.", + "description": "The server needs input from the client before the task can proceed.", "type": "string", "const": "input_required" }, { - "description": "The underlying operation completed successfully and the result is ready.", + "description": "The request completed successfully and the result is available.\nThis includes tool calls that returned results with `isError: true`.", "type": "string", "const": "completed" }, { - "description": "The underlying operation failed and will not continue.", + "description": "The request failed due to a JSON-RPC error during execution.", "type": "string", "const": "failed" }, { - "description": "The task was cancelled and will not continue processing.", + "description": "The request was cancelled before completion.", "type": "string", "const": "cancelled" } @@ -3668,10 +3450,10 @@ "TaskStatusNotificationMethod": { "type": "string", "format": "const", - "const": "notifications/tasks/status" + "const": "notifications/tasks" }, - "TaskStatusNotificationParam": { - "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nThe task fields are flattened at the top level: `NotificationParams & Task`.", + "TaskStatusNotificationParams": { + "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nCarries a complete [`DetailedTask`] for the current status, identical to\nwhat `tasks/get` would have returned at that moment. The task fields are\nflattened at the top level: `NotificationParams & Task`.", "type": "object", "properties": { "_meta": { @@ -3685,15 +3467,15 @@ ] }, "createdAt": { - "description": "ISO-8601 creation timestamp.", + "description": "ISO 8601 timestamp when the task was created.", "type": "string" }, "lastUpdatedAt": { - "description": "ISO-8601 timestamp for the most recent status change.", + "description": "ISO 8601 timestamp when the task was last updated.", "type": "string" }, - "pollInterval": { - "description": "Suggested polling interval (milliseconds).", + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", "type": [ "integer", "null" @@ -3702,7 +3484,7 @@ "minimum": 0 }, "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", + "description": "Current task status.", "allOf": [ { "$ref": "#/definitions/TaskStatus" @@ -3710,18 +3492,18 @@ ] }, "statusMessage": { - "description": "Optional human-readable status message for UI surfaces.", + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", "type": [ "string", "null" ] }, "taskId": { - "description": "Unique task identifier generated by the receiver.", + "description": "Stable identifier for this task, generated by the server.", "type": "string" }, - "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", "type": [ "integer", "null" @@ -3737,56 +3519,6 @@ "lastUpdatedAt" ] }, - "TaskSupport": { - "description": "Per-tool task support mode as defined in the MCP specification.\n\nThis enum indicates whether a tool supports task-based invocation,\nallowing clients to know how to properly call the tool.\n\nSee [Tool-Level Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation).", - "oneOf": [ - { - "description": "Clients MUST NOT invoke this tool as a task (default behavior).", - "type": "string", - "const": "forbidden" - }, - { - "description": "Clients MAY invoke this tool as either a task or a normal call.", - "type": "string", - "const": "optional" - }, - { - "description": "Clients MUST invoke this tool as a task.", - "type": "string", - "const": "required" - } - ] - }, - "TasksCapability": { - "description": "Task capabilities shared by client and server.", - "type": "object", - "properties": { - "cancel": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "list": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "requests": { - "anyOf": [ - { - "$ref": "#/definitions/TaskRequestsCapability" - }, - { - "type": "null" - } - ] - } - } - }, "TextContent": { "description": "Text content block (spec `TextContent`).", "type": "object", @@ -3960,17 +3692,6 @@ "null" ] }, - "execution": { - "description": "Execution-related configuration including task support mode.", - "anyOf": [ - { - "$ref": "#/definitions/ToolExecution" - }, - { - "type": "null" - } - ] - }, "icons": { "description": "Optional list of icons for the tool", "type": [ @@ -4089,23 +3810,6 @@ } ] }, - "ToolExecution": { - "description": "Execution-related configuration for a tool.\n\nThis struct contains settings that control how a tool should be executed,\nincluding task support configuration.", - "type": "object", - "properties": { - "taskSupport": { - "description": "Indicates whether this tool supports task-based invocation.\n\nWhen not present or set to `Forbidden`, clients MUST NOT invoke this tool as a task.\nWhen set to `Optional`, clients MAY invoke this tool as a task or normal call.\nWhen set to `Required`, clients MUST invoke this tool as a task.", - "anyOf": [ - { - "$ref": "#/definitions/TaskSupport" - }, - { - "type": "null" - } - ] - } - } - }, "ToolListChangedNotificationMethod": { "type": "string", "format": "const", @@ -4191,18 +3895,6 @@ } } }, - "ToolsTaskCapability": { - "type": "object", - "properties": { - "call": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "UntitledItems": { "description": "Items for untitled multi-select options", "type": "object", diff --git a/crates/rmcp/tests/test_task.rs b/crates/rmcp/tests/test_task.rs index 6f9d6604b..81bfffffc 100644 --- a/crates/rmcp/tests/test_task.rs +++ b/crates/rmcp/tests/test_task.rs @@ -1,119 +1,253 @@ -use std::{any::Any, time::Duration}; +//! End-to-end tests for the MCP Tasks extension (SEP-2663, +//! `io.modelcontextprotocol/tasks`). +#![cfg(all(feature = "server", feature = "client", not(feature = "local")))] use rmcp::{ - model::TaskStatusNotificationParam, - task_manager::{ - OperationDescriptor, OperationMessage, OperationProcessor, OperationResultTransport, - }, + ErrorData as McpError, ServerHandler, ServiceExt, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::*, + service::{RequestContext, RoleServer}, + task_manager::{TaskManager, TaskOptions}, + tool, tool_router, }; use serde_json::json; -struct DummyTransport { - id: String, - value: u32, +#[derive(Debug, serde::Deserialize, rmcp::schemars::JsonSchema)] +pub struct SumArgs { + pub a: i32, + pub b: i32, } -impl OperationResultTransport for DummyTransport { - fn operation_id(&self) -> &String { - &self.id +#[derive(Clone)] +struct TaskServer { + tool_router: ToolRouter, + tasks: TaskManager, +} + +#[tool_router] +impl TaskServer { + fn new() -> Self { + Self { + tool_router: Self::tool_router(), + tasks: TaskManager::new(), + } + } + + #[tool(description = "Sum two numbers")] + async fn sum( + &self, + Parameters(SumArgs { a, b }): Parameters, + ) -> Result { + Ok(CallToolResult::success(vec![ContentBlock::text( + (a + b).to_string(), + )])) + } +} + +impl ServerHandler for TaskServer { + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let client_supports_tasks = context + .meta + .client_capabilities() + .map(|caps| caps.supports_tasks()) + .unwrap_or_else(|| { + context + .peer + .peer_info() + .is_some_and(|info| info.capabilities.supports_tasks()) + }); + + if request.name == "sum" && client_supports_tasks { + let args: SumArgs = serde_json::from_value(serde_json::Value::Object( + request.arguments.clone().unwrap_or_default(), + )) + .map_err(|e| McpError::invalid_params(e.to_string(), None))?; + let task = + self.tasks + .spawn(TaskOptions::new().with_poll_interval_ms(10), move |_ctx| { + Box::pin(async move { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + Ok(CallToolResult::success(vec![ContentBlock::text( + (args.a + args.b).to_string(), + )])) + }) + }); + return Ok(CallToolResponse::Task(CreateTaskResult::new(task))); + } + + let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); + self.tool_router.call(tcc).await } - fn as_any(&self) -> &dyn Any { - self + async fn get_task( + &self, + request: GetTaskParams, + _context: RequestContext, + ) -> Result { + Ok(GetTaskResult::new(self.tasks.get_task(&request.task_id)?)) } + + async fn update_task( + &self, + request: UpdateTaskParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.tasks + .update_task(&request.task_id, request.input_responses) + } + + async fn cancel_task( + &self, + request: CancelTaskParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.tasks.cancel_task(&request.task_id) + } + + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tasks() + .build(), + ) + } +} + +fn tasks_client_info() -> ClientInfo { + ClientInfo::new( + ClientCapabilities::builder().enable_tasks().build(), + Implementation::from_build_env(), + ) } #[tokio::test] -async fn executes_enqueued_future() { - let mut processor = OperationProcessor::new(); - let descriptor = OperationDescriptor::new("op1", "dummy"); - let future = Box::pin(async { - tokio::time::sleep(Duration::from_millis(10)).await; - Ok(Box::new(DummyTransport { - id: "op1".to_string(), - value: 42, - }) as Box) +async fn task_lifecycle_create_poll_complete() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) }); - processor - .submit_operation(OperationMessage::new(descriptor, future)) - .expect("submit operation"); - - tokio::time::sleep(Duration::from_millis(30)).await; - let results = processor.peek_completed(); - assert_eq!(results.len(), 1); - let payload = results[0] - .result - .as_ref() - .unwrap() - .as_any() - .downcast_ref::() + let client = tasks_client_info().serve(client_transport).await.unwrap(); + + // Server materializes a task because we declared the extension. + let response = client + .call_tool_once( + CallToolRequestParams::new("sum") + .with_arguments(serde_json::from_value(json!({"a": 40, "b": 2})).unwrap()), + ) + .await .unwrap(); - assert_eq!(payload.value, 42); + let create = match response { + CallToolResponse::Task(create) => create, + other => panic!("expected CreateTaskResult, got {other:?}"), + }; + assert_eq!(create.result_type, ResultType::TASK); + let task_id = create.task.task_id.clone(); + + // Poll until terminal. + let final_task = loop { + tokio::time::sleep(std::time::Duration::from_millis( + create.task.poll_interval_ms.unwrap_or(10), + )) + .await; + let info = client + .peer() + .get_task(GetTaskParams::new(task_id.clone())) + .await + .unwrap(); + if info.task.status().is_terminal() { + break info.task; + } + }; + + match final_task.payload { + TaskPayload::Completed { result } => { + let result: CallToolResult = + serde_json::from_value(serde_json::Value::Object(result)).unwrap(); + let text = result.content[0].as_text().unwrap(); + assert_eq!(text.text, "42"); + } + other => panic!("expected completed task, got {other:?}"), + } + + client.cancel().await.unwrap(); + server.abort(); } #[tokio::test] -async fn rejects_duplicate_operation_ids() { - let mut processor = OperationProcessor::new(); - let descriptor = OperationDescriptor::new("dup", "dummy"); - let future = Box::pin(async { - Ok(Box::new(DummyTransport { - id: "dup".to_string(), - value: 1, - }) as Box) - }); - processor - .submit_operation(OperationMessage::new(descriptor, future)) - .expect("first submit"); - - let descriptor_dup = OperationDescriptor::new("dup", "dummy"); - let future_dup = Box::pin(async { - Ok(Box::new(DummyTransport { - id: "dup".to_string(), - value: 2, - }) as Box) +async fn task_cancel_acknowledged() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) }); - let err = processor - .submit_operation(OperationMessage::new(descriptor_dup, future_dup)) - .expect_err("duplicate should fail"); - assert!(format!("{err}").contains("already running")); + let client = tasks_client_info().serve(client_transport).await.unwrap(); + + let response = client + .call_tool_once( + CallToolRequestParams::new("sum") + .with_arguments(serde_json::from_value(json!({"a": 1, "b": 1})).unwrap()), + ) + .await + .unwrap(); + let create = match response { + CallToolResponse::Task(create) => create, + other => panic!("expected CreateTaskResult, got {other:?}"), + }; + + client + .peer() + .cancel_task(CancelTaskParams::new(create.task.task_id.clone())) + .await + .unwrap(); + + let info = client + .peer() + .get_task(GetTaskParams::new(create.task.task_id.clone())) + .await + .unwrap(); + assert_eq!(info.task.status(), TaskStatus::Cancelled); + + client.cancel().await.unwrap(); + server.abort(); } #[tokio::test] -async fn ttl_is_interpreted_as_milliseconds() { - let mut processor = OperationProcessor::new(); - let descriptor = OperationDescriptor::new("slow", "dummy").with_ttl(50); - let future = Box::pin(async { - tokio::time::sleep(Duration::from_millis(500)).await; - Ok(Box::new(DummyTransport { - id: "slow".to_string(), - value: 0, - }) as Box) +async fn no_task_without_extension_capability() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) }); - processor - .submit_operation(OperationMessage::new(descriptor, future)) - .expect("submit operation"); - - tokio::time::sleep(Duration::from_millis(200)).await; - let results = processor.peek_completed(); - assert_eq!( - results.len(), - 1, - "50ms ttl should have timed out the operation well within 200ms" - ); - match &results[0].result { - Err(err) => assert!( - err.to_string().contains("timed out"), - "unexpected error: {err}" - ), - Ok(_) => panic!("expected the operation to time out, but it completed"), - } + // Plain client: no tasks extension declared. + let client = ().serve(client_transport).await.unwrap(); + let result = client + .call_tool( + CallToolRequestParams::new("sum") + .with_arguments(serde_json::from_value(json!({"a": 2, "b": 3})).unwrap()), + ) + .await + .unwrap(); + let text = result.content[0].as_text().unwrap(); + assert_eq!(text.text, "5"); + + client.cancel().await.unwrap(); + server.abort(); } #[test] -fn task_status_notification_param_preserves_meta() { +fn task_status_notification_params_preserve_meta() { let raw = json!({ "_meta": { "traceId": "trace-1" @@ -122,17 +256,17 @@ fn task_status_notification_param_preserves_meta() { "status": "working", "createdAt": "2026-06-24T00:00:00Z", "lastUpdatedAt": "2026-06-24T00:00:01Z", - "ttl": null + "ttlMs": null }); - let params: TaskStatusNotificationParam = serde_json::from_value(raw).unwrap(); + let params: TaskStatusNotificationParams = serde_json::from_value(raw).unwrap(); - assert_eq!(params.task.task_id, "task-1"); - assert_eq!(params.task_id, "task-1"); + assert_eq!(params.task.task.task_id, "task-1"); + assert_eq!(params.status(), TaskStatus::Working); assert_eq!(params.meta.as_ref().unwrap().0["traceId"], json!("trace-1")); let serialized = serde_json::to_value(¶ms).unwrap(); - assert_eq!(serialized["_meta"]["traceId"], json!("trace-1")); assert_eq!(serialized["taskId"], json!("task-1")); + assert_eq!(serialized["ttlMs"], serde_json::Value::Null); } diff --git a/crates/rmcp/tests/test_task_support_validation.rs b/crates/rmcp/tests/test_task_support_validation.rs deleted file mode 100644 index 41d03f031..000000000 --- a/crates/rmcp/tests/test_task_support_validation.rs +++ /dev/null @@ -1,251 +0,0 @@ -#![cfg(not(feature = "local"))] -//! Tests for task support validation in tool calls. -//! -//! Verifies that the server correctly validates `execution.taskSupport` settings -//! per the MCP specification: -//! - `Required`: MUST be invoked as a task, returns -32601 otherwise -//! - `Forbidden`: MUST NOT be invoked as a task, returns error otherwise -//! - `Optional`: MAY be invoked either way -#![cfg(feature = "client")] - -use rmcp::{ - ClientHandler, ServerHandler, ServiceError, ServiceExt, - handler::server::router::tool::ToolRouter, - model::{CallToolRequestParams, ClientInfo, ErrorCode, TaskMetadata}, - tool, tool_handler, tool_router, -}; - -/// Server with tools having different task support modes. -#[derive(Debug, Clone)] -pub struct TaskSupportTestServer { - #[expect(dead_code, reason = "tool_handler macro accesses this router field")] - tool_router: ToolRouter, -} - -impl Default for TaskSupportTestServer { - fn default() -> Self { - Self::new() - } -} - -impl TaskSupportTestServer { - pub fn new() -> Self { - Self { - tool_router: Self::tool_router(), - } - } -} - -#[tool_router] -impl TaskSupportTestServer { - #[tool( - description = "Tool that requires task-based invocation", - execution(task_support = "required") - )] - async fn required_task_tool(&self) -> String { - "required task executed".to_string() - } - - #[tool( - description = "Tool that forbids task-based invocation", - execution(task_support = "forbidden") - )] - async fn forbidden_task_tool(&self) -> String { - "forbidden task executed".to_string() - } - - #[tool( - description = "Tool that optionally supports task-based invocation", - execution(task_support = "optional") - )] - async fn optional_task_tool(&self) -> String { - "optional task executed".to_string() - } -} - -#[tool_handler] -impl ServerHandler for TaskSupportTestServer {} - -#[derive(Debug, Clone, Default)] -struct DummyClientHandler {} - -impl ClientHandler for DummyClientHandler { - fn get_info(&self) -> ClientInfo { - ClientInfo::default() - } -} - -/// Helper to create a task object for tool calls -fn make_task() -> TaskMetadata { - TaskMetadata::new() -} - -#[tokio::test] -async fn test_required_task_tool_without_task_returns_method_not_found() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server = TaskSupportTestServer::new(); - let server_handle = tokio::spawn(async move { - server.serve(server_transport).await?.waiting().await?; - anyhow::Ok(()) - }); - - let client_handler = DummyClientHandler::default(); - let client = client_handler.serve(client_transport).await?; - - // Call the task-required tool without a task - should fail with -32601 - let result = client - .call_tool(CallToolRequestParams::new("required_task_tool")) - .await; - - // Should be an error with code -32601 (METHOD_NOT_FOUND) - assert!( - result.is_err(), - "Expected error for required task tool without task" - ); - let error = result.unwrap_err(); - - // Check the error data contains the expected code - match error { - ServiceError::McpError(error_data) => { - assert_eq!( - error_data.code, - ErrorCode::METHOD_NOT_FOUND, - "Expected METHOD_NOT_FOUND error code (-32601)" - ); - assert!( - error_data - .message - .contains("requires task-based invocation"), - "Error message should indicate task-based invocation is required, got: {}", - error_data.message - ); - } - _ => panic!("Expected McpError variant, got: {:?}", error), - } - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -#[tokio::test] -async fn test_forbidden_task_tool_with_task_returns_error() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server = TaskSupportTestServer::new(); - let server_handle = tokio::spawn(async move { - server.serve(server_transport).await?.waiting().await?; - anyhow::Ok(()) - }); - - let client_handler = DummyClientHandler::default(); - let client = client_handler.serve(client_transport).await?; - - // Call the forbidden task tool WITH a task - should fail - let result = client - .call_tool(CallToolRequestParams::new("forbidden_task_tool").with_task(make_task())) - .await; - - // Should be an error with code INVALID_PARAMS - assert!( - result.is_err(), - "Expected error for forbidden task tool with task" - ); - let error = result.unwrap_err(); - - // Check the error data contains the expected code - match error { - ServiceError::McpError(error_data) => { - assert_eq!( - error_data.code, - ErrorCode::INVALID_PARAMS, - "Expected INVALID_PARAMS error code" - ); - assert!( - error_data - .message - .contains("does not support task-based invocation"), - "Error message should indicate task-based invocation is not supported, got: {}", - error_data.message - ); - } - _ => panic!("Expected McpError variant, got: {:?}", error), - } - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -#[tokio::test] -async fn test_forbidden_task_tool_without_task_succeeds() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server = TaskSupportTestServer::new(); - let server_handle = tokio::spawn(async move { - server.serve(server_transport).await?.waiting().await?; - anyhow::Ok(()) - }); - - let client_handler = DummyClientHandler::default(); - let client = client_handler.serve(client_transport).await?; - - // Call the forbidden task tool WITHOUT a task - should succeed - let result = client - .call_tool(CallToolRequestParams::new("forbidden_task_tool")) - .await; - - assert!( - result.is_ok(), - "Forbidden task tool without task should succeed" - ); - let result = result.unwrap(); - let text = result - .content - .first() - .and_then(|c| c.as_text()) - .map(|t| t.text.as_str()) - .unwrap_or(""); - assert_eq!(text, "forbidden task executed"); - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -#[tokio::test] -async fn test_optional_task_tool_without_task_succeeds() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server = TaskSupportTestServer::new(); - let server_handle = tokio::spawn(async move { - server.serve(server_transport).await?.waiting().await?; - anyhow::Ok(()) - }); - - let client_handler = DummyClientHandler::default(); - let client = client_handler.serve(client_transport).await?; - - // Call the optional task tool WITHOUT a task - should succeed - let result = client - .call_tool(CallToolRequestParams::new("optional_task_tool")) - .await; - - assert!( - result.is_ok(), - "Optional task tool without task should succeed" - ); - let result = result.unwrap(); - let text = result - .content - .first() - .and_then(|c| c.as_text()) - .map(|t| t.text.as_str()) - .unwrap_or(""); - assert_eq!(text, "optional task executed"); - - client.cancel().await?; - server_handle.await??; - Ok(()) -} diff --git a/crates/rmcp/tests/test_tool_macros.rs b/crates/rmcp/tests/test_tool_macros.rs index 89846fa79..9b9530aa8 100644 --- a/crates/rmcp/tests/test_tool_macros.rs +++ b/crates/rmcp/tests/test_tool_macros.rs @@ -397,8 +397,8 @@ fn test_minimal_server_get_info_auto_generated() { "prompts should not be auto-enabled" ); assert!( - info.capabilities.tasks.is_none(), - "tasks should not be auto-enabled" + !info.capabilities.supports_tasks(), + "tasks extension should not be auto-enabled" ); assert!( !info.server_info.name.is_empty(), diff --git a/examples/clients/README.md b/examples/clients/README.md index f082a4926..76aa97389 100644 --- a/examples/clients/README.md +++ b/examples/clients/README.md @@ -69,12 +69,13 @@ A client demonstrating how to use the sampling tool. ### Task Standard I/O Client (`task_stdio.rs`) -A client that exercises the task lifecycle against `servers_task_stdio` -(per [SEP-1319](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)). +A client that exercises the SEP-2663 Tasks extension lifecycle against `servers_task_stdio` +([SEP-2663](https://modelcontextprotocol.io/extensions/tasks/overview), `io.modelcontextprotocol/tasks`). - Spawns `servers_task_stdio` as a child process over stdio +- Declares the tasks extension in its client capabilities - Calls `quick_echo` synchronously -- Calls `slow_sum` as a task via `CallToolRequestParams::with_task(...)`, polls `tasks/get` until completion, then fetches the result via `tasks/result` +- Calls `slow_sum`, receives a `CreateTaskResult` (`resultType: "task"`), polls `tasks/get` honoring `pollIntervalMs`, and reads the final `CallToolResult` inlined in the completed task ### Progress Test Client (`progress_client.rs`) diff --git a/examples/clients/src/task_stdio.rs b/examples/clients/src/task_stdio.rs index ffcc0dc22..465cb7062 100644 --- a/examples/clients/src/task_stdio.rs +++ b/examples/clients/src/task_stdio.rs @@ -1,18 +1,20 @@ //! Client for the task-demo server in `examples/servers/src/task_stdio.rs`. //! -//! Walks through the task lifecycle (SEP-1319): +//! Walks through the SEP-2663 Tasks extension lifecycle: //! 1. Call a regular tool (`quick_echo`) — synchronous response. -//! 2. Call a task-required tool (`slow_sum`) by attaching `task: {}` to -//! the `tools/call` request. The server returns a `Task` with a `task_id`. -//! 3. Poll `tasks/get` until status becomes `Completed`. -//! 4. Fetch the underlying `CallToolResult` via `tasks/result`. +//! 2. Call `slow_sum` while declaring the `io.modelcontextprotocol/tasks` +//! extension capability. The server decides to materialize a task and +//! returns a `CreateTaskResult` (`resultType: "task"`). +//! 3. Poll `tasks/get` (honoring `pollIntervalMs`) until the task reaches a +//! terminal status; the final `CallToolResult` is inlined in the +//! `completed` task's `result` field. use anyhow::{Result, anyhow}; use rmcp::{ ServiceExt, model::{ - CallToolRequestParams, CallToolResult, ClientRequest, GetTaskParams, GetTaskPayloadParams, - Request, ServerResult, TaskMetadata, TaskStatus, + CallToolRequestParams, CallToolResponse, CallToolResult, ClientCapabilities, GetTaskParams, + TaskPayload, TaskStatus, }, object, transport::{ConfigureCommandExt, TokioChildProcess}, @@ -30,8 +32,14 @@ async fn main() -> Result<()> { .with(tracing_subscriber::fmt::layer()) .init(); + // Declare the tasks extension in our client capabilities (SEP-2663). + let client_info = rmcp::model::ClientInfo::new( + ClientCapabilities::builder().enable_tasks().build(), + rmcp::model::Implementation::from_build_env(), + ); + // Spawn the task-demo server as a child process over stdio. - let client = () + let client = client_info .serve(TokioChildProcess::new(Command::new("cargo").configure( |cmd| { cmd.arg("run") @@ -44,7 +52,7 @@ async fn main() -> Result<()> { ))?) .await?; - // 1) Synchronous call. `quick_echo` has the default task_support = forbidden. + // 1) Synchronous call. let echo = client .call_tool( CallToolRequestParams::new("quick_echo") @@ -53,68 +61,63 @@ async fn main() -> Result<()> { .await?; tracing::info!("quick_echo -> {echo:#?}"); - // 2) Task call. `slow_sum` is task_support = required, so we MUST attach - // `task` metadata. An empty `TaskMetadata` is fine; use `.with_ttl(...)` - // to set a retention window. - let create = client - .send_request(ClientRequest::CallToolRequest(Request::new( - CallToolRequestParams::new("slow_sum") - .with_arguments(object!({ "a": 40, "b": 2 })) - .with_task(TaskMetadata::new()), - ))) + // 2) Task-eligible call. The server sees our tasks capability and + // materializes a task instead of blocking. + let response = client + .call_tool_once( + CallToolRequestParams::new("slow_sum").with_arguments(object!({ "a": 40, "b": 2 })), + ) .await?; - let ServerResult::CreateTaskResult(create) = create else { - return Err(anyhow!("expected CreateTaskResult, got {create:?}")); + let create = match response { + CallToolResponse::Task(create) => create, + CallToolResponse::Complete(result) => { + // The server is allowed to answer synchronously. + tracing::info!("slow_sum answered synchronously -> {result:#?}"); + client.cancel().await?; + return Ok(()); + } + other => return Err(anyhow!("unexpected response: {other:?}")), }; let task_id = create.task.task_id.clone(); + let poll_ms = create.task.poll_interval_ms.unwrap_or(500); tracing::info!( - "slow_sum enqueued as task {task_id} (status = {:?})", + "slow_sum materialized as task {task_id} (status = {:?})", create.task.status ); - // 3) Poll `tasks/get` until the server reports a terminal status. - let final_status = loop { - tokio::time::sleep(std::time::Duration::from_millis(250)).await; + // 3) Poll `tasks/get` until the task reaches a terminal status. + let final_task = loop { + tokio::time::sleep(std::time::Duration::from_millis(poll_ms)).await; let info = client - .send_request(ClientRequest::GetTaskRequest(Request::new( - GetTaskParams::new(task_id.clone()), - ))) + .peer() + .get_task(GetTaskParams::new(task_id.clone())) .await?; - let ServerResult::GetTaskResult(info) = info else { - return Err(anyhow!("expected GetTaskResult, got {info:?}")); - }; - tracing::info!("status = {:?}", info.task.status); + tracing::info!("status = {:?}", info.task.status()); - match info.task.status { - TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled => { - break info.task.status; - } - _ => {} + if info.task.status().is_terminal() { + break info.task; } }; - if final_status != TaskStatus::Completed { - return Err(anyhow!("task ended in {final_status:?}")); + // The completed task carries the final CallToolResult inline. + match &final_task.payload { + TaskPayload::Completed { result } => { + let call_result: CallToolResult = + serde_json::from_value(serde_json::Value::Object(result.clone()))?; + tracing::info!("slow_sum result -> {call_result:#?}"); + } + TaskPayload::Failed { error } => { + return Err(anyhow!("task failed: {error:?}")); + } + other => { + return Err(anyhow!( + "task ended in unexpected state {:?}", + other.status() + )); + } } - - // 4) Fetch the payload. The server-side handler returns a serialized - // `CallToolResult`. On the wire the response is just a JSON value, and - // `ServerResult` is `#[serde(untagged)]`, so the client decodes it as - // whichever variant the JSON shape matches first — a `CallToolResult` - // here. (For a non-tool task the same value would surface as - // `ServerResult::CustomResult` and need manual `serde_json::from_value`.) - let payload = client - .send_request(ClientRequest::GetTaskPayloadRequest(Request::new( - GetTaskPayloadParams::new(task_id.clone()), - ))) - .await?; - let call_result: CallToolResult = match payload { - ServerResult::CallToolResult(r) => r, - ServerResult::CustomResult(c) => serde_json::from_value(c.0)?, - other => return Err(anyhow!("unexpected task result: {other:?}")), - }; - tracing::info!("slow_sum result -> {call_result:#?}"); + debug_assert_eq!(final_task.status(), TaskStatus::Completed); client.cancel().await?; Ok(()) diff --git a/examples/servers/README.md b/examples/servers/README.md index 5fa5011ec..86bf3a35e 100644 --- a/examples/servers/README.md +++ b/examples/servers/README.md @@ -71,13 +71,13 @@ A server demonstrating the prompt framework capabilities. ### Task Demo Server (`task_stdio.rs`) -A minimal stdio server demonstrating task-based tool invocation per -[SEP-1319](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks). +A minimal stdio server demonstrating the MCP Tasks extension +([SEP-2663](https://modelcontextprotocol.io/extensions/tasks/overview), `io.modelcontextprotocol/tasks`). -- `slow_sum` is declared with `execution(task_support = "required")`, so clients MUST invoke it as a task +- `slow_sum` is materialized as a task (`CreateTaskResult`, `resultType: "task"`) whenever the client declares the tasks extension capability; other clients get a normal synchronous response - `quick_echo` is a regular synchronous tool for contrast -- Wires up `enqueue_task` / `tasks/get` / `tasks/result` / `tasks/cancel` via `#[task_handler]` -- Pair with `examples/clients/src/task_stdio.rs` to see the full lifecycle (create → poll → fetch result) +- Serves `tasks/get` / `tasks/update` / `tasks/cancel` via a `TaskManager` +- Pair with `examples/clients/src/task_stdio.rs` to see the full lifecycle (create → poll → inline result) ### MRTR Demo (`mrtr.rs`) diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index 3cac2b2ba..c6602770f 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -1,5 +1,5 @@ #![allow(dead_code)] -use std::{any::Any, sync::Arc}; +use std::sync::Arc; use rmcp::{ ErrorData as McpError, RoleServer, ServerHandler, @@ -10,28 +10,11 @@ use rmcp::{ model::*, prompt, prompt_handler, prompt_router, schemars, service::RequestContext, - task_handler, - task_manager::{OperationProcessor, OperationResultTransport}, tool, tool_handler, tool_router, }; use serde_json::json; use tokio::sync::Mutex; -struct ToolCallOperationResult { - id: String, - result: Result, -} - -impl OperationResultTransport for ToolCallOperationResult { - fn operation_id(&self) -> &String { - &self.id - } - - fn as_any(&self) -> &dyn Any { - self - } -} - #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct StructRequest { pub a: i32, @@ -78,7 +61,6 @@ pub struct Counter { counter: Arc>, tool_router: ToolRouter, prompt_router: PromptRouter, - processor: Arc>, } #[tool_router] @@ -89,7 +71,6 @@ impl Counter { counter: Arc::new(Mutex::new(0)), tool_router: Self::tool_router(), prompt_router: Self::prompt_router(), - processor: Arc::new(Mutex::new(OperationProcessor::new())), } } @@ -123,10 +104,7 @@ impl Counter { )])) } - #[tool( - description = "Long running task example", - execution(task_support = "optional") - )] + #[tool(description = "Long running task example")] async fn long_task(&self) -> Result { tokio::time::sleep(std::time::Duration::from_secs(10)).await; Ok(CallToolResult::success(vec![ContentBlock::text( @@ -227,7 +205,6 @@ impl Counter { #[tool_handler(meta = MetaObject(rmcp::object!({"tool_meta_key": "tool_meta_value"})))] #[prompt_handler(meta = MetaObject(rmcp::object!({"router_meta_key": "router_meta_value"})))] -#[task_handler] impl ServerHandler for Counter { fn get_info(&self) -> ServerInfo { ServerInfo::new( @@ -380,49 +357,4 @@ mod tests { let prompts = router.list_all(); assert_eq!(prompts.len(), 2); } - - #[tokio::test] - async fn test_client_enqueues_long_task() -> anyhow::Result<()> { - let counter = Counter::new(); - let processor = counter.processor.clone(); - let client = TestClient::default(); - - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server_handle = tokio::spawn(async move { - let service = counter.serve(server_transport).await?; - service.waiting().await?; - anyhow::Ok(()) - }); - - let client_service = client.serve(client_transport).await?; - let params = CallToolRequestParams::new("long_task").with_task(TaskMetadata::new()); - let response = client_service - .send_request(ClientRequest::CallToolRequest(Request::new(params.clone()))) - .await?; - - let ServerResult::CreateTaskResult(info) = response else { - panic!("expected task creation result, got {response:?}"); - }; - let task = info.task; - - assert_eq!(task.status, TaskStatus::Working); - // task list should show the task - let tasks = client_service - .send_request(ClientRequest::ListTasksRequest( - RequestOptionalParam::default(), - )) - .await - .unwrap(); - let ServerResult::ListTasksResult(listed) = tasks else { - panic!("expected list tasks result, got {tasks:?}"); - }; - assert_eq!(listed.tasks[0].task_id, task.task_id); - tokio::time::sleep(Duration::from_millis(50)).await; - let running = processor.lock().await.running_task_count(); - assert_eq!(running, 1); - - client_service.cancel().await?; - let _ = server_handle.await; - Ok(()) - } } diff --git a/examples/servers/src/common/task_demo.rs b/examples/servers/src/common/task_demo.rs index 275047a55..d56550474 100644 --- a/examples/servers/src/common/task_demo.rs +++ b/examples/servers/src/common/task_demo.rs @@ -1,27 +1,26 @@ -//! Minimal example of a tool that supports task-based invocation (SEP-1319). +//! Minimal example of a server that supports the MCP Tasks extension +//! (SEP-2663, `io.modelcontextprotocol/tasks`). //! -//! - `slow_sum` is marked `task_support = "required"`, so the client MUST invoke -//! it as a task. The server enqueues the call into an `OperationProcessor`, -//! returns a task id immediately, and the client polls `tasks/get` and -//! fetches the payload via `tasks/result`. -//! - `quick_echo` is a regular synchronous tool for contrast (the default, -//! `task_support = "forbidden"`). +//! - `slow_sum` is executed as a task whenever the client declares the tasks +//! extension capability: the server returns a `CreateTaskResult` +//! (`resultType: "task"`) immediately and the client polls `tasks/get`. +//! Clients that do not declare the extension get a normal synchronous +//! response. +//! - `quick_echo` is a regular synchronous tool for contrast. //! //! See `examples/clients/src/task_stdio.rs` for the matching client. #![allow(dead_code)] -use std::sync::Arc; - use rmcp::{ ErrorData as McpError, ServerHandler, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{CallToolResult, ContentBlock}, - schemars, task_handler, - task_manager::OperationProcessor, - tool, tool_handler, tool_router, + model::*, + schemars, + service::{RequestContext, RoleServer}, + task_manager::{TaskManager, TaskOptions}, + tool, tool_router, }; -use tokio::sync::Mutex; #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct SumArgs { @@ -34,13 +33,10 @@ pub struct EchoArgs { pub message: String, } -/// Server state. The `processor` field is required by `#[task_handler]`: -/// the macro generates `enqueue_task` / `tasks/*` handlers that submit and -/// poll operations through it. #[derive(Clone)] pub struct TaskDemo { tool_router: ToolRouter, - processor: Arc>, + tasks: TaskManager, } impl Default for TaskDemo { @@ -54,17 +50,12 @@ impl TaskDemo { pub fn new() -> Self { Self { tool_router: Self::tool_router(), - processor: Arc::new(Mutex::new(OperationProcessor::new())), + tasks: TaskManager::new(), } } - /// Long-running tool. The `execution(task_support = "required")` attribute - /// tells clients they MUST call this tool as a task; the server returns - /// `-32601` if they don't. - #[tool( - description = "Sum two numbers after a 2-second delay", - execution(task_support = "required") - )] + /// Long-running tool. Run as a task when the client supports tasks. + #[tool(description = "Sum two numbers after a 2-second delay")] async fn slow_sum( &self, Parameters(SumArgs { a, b }): Parameters, @@ -75,7 +66,7 @@ impl TaskDemo { )])) } - /// Synchronous tool with the default `task_support = "forbidden"`. + /// Synchronous tool. #[tool(description = "Echo a message back immediately")] async fn quick_echo( &self, @@ -85,9 +76,85 @@ impl TaskDemo { } } -/// `#[task_handler]` reads `self.processor` (configurable via the macro's -/// `processor = ...` argument) and synthesizes `enqueue_task`, `list_tasks`, -/// `get_task_info`, `get_task_result`, and `cancel_task` for us. -#[tool_handler] -#[task_handler] -impl ServerHandler for TaskDemo {} +impl ServerHandler for TaskDemo { + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + // SEP-2663: the server decides per-request whether to materialize a + // task, but MUST NOT return one unless the request declared the tasks + // extension capability. + let client_supports_tasks = context + .meta + .client_capabilities() + .is_some_and(|caps| caps.supports_tasks()); + + if request.name == "slow_sum" && client_supports_tasks { + let params: SumArgs = serde_json::from_value(serde_json::Value::Object( + request.arguments.clone().unwrap_or_default(), + )) + .map_err(|e| McpError::invalid_params(e.to_string(), None))?; + let task = self.tasks.spawn(TaskOptions::default(), move |_ctx| { + Box::pin(async move { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + Ok(CallToolResult::success(vec![ContentBlock::text( + (params.a + params.b).to_string(), + )])) + }) + }); + return Ok(CallToolResponse::Task(CreateTaskResult::new(task))); + } + + // Fall back to synchronous execution via the tool router. + let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); + self.tool_router.call(tcc).await + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListToolsResult::with_all_items(self.tool_router.list_all())) + } + + async fn get_task( + &self, + request: GetTaskParams, + _context: RequestContext, + ) -> Result { + Ok(GetTaskResult::new(self.tasks.get_task(&request.task_id)?)) + } + + async fn update_task( + &self, + request: UpdateTaskParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.tasks + .update_task(&request.task_id, request.input_responses) + } + + async fn cancel_task( + &self, + request: CancelTaskParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.tasks.cancel_task(&request.task_id) + } + + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tasks() + .build(), + ) + .with_instructions( + "Task demo server (SEP-2663). `slow_sum` runs as a task for \ + clients that declare the tasks extension." + .to_string(), + ) + } +} From b1588ab5bd7f7a5a89fbda7834c9e5da5e4bbca6 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 21 Jul 2026 12:21:04 -0400 Subject: [PATCH 02/13] feat: gate tasks/* methods on the client tasks-extension capability (SEP-2663) When the server advertises io.modelcontextprotocol/tasks but the client did not declare it (per-request _meta clientCapabilities, or initialize-time capabilities in session mode), tasks/get, tasks/update, and tasks/cancel now return -32021 Missing Required Client Capability with the required extension in the error data, instead of falling through to the handler's -32601 default. Servers that do not advertise the extension keep returning -32601. Also add regression tests confirming that unknown taskIds yield -32602 and that a legacy 2025-11-25 'task' param on tools/call is silently ignored. --- crates/rmcp/src/handler/server.rs | 58 +++++++++++++---- crates/rmcp/tests/test_task.rs | 100 ++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 12 deletions(-) diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 4529e8f8c..43cc7bef7 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -19,6 +19,34 @@ pub mod tool; pub mod tool_name_validation; pub mod wrapper; +/// SEP-2663: gate `tasks/*` methods on the client's declared tasks-extension +/// capability. +/// +/// - If the server does not advertise the tasks extension, the methods are +/// simply unimplemented: `-32601` Method not found. +/// - If the server advertises it but the client did not declare it (either in +/// the request's `_meta` per-request capabilities or, for session-mode +/// peers, at `initialize` time), the spec requires `-32021` Missing +/// Required Client Capability with the required capability in `data`. +fn validate_tasks_capability( + handler: &H, + context: &RequestContext, +) -> Result<(), McpError> { + if !handler.get_info().capabilities.supports_tasks() { + return Err(McpError::method_not_found::()); + } + let client_declared = context + .client_capabilities() + .is_some_and(|caps| caps.supports_tasks()); + if client_declared { + Ok(()) + } else { + Err(McpError::missing_required_client_capability( + ClientCapabilities::builder().enable_tasks().build(), + )) + } +} + impl Service for H { async fn handle_request( &self, @@ -181,18 +209,24 @@ impl Service for H { .on_custom_request(request, context) .await .map(ServerResult::CustomResult), - ClientRequest::GetTaskRequest(request) => self - .get_task(request.params, context) - .await - .map(ServerResult::GetTaskResult), - ClientRequest::UpdateTaskRequest(request) => self - .update_task(request.params, context) - .await - .map(ServerResult::empty), - ClientRequest::CancelTaskRequest(request) => self - .cancel_task(request.params, context) - .await - .map(ServerResult::empty), + ClientRequest::GetTaskRequest(request) => { + validate_tasks_capability::(self, &context)?; + self.get_task(request.params, context) + .await + .map(ServerResult::GetTaskResult) + } + ClientRequest::UpdateTaskRequest(request) => { + validate_tasks_capability::(self, &context)?; + self.update_task(request.params, context) + .await + .map(ServerResult::empty) + } + ClientRequest::CancelTaskRequest(request) => { + validate_tasks_capability::(self, &context)?; + self.cancel_task(request.params, context) + .await + .map(ServerResult::empty) + } }; let result = result.and_then(|result| { if matches!(result, ServerResult::InputRequiredResult(_)) && !mrtr_supported { diff --git a/crates/rmcp/tests/test_task.rs b/crates/rmcp/tests/test_task.rs index 81bfffffc..04e555473 100644 --- a/crates/rmcp/tests/test_task.rs +++ b/crates/rmcp/tests/test_task.rs @@ -246,6 +246,106 @@ async fn no_task_without_extension_capability() { server.abort(); } +#[tokio::test] +async fn tasks_methods_without_capability_return_missing_capability_error() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) + }); + + // Plain client: no tasks extension declared. tasks/* must be rejected + // with -32021 Missing Required Client Capability (SEP-2663), not -32601. + let client = ().serve(client_transport).await.unwrap(); + let err = client + .peer() + .get_task(GetTaskParams::new("whatever")) + .await + .unwrap_err(); + match err { + rmcp::ServiceError::McpError(e) => { + assert_eq!(e.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); + let data = e.data.expect("error data should be present"); + assert!( + data["requiredCapabilities"]["extensions"] + .as_object() + .is_some_and(|ext| ext.contains_key("io.modelcontextprotocol/tasks")), + "error data should name the tasks extension: {data}" + ); + } + other => panic!("expected McpError, got {other:?}"), + } + + let err = client + .peer() + .cancel_task(CancelTaskParams::new("whatever")) + .await + .unwrap_err(); + match err { + rmcp::ServiceError::McpError(e) => { + assert_eq!(e.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); + } + other => panic!("expected McpError, got {other:?}"), + } + + client.cancel().await.unwrap(); + server.abort(); +} + +#[tokio::test] +async fn unknown_task_id_returns_invalid_params() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) + }); + + let client = tasks_client_info().serve(client_transport).await.unwrap(); + let err = client + .peer() + .get_task(GetTaskParams::new("no-such-task")) + .await + .unwrap_err(); + match err { + rmcp::ServiceError::McpError(e) => { + // SEP-2663: unknown taskId is -32602 Invalid params. + assert_eq!(e.code, ErrorCode::INVALID_PARAMS); + } + other => panic!("expected McpError, got {other:?}"), + } + + client.cancel().await.unwrap(); + server.abort(); +} + +#[tokio::test] +async fn legacy_task_param_is_ignored() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) + }); + + // Plain client sending a legacy 2025-11-25 `task: {...}` param: it must + // be silently ignored and the call answered synchronously (SEP-2663). + let client = ().serve(client_transport).await.unwrap(); + let params: CallToolRequestParams = serde_json::from_value(json!({ + "name": "sum", + "arguments": {"a": 2, "b": 3}, + "task": {"ttl": 60000} + })) + .expect("legacy task param must not break deserialization"); + let result = client.call_tool(params).await.unwrap(); + let text = result.content[0].as_text().unwrap(); + assert_eq!(text.text, "5"); + + client.cancel().await.unwrap(); + server.abort(); +} + #[test] fn task_status_notification_params_preserve_meta() { let raw = json!({ From 132d73c0fcd3a8a88e32106a8b5167daf6dc4a96 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 21 Jul 2026 13:13:06 -0400 Subject: [PATCH 03/13] feat: pass SEP-2663 Tasks extension conformance suite Conformance fixtures (conformance/src/bin/server.rs): - Add the required fixture tools: greet (sync-only), slow_compute, failing_job (task support: required), protocol_error_job, confirm_delete, multi_input, and test_tool_with_task (MRTR -> task escalation), all backed by TaskManager with server-directed task creation gated on the client's tasks-extension capability - Advertise io.modelcontextprotocol/tasks in server capabilities and wire get_task/update_task/cancel_task handlers - Task-required tools reject with -32021 when the client did not declare the extension SDK wire-shape fixes surfaced by the suite: - Add resultType: "complete" to GetTaskResult and introduce TaskAckResult so tasks/update and tasks/cancel acks carry the SEP-2322 discriminator (spec: every non-CreateTaskResult response on the tasks surface is resultType complete); dispatch now returns ServerResult::task_ack - CreateTaskResult gets a strict deserializer requiring resultType: "task" so it does not shadow task-shaped results in untagged unions - Client update_task/cancel_task accept both TaskAckResult and EmptyResult All 9 runnable Tasks extension server scenarios now pass (35/35 checks; tasks-status-notifications remains upstream-skipped), so the corresponding entries are removed from conformance/expected-failures-extensions.yaml. 2025-11-25 server suite (40/40), 2026-07-28 server suite (114/114), draft client suite, and extensions client suite all pass their baselines. --- conformance/expected-failures-extensions.yaml | 18 +- conformance/src/bin/server.rs | 336 ++++++++++++++++++ crates/rmcp/src/handler/server.rs | 4 +- crates/rmcp/src/model.rs | 10 + crates/rmcp/src/model/task.rs | 76 +++- crates/rmcp/src/service/client.rs | 4 +- .../server_json_rpc_message_schema.json | 39 ++ ...erver_json_rpc_message_schema_current.json | 39 ++ 8 files changed, 507 insertions(+), 19 deletions(-) diff --git a/conformance/expected-failures-extensions.yaml b/conformance/expected-failures-extensions.yaml index 5230f523f..13b94b668 100644 --- a/conformance/expected-failures-extensions.yaml +++ b/conformance/expected-failures-extensions.yaml @@ -12,19 +12,11 @@ # When bumping DRAFT_CONFORMANCE_VERSION, review the available extension and # pending scenarios and update this file deliberately. -server: - # SEP-2663 Tasks Extension, tracked in #868. - # `tasks-status-notifications` is intentionally absent: the upstream check is - # currently skipped, and CI should fail if it becomes active but does not pass. - - tasks-lifecycle - - tasks-capability-negotiation - - tasks-wire-fields - - tasks-request-state-removal - - tasks-mrtr-input - - tasks-request-headers - - tasks-dispatch-and-envelope - - tasks-required-task-error - - tasks-mrtr-composition +# The SEP-2663 Tasks Extension server scenarios (tracked in #868) all pass and +# were removed from this baseline. `tasks-status-notifications` remains +# upstream-skipped pending the subscriptions/listen rewrite; CI will fail if it +# becomes active but does not pass. +server: [] client: # Informational OAuth extension scenarios. diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 441c5512b..38103735d 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -11,6 +11,7 @@ use rmcp::{ ErrorData, RoleServer, ServerHandler, model::*, service::{RequestContext, SubscriptionContext, SubscriptionSink}, + task_manager::{TaskManager, TaskOptions}, transport::{ StreamableHttpServerConfig, StreamableHttpService, streamable_http_server::session::local::LocalSessionManager, @@ -59,6 +60,7 @@ struct ConformanceServer { next_subscription: Arc, log_level: Arc>, request_state_codec: RequestStateCodec, + tasks: TaskManager, } impl ConformanceServer { @@ -69,10 +71,88 @@ impl ConformanceServer { next_subscription: Arc::new(AtomicU64::new(0)), log_level: Arc::new(Mutex::new(LoggingLevel::Debug)), request_state_codec: RequestStateCodec::new(REQUEST_STATE_KEY), + tasks: TaskManager::new(), } } } +// ─── SEP-2663 Tasks extension fixtures ────────────────────────────────────── + +/// Fixture tools required by the Tasks extension conformance scenarios. +const TASK_FIXTURE_TOOLS: &[&str] = &[ + "greet", + "slow_compute", + "failing_job", + "protocol_error_job", + "confirm_delete", + "multi_input", + "test_tool_with_task", +]; + +/// Tools that are registered as task-supporting. `greet` is deliberately +/// sync-only. +const TASK_SUPPORTING_TOOLS: &[&str] = &[ + "slow_compute", + "failing_job", + "protocol_error_job", + "confirm_delete", + "multi_input", + "test_tool_with_task", +]; + +/// Tools whose registration declares task support as *required*: calling them +/// without the tasks extension capability is rejected with -32021 before the +/// handler runs (SEP-2663 §Required Capabilities). +const TASK_REQUIRED_TOOLS: &[&str] = &["failing_job", "test_tool_with_task"]; + +fn task_fixture_tool(name: &str) -> Tool { + let (description, schema) = match name { + "greet" => ( + "Sync-only greeting fixture (SEP-2663)", + json!({ + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + }), + ), + "slow_compute" => ( + "Task-supporting fixture: sleeps `seconds` then returns a result (SEP-2663)", + json!({ + "type": "object", + "properties": { + "seconds": { "type": "number" }, + "label": { "type": "string" } + } + }), + ), + "failing_job" => ( + "Task-supporting fixture (task support: required): returns a tool execution error (SEP-2663)", + json!({ "type": "object", "properties": {} }), + ), + "protocol_error_job" => ( + "Task-supporting fixture: fails with a protocol-level error (SEP-2663)", + json!({ "type": "object", "properties": {} }), + ), + "confirm_delete" => ( + "Task-supporting fixture: parks on a single elicitation inputRequest (SEP-2663)", + json!({ + "type": "object", + "properties": { "filename": { "type": "string" } } + }), + ), + "multi_input" => ( + "Task-supporting fixture: parks on two parallel elicitation inputRequests (SEP-2663)", + json!({ "type": "object", "properties": {} }), + ), + "test_tool_with_task" => ( + "MRTR round 1 gathers user_name, round 2 escalates to a task (SEP-2663 composition)", + json!({ "type": "object", "properties": {} }), + ), + other => panic!("unknown task fixture tool: {other}"), + }; + Tool::new(name.to_string(), description, json_object(schema)) +} + // ─── SEP-2322 MRTR (InputRequiredResult) helpers ──────────────────────────── fn mrtr_elicitation_request(message: &str, properties: Value, required: Value) -> InputRequest { @@ -118,6 +198,228 @@ impl ConformanceServer { ErrorData::invalid_params("requestState failed integrity verification", None) } + /// SEP-2663 task fixture tools. The server decides per request whether to + /// materialize a task: task-supporting tools create one when the client + /// declared the tasks extension capability; otherwise they fall through to + /// synchronous execution (except task-*required* tools, which reject with + /// -32021). + async fn call_task_fixture_tool( + &self, + request: CallToolRequestParams, + cx: &RequestContext, + ) -> Result { + let client_supports_tasks = cx + .client_capabilities() + .is_some_and(|caps| caps.supports_tasks()); + let name = request.name.as_ref(); + let args = request.arguments.clone().unwrap_or_default(); + + if TASK_REQUIRED_TOOLS.contains(&name) && !client_supports_tasks { + // SEP-2663 §Required Capabilities: this tool cannot be serviced + // without returning CreateTaskResult. + return Err(ErrorData::missing_required_client_capability( + ClientCapabilities::builder().enable_tasks().build(), + )); + } + + let create_task = client_supports_tasks && TASK_SUPPORTING_TOOLS.contains(&name); + + match name { + "greet" => { + let who = args.get("name").and_then(Value::as_str).unwrap_or("friend"); + Ok( + CallToolResult::success(vec![ContentBlock::text(format!("Hello, {who}!"))]) + .into(), + ) + } + + "slow_compute" => { + let seconds = args.get("seconds").and_then(Value::as_f64).unwrap_or(1.0); + let label = args + .get("label") + .and_then(Value::as_str) + .unwrap_or("compute") + .to_string(); + let work = move || async move { + tokio::time::sleep(std::time::Duration::from_secs_f64(seconds)).await; + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "slow_compute({label}) done after {seconds}s" + ))])) + }; + if create_task { + let task = self + .tasks + .spawn(TaskOptions::default(), move |_ctx| Box::pin(work())); + Ok(CreateTaskResult::new(task).into()) + } else { + Ok(work().await?.into()) + } + } + + "failing_job" => { + // Tool execution error: surfaces as status "completed" with + // result.isError = true when run as a task. + let work = || async { + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + Ok(CallToolResult::error(vec![ContentBlock::text( + "failing_job: intentional tool execution error", + )])) + }; + if create_task { + let task = self + .tasks + .spawn(TaskOptions::default(), move |_ctx| Box::pin(work())); + Ok(CreateTaskResult::new(task).into()) + } else { + Ok(work().await?.into()) + } + } + + "protocol_error_job" => { + // Protocol-level failure: surfaces as status "failed" with an + // inlined `error` object when run as a task. + let work = || async { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + Err(ErrorData::internal_error( + "protocol_error_job: intentional protocol-level failure", + None, + )) + }; + if create_task { + let task = self + .tasks + .spawn(TaskOptions::default(), move |_ctx| Box::pin(work())); + Ok(CreateTaskResult::new(task).into()) + } else { + work().await.map(CallToolResponse::from) + } + } + + "confirm_delete" => { + let filename = args + .get("filename") + .and_then(Value::as_str) + .unwrap_or("file.txt") + .to_string(); + if !create_task { + return Err(ErrorData::missing_required_client_capability( + ClientCapabilities::builder().enable_tasks().build(), + )); + } + let task = self.tasks.spawn(TaskOptions::default(), move |ctx| { + Box::pin(async move { + let response = ctx + .request_input( + "confirm", + mrtr_elicitation_request( + &format!("Delete {filename}?"), + json!({ "confirm": { "type": "boolean" } }), + json!(["confirm"]), + ), + ) + .await?; + let confirmed = response + .get("content") + .and_then(|c| c.get("confirm")) + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "confirm_delete({filename}): confirmed = {confirmed}" + ))])) + }) + }); + Ok(CreateTaskResult::new(task).into()) + } + + "multi_input" => { + if !create_task { + return Err(ErrorData::missing_required_client_capability( + ClientCapabilities::builder().enable_tasks().build(), + )); + } + let task = self.tasks.spawn(TaskOptions::default(), move |ctx| { + Box::pin(async move { + // Fan out two elicitation requests in parallel so two + // keys are pending at once (partial fulfillment check). + let first = ctx.request_input( + "input-a", + mrtr_elicitation_request( + "Provide value A", + json!({ "value": { "type": "string" } }), + json!(["value"]), + ), + ); + let second = ctx.request_input( + "input-b", + mrtr_elicitation_request( + "Provide value B", + json!({ "value": { "type": "string" } }), + json!(["value"]), + ), + ); + let (a, b) = tokio::join!(first, second); + let (a, b) = (a?, b?); + let get = |v: &Value| { + v.get("content") + .and_then(|c| c.get("value")) + .and_then(Value::as_str) + .unwrap_or("(none)") + .to_string() + }; + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "multi_input: a = {}, b = {}", + get(&a), + get(&b) + ))])) + }) + }); + Ok(CreateTaskResult::new(task).into()) + } + + "test_tool_with_task" => { + // SEP-2663 MRTR → Tasks composition. Round 1 (no inputResponses) + // is a plain MRTR InputRequiredResult; round 2 escalates to a + // task whose result reflects the gathered user_name. + match mrtr_response(request.input_responses.as_ref(), "user_name") { + None => { + let mut requests = InputRequests::new(); + requests.insert( + "user_name".into(), + mrtr_elicitation_request( + "What is your name?", + json!({ "name": { "type": "string" } }), + json!(["name"]), + ), + ); + Ok(InputRequiredResult::from_input_requests(requests).into()) + } + Some(response) => { + let user_name = response + .get("content") + .and_then(|c| c.get("name")) + .and_then(Value::as_str) + .unwrap_or("friend") + .to_string(); + let task = self.tasks.spawn(TaskOptions::default(), move |_ctx| { + Box::pin(async move { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "Hello, {user_name}! (async)" + ))])) + }) + }); + Ok(CreateTaskResult::new(task).into()) + } + } + } + + other => Err(ErrorData::invalid_params( + format!("Unknown task fixture tool: {other}"), + None, + )), + } + } + /// SEP-2322 test tools. Each returns an `InputRequiredResult` until the /// client retries with the expected `inputResponses` (and, where used, the /// echoed `requestState`). @@ -400,6 +702,7 @@ impl ServerHandler for ConformanceServer { .enable_tools() .enable_tool_list_changed() .enable_logging() + .enable_tasks() .build(), ) .with_server_info(Implementation::new("rust-conformance-server", "0.1.0")) @@ -440,6 +743,31 @@ impl ServerHandler for ConformanceServer { Ok(()) } + async fn get_task( + &self, + request: GetTaskParams, + _cx: RequestContext, + ) -> Result { + Ok(GetTaskResult::new(self.tasks.get_task(&request.task_id)?)) + } + + async fn update_task( + &self, + request: UpdateTaskParams, + _cx: RequestContext, + ) -> Result<(), ErrorData> { + self.tasks + .update_task(&request.task_id, request.input_responses) + } + + async fn cancel_task( + &self, + request: CancelTaskParams, + _cx: RequestContext, + ) -> Result<(), ErrorData> { + self.tasks.cancel_task(&request.task_id) + } + async fn list_tools( &self, _request: Option, @@ -678,6 +1006,11 @@ impl ServerHandler for ConformanceServer { json_object(json!({ "type": "object", "properties": {} })), ) })) + .chain( + TASK_FIXTURE_TOOLS + .iter() + .map(|name| task_fixture_tool(name)), + ) .collect(); Ok(ListToolsResult { tools, @@ -695,6 +1028,9 @@ impl ServerHandler for ConformanceServer { if request.name.starts_with("test_input_required_result_") { return self.call_mrtr_tool(request, &cx.meta).await; } + if TASK_FIXTURE_TOOLS.contains(&request.name.as_ref()) { + return self.call_task_fixture_tool(request, &cx).await; + } let args = request.arguments.unwrap_or_default(); let result = match request.name.as_ref() { "test_simple_text" => Ok(CallToolResult::success(vec![ContentBlock::text( diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 43cc7bef7..af5fd462e 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -219,13 +219,13 @@ impl Service for H { validate_tasks_capability::(self, &context)?; self.update_task(request.params, context) .await - .map(ServerResult::empty) + .map(ServerResult::task_ack) } ClientRequest::CancelTaskRequest(request) => { validate_tasks_capability::(self, &context)?; self.cancel_task(request.params, context) .await - .map(ServerResult::empty) + .map(ServerResult::task_ack) } }; let result = result.and_then(|result| { diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 865565d4f..6531e6ee5 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -4405,6 +4405,10 @@ ts_union!( | GetTaskResult | CallToolResult | InputRequiredResult + // TaskAckResult must come after CallToolResult/InputRequiredResult in this + // untagged union: it only carries `resultType`, so it would otherwise + // shadow any result that includes `resultType: "complete"`. + | TaskAckResult | EmptyResult | CustomResult ; @@ -4414,6 +4418,12 @@ impl ServerResult { pub fn empty(_: ()) -> ServerResult { ServerResult::EmptyResult(EmptyResult {}) } + + /// Empty `tasks/update` / `tasks/cancel` acknowledgement carrying the + /// SEP-2322 `resultType: "complete"` discriminator (SEP-2663). + pub fn task_ack(_: ()) -> ServerResult { + ServerResult::TaskAckResult(TaskAckResult::new()) + } } pub type ServerJsonRpcMessage = JsonRpcMessage; diff --git a/crates/rmcp/src/model/task.rs b/crates/rmcp/src/model/task.rs index f52461669..e2c0d5569 100644 --- a/crates/rmcp/src/model/task.rs +++ b/crates/rmcp/src/model/task.rs @@ -273,7 +273,7 @@ impl schemars::JsonSchema for DetailedTask { /// The embedded task is the seed state for the task; the client uses /// `task.task_id` for all subsequent `tasks/get`, `tasks/update`, and /// `tasks/cancel` calls. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] @@ -287,6 +287,39 @@ pub struct CreateTaskResult { pub meta: Option, } +// Custom deserializer that requires `resultType: "task"`. Without this, +// `CreateTaskResult` would greedily match other task-shaped results (e.g. +// `tasks/get` responses, which also carry `taskId`/`status` at the top level +// but use `resultType: "complete"`) inside `#[serde(untagged)]` unions such +// as `ServerResult`. +impl<'de> Deserialize<'de> for CreateTaskResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Helper { + result_type: ResultType, + #[serde(flatten)] + task: Task, + #[serde(rename = "_meta", default)] + meta: Option, + } + let helper = Helper::deserialize(deserializer)?; + if !helper.result_type.is_task() { + return Err(serde::de::Error::custom( + "CreateTaskResult requires resultType to be \"task\"", + )); + } + Ok(CreateTaskResult { + result_type: helper.result_type, + task: helper.task, + meta: helper.meta, + }) + } +} + impl CreateTaskResult { /// Create a new `CreateTaskResult` from the seed task state. pub fn new(task: Task) -> Self { @@ -313,6 +346,10 @@ impl CreateTaskResult { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct GetTaskResult { + /// Result type discriminator. `tasks/get` responses are standard results: + /// `"complete"` (SEP-2322). Absent values deserialize as `"complete"`. + #[serde(default)] + pub result_type: ResultType, #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, /// The task with status-specific payload inlined. @@ -322,7 +359,42 @@ pub struct GetTaskResult { impl GetTaskResult { pub fn new(task: DetailedTask) -> Self { - Self { meta: None, task } + Self { + result_type: ResultType::COMPLETE, + meta: None, + task, + } + } +} + +/// Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663). +/// +/// The spec requires these acks to be empty results carrying the SEP-2322 +/// `resultType: "complete"` discriminator; task state changes are observed +/// via the next `tasks/get`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct TaskAckResult { + /// Always `"complete"`. + pub result_type: ResultType, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl Default for TaskAckResult { + fn default() -> Self { + Self { + result_type: ResultType::COMPLETE, + meta: None, + } + } +} + +impl TaskAckResult { + pub fn new() -> Self { + Self::default() } } diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 3d9409109..88336ed5a 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -1071,7 +1071,7 @@ impl Peer { ))) .await?; match result { - ServerResult::EmptyResult(_) => Ok(()), + ServerResult::TaskAckResult(_) | ServerResult::EmptyResult(_) => Ok(()), _ => Err(ServiceError::UnexpectedResponse), } } @@ -1085,7 +1085,7 @@ impl Peer { ))) .await?; match result { - ServerResult::EmptyResult(_) => Ok(()), + ServerResult::TaskAckResult(_) | ServerResult::EmptyResult(_) => Ok(()), _ => Err(ServiceError::UnexpectedResponse), } } 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 2fba99a6a..2e9a38d75 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 @@ -1184,6 +1184,15 @@ "format": "uint64", "minimum": 0 }, + "resultType": { + "description": "Result type discriminator. `tasks/get` responses are standard results:\n`\"complete\"` (SEP-2322). Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, "status": { "description": "Current task status.", "allOf": [ @@ -3232,6 +3241,9 @@ { "$ref": "#/definitions/InputRequiredResult" }, + { + "$ref": "#/definitions/TaskAckResult" + }, { "$ref": "#/definitions/EmptyObject" }, @@ -3417,6 +3429,33 @@ "io.modelcontextprotocol/subscriptionId" ] }, + "TaskAckResult": { + "description": "Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663).\n\nThe spec requires these acks to be empty results carrying the SEP-2322\n`resultType: \"complete\"` discriminator; task state changes are observed\nvia the next `tasks/get`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "resultType": { + "description": "Always `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + } + }, + "required": [ + "resultType" + ] + }, "TaskStatus": { "description": "Canonical task lifecycle status (SEP-2663).", "oneOf": [ diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 2fba99a6a..2e9a38d75 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 @@ -1184,6 +1184,15 @@ "format": "uint64", "minimum": 0 }, + "resultType": { + "description": "Result type discriminator. `tasks/get` responses are standard results:\n`\"complete\"` (SEP-2322). Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, "status": { "description": "Current task status.", "allOf": [ @@ -3232,6 +3241,9 @@ { "$ref": "#/definitions/InputRequiredResult" }, + { + "$ref": "#/definitions/TaskAckResult" + }, { "$ref": "#/definitions/EmptyObject" }, @@ -3417,6 +3429,33 @@ "io.modelcontextprotocol/subscriptionId" ] }, + "TaskAckResult": { + "description": "Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663).\n\nThe spec requires these acks to be empty results carrying the SEP-2322\n`resultType: \"complete\"` discriminator; task state changes are observed\nvia the next `tasks/get`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "resultType": { + "description": "Always `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + } + }, + "required": [ + "resultType" + ] + }, "TaskStatus": { "description": "Canonical task lifecycle status (SEP-2663).", "oneOf": [ From 276cb2ec4a253d7b01ddacdc8cfa65010effb103 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 21 Jul 2026 13:25:48 -0400 Subject: [PATCH 04/13] fix: address PR 1020 review feedback on DetailedTask schema and cooperative cancel - DetailedTask's JsonSchema now derives from the actual flattened wire shape (DetailedTaskWire: base Task + optional inputRequests/result/error) instead of approximating with the base Task schema, so generated schemas for GetTaskResult and notifications/tasks document the status-specific payload fields. Golden message schemas regenerated. - TaskManager::cancel_task no longer aborts the underlying future. It records the observable cancelled state and acks immediately (spec's eventually consistent semantics), but lets the operation keep running so it can observe is_cancel_requested() or the error from a woken request_input() and perform cleanup; any late result is discarded. New unit tests cover both the cooperative-cleanup path and waking parked input requests. --- crates/rmcp/src/model/task.rs | 6 +- crates/rmcp/src/task_manager.rs | 97 +++++++++++++++++-- .../server_json_rpc_message_schema.json | 46 +++++++++ ...erver_json_rpc_message_schema_current.json | 46 +++++++++ 4 files changed, 187 insertions(+), 8 deletions(-) diff --git a/crates/rmcp/src/model/task.rs b/crates/rmcp/src/model/task.rs index e2c0d5569..d655289c8 100644 --- a/crates/rmcp/src/model/task.rs +++ b/crates/rmcp/src/model/task.rs @@ -185,6 +185,7 @@ impl DetailedTask { // Wire shape helper: base Task fields + optional payload fields, all flattened. #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] struct DetailedTaskWire { #[serde(flatten)] task: Task, @@ -262,8 +263,9 @@ impl schemars::JsonSchema for DetailedTask { "DetailedTask".into() } fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - // Approximate with the wire shape (base Task + optional payload fields). - ::json_schema(generator) + // The actual wire shape: base Task fields plus the optional + // status-specific payload fields (inputRequests / result / error). + ::json_schema(generator) } } diff --git a/crates/rmcp/src/task_manager.rs b/crates/rmcp/src/task_manager.rs index fb4168536..77058dd99 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -335,9 +335,17 @@ impl TaskManager { Ok(()) } - /// Handle `tasks/cancel`: cooperative cancellation. Acknowledges - /// immediately; the operation is aborted and the task transitions to - /// `cancelled` unless it already reached a terminal state. + /// Handle `tasks/cancel`: cooperative cancellation. + /// + /// Acknowledges immediately and transitions the *observable* task state to + /// `cancelled` (unless already terminal), but does **not** abort the + /// underlying future: the operation keeps running so it can perform + /// cleanup, observing cancellation via + /// [`TaskContext::is_cancel_requested`] or via the error returned from a + /// pending [`TaskContext::request_input`] call (whose response channel is + /// dropped here). Whatever the future eventually produces is discarded — + /// the terminal `cancelled` state has already been recorded, matching the + /// spec's eventually-consistent cancellation semantics. pub fn cancel_task(&self, task_id: &str) -> Result<(), McpError> { let mut inner = self.inner.lock().expect("task manager lock poisoned"); let entry = inner @@ -346,10 +354,9 @@ impl TaskManager { .ok_or_else(|| unknown_task(task_id))?; entry.cancel_requested = true; if entry.terminal.is_none() { - if let Some(handle) = entry.join_handle.take() { - handle.abort(); - } entry.terminal = Some(TaskPayload::Cancelled); + // Dropping the response senders wakes any `request_input` await + // with an error, giving parked operations a cooperative exit path. entry.pending_inputs.clear(); entry.touch(); entry.task.status = TaskStatus::Cancelled; @@ -471,6 +478,84 @@ mod tests { assert_eq!(detailed.status(), TaskStatus::Cancelled); } + #[tokio::test] + async fn cancel_is_cooperative_and_lets_the_operation_clean_up() { + let manager = TaskManager::new(); + let (cleanup_tx, cleanup_rx) = oneshot::channel::<&'static str>(); + let task = manager.spawn(TaskOptions::default(), |ctx| { + Box::pin(async move { + // Poll cancellation cooperatively, then run cleanup. + for _ in 0..500 { + if ctx.is_cancel_requested() { + let _ = cleanup_tx.send("cleaned up"); + return Ok(ok_result("cancelled cooperatively")); + } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + Ok(ok_result("never cancelled")) + }) + }); + + manager.cancel_task(&task.task_id).unwrap(); + + // Observable state is cancelled immediately (ack + tasks/get)... + let detailed = manager.get_task(&task.task_id).unwrap(); + assert_eq!(detailed.status(), TaskStatus::Cancelled); + + // ...but the operation keeps running and gets to perform cleanup. + let cleanup = tokio::time::timeout(std::time::Duration::from_secs(5), cleanup_rx) + .await + .expect("cleanup should not time out") + .expect("cleanup channel should not be dropped"); + assert_eq!(cleanup, "cleaned up"); + + // The late result is discarded; the terminal state stays cancelled. + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + assert_eq!(detailed.status(), TaskStatus::Cancelled); + } + + #[tokio::test] + async fn cancel_wakes_parked_input_requests() { + let manager = TaskManager::new(); + let (exit_tx, exit_rx) = oneshot::channel::<&'static str>(); + let task = manager.spawn(TaskOptions::default(), |ctx| { + Box::pin(async move { + let request: InputRequest = serde_json::from_value(serde_json::json!({ + "method": "elicitation/create", + "params": { + "message": "Waiting forever", + "requestedSchema": {"type": "object", "properties": {}} + } + })) + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + // Parked on input; cancel must wake this await with an error. + let err = ctx.request_input("k1", request).await.unwrap_err(); + let _ = exit_tx.send("woken"); + Err(err) + }) + }); + + // Wait until the task is parked on the input request. + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + if manager.get_task(&task.task_id).unwrap().status() == TaskStatus::InputRequired { + break; + } + } + + manager.cancel_task(&task.task_id).unwrap(); + let woken = tokio::time::timeout(std::time::Duration::from_secs(5), exit_rx) + .await + .expect("parked operation should be woken by cancel") + .expect("exit channel should not be dropped"); + assert_eq!(woken, "woken"); + assert_eq!( + manager.get_task(&task.task_id).unwrap().status(), + TaskStatus::Cancelled + ); + } + #[tokio::test] async fn unknown_task_is_an_error() { let manager = TaskManager::new(); 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 2e9a38d75..b38db540c 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 @@ -1171,6 +1171,22 @@ "description": "ISO 8601 timestamp when the task was created.", "type": "string" }, + "error": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, "lastUpdatedAt": { "description": "ISO 8601 timestamp when the task was last updated.", "type": "string" @@ -1184,6 +1200,13 @@ "format": "uint64", "minimum": 0 }, + "result": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "resultType": { "description": "Result type discriminator. `tasks/get` responses are standard results:\n`\"complete\"` (SEP-2322). Absent values deserialize as `\"complete\"`.", "allOf": [ @@ -3509,6 +3532,22 @@ "description": "ISO 8601 timestamp when the task was created.", "type": "string" }, + "error": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, "lastUpdatedAt": { "description": "ISO 8601 timestamp when the task was last updated.", "type": "string" @@ -3522,6 +3561,13 @@ "format": "uint64", "minimum": 0 }, + "result": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "status": { "description": "Current task status.", "allOf": [ 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 2e9a38d75..b38db540c 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 @@ -1171,6 +1171,22 @@ "description": "ISO 8601 timestamp when the task was created.", "type": "string" }, + "error": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, "lastUpdatedAt": { "description": "ISO 8601 timestamp when the task was last updated.", "type": "string" @@ -1184,6 +1200,13 @@ "format": "uint64", "minimum": 0 }, + "result": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "resultType": { "description": "Result type discriminator. `tasks/get` responses are standard results:\n`\"complete\"` (SEP-2322). Absent values deserialize as `\"complete\"`.", "allOf": [ @@ -3509,6 +3532,22 @@ "description": "ISO 8601 timestamp when the task was created.", "type": "string" }, + "error": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, "lastUpdatedAt": { "description": "ISO 8601 timestamp when the task was last updated.", "type": "string" @@ -3522,6 +3561,13 @@ "format": "uint64", "minimum": 0 }, + "result": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "status": { "description": "Current task status.", "allOf": [ From 538e67152167496928eebada7a0ed739ee7b2388 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 21 Jul 2026 13:53:10 -0400 Subject: [PATCH 05/13] fix: make tasks/cancel truly cooperative per SEP-2663 (FEEDBACK_2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TaskManager::cancel_task no longer forces terminal 'cancelled'. It records the cancellation intent, acks immediately, and wakes parked request_input awaits, but the operation decides its own terminal state: a post-cancel error settles as 'cancelled', while an operation that finishes its work settles as 'completed' — per the spec, 'the task may still reach a non-cancelled terminal status'. - Add TaskContext::cancelled(), a watch-based await for use with tokio::select! as the cooperative cancellation exit path. - Correct the unsupported-notification method string from 'notifications/tasks/status' to 'notifications/tasks' and document that task status notifications are not yet routable through subscriptions/listen (SubscriptionFilter has no taskIds field; upstream check still skipped). - Update conformance slow_compute fixture, task_demo example, and tests to honor cancellation via ctx.cancelled(); lifecycle conformance still 8/8 (all 9 scenarios remain green, 35/35 checks). --- conformance/src/bin/server.rs | 33 ++++-- crates/rmcp/src/service/server.rs | 7 +- crates/rmcp/src/task_manager.rs | 134 ++++++++++++++++------- crates/rmcp/tests/test_task.rs | 48 +++++--- examples/servers/src/common/task_demo.rs | 19 +++- 5 files changed, 172 insertions(+), 69 deletions(-) diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 38103735d..89e2c44a9 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -240,19 +240,32 @@ impl ConformanceServer { .and_then(Value::as_str) .unwrap_or("compute") .to_string(); - let work = move || async move { - tokio::time::sleep(std::time::Duration::from_secs_f64(seconds)).await; - Ok(CallToolResult::success(vec![ContentBlock::text(format!( - "slow_compute({label}) done after {seconds}s" - ))])) - }; if create_task { - let task = self - .tasks - .spawn(TaskOptions::default(), move |_ctx| Box::pin(work())); + // The lifecycle scenario requires slow_compute to settle + // to `cancelled` when tasks/cancel arrives while running; + // cancellation is cooperative, so honor it explicitly. + let task = self.tasks.spawn(TaskOptions::default(), move |ctx| { + Box::pin(async move { + tokio::select! { + _ = ctx.cancelled() => Err(ErrorData::internal_error( + "slow_compute cancelled", + None, + )), + _ = tokio::time::sleep( + std::time::Duration::from_secs_f64(seconds), + ) => Ok(CallToolResult::success(vec![ContentBlock::text( + format!("slow_compute({label}) done after {seconds}s"), + )])), + } + }) + }); Ok(CreateTaskResult::new(task).into()) } else { - Ok(work().await?.into()) + tokio::time::sleep(std::time::Duration::from_secs_f64(seconds)).await; + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "slow_compute({label}) done after {seconds}s" + ))]) + .into()) } } diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index b7aa6b968..f45299082 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -224,9 +224,14 @@ impl SubscriptionSink { "notifications/message", )); } + // SEP-2663 task status notifications are not yet routable through + // `subscriptions/listen`: `SubscriptionFilter` has no `taskIds` + // field yet (the upstream conformance check for this flow is also + // still skipped, pending the subscriptions/listen rewrite). + // Clients currently observe task state by polling `tasks/get`. ServerNotification::TaskStatusNotification(_) => { return Err(SubscriptionSendError::UnsupportedNotification( - "notifications/tasks/status", + "notifications/tasks", )); } ServerNotification::CustomNotification(_) => { diff --git a/crates/rmcp/src/task_manager.rs b/crates/rmcp/src/task_manager.rs index 77058dd99..aade032f1 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -112,6 +112,36 @@ impl TaskContext { .get(&self.task_id) .is_some_and(|e| e.cancel_requested) } + + /// Resolves once `tasks/cancel` has been received for this task (or + /// immediately, if it already has). Cooperative: pair with + /// `tokio::select!` around long-running work to implement a cancellation + /// exit path. + /// + /// An operation that stops in response should return an error — the + /// manager records a post-cancel error as terminal `cancelled` rather + /// than `failed`. An operation that finishes its work anyway settles as + /// `completed`; per SEP-2663 cancellation is cooperative and a task may + /// reach a non-`cancelled` terminal status. + pub async fn cancelled(&self) { + let mut rx = { + let inner = self.inner.lock().expect("task manager lock poisoned"); + let Some(entry) = inner.tasks.get(&self.task_id) else { + return; + }; + if entry.cancel_requested { + return; + } + entry.cancel_signal.subscribe() + }; + // Wait until the watch flips to true; a closed channel means the + // manager dropped the entry, which also unblocks the operation. + while !*rx.borrow_and_update() { + if rx.changed().await.is_err() { + return; + } + } + } } /// Boxed future representing the async operation backing a task. @@ -126,6 +156,10 @@ struct TaskEntry { /// Every key ever used, to enforce uniqueness across the task lifetime. used_input_keys: std::collections::HashSet, cancel_requested: bool, + /// Signals the running operation that cancellation was requested + /// (`true` once `tasks/cancel` arrives). Cooperative: the operation + /// decides whether and how to stop. + cancel_signal: tokio::sync::watch::Sender, created: Instant, join_handle: Option>, } @@ -246,6 +280,7 @@ impl TaskManager { pending_inputs: HashMap::new(), used_input_keys: std::collections::HashSet::new(), cancel_requested: false, + cancel_signal: tokio::sync::watch::channel(false).0, created: Instant::now(), join_handle: None, }; @@ -335,17 +370,20 @@ impl TaskManager { Ok(()) } - /// Handle `tasks/cancel`: cooperative cancellation. + /// Handle `tasks/cancel`: cooperative cancellation (SEP-2663). + /// + /// Records the cancellation *intent* and acknowledges immediately, but + /// does **not** abort the underlying future or force a terminal state. + /// The operation observes cancellation via + /// [`TaskContext::is_cancel_requested`] / [`TaskContext::cancelled`], or + /// via the error returned from a pending [`TaskContext::request_input`] + /// call (whose response channel is dropped here), and decides its own + /// terminal status: /// - /// Acknowledges immediately and transitions the *observable* task state to - /// `cancelled` (unless already terminal), but does **not** abort the - /// underlying future: the operation keeps running so it can perform - /// cleanup, observing cancellation via - /// [`TaskContext::is_cancel_requested`] or via the error returned from a - /// pending [`TaskContext::request_input`] call (whose response channel is - /// dropped here). Whatever the future eventually produces is discarded — - /// the terminal `cancelled` state has already been recorded, matching the - /// spec's eventually-consistent cancellation semantics. + /// - stops with an error → recorded as `cancelled` (post-cancel errors + /// are treated as honoring the request, not `failed`), + /// - finishes its work anyway → recorded as `completed` — per the spec, + /// "the task may still reach a non-`cancelled` terminal status". pub fn cancel_task(&self, task_id: &str) -> Result<(), McpError> { let mut inner = self.inner.lock().expect("task manager lock poisoned"); let entry = inner @@ -353,13 +391,15 @@ impl TaskManager { .get_mut(task_id) .ok_or_else(|| unknown_task(task_id))?; entry.cancel_requested = true; + let _ = entry.cancel_signal.send(true); if entry.terminal.is_none() { - entry.terminal = Some(TaskPayload::Cancelled); - // Dropping the response senders wakes any `request_input` await - // with an error, giving parked operations a cooperative exit path. + // Wake any operation parked on `request_input`: dropping the + // response senders resolves those awaits with an error, giving + // parked operations a cooperative exit path. The task leaves + // `input_required` and reports `working` until it settles. entry.pending_inputs.clear(); entry.touch(); - entry.task.status = TaskStatus::Cancelled; + entry.task.status = entry.current_status(); } Ok(()) } @@ -465,17 +505,31 @@ mod tests { } #[tokio::test] - async fn cancel_marks_task_cancelled() { + async fn cancel_settles_to_cancelled_when_operation_honors_it() { let manager = TaskManager::new(); - let task = manager.spawn(TaskOptions::default(), |_ctx| { - Box::pin(async { - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - Ok(ok_result("never")) + let task = manager.spawn(TaskOptions::default(), |ctx| { + Box::pin(async move { + tokio::select! { + _ = ctx.cancelled() => Err(McpError::internal_error("cancelled", None)), + _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => { + Ok(ok_result("never")) + } + } }) }); manager.cancel_task(&task.task_id).unwrap(); - let detailed = manager.get_task(&task.task_id).unwrap(); - assert_eq!(detailed.status(), TaskStatus::Cancelled); + + // The ack is immediate but the terminal state is set by the + // operation; poll until it settles. + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if detailed.status().is_terminal() { + assert_eq!(detailed.status(), TaskStatus::Cancelled); + return; + } + } + panic!("task did not settle after cancel"); } #[tokio::test] @@ -484,35 +538,41 @@ mod tests { let (cleanup_tx, cleanup_rx) = oneshot::channel::<&'static str>(); let task = manager.spawn(TaskOptions::default(), |ctx| { Box::pin(async move { - // Poll cancellation cooperatively, then run cleanup. - for _ in 0..500 { - if ctx.is_cancel_requested() { - let _ = cleanup_tx.send("cleaned up"); - return Ok(ok_result("cancelled cooperatively")); - } - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - } - Ok(ok_result("never cancelled")) + // Wait for cancellation, then run cleanup and finish the + // work anyway (spec: a task may still reach a + // non-`cancelled` terminal status). + ctx.cancelled().await; + let _ = cleanup_tx.send("cleaned up"); + Ok(ok_result("finished despite cancel")) }) }); manager.cancel_task(&task.task_id).unwrap(); - // Observable state is cancelled immediately (ack + tasks/get)... + // The ack is immediate and does not force a terminal state. let detailed = manager.get_task(&task.task_id).unwrap(); - assert_eq!(detailed.status(), TaskStatus::Cancelled); + assert!( + !detailed.status().is_terminal(), + "cancel must not force terminal state" + ); - // ...but the operation keeps running and gets to perform cleanup. + // The operation observes the cancel and performs cleanup. let cleanup = tokio::time::timeout(std::time::Duration::from_secs(5), cleanup_rx) .await .expect("cleanup should not time out") .expect("cleanup channel should not be dropped"); assert_eq!(cleanup, "cleaned up"); - // The late result is discarded; the terminal state stays cancelled. - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - let detailed = manager.get_task(&task.task_id).unwrap(); - assert_eq!(detailed.status(), TaskStatus::Cancelled); + // The operation chose to complete: the task settles as `completed`. + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if detailed.status().is_terminal() { + assert_eq!(detailed.status(), TaskStatus::Completed); + return; + } + } + panic!("task did not settle after cancel"); } #[tokio::test] diff --git a/crates/rmcp/tests/test_task.rs b/crates/rmcp/tests/test_task.rs index 04e555473..433468679 100644 --- a/crates/rmcp/tests/test_task.rs +++ b/crates/rmcp/tests/test_task.rs @@ -66,16 +66,22 @@ impl ServerHandler for TaskServer { request.arguments.clone().unwrap_or_default(), )) .map_err(|e| McpError::invalid_params(e.to_string(), None))?; - let task = - self.tasks - .spawn(TaskOptions::new().with_poll_interval_ms(10), move |_ctx| { - Box::pin(async move { - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - Ok(CallToolResult::success(vec![ContentBlock::text( - (args.a + args.b).to_string(), - )])) - }) - }); + let task = self + .tasks + .spawn(TaskOptions::new().with_poll_interval_ms(10), move |ctx| { + Box::pin(async move { + tokio::select! { + _ = ctx.cancelled() => { + Err(McpError::internal_error("cancelled", None)) + } + _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => { + Ok(CallToolResult::success(vec![ContentBlock::text( + (args.a + args.b).to_string(), + )])) + } + } + }) + }); return Ok(CallToolResponse::Task(CreateTaskResult::new(task))); } @@ -210,12 +216,22 @@ async fn task_cancel_acknowledged() { .await .unwrap(); - let info = client - .peer() - .get_task(GetTaskParams::new(create.task.task_id.clone())) - .await - .unwrap(); - assert_eq!(info.task.status(), TaskStatus::Cancelled); + // Cancellation is cooperative (SEP-2663): the ack is immediate, and the + // operation settles the terminal status; poll until it does. + let mut final_status = None; + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let info = client + .peer() + .get_task(GetTaskParams::new(create.task.task_id.clone())) + .await + .unwrap(); + if info.task.status().is_terminal() { + final_status = Some(info.task.status()); + break; + } + } + assert_eq!(final_status, Some(TaskStatus::Cancelled)); client.cancel().await.unwrap(); server.abort(); diff --git a/examples/servers/src/common/task_demo.rs b/examples/servers/src/common/task_demo.rs index d56550474..e6df0752e 100644 --- a/examples/servers/src/common/task_demo.rs +++ b/examples/servers/src/common/task_demo.rs @@ -95,12 +95,21 @@ impl ServerHandler for TaskDemo { request.arguments.clone().unwrap_or_default(), )) .map_err(|e| McpError::invalid_params(e.to_string(), None))?; - let task = self.tasks.spawn(TaskOptions::default(), move |_ctx| { + let task = self.tasks.spawn(TaskOptions::default(), move |ctx| { Box::pin(async move { - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - Ok(CallToolResult::success(vec![ContentBlock::text( - (params.a + params.b).to_string(), - )])) + // Cancellation is cooperative (SEP-2663): honor + // tasks/cancel by exiting early with an error, which the + // manager records as terminal `cancelled`. + tokio::select! { + _ = ctx.cancelled() => { + Err(McpError::internal_error("slow_sum cancelled", None)) + } + _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => { + Ok(CallToolResult::success(vec![ContentBlock::text( + (params.a + params.b).to_string(), + )])) + } + } }) }); return Ok(CallToolResponse::Task(CreateTaskResult::new(task))); From dbc95ffe97bc714d68a5d2c5a05bd3c9989403b7 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 21 Jul 2026 14:12:34 -0400 Subject: [PATCH 06/13] fix: sweep and evict expired tasks from TaskManager (FEEDBACK_3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TTL handling previously only ran from get_task and only flipped overdue non-terminal tasks to 'failed', never removing entries — an unbounded leak for long-lived servers, and it made ttl_ms: None = 'unlimited retention' meaningless since everything was retained forever. - Rename expire_overdue to sweep_expired and run it from every entry point (spawn, get_task, update_task, cancel_task). - Track terminal_at on each entry; terminal tasks are evicted after being retained for one further ttl_ms window past their terminal transition, so well-behaved pollers can observe the final state before late tasks/get calls return -32602 (spec: servers may delete expired tasks at any time, and returning task-not-found for purged tasks is compliant behavior). - ttl_ms: None entries are never evicted (spec: unlimited retention); document the retention model on TaskManager, including that there is no background sweeper. - New tests: retention-window eviction, sweep of abandoned tasks via other entry points, unlimited-TTL retention, and error-code assertions (-32602) for unknown ids across get/update/cancel. Reviewed the second feedback item (dedicated task-not-found error code) against SEP-2663 §Protocol Errors and it is incorrect: the SEP explicitly specifies -32602 (Invalid params) for invalid or nonexistent taskIds, which is what unknown_task already returns. Kept -32602; strengthened tests to assert the code. --- crates/rmcp/src/task_manager.rs | 143 +++++++++++++++++++++++++++++--- 1 file changed, 130 insertions(+), 13 deletions(-) diff --git a/crates/rmcp/src/task_manager.rs b/crates/rmcp/src/task_manager.rs index aade032f1..4c66f1654 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -151,6 +151,8 @@ struct TaskEntry { task: Task, /// Terminal payload, if the task has finished. terminal: Option, + /// When the task reached its terminal state; drives retention eviction. + terminal_at: Option, /// Outstanding input requests keyed by their unique identifier. pending_inputs: HashMap)>, /// Every key ever used, to enforce uniqueness across the task lifetime. @@ -247,6 +249,18 @@ impl TaskOptions { /// Server-side task store and executor for the SEP-2663 Tasks extension. /// /// Cheaply cloneable; all clones share the same state. +/// +/// # Retention +/// +/// Entries are swept opportunistically on every `spawn` / `get_task` / +/// `update_task` / `cancel_task` call: non-terminal tasks whose `ttl_ms` has +/// elapsed are marked `failed` (their operation is aborted), and terminal +/// tasks are evicted after being retained for one further `ttl_ms` window so +/// pollers can observe the final state. Tasks with `ttl_ms: None` are +/// retained for the lifetime of the manager (spec: unlimited retention) — +/// bound task creation or call [`Self::shutdown`] yourself if you spawn such +/// tasks in a long-lived server. There is no background sweeper; an idle +/// manager holds its entries until the next call. #[derive(Clone, Default)] pub struct TaskManager { inner: Arc>, @@ -277,6 +291,7 @@ impl TaskManager { let entry = TaskEntry { task: task.clone(), terminal: None, + terminal_at: None, pending_inputs: HashMap::new(), used_input_keys: std::collections::HashSet::new(), cancel_requested: false, @@ -284,11 +299,13 @@ impl TaskManager { created: Instant::now(), join_handle: None, }; - self.inner - .lock() - .expect("task manager lock poisoned") - .tasks - .insert(task_id.clone(), entry); + { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + // Opportunistic TTL sweep on every task creation, so terminal + // entries are evicted even if clients never poll again. + Self::sweep_expired(&mut inner); + inner.tasks.insert(task_id.clone(), entry); + } let context = TaskContext { task_id: task_id.clone(), @@ -316,6 +333,7 @@ impl TaskManager { } } }); + entry.terminal_at = Some(Instant::now()); entry.pending_inputs.clear(); entry.touch(); entry.task.status = entry.current_status(); @@ -337,7 +355,7 @@ impl TaskManager { /// Handle `tasks/get`: return the current [`DetailedTask`] state. pub fn get_task(&self, task_id: &str) -> Result { let mut inner = self.inner.lock().expect("task manager lock poisoned"); - Self::expire_overdue(&mut inner); + Self::sweep_expired(&mut inner); let entry = inner .tasks .get_mut(task_id) @@ -355,6 +373,7 @@ impl TaskManager { input_responses: impl IntoIterator, ) -> Result<(), McpError> { let mut inner = self.inner.lock().expect("task manager lock poisoned"); + Self::sweep_expired(&mut inner); let entry = inner .tasks .get_mut(task_id) @@ -386,6 +405,7 @@ impl TaskManager { /// "the task may still reach a non-`cancelled` terminal status". pub fn cancel_task(&self, task_id: &str) -> Result<(), McpError> { let mut inner = self.inner.lock().expect("task manager lock poisoned"); + Self::sweep_expired(&mut inner); let entry = inner .tasks .get_mut(task_id) @@ -424,9 +444,21 @@ impl TaskManager { } } - /// Mark tasks whose TTL has elapsed as `failed` (spec: servers MAY fail - /// tasks any time after TTL expiry). - fn expire_overdue(inner: &mut TaskManagerInner) { + /// TTL sweep, run from every `TaskManager` entry point (SEP-2663: servers + /// MAY mark a task `failed` any time after its TTL elapses, and + /// subsequently delete it at any time; `ttl_ms: None` means unlimited + /// retention). + /// + /// Two phases per entry: + /// 1. A non-terminal task whose TTL has elapsed is marked `failed` (its + /// operation is aborted — the TTL is the SDK's hard-stop safety valve, + /// unlike cooperative `tasks/cancel`). + /// 2. A *terminal* task is evicted once it has been retained for a full + /// TTL window after reaching its terminal state, so well-behaved + /// pollers get a chance to observe the final status before late + /// `tasks/get` calls start returning `-32602`. + fn sweep_expired(inner: &mut TaskManagerInner) { + // Phase 1: fail overdue non-terminal tasks. for entry in inner.tasks.values_mut() { if entry.terminal.is_none() && let Some(ttl_ms) = entry.task.ttl_ms @@ -441,11 +473,19 @@ impl TaskManager { None, )), }); + entry.terminal_at = Some(Instant::now()); entry.pending_inputs.clear(); entry.touch(); entry.task.status = TaskStatus::Failed; } } + // Phase 2: evict terminal tasks whose retention window has passed. + inner.tasks.retain(|_, entry| { + let (Some(ttl_ms), Some(terminal_at)) = (entry.task.ttl_ms, entry.terminal_at) else { + return true; + }; + terminal_at.elapsed().as_millis() <= u128::from(ttl_ms) + }); } } @@ -617,11 +657,88 @@ mod tests { } #[tokio::test] - async fn unknown_task_is_an_error() { + async fn unknown_task_is_invalid_params() { + // SEP-2663 §Protocol Errors: invalid or nonexistent taskId is -32602 + // (Invalid params) — MUST for tasks/get, SHOULD for update/cancel. + let manager = TaskManager::new(); + for err in [ + manager.get_task("nope").unwrap_err(), + manager.cancel_task("nope").unwrap_err(), + manager.update_task("nope", []).unwrap_err(), + ] { + assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS); + } + } + + #[tokio::test] + async fn terminal_tasks_are_evicted_after_retention_window() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::new().with_ttl_ms(50), |_ctx| { + Box::pin(async { Ok(ok_result("fast")) }) + }); + + // Wait for completion; the terminal state stays observable during + // the retention window. + let mut completed = false; + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + if manager.get_task(&task.task_id).unwrap().status() == TaskStatus::Completed { + completed = true; + break; + } + } + assert!(completed, "task should have completed"); + + // After a full TTL window past terminal, the entry is evicted and + // late polls get -32602. + tokio::time::sleep(std::time::Duration::from_millis(120)).await; + let err = manager.get_task(&task.task_id).unwrap_err(); + assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS); + assert_eq!(manager.running_task_count(), 0); + } + + #[tokio::test] + async fn abandoned_tasks_are_swept_by_other_entry_points() { + // A task nobody ever polls again must still be failed + evicted; the + // sweep runs from spawn() too, so activity on *other* tasks is enough. let manager = TaskManager::new(); - assert!(manager.get_task("nope").is_err()); - assert!(manager.cancel_task("nope").is_err()); - assert!(manager.update_task("nope", []).is_err()); + let abandoned = manager.spawn(TaskOptions::new().with_ttl_ms(10), |_ctx| { + Box::pin(async { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + Ok(ok_result("never")) + }) + }); + + // Let TTL elapse (fails the task), then a second full window + // (evicts it), without ever calling get_task on the abandoned id. + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + let _ = manager.spawn(TaskOptions::default(), |_ctx| { + Box::pin(async { Ok(ok_result("other")) }) + }); + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + let _ = manager.spawn(TaskOptions::default(), |_ctx| { + Box::pin(async { Ok(ok_result("other2")) }) + }); + + let err = manager.get_task(&abandoned.task_id).unwrap_err(); + assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS); + } + + #[tokio::test] + async fn unlimited_ttl_tasks_are_retained() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::new().with_ttl_ms(None), |_ctx| { + Box::pin(async { Ok(ok_result("kept")) }) + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + // Sweeps triggered by other entry points must not evict it. + let _ = manager.spawn(TaskOptions::default(), |_ctx| { + Box::pin(async { Ok(ok_result("other")) }) + }); + assert_eq!( + manager.get_task(&task.task_id).unwrap().status(), + TaskStatus::Completed + ); } #[tokio::test] From c8580ff0a55b4422f10153a51353db873de4a975 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 21 Jul 2026 15:46:59 -0400 Subject: [PATCH 07/13] fix: address Copilot review feedback on PR #1020 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TaskManager: drop the JoinHandle as soon as the operation settles instead of retaining it for the whole retention window, and only store it at spawn time if the task is still non-terminal (avoids keeping a completed handle that the completion path could never clear). - Use RequestContext::client_capabilities() (which applies the initialize-time fallback for session peers) instead of raw context.meta.client_capabilities() in the task_demo example, the README snippet, and the test server — the meta-only form would wrongly treat session-declared tasks clients as unsupported. All 9 Tasks extension conformance scenarios remain green (35/35 checks). --- README.md | 1 - crates/rmcp/src/task_manager.rs | 10 +++++++++- crates/rmcp/tests/test_task.rs | 9 +-------- examples/servers/src/common/task_demo.rs | 1 - 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index b84cc2b76..13219440f 100644 --- a/README.md +++ b/README.md @@ -988,7 +988,6 @@ async fn call_tool(&self, request: CallToolRequestParams, context: RequestContex -> Result { let client_supports_tasks = context - .meta .client_capabilities() .is_some_and(|caps| caps.supports_tasks()); if client_supports_tasks { diff --git a/crates/rmcp/src/task_manager.rs b/crates/rmcp/src/task_manager.rs index 4c66f1654..46dbb1f07 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -338,6 +338,9 @@ impl TaskManager { entry.touch(); entry.task.status = entry.current_status(); } + // The operation has finished; drop the JoinHandle so it is + // not retained for the rest of the retention window. + entry.join_handle = None; } }); if let Some(entry) = self @@ -347,7 +350,12 @@ impl TaskManager { .tasks .get_mut(&task_id) { - entry.join_handle = Some(handle); + // Only store the handle while the operation is still running: if + // it already settled, the completion path above ran first and a + // stored handle would never be cleared. + if entry.terminal.is_none() { + entry.join_handle = Some(handle); + } } task } diff --git a/crates/rmcp/tests/test_task.rs b/crates/rmcp/tests/test_task.rs index 433468679..b896e8b99 100644 --- a/crates/rmcp/tests/test_task.rs +++ b/crates/rmcp/tests/test_task.rs @@ -51,15 +51,8 @@ impl ServerHandler for TaskServer { context: RequestContext, ) -> Result { let client_supports_tasks = context - .meta .client_capabilities() - .map(|caps| caps.supports_tasks()) - .unwrap_or_else(|| { - context - .peer - .peer_info() - .is_some_and(|info| info.capabilities.supports_tasks()) - }); + .is_some_and(|caps| caps.supports_tasks()); if request.name == "sum" && client_supports_tasks { let args: SumArgs = serde_json::from_value(serde_json::Value::Object( diff --git a/examples/servers/src/common/task_demo.rs b/examples/servers/src/common/task_demo.rs index e6df0752e..18a4e8611 100644 --- a/examples/servers/src/common/task_demo.rs +++ b/examples/servers/src/common/task_demo.rs @@ -86,7 +86,6 @@ impl ServerHandler for TaskDemo { // task, but MUST NOT return one unless the request declared the tasks // extension capability. let client_supports_tasks = context - .meta .client_capabilities() .is_some_and(|caps| caps.supports_tasks()); From 035080c20b3207689bffe2bd01c657cc72e1f5b9 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 21 Jul 2026 16:59:17 -0400 Subject: [PATCH 08/13] fix: enforce SEP-2663 task-result gating in tools/call dispatch A handler could return CallToolResponse::Task without checking whether the request declared the io.modelcontextprotocol/tasks extension, sending a CreateTaskResult to a client that cannot parse it. The SDK dispatch now rejects that case with -32021 Missing Required Client Capability before the response leaves the server, using RequestContext::client_capabilities() (per-request _meta with initialize-time fallback). Adds a regression test with a deliberately misbehaving handler that always materializes a task; all Tasks extension conformance scenarios remain green (35/35 checks). --- crates/rmcp/src/handler/server.rs | 20 ++++++++-- crates/rmcp/tests/test_task.rs | 61 +++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index af5fd462e..bebbcb9a9 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -197,10 +197,22 @@ impl Service for H { .map(ServerResult::empty) } } - ClientRequest::CallToolRequest(request) => self - .call_tool(request.params, context) - .await - .map(ServerResult::from), + ClientRequest::CallToolRequest(request) => { + let client_declared_tasks = context + .client_capabilities() + .is_some_and(|caps| caps.supports_tasks()); + let response = self.call_tool(request.params, context).await?; + // SEP-2663: the server MUST NOT return CreateTaskResult unless + // the request declared the tasks extension capability. Guard + // against handlers that fail to check before materializing a + // task; such clients cannot parse a task handle. + if matches!(response, CallToolResponse::Task(_)) && !client_declared_tasks { + return Err(McpError::missing_required_client_capability( + ClientCapabilities::builder().enable_tasks().build(), + )); + } + Ok(ServerResult::from(response)) + } ClientRequest::ListToolsRequest(request) => self .list_tools(request.params, context) .await diff --git a/crates/rmcp/tests/test_task.rs b/crates/rmcp/tests/test_task.rs index b896e8b99..033830265 100644 --- a/crates/rmcp/tests/test_task.rs +++ b/crates/rmcp/tests/test_task.rs @@ -255,6 +255,67 @@ async fn no_task_without_extension_capability() { server.abort(); } +/// A misbehaving handler that materializes a task without checking the +/// client's capabilities. The SDK dispatch must catch this and reject with +/// -32021 rather than sending a task handle the client cannot parse. +#[derive(Clone)] +struct AlwaysTaskServer { + tasks: TaskManager, +} + +impl ServerHandler for AlwaysTaskServer { + async fn call_tool( + &self, + _request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + let task = self.tasks.spawn(TaskOptions::default(), |_ctx| { + Box::pin(async { Ok(CallToolResult::success(vec![ContentBlock::text("late")])) }) + }); + Ok(CallToolResponse::Task(CreateTaskResult::new(task))) + } + + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tasks() + .build(), + ) + } +} + +#[tokio::test] +async fn dispatch_rejects_task_result_for_non_declaring_client() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = AlwaysTaskServer { + tasks: TaskManager::new(), + } + .serve(server_transport) + .await?; + service.waiting().await?; + anyhow::Ok(()) + }); + + // Plain client: no tasks extension declared. The handler tries to return + // a CreateTaskResult anyway; dispatch must reject with -32021. + let client = ().serve(client_transport).await.unwrap(); + let err = client + .call_tool(CallToolRequestParams::new("anything")) + .await + .unwrap_err(); + match err { + rmcp::ServiceError::McpError(e) => { + assert_eq!(e.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); + } + other => panic!("expected McpError, got {other:?}"), + } + + client.cancel().await.unwrap(); + server.abort(); +} + #[tokio::test] async fn tasks_methods_without_capability_return_missing_capability_error() { let (server_transport, client_transport) = tokio::io::duplex(4096); From 16e0c43d5d7e660d3c841354f4f5bf7c0e8a141f Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 21 Jul 2026 19:51:47 -0400 Subject: [PATCH 09/13] fix: align TTL boundary comparisons in sweep_expired Copilot review: ttlMs: 0 never expired immediately because phase 1 used a strict 'elapsed > ttl_ms' comparison, and phase 2 retained terminal tasks at exactly the TTL boundary ('elapsed <= ttl_ms'). Treat elapsed >= ttl_ms as expired in phase 1 and evict when elapsed >= ttl_ms in phase 2, so both phases agree at the boundary and ttlMs: 0 expires/evicts on the first sweep. --- crates/rmcp/src/task_manager.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/rmcp/src/task_manager.rs b/crates/rmcp/src/task_manager.rs index 46dbb1f07..343f4b973 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -470,7 +470,7 @@ impl TaskManager { for entry in inner.tasks.values_mut() { if entry.terminal.is_none() && let Some(ttl_ms) = entry.task.ttl_ms - && entry.created.elapsed().as_millis() > u128::from(ttl_ms) + && entry.created.elapsed().as_millis() >= u128::from(ttl_ms) { if let Some(handle) = entry.join_handle.take() { handle.abort(); @@ -492,7 +492,7 @@ impl TaskManager { let (Some(ttl_ms), Some(terminal_at)) = (entry.task.ttl_ms, entry.terminal_at) else { return true; }; - terminal_at.elapsed().as_millis() <= u128::from(ttl_ms) + terminal_at.elapsed().as_millis() < u128::from(ttl_ms) }); } } From aae19013e30e8c36f0bf39be0114fce829a461d8 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 21 Jul 2026 20:20:53 -0400 Subject: [PATCH 10/13] fix: strict TaskAckResult deserializer and TASK_REQUIRED_TOOLS consistency - TaskAckResult carried only resultType (+ optional _meta) with a derived Deserialize, so inside the untagged ServerResult union it greedily matched any result object containing a resultType key, shadowing CustomResult and losing data (verified with a probe). Replace with a strict deserializer: deny_unknown_fields and require resultType == "complete". Regression tests cover both the non-matching shapes and the genuine ack shape. - Conformance fixtures: confirm_delete and multi_input rejected non-tasks clients inline, contradicting the TASK_REQUIRED_TOOLS doc/constant. Move them into TASK_REQUIRED_TOOLS (they park on in-task elicitation and have no synchronous fallback) so the upfront -32021 gate is the single source of truth, and drop the now-unreachable inline checks. All 9 Tasks extension conformance scenarios remain green (35/35 checks). --- conformance/src/bin/server.rs | 27 +++++++++---------- crates/rmcp/src/model/task.rs | 31 +++++++++++++++++++++- crates/rmcp/tests/test_deserialization.rs | 32 +++++++++++++++++++++++ 3 files changed, 75 insertions(+), 15 deletions(-) diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 89e2c44a9..57bba1a0e 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -100,10 +100,19 @@ const TASK_SUPPORTING_TOOLS: &[&str] = &[ "test_tool_with_task", ]; -/// Tools whose registration declares task support as *required*: calling them -/// without the tasks extension capability is rejected with -32021 before the -/// handler runs (SEP-2663 §Required Capabilities). -const TASK_REQUIRED_TOOLS: &[&str] = &["failing_job", "test_tool_with_task"]; +/// Tools that cannot be serviced without returning a `CreateTaskResult`: +/// calling them from a client that did not declare the tasks extension is +/// rejected with -32021 before the tool body runs (SEP-2663 §Required +/// Capabilities). `failing_job` and `test_tool_with_task` are registered +/// this way for the required-task-error and MRTR-composition scenarios; +/// `confirm_delete` and `multi_input` must park on in-task elicitation, so +/// they have no synchronous fallback either. +const TASK_REQUIRED_TOOLS: &[&str] = &[ + "failing_job", + "test_tool_with_task", + "confirm_delete", + "multi_input", +]; fn task_fixture_tool(name: &str) -> Tool { let (description, schema) = match name { @@ -314,11 +323,6 @@ impl ConformanceServer { .and_then(Value::as_str) .unwrap_or("file.txt") .to_string(); - if !create_task { - return Err(ErrorData::missing_required_client_capability( - ClientCapabilities::builder().enable_tasks().build(), - )); - } let task = self.tasks.spawn(TaskOptions::default(), move |ctx| { Box::pin(async move { let response = ctx @@ -345,11 +349,6 @@ impl ConformanceServer { } "multi_input" => { - if !create_task { - return Err(ErrorData::missing_required_client_capability( - ClientCapabilities::builder().enable_tasks().build(), - )); - } let task = self.tasks.spawn(TaskOptions::default(), move |ctx| { Box::pin(async move { // Fan out two elicitation requests in parallel so two diff --git a/crates/rmcp/src/model/task.rs b/crates/rmcp/src/model/task.rs index d655289c8..bbdd1c99d 100644 --- a/crates/rmcp/src/model/task.rs +++ b/crates/rmcp/src/model/task.rs @@ -374,7 +374,7 @@ impl GetTaskResult { /// The spec requires these acks to be empty results carrying the SEP-2322 /// `resultType: "complete"` discriminator; task state changes are observed /// via the next `tasks/get`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] @@ -385,6 +385,35 @@ pub struct TaskAckResult { pub meta: Option, } +// Custom deserializer that requires `resultType: "complete"` and rejects any +// other fields. Without this, `TaskAckResult` would greedily match arbitrary +// result objects carrying a `resultType` key inside `#[serde(untagged)]` +// unions such as `ServerResult`, shadowing `CustomResult` and losing data. +impl<'de> Deserialize<'de> for TaskAckResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Helper { + result_type: ResultType, + #[serde(rename = "_meta", default)] + meta: Option, + } + let helper = Helper::deserialize(deserializer)?; + if !helper.result_type.is_complete() { + return Err(serde::de::Error::custom( + "TaskAckResult requires resultType to be \"complete\"", + )); + } + Ok(TaskAckResult { + result_type: helper.result_type, + meta: helper.meta, + }) + } +} + impl Default for TaskAckResult { fn default() -> Self { Self { diff --git a/crates/rmcp/tests/test_deserialization.rs b/crates/rmcp/tests/test_deserialization.rs index 5346ab052..c3d08cd52 100644 --- a/crates/rmcp/tests/test_deserialization.rs +++ b/crates/rmcp/tests/test_deserialization.rs @@ -93,6 +93,38 @@ mod untagged_server_result { ); } + #[test] + fn result_type_bearing_objects_do_not_match_task_ack() { + // TaskAckResult carries only `resultType` (+ optional `_meta`), so it + // must not greedily swallow arbitrary results that happen to include + // a `resultType` key inside the untagged ServerResult union. + let result = parse_result(wrap_response(json!({ + "resultType": "weird-custom", + "payload": { "a": 1 } + }))); + assert!( + matches!(result, ServerResult::CustomResult(_)), + "expected CustomResult, got {result:?}" + ); + + let result = parse_result(wrap_response(json!({ + "resultType": "complete", + "customField": 42 + }))); + assert!( + matches!(result, ServerResult::CustomResult(_)), + "expected CustomResult, got {result:?}" + ); + + // A bare complete ack (the actual tasks/update / tasks/cancel ack + // shape) still parses as TaskAckResult. + let result = parse_result(wrap_response(json!({ "resultType": "complete" }))); + assert!( + matches!(result, ServerResult::TaskAckResult(_)), + "expected TaskAckResult, got {result:?}" + ); + } + #[test] fn arbitrary_json_value_falls_through_to_custom_result() { // Any bare JSON value must fall through to CustomResult. From a821dc8cc29f2d174eda695615fbc3847b08b704 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Wed, 22 Jul 2026 11:44:00 -0400 Subject: [PATCH 11/13] fix: let the operation decide its terminal state via TaskExit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After tasks/cancel, any operation error was coerced to terminal 'cancelled', masking real failures (e.g. an unrelated error landing just after a late cancel request) and contradicting the documented 'operation decides its own terminal state' contract. Change TaskFuture's error type from McpError to a new TaskExit enum: - TaskExit::Cancelled — an explicit cooperative-cancellation exit; settles as terminal 'cancelled'. - TaskExit::Error(McpError) — a real failure; settles as terminal 'failed' with the error inlined, even after tasks/cancel was received. From for TaskExit keeps '?' ergonomic in task bodies, and request_input() now returns TaskExit directly (its wake-on-cancel path yields TaskExit::Cancelled), so parked operations that propagate it with '?' settle as 'cancelled' automatically. Update the conformance fixtures, task_demo example, and tests; add a regression test asserting a post-cancel unrelated error settles as 'failed' with its error payload preserved. All 9 Tasks extension conformance scenarios remain green (35/35 checks). --- conformance/src/bin/server.rs | 19 ++--- crates/rmcp/src/task_manager.rs | 103 +++++++++++++++++------ crates/rmcp/tests/test_task.rs | 4 +- examples/servers/src/common/task_demo.rs | 7 +- 4 files changed, 91 insertions(+), 42 deletions(-) diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 57bba1a0e..bf902f86f 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -11,7 +11,7 @@ use rmcp::{ ErrorData, RoleServer, ServerHandler, model::*, service::{RequestContext, SubscriptionContext, SubscriptionSink}, - task_manager::{TaskManager, TaskOptions}, + task_manager::{TaskExit, TaskManager, TaskOptions}, transport::{ StreamableHttpServerConfig, StreamableHttpService, streamable_http_server::session::local::LocalSessionManager, @@ -256,10 +256,7 @@ impl ConformanceServer { let task = self.tasks.spawn(TaskOptions::default(), move |ctx| { Box::pin(async move { tokio::select! { - _ = ctx.cancelled() => Err(ErrorData::internal_error( - "slow_compute cancelled", - None, - )), + _ = ctx.cancelled() => Err(TaskExit::Cancelled), _ = tokio::time::sleep( std::time::Duration::from_secs_f64(seconds), ) => Ok(CallToolResult::success(vec![ContentBlock::text( @@ -288,9 +285,9 @@ impl ConformanceServer { )])) }; if create_task { - let task = self - .tasks - .spawn(TaskOptions::default(), move |_ctx| Box::pin(work())); + let task = self.tasks.spawn(TaskOptions::default(), move |_ctx| { + Box::pin(async move { work().await.map_err(TaskExit::Error) }) + }); Ok(CreateTaskResult::new(task).into()) } else { Ok(work().await?.into()) @@ -308,9 +305,9 @@ impl ConformanceServer { )) }; if create_task { - let task = self - .tasks - .spawn(TaskOptions::default(), move |_ctx| Box::pin(work())); + let task = self.tasks.spawn(TaskOptions::default(), move |_ctx| { + Box::pin(async move { work().await.map_err(TaskExit::Error) }) + }); Ok(CreateTaskResult::new(task).into()) } else { work().await.map(CallToolResponse::from) diff --git a/crates/rmcp/src/task_manager.rs b/crates/rmcp/src/task_manager.rs index 343f4b973..a63b7fa3a 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -72,26 +72,28 @@ impl TaskContext { &self, key: impl Into, request: InputRequest, - ) -> Result { + ) -> Result { let key = key.into(); let (tx, rx) = oneshot::channel(); { let mut inner = self.inner.lock().expect("task manager lock poisoned"); let entry = inner.tasks.get_mut(&self.task_id).ok_or_else(|| { - McpError::internal_error("task no longer exists".to_string(), None) + TaskExit::Error(McpError::internal_error( + "task no longer exists".to_string(), + None, + )) })?; if !entry.used_input_keys.insert(key.clone()) { - return Err(McpError::internal_error( + return Err(TaskExit::Error(McpError::internal_error( format!("inputRequests key {key:?} was already used for this task"), None, - )); + ))); } entry.pending_inputs.insert(key.clone(), (request, tx)); entry.touch(); } - rx.await.map_err(|_| { - McpError::internal_error("task cancelled while awaiting input".to_string(), None) - }) + // The sender is dropped when `tasks/cancel` clears pending inputs. + rx.await.map_err(|_| TaskExit::Cancelled) } /// Update the task's human-readable status message. @@ -118,11 +120,11 @@ impl TaskContext { /// `tokio::select!` around long-running work to implement a cancellation /// exit path. /// - /// An operation that stops in response should return an error — the - /// manager records a post-cancel error as terminal `cancelled` rather - /// than `failed`. An operation that finishes its work anyway settles as - /// `completed`; per SEP-2663 cancellation is cooperative and a task may - /// reach a non-`cancelled` terminal status. + /// An operation that stops in response should return + /// [`TaskExit::Cancelled`] so the task settles as `cancelled`. Returning + /// [`TaskExit::Error`] settles as `failed`, and finishing the work + /// anyway settles as `completed` — per SEP-2663 cancellation is + /// cooperative and a task may reach a non-`cancelled` terminal status. pub async fn cancelled(&self) { let mut rx = { let inner = self.inner.lock().expect("task manager lock poisoned"); @@ -144,8 +146,25 @@ impl TaskContext { } } +/// How a task operation finished without producing a result. +#[derive(Debug)] +pub enum TaskExit { + /// The operation is exiting in response to a cancellation request; + /// the task settles as terminal `cancelled`. + Cancelled, + /// A real failure; the task settles as terminal `failed` with the + /// error inlined, even after `tasks/cancel` was received. + Error(McpError), +} + +impl From for TaskExit { + fn from(error: McpError) -> Self { + TaskExit::Error(error) + } +} + /// Boxed future representing the async operation backing a task. -pub type TaskFuture = Pin> + Send>>; +pub type TaskFuture = Pin> + Send>>; struct TaskEntry { task: Task, @@ -323,15 +342,10 @@ impl TaskManager { Ok(result) => TaskPayload::Completed { result: result_to_object(&result), }, - Err(error) => { - if entry.cancel_requested { - TaskPayload::Cancelled - } else { - TaskPayload::Failed { - error: error_to_object(&error), - } - } - } + Err(TaskExit::Cancelled) => TaskPayload::Cancelled, + Err(TaskExit::Error(error)) => TaskPayload::Failed { + error: error_to_object(&error), + }, }); entry.terminal_at = Some(Instant::now()); entry.pending_inputs.clear(); @@ -407,8 +421,9 @@ impl TaskManager { /// call (whose response channel is dropped here), and decides its own /// terminal status: /// - /// - stops with an error → recorded as `cancelled` (post-cancel errors - /// are treated as honoring the request, not `failed`), + /// - stops with [`TaskExit::Cancelled`] → recorded as `cancelled`, + /// - stops with [`TaskExit::Error`] → recorded as `failed` with the + /// error inlined (a real failure after a cancel request is not masked), /// - finishes its work anyway → recorded as `completed` — per the spec, /// "the task may still reach a non-`cancelled` terminal status". pub fn cancel_task(&self, task_id: &str) -> Result<(), McpError> { @@ -558,7 +573,7 @@ mod tests { let task = manager.spawn(TaskOptions::default(), |ctx| { Box::pin(async move { tokio::select! { - _ = ctx.cancelled() => Err(McpError::internal_error("cancelled", None)), + _ = ctx.cancelled() => Err(TaskExit::Cancelled), _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => { Ok(ok_result("never")) } @@ -580,6 +595,44 @@ mod tests { panic!("task did not settle after cancel"); } + #[tokio::test] + async fn post_cancel_unrelated_error_settles_as_failed() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::default(), |ctx| { + Box::pin(async move { + // Fail for an unrelated reason after observing the cancel: + // must be recorded as `failed`, not masked as `cancelled`. + ctx.cancelled().await; + Err(TaskExit::Error(McpError::internal_error( + "database write failed", + None, + ))) + }) + }); + manager.cancel_task(&task.task_id).unwrap(); + + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if detailed.status().is_terminal() { + assert_eq!(detailed.status(), TaskStatus::Failed); + match detailed.payload { + TaskPayload::Failed { error } => { + assert!( + error.get("message").is_some_and(|m| m + .as_str() + .is_some_and(|s| s.contains("database write failed"))), + "error payload should be preserved: {error:?}" + ); + } + other => panic!("unexpected payload: {other:?}"), + } + return; + } + } + panic!("task did not settle after cancel"); + } + #[tokio::test] async fn cancel_is_cooperative_and_lets_the_operation_clean_up() { let manager = TaskManager::new(); diff --git a/crates/rmcp/tests/test_task.rs b/crates/rmcp/tests/test_task.rs index 033830265..ea1a2595e 100644 --- a/crates/rmcp/tests/test_task.rs +++ b/crates/rmcp/tests/test_task.rs @@ -7,7 +7,7 @@ use rmcp::{ handler::server::{router::tool::ToolRouter, wrapper::Parameters}, model::*, service::{RequestContext, RoleServer}, - task_manager::{TaskManager, TaskOptions}, + task_manager::{TaskExit, TaskManager, TaskOptions}, tool, tool_router, }; use serde_json::json; @@ -65,7 +65,7 @@ impl ServerHandler for TaskServer { Box::pin(async move { tokio::select! { _ = ctx.cancelled() => { - Err(McpError::internal_error("cancelled", None)) + Err(TaskExit::Cancelled) } _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => { Ok(CallToolResult::success(vec![ContentBlock::text( diff --git a/examples/servers/src/common/task_demo.rs b/examples/servers/src/common/task_demo.rs index 18a4e8611..4d8d53acf 100644 --- a/examples/servers/src/common/task_demo.rs +++ b/examples/servers/src/common/task_demo.rs @@ -18,7 +18,7 @@ use rmcp::{ model::*, schemars, service::{RequestContext, RoleServer}, - task_manager::{TaskManager, TaskOptions}, + task_manager::{TaskExit, TaskManager, TaskOptions}, tool, tool_router, }; @@ -97,11 +97,10 @@ impl ServerHandler for TaskDemo { let task = self.tasks.spawn(TaskOptions::default(), move |ctx| { Box::pin(async move { // Cancellation is cooperative (SEP-2663): honor - // tasks/cancel by exiting early with an error, which the - // manager records as terminal `cancelled`. + // tasks/cancel by exiting with TaskExit::Cancelled. tokio::select! { _ = ctx.cancelled() => { - Err(McpError::internal_error("slow_sum cancelled", None)) + Err(TaskExit::Cancelled) } _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => { Ok(CallToolResult::success(vec![ContentBlock::text( From 8f0980b79e66824bf0a4ef3b445840b40f92ab35 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Wed, 22 Jul 2026 12:52:40 -0400 Subject: [PATCH 12/13] fix: allow TaskExit enum to be exhaustive --- crates/rmcp/src/task_manager.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/rmcp/src/task_manager.rs b/crates/rmcp/src/task_manager.rs index a63b7fa3a..8f896d480 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -147,6 +147,10 @@ impl TaskContext { } /// How a task operation finished without producing a result. +#[expect( + clippy::exhaustive_enums, + reason = "error variant for task exit may only be due to error or cancellation" +)] #[derive(Debug)] pub enum TaskExit { /// The operation is exiting in response to a cancellation request; From e636f3a0f71b3161881acd4a3ceb4e7197a4d6c4 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Wed, 22 Jul 2026 13:36:57 -0400 Subject: [PATCH 13/13] fix: close spawn/shutdown race, sweep in running_task_count, clarify TTL retention docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses branch-review findings: - spawn() raced with shutdown(): if shutdown drained the task map between the entry insert and the JoinHandle store, the handle was dropped and the operation kept running detached. If the entry is gone at store time, the handle is now aborted instead. - running_task_count() now runs the TTL sweep like every other entry point, so it no longer reports overdue tasks as running (matching the documented sweep-on-every-entry-point behavior). Regression test added. - Document that terminal-task retention intentionally extends one ttl_ms window past the terminal transition (observation grace period) beyond the creation-based lifetime ttlMs advertises on the wire — compliant since SEP-2663 allows deleting expired tasks at any time after the TTL. --- crates/rmcp/src/task_manager.rs | 58 ++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/crates/rmcp/src/task_manager.rs b/crates/rmcp/src/task_manager.rs index 8f896d480..df1c389a3 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -276,14 +276,26 @@ impl TaskOptions { /// # Retention /// /// Entries are swept opportunistically on every `spawn` / `get_task` / -/// `update_task` / `cancel_task` call: non-terminal tasks whose `ttl_ms` has -/// elapsed are marked `failed` (their operation is aborted), and terminal -/// tasks are evicted after being retained for one further `ttl_ms` window so -/// pollers can observe the final state. Tasks with `ttl_ms: None` are -/// retained for the lifetime of the manager (spec: unlimited retention) — -/// bound task creation or call [`Self::shutdown`] yourself if you spawn such -/// tasks in a long-lived server. There is no background sweeper; an idle -/// manager holds its entries until the next call. +/// `update_task` / `cancel_task` / `running_task_count` call: non-terminal +/// tasks whose `ttl_ms` has elapsed are marked `failed` (their operation is +/// aborted), and terminal tasks are evicted after being retained for one +/// further `ttl_ms` window past their terminal transition so pollers can +/// observe the final state. +/// +/// Note that the retention window intentionally extends past the +/// creation-based lifetime that `ttl_ms` advertises on the wire: a task that +/// runs to its TTL deadline is marked `failed` around `created + ttl_ms` and +/// stays observable until roughly `created + 2 × ttl_ms`. This is compliant — +/// SEP-2663 lets servers delete expired tasks *at any time* after the TTL, +/// so retaining them longer as an observation grace period is a server-side +/// policy choice, not a wire-contract change. Clients may treat the task as +/// unusable after `createdAt + ttlMs` regardless. +/// +/// Tasks with `ttl_ms: None` are retained for the lifetime of the manager +/// (spec: unlimited retention) — bound task creation or call +/// [`Self::shutdown`] yourself if you spawn such tasks in a long-lived +/// server. There is no background sweeper; an idle manager holds its entries +/// until the next call. #[derive(Clone, Default)] pub struct TaskManager { inner: Arc>, @@ -361,7 +373,7 @@ impl TaskManager { entry.join_handle = None; } }); - if let Some(entry) = self + match self .inner .lock() .expect("task manager lock poisoned") @@ -371,9 +383,14 @@ impl TaskManager { // Only store the handle while the operation is still running: if // it already settled, the completion path above ran first and a // stored handle would never be cleared. - if entry.terminal.is_none() { - entry.join_handle = Some(handle); + Some(entry) => { + if entry.terminal.is_none() { + entry.join_handle = Some(handle); + } } + // The entry is gone: shutdown() drained the map between the + // insert and here. Abort rather than leak a detached operation. + None => handle.abort(), } task } @@ -453,7 +470,8 @@ impl TaskManager { /// Number of tasks currently in a non-terminal state. pub fn running_task_count(&self) -> usize { - let inner = self.inner.lock().expect("task manager lock poisoned"); + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + Self::sweep_expired(&mut inner); inner .tasks .values() @@ -789,6 +807,22 @@ mod tests { assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS); } + #[tokio::test] + async fn running_task_count_sweeps_expired_tasks() { + let manager = TaskManager::new(); + let _task = manager.spawn(TaskOptions::new().with_ttl_ms(10), |_ctx| { + Box::pin(async { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + Ok(ok_result("never")) + }) + }); + assert_eq!(manager.running_task_count(), 1); + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + // The count itself must sweep: the overdue task is failed, not + // reported as running. + assert_eq!(manager.running_task_count(), 0); + } + #[tokio::test] async fn unlimited_ttl_tasks_are_retained() { let manager = TaskManager::new();