diff --git a/README.md b/README.md index 7586d5c58..13219440f 100644 --- a/README.md +++ b/README.md @@ -971,21 +971,33 @@ 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 + .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/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..bf902f86f 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::{TaskExit, 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,97 @@ 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 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 { + "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 +207,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(); + if create_task { + // 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(TaskExit::Cancelled), + _ = 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 { + 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()) + } + } + + "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(async move { work().await.map_err(TaskExit::Error) }) + }); + 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(async move { work().await.map_err(TaskExit::Error) }) + }); + 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(); + 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" => { + 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 +711,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 +752,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 +1015,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 +1037,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-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..bebbcb9a9 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, @@ -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, @@ -170,41 +198,20 @@ impl Service for H { } } 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) + 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) @@ -214,22 +221,24 @@ 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) - .await - .map(ServerResult::GetTaskResult), - ClientRequest::GetTaskPayloadRequest(request) => self - .get_task_result(request.params, context) - .await - .map(ServerResult::GetTaskPayloadResult), - ClientRequest::CancelTaskRequest(request) => self - .cancel_task(request.params, context) - .await - .map(ServerResult::CancelTaskResult), + 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::task_ack) + } + ClientRequest::CancelTaskRequest(request) => { + validate_tasks_capability::(self, &context)?; + self.cancel_task(request.params, context) + .await + .map(ServerResult::task_ack) + } }; let result = result.and_then(|result| { if matches!(result, ServerResult::InputRequiredResult(_)) && !mrtr_supported { @@ -273,9 +282,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 +296,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 +522,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 +535,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 +545,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 +584,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 +755,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 +767,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..6531e6ee5 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,13 @@ ts_union!( | ListToolsResult | ElicitResult | CreateTaskResult - | ListTasksResult | GetTaskResult - | CancelTaskResult | CallToolResult | InputRequiredResult - | GetTaskPayloadResult + // 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 ; @@ -4492,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; @@ -4533,7 +4465,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..bbdd1c99d 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, -} +//! 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`. -impl TaskMetadata { - pub fn new() -> Self { - Self::default() - } - - pub fn with_ttl(mut self, ttl: u64) -> Self { - self.ttl = Some(ttl); - self - } -} - -/// 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,241 @@ 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). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +/// 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")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +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 { + // The actual wire shape: base Task fields plus the optional + // status-specific payload fields (inputRequests / result / error). + ::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)] #[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, } +// 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. + /// 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,80 +339,206 @@ 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))] #[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. #[serde(flatten)] - pub task: Task, + pub task: DetailedTask, } impl GetTaskResult { - pub fn new(task: Task) -> Self { - Self { meta: None, task } + pub fn new(task: DetailedTask) -> Self { + Self { + result_type: ResultType::COMPLETE, + meta: None, + task, + } } } -/// Response to a `tasks/result` request. +/// Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663). /// -/// 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. +/// 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)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] -pub struct GetTaskPayloadResult(pub Value); - -impl GetTaskPayloadResult { - /// Create a new GetTaskPayloadResult with the given value. - pub fn new(value: Value) -> Self { - Self(value) - } +pub struct TaskAckResult { + /// Always `"complete"`. + pub result_type: ResultType, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, } -// 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 { +// 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>, { - // 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", - )) + #[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, + }) } } -/// 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, +impl Default for TaskAckResult { + fn default() -> Self { + Self { + result_type: ResultType::COMPLETE, + meta: None, + } + } } -impl CancelTaskResult { - pub fn new(task: Task) -> Self { - Self { meta: None, task } +impl TaskAckResult { + pub fn new() -> Self { + Self::default() + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + 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) + } + + #[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); + } + + #[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); + } + + #[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..88336ed5a 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::TaskAckResult(_) | 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::TaskAckResult(_) | 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/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 21adb38b3..df1c389a3 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -1,307 +1,920 @@ -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 + } + + /// 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(|| { + TaskExit::Error(McpError::internal_error( + "task no longer exists".to_string(), + None, + )) + })?; + if !entry.used_input_keys.insert(key.clone()) { + 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(); } + // The sender is dropped when `tasks/cancel` clears pending inputs. + rx.await.map_err(|_| TaskExit::Cancelled) } - pub fn with_client_request(mut self, request: ClientRequest) -> Self { - self.client_request = Some(request); - 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(); + } } - pub fn with_context(mut self, context: RequestContext) -> Self { - self.context = Some(context); - 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) } - /// 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 + /// 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 + /// [`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"); + 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; + } + } } } -/// Operation message describing a unit of asynchronous work. -#[non_exhaustive] -pub struct OperationMessage { - pub descriptor: OperationDescriptor, - pub future: OperationFuture, +/// 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; + /// 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 OperationMessage { - pub fn new(descriptor: OperationDescriptor, future: OperationFuture) -> Self { - Self { descriptor, future } +impl From for TaskExit { + fn from(error: McpError) -> Self { + TaskExit::Error(error) } } -/// Trait for operation result transport -pub trait OperationResultTransport: Send + Sync + 'static { - fn operation_id(&self) -> &String; - fn as_any(&self) -> &dyn std::any::Any; +/// 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, + /// 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. + 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>, } -// ===== 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, -} +impl TaskEntry { + fn touch(&mut self) { + self.task.last_updated_at = current_timestamp(); + } -struct RunningTask { - task_handle: tokio::task::JoinHandle<()>, - started_at: std::time::Instant, - timeout: Option, - descriptor: OperationDescriptor, -} + fn current_status(&self) -> TaskStatus { + match &self.terminal { + Some(payload) => payload.status(), + None if !self.pending_inputs.is_empty() => TaskStatus::InputRequired, + None => TaskStatus::Working, + } + } -#[non_exhaustive] -pub struct TaskResult { - pub descriptor: OperationDescriptor, - pub result: Result, Error>, + 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) + } } -/// Helper to generate an ISO 8601 timestamp for task metadata. -pub fn current_timestamp() -> String { - chrono::Utc::now().to_rfc3339() +#[derive(Default)] +struct TaskManagerInner { + tasks: HashMap, } -/// Result transport for tool calls executed as tasks. -pub struct ToolCallTaskResult { - id: String, - pub result: Result, +/// Options controlling a spawned task. +#[derive(Debug, Clone)] +#[non_exhaustive] +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 + } +} + +/// 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` / `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>, } -impl OperationProcessor { +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, + terminal_at: None, + 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, + }; + { + 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(), + 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(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(); + 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; + } + }); + match 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 - ))); + // 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. + 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 + } + + /// 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::sweep_expired(&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"); + Self::sweep_expired(&mut inner); + 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); + } } - self.spawn_async_task(message); + 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(); + /// 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: + /// + /// - 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> { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + Self::sweep_expired(&mut inner); + let entry = inner + .tasks + .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() { + // 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 = entry.current_status(); + } + Ok(()) + } - 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 + /// Number of tasks currently in a non-terminal state. + pub fn running_task_count(&self) -> usize { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + Self::sweep_expired(&mut inner); + inner + .tasks + .values() + .filter(|e| e.terminal.is_none()) + .count() + } + + /// 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(); } - }; + } + } - let handle = tokio::spawn(async move { - let result = timed_future.await; - let task_result = TaskResult { - descriptor: descriptor_for_result, - result, + /// 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 + && 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.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; }; - let _ = sender.send(task_result); + terminal_at.elapsed().as_millis() < u128::from(ttl_ms) }); - 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); } +} - /// 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); - } +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(), } +} - /// 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(); +fn error_to_object(error: &McpError) -> JsonObject { + match serde_json::to_value(error) { + Ok(serde_json::Value::Object(map)) => map, + _ => JsonObject::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()); +#[cfg(test)] +mod tests { + use super::*; + use crate::model::ContentBlock; + + fn ok_result(text: &str) -> CallToolResult { + CallToolResult::success(vec![ContentBlock::text(text.to_string())]) + } + + #[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"); + } - 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); + #[tokio::test] + async fn cancel_settles_to_cancelled_when_operation_honors_it() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::default(), |ctx| { + Box::pin(async move { + tokio::select! { + _ = ctx.cancelled() => Err(TaskExit::Cancelled), + _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => { + Ok(ok_result("never")) + } + } + }) + }); + manager.cancel_task(&task.task_id).unwrap(); + + // 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"); } - /// Get the number of running tasks. - pub fn running_task_count(&mut self) -> usize { - self.collect_completed_results(); - self.running_tasks.len() + #[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"); } - /// Cancel all running tasks. - pub fn cancel_all_tasks(&mut self) { - for (_, task) in self.running_tasks.drain() { - task.task_handle.abort(); + #[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 { + // 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(); + + // The ack is immediate and does not force a terminal state. + let detailed = manager.get_task(&task.task_id).unwrap(); + assert!( + !detailed.status().is_terminal(), + "cancel must not force terminal state" + ); + + // 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 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; + } } - while self.task_result_receiver.try_recv().is_ok() {} - self.completed_results.clear(); + panic!("task did not settle after cancel"); } - /// 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 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; + } + } - /// Returns a snapshot of completed task results. - pub fn peek_completed(&mut self) -> &[TaskResult] { - self.collect_completed_results(); - &self.completed_results + 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 + ); } - /// 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); + #[tokio::test] + 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); } - self.completed_results - .iter() - .rev() - .find(|result| result.descriptor.operation_id == task_id) - .map(|result| &result.descriptor) - } - - /// 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; + } + + #[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; + } } - false + 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); } - /// 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 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(); + 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 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(); + 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] + 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); + } + + #[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..c3d08cd52 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,40 @@ 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 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. 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..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 @@ -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,31 @@ ] }, "createdAt": { - "description": "ISO-8601 creation timestamp.", + "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 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" @@ -1233,8 +1200,24 @@ "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": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", + "description": "Current task status.", "allOf": [ { "$ref": "#/definitions/TaskStatus" @@ -1242,18 +1225,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 +1928,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 +2199,7 @@ "$ref": "#/definitions/TaskStatusNotificationMethod" }, "params": { - "$ref": "#/definitions/TaskStatusNotificationParam" + "$ref": "#/definitions/TaskStatusNotificationParams" } }, "required": [ @@ -3191,19 +3144,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 +3205,6 @@ } ] }, - "tasks": { - "anyOf": [ - { - "$ref": "#/definitions/TasksCapability" - }, - { - "type": "null" - } - ] - }, "tools": { "anyOf": [ { @@ -3325,15 +3255,9 @@ { "$ref": "#/definitions/CreateTaskResult" }, - { - "$ref": "#/definitions/ListTasksResult" - }, { "$ref": "#/definitions/GetTaskResult" }, - { - "$ref": "#/definitions/CancelTaskResult" - }, { "$ref": "#/definitions/CallToolResult" }, @@ -3341,7 +3265,7 @@ "$ref": "#/definitions/InputRequiredResult" }, { - "$ref": "#/definitions/GetTaskPayloadResult" + "$ref": "#/definitions/TaskAckResult" }, { "$ref": "#/definitions/EmptyObject" @@ -3528,138 +3452,58 @@ "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.", + "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": { - "elicitation": { - "anyOf": [ - { - "$ref": "#/definitions/ElicitationTaskCapability" - }, - { - "type": "null" - } - ] - }, - "sampling": { + "_meta": { "anyOf": [ { - "$ref": "#/definitions/SamplingTaskCapability" + "$ref": "#/definitions/MetaObject" }, { "type": "null" } ] }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsTaskCapability" - }, + "resultType": { + "description": "Always `\"complete\"`.", + "allOf": [ { - "type": "null" + "$ref": "#/definitions/ResultType" } ] } - } + }, + "required": [ + "resultType" + ] }, "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 +3512,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 +3529,31 @@ ] }, "createdAt": { - "description": "ISO-8601 creation timestamp.", + "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 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" @@ -3701,8 +3561,15 @@ "format": "uint64", "minimum": 0 }, + "result": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", + "description": "Current task status.", "allOf": [ { "$ref": "#/definitions/TaskStatus" @@ -3710,18 +3577,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 +3604,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 +3777,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 +3895,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 +3980,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..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 @@ -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,31 @@ ] }, "createdAt": { - "description": "ISO-8601 creation timestamp.", + "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 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" @@ -1233,8 +1200,24 @@ "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": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", + "description": "Current task status.", "allOf": [ { "$ref": "#/definitions/TaskStatus" @@ -1242,18 +1225,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 +1928,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 +2199,7 @@ "$ref": "#/definitions/TaskStatusNotificationMethod" }, "params": { - "$ref": "#/definitions/TaskStatusNotificationParam" + "$ref": "#/definitions/TaskStatusNotificationParams" } }, "required": [ @@ -3191,19 +3144,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 +3205,6 @@ } ] }, - "tasks": { - "anyOf": [ - { - "$ref": "#/definitions/TasksCapability" - }, - { - "type": "null" - } - ] - }, "tools": { "anyOf": [ { @@ -3325,15 +3255,9 @@ { "$ref": "#/definitions/CreateTaskResult" }, - { - "$ref": "#/definitions/ListTasksResult" - }, { "$ref": "#/definitions/GetTaskResult" }, - { - "$ref": "#/definitions/CancelTaskResult" - }, { "$ref": "#/definitions/CallToolResult" }, @@ -3341,7 +3265,7 @@ "$ref": "#/definitions/InputRequiredResult" }, { - "$ref": "#/definitions/GetTaskPayloadResult" + "$ref": "#/definitions/TaskAckResult" }, { "$ref": "#/definitions/EmptyObject" @@ -3528,138 +3452,58 @@ "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.", + "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": { - "elicitation": { - "anyOf": [ - { - "$ref": "#/definitions/ElicitationTaskCapability" - }, - { - "type": "null" - } - ] - }, - "sampling": { + "_meta": { "anyOf": [ { - "$ref": "#/definitions/SamplingTaskCapability" + "$ref": "#/definitions/MetaObject" }, { "type": "null" } ] }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsTaskCapability" - }, + "resultType": { + "description": "Always `\"complete\"`.", + "allOf": [ { - "type": "null" + "$ref": "#/definitions/ResultType" } ] } - } + }, + "required": [ + "resultType" + ] }, "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 +3512,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 +3529,31 @@ ] }, "createdAt": { - "description": "ISO-8601 creation timestamp.", + "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 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" @@ -3701,8 +3561,15 @@ "format": "uint64", "minimum": 0 }, + "result": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", + "description": "Current task status.", "allOf": [ { "$ref": "#/definitions/TaskStatus" @@ -3710,18 +3577,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 +3604,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 +3777,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 +3895,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 +3980,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..ea1a2595e 100644 --- a/crates/rmcp/tests/test_task.rs +++ b/crates/rmcp/tests/test_task.rs @@ -1,119 +1,423 @@ -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::{TaskExit, 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 + .client_capabilities() + .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( + 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::select! { + _ = ctx.cancelled() => { + Err(TaskExit::Cancelled) + } + _ = 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))); + } + + let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); + self.tool_router.call(tcc).await + } + + 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 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(()) + }); + + 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(); + 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 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 client = tasks_client_info().serve(client_transport).await.unwrap(); - fn as_any(&self) -> &dyn Any { - self + 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(); + + // 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(); } #[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 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(30)).await; - let results = processor.peek_completed(); - assert_eq!(results.len(), 1); - let payload = results[0] - .result - .as_ref() - .unwrap() - .as_any() - .downcast_ref::() + // 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(); - assert_eq!(payload.value, 42); + let text = result.content[0].as_text().unwrap(); + assert_eq!(text.text, "5"); + + client.cancel().await.unwrap(); + 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 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) +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(()) }); - 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) + + // 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); + 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")); + // 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 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 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(()) }); - 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"), + 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_param_preserves_meta() { +fn task_status_notification_params_preserve_meta() { let raw = json!({ "_meta": { "traceId": "trace-1" @@ -122,17 +426,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..4d8d53acf 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::{TaskExit, 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,92 @@ 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 + .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 { + // Cancellation is cooperative (SEP-2663): honor + // tasks/cancel by exiting with TaskExit::Cancelled. + tokio::select! { + _ = ctx.cancelled() => { + Err(TaskExit::Cancelled) + } + _ = 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))); + } + + // 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(), + ) + } +}