feat(task): add task support (SEP-1686) (#536)

Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
This commit is contained in:
jokemanfire 2025-12-22 22:01:00 +08:00 • committed by GitHub
commit 621c9f619e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1922 additions and 57 deletions

View file

@ -197,3 +197,8 @@ path = "tests/test_progress_subscriber.rs"
name = "test_elicitation"
required-features = ["elicitation", "client", "server"]
path = "tests/test_elicitation.rs"
[[test]]
name = "test_task"
required-features = ["server", "client", "macros"]
path = "tests/test_task.rs"

View file

@ -2,9 +2,6 @@
`rmcp` is the official Rust implementation of the Model Context Protocol (MCP), a protocol designed for AI assistants to communicate with other services. This library can be used to build both servers that expose capabilities to AI assistants and clients that interact with such servers.
wait for the first release.
<!-- [![Crates.io](todo)](todo)
[![Documentation](todo)](todo) -->
@ -81,6 +78,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
```
## Tasks
RMCP implements the task lifecycle from SEP-1686 so long-running or asynchronous tool calls can be queued and polled safely.
- **Create:** set the `task` field on `CallToolRequestParam` to ask the server to enqueue the tool call. The response is a `CreateTaskResult` that includes the generated `task.task_id`.
- **Inspect:** use `tasks/get` (`GetTaskInfoRequest`) to retrieve metadata such as status, timestamps, TTL, and poll interval.
- **Await results:** call `tasks/result` (`GetTaskResultRequest`) to block until the task completes and receive either the final `CallToolResult` payload or a protocol error.
- **Cancel:** call `tasks/cancel` (`CancelTaskRequest`) to request termination of a running task.
To expose task support, enable the `tasks` capability when building `ServerCapabilities`. The `#[task_handler]` macro and `OperationProcessor` utility provide reference implementations for enqueuing, tracking, and collecting task results.
### Client Implementation
Creating a client to interact with a server:
@ -117,6 +125,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.call_tool(CallToolRequestParam {
name: "increment".into(),
arguments: None,
task: None,
})
.await?;
println!("Result: {result:#?}");

View file

@ -41,6 +41,10 @@ pub enum RmcpError {
error: Box<dyn std::error::Error + Send + Sync>,
},
// and cancellation shouldn't be an error?
// TODO: add more error variants as needed
#[error("Task error: {0}")]
TaskError(String),
}
impl RmcpError {

View file

@ -11,6 +11,7 @@ pub mod router;
pub mod tool;
pub mod tool_name_validation;
pub mod wrapper;
impl<H: ServerHandler> Service<RoleServer> for H {
async fn handle_request(
&self,
@ -61,10 +62,18 @@ impl<H: ServerHandler> Service<RoleServer> for H {
.unsubscribe(request.params, context)
.await
.map(ServerResult::empty),
ClientRequest::CallToolRequest(request) => self
.call_tool(request.params, context)
.await
.map(ServerResult::CallToolResult),
ClientRequest::CallToolRequest(request) => {
if request.params.task.is_some() {
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::CallToolResult)
}
}
ClientRequest::ListToolsRequest(request) => self
.list_tools(request.params, context)
.await
@ -73,6 +82,22 @@ impl<H: ServerHandler> Service<RoleServer> 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::GetTaskInfoRequest(request) => self
.get_task_info(request.params, context)
.await
.map(ServerResult::GetTaskInfoResult),
ClientRequest::GetTaskResultRequest(request) => self
.get_task_result(request.params, context)
.await
.map(ServerResult::TaskResult),
ClientRequest::CancelTaskRequest(request) => self
.cancel_task(request.params, context)
.await
.map(ServerResult::empty),
}
}
@ -108,6 +133,16 @@ impl<H: ServerHandler> Service<RoleServer> for H {
#[allow(unused_variables)]
pub trait ServerHandler: Sized + Send + Sync + 'static {
fn enqueue_task(
&self,
_request: CallToolRequestParam,
_context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<CreateTaskResult, McpError>> + Send + '_ {
std::future::ready(Err(McpError::internal_error(
"Task processing not implemented".to_string(),
None,
)))
}
fn ping(
&self,
context: RequestContext<RoleServer>,
@ -257,4 +292,38 @@ pub trait ServerHandler: Sized + Send + Sync + 'static {
fn get_info(&self) -> ServerInfo {
ServerInfo::default()
}
fn list_tasks(
&self,
request: Option<PaginatedRequestParam>,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<ListTasksResult, McpError>> + Send + '_ {
std::future::ready(Err(McpError::method_not_found::<ListTasksMethod>()))
}
fn get_task_info(
&self,
request: GetTaskInfoParam,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<GetTaskInfoResult, McpError>> + Send + '_ {
std::future::ready(Err(McpError::method_not_found::<GetTaskInfoMethod>()))
}
fn get_task_result(
&self,
request: GetTaskResultParam,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<TaskResult, McpError>> + Send + '_ {
let _ = (request, context);
std::future::ready(Err(McpError::method_not_found::<GetTaskResultMethod>()))
}
fn cancel_task(
&self,
request: CancelTaskParam,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<(), McpError>> + Send + '_ {
let _ = (request, context);
std::future::ready(Err(McpError::method_not_found::<CancelTaskMethod>()))
}
}

View file

@ -33,12 +33,17 @@ pub struct ToolCallContext<'s, S> {
pub service: &'s S,
pub name: Cow<'static, str>,
pub arguments: Option<JsonObject>,
pub task: Option<JsonObject>,
}
impl<'s, S> ToolCallContext<'s, S> {
pub fn new(
service: &'s S,
CallToolRequestParam { name, arguments }: CallToolRequestParam,
CallToolRequestParam {
name,
arguments,
task,
}: CallToolRequestParam,
request_context: RequestContext<RoleServer>,
) -> Self {
Self {
@ -46,6 +51,7 @@ impl<'s, S> ToolCallContext<'s, S> {
service,
name,
arguments,
task,
}
}
pub fn name(&self) -> &str {

View file

@ -125,6 +125,7 @@
//! .call_tool(CallToolRequestParam {
//! name: "git_status".into(),
//! arguments: serde_json::json!({ "repo_path": "." }).as_object().cloned(),
//! task: None,
//! })
//! .await?;
//! println!("Tool result: {tool_result:#?}");
@ -162,6 +163,7 @@ pub use service::{RoleClient, serve_client};
pub use service::{RoleServer, serve_server};
pub mod handler;
pub mod task_manager;
pub mod transport;
// re-export

View file

@ -8,6 +8,7 @@ mod meta;
mod prompt;
mod resource;
mod serde_impl;
mod task;
mod tool;
pub use annotated::*;
pub use capabilities::*;
@ -19,6 +20,7 @@ pub use prompt::*;
pub use resource::*;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::Value;
pub use task::*;
pub use tool::*;
/// A JSON object type alias for convenient handling of JSON data.
@ -1705,6 +1707,8 @@ pub struct CallToolRequestParam {
/// Arguments to pass to the tool (must match the tool's input schema)
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<JsonObject>,
#[serde(skip_serializing_if = "Option::is_none")]
pub task: Option<JsonObject>,
}
/// Request to call a specific tool
@ -1743,6 +1747,61 @@ pub struct GetPromptResult {
pub messages: Vec<PromptMessage>,
}
// =============================================================================
// TASK MANAGEMENT
// =============================================================================
const_string!(GetTaskInfoMethod = "tasks/get");
pub type GetTaskInfoRequest = Request<GetTaskInfoMethod, GetTaskInfoParam>;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct GetTaskInfoParam {
pub task_id: String,
}
const_string!(ListTasksMethod = "tasks/list");
pub type ListTasksRequest = RequestOptionalParam<ListTasksMethod, PaginatedRequestParam>;
const_string!(GetTaskResultMethod = "tasks/result");
pub type GetTaskResultRequest = Request<GetTaskResultMethod, GetTaskResultParam>;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct GetTaskResultParam {
pub task_id: String,
}
const_string!(CancelTaskMethod = "tasks/cancel");
pub type CancelTaskRequest = Request<CancelTaskMethod, CancelTaskParam>;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CancelTaskParam {
pub task_id: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct GetTaskInfoResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub task: Option<crate::model::Task>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ListTasksResult {
pub tasks: Vec<crate::model::Task>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total: Option<u64>,
}
// =============================================================================
// MESSAGE TYPE UNIONS
// =============================================================================
@ -1810,7 +1869,11 @@ ts_union!(
| UnsubscribeRequest
| CallToolRequest
| ListToolsRequest
| CustomRequest;
| CustomRequest
| GetTaskInfoRequest
| ListTasksRequest
| GetTaskResultRequest
| CancelTaskRequest;
);
impl ClientRequest {
@ -1830,6 +1893,10 @@ impl ClientRequest {
ClientRequest::CallToolRequest(r) => r.method.as_str(),
ClientRequest::ListToolsRequest(r) => r.method.as_str(),
ClientRequest::CustomRequest(r) => r.method.as_str(),
ClientRequest::GetTaskInfoRequest(r) => r.method.as_str(),
ClientRequest::ListTasksRequest(r) => r.method.as_str(),
ClientRequest::GetTaskResultRequest(r) => r.method.as_str(),
ClientRequest::CancelTaskRequest(r) => r.method.as_str(),
}
}
}
@ -1895,6 +1962,10 @@ ts_union!(
| CreateElicitationResult
| EmptyResult
| CustomResult
| CreateTaskResult
| ListTasksResult
| GetTaskInfoResult
| TaskResult
;
);

View file

@ -40,6 +40,25 @@ pub struct RootsCapabilities {
pub list_changed: Option<bool>,
}
/// Task capability negotiation for SEP-1686.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct TasksCapability {
/// Map of request category (e.g. "tools.call") to a boolean indicating support.
#[serde(skip_serializing_if = "Option::is_none")]
pub requests: Option<TaskRequestMap>,
/// Whether the receiver supports `tasks/list`.
#[serde(skip_serializing_if = "Option::is_none")]
pub list: Option<bool>,
/// Whether the receiver supports `tasks/cancel`.
#[serde(skip_serializing_if = "Option::is_none")]
pub cancel: Option<bool>,
}
/// A convenience alias for describing per-request task support.
pub type TaskRequestMap = BTreeMap<String, bool>;
/// Capability for handling elicitation requests from servers.
///
/// Elicitation allows servers to request interactive input from users during tool execution.
@ -78,6 +97,8 @@ pub struct ClientCapabilities {
/// Capability to handle elicitation requests from servers for interactive user input
#[serde(skip_serializing_if = "Option::is_none")]
pub elicitation: Option<ElicitationCapability>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tasks: Option<TasksCapability>,
}
///
@ -109,6 +130,8 @@ pub struct ServerCapabilities {
pub resources: Option<ResourcesCapability>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<ToolsCapability>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tasks: Option<TasksCapability>,
}
macro_rules! builder {
@ -223,12 +246,13 @@ builder! {
completions: JsonObject,
prompts: PromptsCapability,
resources: ResourcesCapability,
tools: ToolsCapability
tools: ToolsCapability,
tasks: TasksCapability
}
}
impl<const E: bool, const L: bool, const C: bool, const P: bool, const R: bool>
ServerCapabilitiesBuilder<ServerCapabilitiesBuilderState<E, L, C, P, R, true>>
impl<const E: bool, const L: bool, const C: bool, const P: bool, const R: bool, const TASKS: bool>
ServerCapabilitiesBuilder<ServerCapabilitiesBuilderState<E, L, C, P, R, true, TASKS>>
{
pub fn enable_tool_list_changed(mut self) -> Self {
if let Some(c) = self.tools.as_mut() {
@ -238,8 +262,8 @@ impl<const E: bool, const L: bool, const C: bool, const P: bool, const R: bool>
}
}
impl<const E: bool, const L: bool, const C: bool, const R: bool, const T: bool>
ServerCapabilitiesBuilder<ServerCapabilitiesBuilderState<E, L, C, true, R, T>>
impl<const E: bool, const L: bool, const C: bool, const R: bool, const T: bool, const TASKS: bool>
ServerCapabilitiesBuilder<ServerCapabilitiesBuilderState<E, L, C, true, R, T, TASKS>>
{
pub fn enable_prompts_list_changed(mut self) -> Self {
if let Some(c) = self.prompts.as_mut() {
@ -249,8 +273,8 @@ impl<const E: bool, const L: bool, const C: bool, const R: bool, const T: bool>
}
}
impl<const E: bool, const L: bool, const C: bool, const P: bool, const T: bool>
ServerCapabilitiesBuilder<ServerCapabilitiesBuilderState<E, L, C, P, true, T>>
impl<const E: bool, const L: bool, const C: bool, const P: bool, const T: bool, const TASKS: bool>
ServerCapabilitiesBuilder<ServerCapabilitiesBuilderState<E, L, C, P, true, T, TASKS>>
{
pub fn enable_resources_list_changed(mut self) -> Self {
if let Some(c) = self.resources.as_mut() {
@ -273,11 +297,12 @@ builder! {
roots: RootsCapabilities,
sampling: JsonObject,
elicitation: ElicitationCapability,
tasks: TasksCapability,
}
}
impl<const E: bool, const S: bool>
ClientCapabilitiesBuilder<ClientCapabilitiesBuilderState<E, true, S>>
impl<const E: bool, const S: bool, const EL: bool, const TASKS: bool>
ClientCapabilitiesBuilder<ClientCapabilitiesBuilderState<E, true, S, EL, TASKS>>
{
pub fn enable_roots_list_changed(mut self) -> Self {
if let Some(c) = self.roots.as_mut() {
@ -288,8 +313,8 @@ impl<const E: bool, const S: bool>
}
#[cfg(feature = "elicitation")]
impl<const E: bool, const R: bool, const S: bool>
ClientCapabilitiesBuilder<ClientCapabilitiesBuilderState<E, R, S, true>>
impl<const E: bool, const R: bool, const S: bool, const TASKS: bool>
ClientCapabilitiesBuilder<ClientCapabilitiesBuilderState<E, R, S, true, TASKS>>
{
/// Enable JSON Schema validation for elicitation responses.
/// When enabled, the client will validate user input against the requested_schema

View file

@ -107,6 +107,10 @@ variant_extension! {
CallToolRequest
ListToolsRequest
CustomRequest
GetTaskInfoRequest
ListTasksRequest
GetTaskResultRequest
CancelTaskRequest
}
}

View file

@ -0,0 +1,79 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Canonical task lifecycle status as defined by SEP-1686.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum TaskStatus {
/// The receiver accepted the request and is currently working on it.
#[default]
Working,
/// The receiver requires additional input before work can continue.
InputRequired,
/// The underlying operation completed successfully and the result is ready.
Completed,
/// The underlying operation failed and will not continue.
Failed,
/// The task was cancelled and will not continue processing.
Cancelled,
}
/// Final result for a succeeded task (returned from `tasks/result`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct TaskResult {
/// MIME type or custom content-type identifier.
pub content_type: String,
/// The actual result payload, matching the underlying request's schema.
pub value: Value,
/// Optional short summary for UI surfaces.
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
/// Primary Task object that surfaces metadata during the task lifecycle.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Task {
/// Unique task identifier generated by the receiver.
pub task_id: String,
/// Current lifecycle status (see [`TaskStatus`]).
pub status: TaskStatus,
/// Optional human-readable status message for UI surfaces.
#[serde(skip_serializing_if = "Option::is_none")]
pub status_message: Option<String>,
/// ISO-8601 creation timestamp.
pub created_at: String,
/// ISO-8601 timestamp for the most recent status change.
#[serde(skip_serializing_if = "Option::is_none")]
pub last_updated_at: Option<String>,
/// Retention window in milliseconds that the receiver agreed to honor.
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl: Option<u64>,
/// Suggested polling interval (milliseconds).
#[serde(skip_serializing_if = "Option::is_none")]
pub poll_interval: Option<u64>,
}
/// Wrapper returned by task-augmented requests (CreateTaskResult in SEP-1686).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CreateTaskResult {
pub task: Task,
}
/// Paginated list of tasks
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct TaskList {
pub tasks: Vec<Task>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total: Option<u64>,
}

View file

@ -0,0 +1,294 @@
use std::{any::Any, collections::HashMap, pin::Pin};
use futures::Future;
use tokio::{
sync::mpsc,
time::{Duration, timeout},
};
use crate::{
RoleServer,
error::{ErrorData as McpError, RmcpError as Error},
model::{CallToolResult, ClientRequest},
service::RequestContext,
};
/// Boxed future that represents an asynchronous operation managed by the processor.
pub type OperationFuture =
Pin<Box<dyn Future<Output = Result<Box<dyn OperationResultTransport>, Error>> + Send>>;
/// Describes metadata associated with an enqueued task.
#[derive(Debug, Clone)]
pub struct OperationDescriptor {
pub operation_id: String,
pub name: String,
pub client_request: Option<ClientRequest>,
pub context: Option<RequestContext<RoleServer>>,
pub ttl: Option<u64>,
}
impl OperationDescriptor {
pub fn new(operation_id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
operation_id: operation_id.into(),
name: name.into(),
client_request: None,
context: None,
ttl: None,
}
}
pub fn with_client_request(mut self, request: ClientRequest) -> Self {
self.client_request = Some(request);
self
}
pub fn with_context(mut self, context: RequestContext<RoleServer>) -> Self {
self.context = Some(context);
self
}
pub fn with_ttl(mut self, ttl: u64) -> Self {
self.ttl = Some(ttl);
self
}
}
/// Operation message describing a unit of asynchronous work.
pub struct OperationMessage {
pub descriptor: OperationDescriptor,
pub future: OperationFuture,
}
impl OperationMessage {
pub fn new(descriptor: OperationDescriptor, future: OperationFuture) -> Self {
Self { descriptor, future }
}
}
/// Trait for operation result transport
pub trait OperationResultTransport: Send + Sync + 'static {
fn operation_id(&self) -> &String;
fn as_any(&self) -> &dyn std::any::Any;
}
// ===== Operation Processor =====
pub const DEFAULT_TASK_TIMEOUT_SECS: u64 = 300; // 5 minutes
/// Operation processor that coordinates extractors and handlers
pub struct OperationProcessor {
/// Currently running tasks keyed by id
running_tasks: HashMap<String, RunningTask>,
/// Completed results waiting to be collected
completed_results: Vec<TaskResult>,
task_result_receiver: Option<mpsc::UnboundedReceiver<TaskResult>>,
task_result_sender: mpsc::UnboundedSender<TaskResult>,
}
struct RunningTask {
task_handle: tokio::task::JoinHandle<()>,
started_at: std::time::Instant,
timeout: Option<u64>,
descriptor: OperationDescriptor,
}
pub struct TaskResult {
pub descriptor: OperationDescriptor,
pub result: Result<Box<dyn OperationResultTransport>, Error>,
}
/// Helper to generate an ISO 8601 timestamp for task metadata.
pub fn current_timestamp() -> String {
chrono::Utc::now().to_rfc3339()
}
/// Result transport for tool calls executed as tasks.
pub struct ToolCallTaskResult {
id: String,
pub result: Result<CallToolResult, McpError>,
}
impl ToolCallTaskResult {
pub fn new(id: impl Into<String>, result: Result<CallToolResult, McpError>) -> Self {
Self {
id: id.into(),
result,
}
}
}
impl OperationResultTransport for ToolCallTaskResult {
fn operation_id(&self) -> &String {
&self.id
}
fn as_any(&self) -> &dyn Any {
self
}
}
impl Default for OperationProcessor {
fn default() -> Self {
Self::new()
}
}
impl OperationProcessor {
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: Some(task_result_receiver),
task_result_sender,
}
}
/// 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)
{
return Err(Error::TaskError(format!(
"Operation with id {} is already running",
message.descriptor.operation_id
)));
}
self.spawn_async_task(message);
Ok(())
}
fn spawn_async_task(&mut self, message: OperationMessage) {
let OperationMessage { descriptor, future } = message;
let task_id = descriptor.operation_id.clone();
let timeout_secs = descriptor.ttl.or(Some(DEFAULT_TASK_TIMEOUT_SECS));
let sender = self.task_result_sender.clone();
let descriptor_for_result = descriptor.clone();
let timed_future = async move {
if let Some(secs) = timeout_secs {
match timeout(Duration::from_secs(secs), future).await {
Ok(result) => result,
Err(_) => Err(Error::TaskError("Operation timed out".to_string())),
}
} else {
future.await
}
};
let handle = tokio::spawn(async move {
let result = timed_future.await;
let task_result = TaskResult {
descriptor: descriptor_for_result,
result,
};
let _ = sender.send(task_result);
});
let running_task = RunningTask {
task_handle: handle,
started_at: std::time::Instant::now(),
timeout: timeout_secs,
descriptor,
};
self.running_tasks.insert(task_id, running_task);
}
/// Collect completed results from running tasks and remove them from the running tasks map.
pub fn collect_completed_results(&mut self) -> Vec<TaskResult> {
if let Some(receiver) = &mut self.task_result_receiver {
while let Ok(result) = receiver.try_recv() {
self.running_tasks.remove(&result.descriptor.operation_id);
self.completed_results.push(result);
}
}
std::mem::take(&mut self.completed_results)
}
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();
for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
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);
}
}
}
/// Get the number of running tasks.
pub fn running_task_count(&self) -> usize {
self.running_tasks.len()
}
/// Cancel all running tasks.
pub fn cancel_all_tasks(&mut self) {
for (_, task) in self.running_tasks.drain() {
task.task_handle.abort();
}
self.completed_results.clear();
}
/// List running task ids.
pub fn list_running(&self) -> Vec<String> {
self.running_tasks.keys().cloned().collect()
}
/// Note: collectors should call collect_completed_results; this provides a snapshot of queued results.
pub fn peek_completed(&self) -> &[TaskResult] {
&self.completed_results
}
/// Fetch the metadata for a running or recently completed task.
pub fn task_descriptor(&self, task_id: &str) -> Option<&OperationDescriptor> {
if let Some(task) = self.running_tasks.get(task_id) {
return Some(&task.descriptor);
}
self.completed_results
.iter()
.rev()
.find(|result| result.descriptor.operation_id == task_id)
.map(|result| &result.descriptor)
}
/// Attempt to cancel a running task.
pub fn cancel_task(&mut self, task_id: &str) -> bool {
if let Some(task) = self.running_tasks.remove(task_id) {
task.task_handle.abort();
// Insert a cancelled result so callers can observe the terminal state.
let cancel_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation cancelled".to_string())),
};
self.completed_results.push(cancel_result);
return true;
}
false
}
/// Retrieve a completed task result if available.
pub fn take_completed_result(&mut self, task_id: &str) -> Option<TaskResult> {
if let Some(position) = self
.completed_results
.iter()
.position(|result| result.descriptor.operation_id == task_id)
{
Some(self.completed_results.remove(position))
} else {
None
}
}
}

View file

@ -384,6 +384,7 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
"process initialized notification response",
))?;
let _ = initialized_notification.responder.send(Ok(()));
#[allow(clippy::large_enum_variant)]
enum Event<W: Worker, E: std::error::Error + Send + Sync + 'static> {
ClientMessage(WorkerSendRequest<W>),
ServerMessage(ServerJsonRpcMessage),

View file

@ -202,12 +202,35 @@
"name": {
"description": "The name of the tool to call",
"type": "string"
},
"task": {
"type": [
"object",
"null"
],
"additionalProperties": true
}
},
"required": [
"name"
]
},
"CancelTaskMethod": {
"type": "string",
"format": "const",
"const": "tasks/cancel"
},
"CancelTaskParam": {
"type": "object",
"properties": {
"taskId": {
"type": "string"
}
},
"required": [
"taskId"
]
},
"CancelledNotificationMethod": {
"type": "string",
"format": "const",
@ -272,6 +295,16 @@
"null"
],
"additionalProperties": true
},
"tasks": {
"anyOf": [
{
"$ref": "#/definitions/TasksCapability"
},
{
"type": "null"
}
]
}
}
},
@ -520,6 +553,38 @@
"name"
]
},
"GetTaskInfoMethod": {
"type": "string",
"format": "const",
"const": "tasks/get"
},
"GetTaskInfoParam": {
"type": "object",
"properties": {
"taskId": {
"type": "string"
}
},
"required": [
"taskId"
]
},
"GetTaskResultMethod": {
"type": "string",
"format": "const",
"const": "tasks/result"
},
"GetTaskResultParam": {
"type": "object",
"properties": {
"taskId": {
"type": "string"
}
},
"required": [
"taskId"
]
},
"Icon": {
"description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)",
"type": "object",
@ -730,6 +795,18 @@
},
{
"$ref": "#/definitions/CustomRequest"
},
{
"$ref": "#/definitions/Request9"
},
{
"$ref": "#/definitions/RequestOptionalParam5"
},
{
"$ref": "#/definitions/Request10"
},
{
"$ref": "#/definitions/Request11"
}
],
"required": [
@ -790,6 +867,11 @@
"roots"
]
},
"ListTasksMethod": {
"type": "string",
"format": "const",
"const": "tasks/list"
},
"ListToolsRequestMethod": {
"type": "string",
"format": "const",
@ -1168,6 +1250,38 @@
"params"
]
},
"Request10": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/GetTaskResultMethod"
},
"params": {
"$ref": "#/definitions/GetTaskResultParam"
}
},
"required": [
"method",
"params"
]
},
"Request11": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/CancelTaskMethod"
},
"params": {
"$ref": "#/definitions/CancelTaskParam"
}
},
"required": [
"method",
"params"
]
},
"Request2": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
@ -1280,6 +1394,22 @@
"params"
]
},
"Request9": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/GetTaskInfoMethod"
},
"params": {
"$ref": "#/definitions/GetTaskInfoParam"
}
},
"required": [
"method",
"params"
]
},
"RequestNoParam": {
"type": "object",
"properties": {
@ -1375,6 +1505,27 @@
"method"
]
},
"RequestOptionalParam5": {
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/ListTasksMethod"
},
"params": {
"anyOf": [
{
"$ref": "#/definitions/PaginatedRequestParam"
},
{
"type": "null"
}
]
}
},
"required": [
"method"
]
},
"ResourceContents": {
"anyOf": [
{
@ -1534,6 +1685,36 @@
"uri"
]
},
"TasksCapability": {
"description": "Task capability negotiation for SEP-1686.",
"type": "object",
"properties": {
"cancel": {
"description": "Whether the receiver supports `tasks/cancel`.",
"type": [
"boolean",
"null"
]
},
"list": {
"description": "Whether the receiver supports `tasks/list`.",
"type": [
"boolean",
"null"
]
},
"requests": {
"description": "Map of request category (e.g. \"tools.call\") to a boolean indicating support.",
"type": [
"object",
"null"
],
"additionalProperties": {
"type": "boolean"
}
}
}
},
"UnsubscribeRequestMethod": {
"type": "string",
"format": "const",

View file

@ -202,12 +202,35 @@
"name": {
"description": "The name of the tool to call",
"type": "string"
},
"task": {
"type": [
"object",
"null"
],
"additionalProperties": true
}
},
"required": [
"name"
]
},
"CancelTaskMethod": {
"type": "string",
"format": "const",
"const": "tasks/cancel"
},
"CancelTaskParam": {
"type": "object",
"properties": {
"taskId": {
"type": "string"
}
},
"required": [
"taskId"
]
},
"CancelledNotificationMethod": {
"type": "string",
"format": "const",
@ -272,6 +295,16 @@
"null"
],
"additionalProperties": true
},
"tasks": {
"anyOf": [
{
"$ref": "#/definitions/TasksCapability"
},
{
"type": "null"
}
]
}
}
},
@ -520,6 +553,38 @@
"name"
]
},
"GetTaskInfoMethod": {
"type": "string",
"format": "const",
"const": "tasks/get"
},
"GetTaskInfoParam": {
"type": "object",
"properties": {
"taskId": {
"type": "string"
}
},
"required": [
"taskId"
]
},
"GetTaskResultMethod": {
"type": "string",
"format": "const",
"const": "tasks/result"
},
"GetTaskResultParam": {
"type": "object",
"properties": {
"taskId": {
"type": "string"
}
},
"required": [
"taskId"
]
},
"Icon": {
"description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)",
"type": "object",
@ -730,6 +795,18 @@
},
{
"$ref": "#/definitions/CustomRequest"
},
{
"$ref": "#/definitions/Request9"
},
{
"$ref": "#/definitions/RequestOptionalParam5"
},
{
"$ref": "#/definitions/Request10"
},
{
"$ref": "#/definitions/Request11"
}
],
"required": [
@ -790,6 +867,11 @@
"roots"
]
},
"ListTasksMethod": {
"type": "string",
"format": "const",
"const": "tasks/list"
},
"ListToolsRequestMethod": {
"type": "string",
"format": "const",
@ -1168,6 +1250,38 @@
"params"
]
},
"Request10": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/GetTaskResultMethod"
},
"params": {
"$ref": "#/definitions/GetTaskResultParam"
}
},
"required": [
"method",
"params"
]
},
"Request11": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/CancelTaskMethod"
},
"params": {
"$ref": "#/definitions/CancelTaskParam"
}
},
"required": [
"method",
"params"
]
},
"Request2": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
@ -1280,6 +1394,22 @@
"params"
]
},
"Request9": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/GetTaskInfoMethod"
},
"params": {
"$ref": "#/definitions/GetTaskInfoParam"
}
},
"required": [
"method",
"params"
]
},
"RequestNoParam": {
"type": "object",
"properties": {
@ -1375,6 +1505,27 @@
"method"
]
},
"RequestOptionalParam5": {
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/ListTasksMethod"
},
"params": {
"anyOf": [
{
"$ref": "#/definitions/PaginatedRequestParam"
},
{
"type": "null"
}
]
}
},
"required": [
"method"
]
},
"ResourceContents": {
"anyOf": [
{
@ -1534,6 +1685,36 @@
"uri"
]
},
"TasksCapability": {
"description": "Task capability negotiation for SEP-1686.",
"type": "object",
"properties": {
"cancel": {
"description": "Whether the receiver supports `tasks/cancel`.",
"type": [
"boolean",
"null"
]
},
"list": {
"description": "Whether the receiver supports `tasks/list`.",
"type": [
"boolean",
"null"
]
},
"requests": {
"description": "Map of request category (e.g. \"tools.call\") to a boolean indicating support.",
"type": [
"object",
"null"
],
"additionalProperties": {
"type": "boolean"
}
}
}
},
"UnsubscribeRequestMethod": {
"type": "string",
"format": "const",

View file

@ -603,6 +603,18 @@
"maxTokens"
]
},
"CreateTaskResult": {
"description": "Wrapper returned by task-augmented requests (CreateTaskResult in SEP-1686).",
"type": "object",
"properties": {
"task": {
"$ref": "#/definitions/Task"
}
},
"required": [
"task"
]
},
"CustomNotification": {
"description": "A catch-all notification either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.",
"type": "object",
@ -810,6 +822,21 @@
"messages"
]
},
"GetTaskInfoResult": {
"type": "object",
"properties": {
"task": {
"anyOf": [
{
"$ref": "#/definitions/Task"
},
{
"type": "null"
}
]
}
}
},
"Icon": {
"description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)",
"type": "object",
@ -1168,6 +1195,34 @@
"format": "const",
"const": "roots/list"
},
"ListTasksResult": {
"type": "object",
"properties": {
"nextCursor": {
"type": [
"string",
"null"
]
},
"tasks": {
"type": "array",
"items": {
"$ref": "#/definitions/Task"
}
},
"total": {
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 0
}
},
"required": [
"tasks"
]
},
"ListToolsResult": {
"type": "object",
"properties": {
@ -2255,6 +2310,16 @@
}
]
},
"tasks": {
"anyOf": [
{
"$ref": "#/definitions/TasksCapability"
},
{
"type": "null"
}
]
},
"tools": {
"anyOf": [
{
@ -2304,6 +2369,18 @@
},
{
"$ref": "#/definitions/CustomResult"
},
{
"$ref": "#/definitions/CreateTaskResult"
},
{
"$ref": "#/definitions/ListTasksResult"
},
{
"$ref": "#/definitions/GetTaskInfoResult"
},
{
"$ref": "#/definitions/TaskResult"
}
]
},
@ -2397,6 +2474,149 @@
"format": "const",
"const": "string"
},
"Task": {
"description": "Primary Task object that surfaces metadata during the task lifecycle.",
"type": "object",
"properties": {
"createdAt": {
"description": "ISO-8601 creation timestamp.",
"type": "string"
},
"lastUpdatedAt": {
"description": "ISO-8601 timestamp for the most recent status change.",
"type": [
"string",
"null"
]
},
"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.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 0
}
},
"required": [
"taskId",
"status",
"createdAt"
]
},
"TaskResult": {
"description": "Final result for a succeeded task (returned from `tasks/result`).",
"type": "object",
"properties": {
"contentType": {
"description": "MIME type or custom content-type identifier.",
"type": "string"
},
"summary": {
"description": "Optional short summary for UI surfaces.",
"type": [
"string",
"null"
]
},
"value": {
"description": "The actual result payload, matching the underlying request's schema."
}
},
"required": [
"contentType",
"value"
]
},
"TaskStatus": {
"description": "Canonical task lifecycle status as defined by SEP-1686.",
"oneOf": [
{
"description": "The receiver accepted the request and is currently working on it.",
"type": "string",
"const": "working"
},
{
"description": "The receiver requires additional input before work can continue.",
"type": "string",
"const": "input_required"
},
{
"description": "The underlying operation completed successfully and the result is ready.",
"type": "string",
"const": "completed"
},
{
"description": "The underlying operation failed and will not continue.",
"type": "string",
"const": "failed"
},
{
"description": "The task was cancelled and will not continue processing.",
"type": "string",
"const": "cancelled"
}
]
},
"TasksCapability": {
"description": "Task capability negotiation for SEP-1686.",
"type": "object",
"properties": {
"cancel": {
"description": "Whether the receiver supports `tasks/cancel`.",
"type": [
"boolean",
"null"
]
},
"list": {
"description": "Whether the receiver supports `tasks/list`.",
"type": [
"boolean",
"null"
]
},
"requests": {
"description": "Map of request category (e.g. \"tools.call\") to a boolean indicating support.",
"type": [
"object",
"null"
],
"additionalProperties": {
"type": "boolean"
}
}
}
},
"Tool": {
"description": "A tool that can be used by a model.",
"type": "object",

View file

@ -603,6 +603,18 @@
"maxTokens"
]
},
"CreateTaskResult": {
"description": "Wrapper returned by task-augmented requests (CreateTaskResult in SEP-1686).",
"type": "object",
"properties": {
"task": {
"$ref": "#/definitions/Task"
}
},
"required": [
"task"
]
},
"CustomNotification": {
"description": "A catch-all notification either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.",
"type": "object",
@ -810,6 +822,21 @@
"messages"
]
},
"GetTaskInfoResult": {
"type": "object",
"properties": {
"task": {
"anyOf": [
{
"$ref": "#/definitions/Task"
},
{
"type": "null"
}
]
}
}
},
"Icon": {
"description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)",
"type": "object",
@ -1168,6 +1195,34 @@
"format": "const",
"const": "roots/list"
},
"ListTasksResult": {
"type": "object",
"properties": {
"nextCursor": {
"type": [
"string",
"null"
]
},
"tasks": {
"type": "array",
"items": {
"$ref": "#/definitions/Task"
}
},
"total": {
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 0
}
},
"required": [
"tasks"
]
},
"ListToolsResult": {
"type": "object",
"properties": {
@ -2255,6 +2310,16 @@
}
]
},
"tasks": {
"anyOf": [
{
"$ref": "#/definitions/TasksCapability"
},
{
"type": "null"
}
]
},
"tools": {
"anyOf": [
{
@ -2304,6 +2369,18 @@
},
{
"$ref": "#/definitions/CustomResult"
},
{
"$ref": "#/definitions/CreateTaskResult"
},
{
"$ref": "#/definitions/ListTasksResult"
},
{
"$ref": "#/definitions/GetTaskInfoResult"
},
{
"$ref": "#/definitions/TaskResult"
}
]
},
@ -2397,6 +2474,149 @@
"format": "const",
"const": "string"
},
"Task": {
"description": "Primary Task object that surfaces metadata during the task lifecycle.",
"type": "object",
"properties": {
"createdAt": {
"description": "ISO-8601 creation timestamp.",
"type": "string"
},
"lastUpdatedAt": {
"description": "ISO-8601 timestamp for the most recent status change.",
"type": [
"string",
"null"
]
},
"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.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 0
}
},
"required": [
"taskId",
"status",
"createdAt"
]
},
"TaskResult": {
"description": "Final result for a succeeded task (returned from `tasks/result`).",
"type": "object",
"properties": {
"contentType": {
"description": "MIME type or custom content-type identifier.",
"type": "string"
},
"summary": {
"description": "Optional short summary for UI surfaces.",
"type": [
"string",
"null"
]
},
"value": {
"description": "The actual result payload, matching the underlying request's schema."
}
},
"required": [
"contentType",
"value"
]
},
"TaskStatus": {
"description": "Canonical task lifecycle status as defined by SEP-1686.",
"oneOf": [
{
"description": "The receiver accepted the request and is currently working on it.",
"type": "string",
"const": "working"
},
{
"description": "The receiver requires additional input before work can continue.",
"type": "string",
"const": "input_required"
},
{
"description": "The underlying operation completed successfully and the result is ready.",
"type": "string",
"const": "completed"
},
{
"description": "The underlying operation failed and will not continue.",
"type": "string",
"const": "failed"
},
{
"description": "The task was cancelled and will not continue processing.",
"type": "string",
"const": "cancelled"
}
]
},
"TasksCapability": {
"description": "Task capability negotiation for SEP-1686.",
"type": "object",
"properties": {
"cancel": {
"description": "Whether the receiver supports `tasks/cancel`.",
"type": [
"boolean",
"null"
]
},
"list": {
"description": "Whether the receiver supports `tasks/list`.",
"type": [
"boolean",
"null"
]
},
"requests": {
"description": "Map of request category (e.g. \"tools.call\") to a boolean indicating support.",
"type": [
"object",
"null"
],
"additionalProperties": {
"type": "boolean"
}
}
}
},
"Tool": {
"description": "A tool that can be used by a model.",
"type": "object",

View file

@ -110,6 +110,7 @@ async fn test_progress_subscriber() -> anyhow::Result<()> {
ClientRequest::CallToolRequest(Request::new(CallToolRequestParam {
name: "some_progress".into(),
arguments: None,
task: None,
})),
PeerRequestOptions::no_options(),
)

View file

@ -0,0 +1,77 @@
use std::{any::Any, time::Duration};
use rmcp::task_manager::{
OperationDescriptor, OperationMessage, OperationProcessor, OperationResultTransport,
};
struct DummyTransport {
id: String,
value: u32,
}
impl OperationResultTransport for DummyTransport {
fn operation_id(&self) -> &String {
&self.id
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[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<dyn OperationResultTransport>)
});
processor
.submit_operation(OperationMessage::new(descriptor, future))
.expect("submit operation");
tokio::time::sleep(Duration::from_millis(30)).await;
let results = processor.collect_completed_results();
assert_eq!(results.len(), 1);
let payload = results[0]
.result
.as_ref()
.unwrap()
.as_any()
.downcast_ref::<DummyTransport>()
.unwrap();
assert_eq!(payload.value, 42);
}
#[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<dyn OperationResultTransport>)
});
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<dyn OperationResultTransport>)
});
let err = processor
.submit_operation(OperationMessage::new(descriptor_dup, future_dup))
.expect_err("duplicate should fail");
assert!(format!("{err}").contains("already running"));
}

View file

@ -320,6 +320,7 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> {
.unwrap()
.clone(),
),
task: None,
})
.await?;
@ -348,6 +349,7 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> {
.unwrap()
.clone(),
),
task: None,
})
.await?;