Skip to main content

rmcp/
task_manager.rs

1//! Server-side runtime for the MCP Tasks extension (SEP-2663,
2//! `io.modelcontextprotocol/tasks`).
3//!
4//! [`TaskManager`] owns the durable state for tasks a server has materialized
5//! in response to task-eligible requests (currently `tools/call`). It:
6//!
7//! - spawns the underlying operation and tracks its lifecycle as a
8//!   [`DetailedTask`] (`working` → terminal, optionally via `input_required`),
9//! - answers `tasks/get` with the current state (including in-flight
10//!   `inputRequests` and terminal `result`/`error` payloads),
11//! - accepts `tasks/update` `inputResponses` and routes them to the running
12//!   operation (ignoring unknown or already-answered keys per spec),
13//! - handles cooperative `tasks/cancel`,
14//! - enforces TTL-based expiry (`ttl_ms`), marking overdue tasks `failed`.
15//!
16//! Tasks are only durably observable once [`TaskManager::spawn`] returns,
17//! satisfying the spec requirement that a server not return `CreateTaskResult`
18//! before `tasks/get` for that id would resolve.
19
20use std::{
21    collections::HashMap,
22    pin::Pin,
23    sync::{Arc, Mutex},
24    time::Instant,
25};
26
27use futures::Future;
28use tokio::sync::oneshot;
29
30use crate::{
31    error::ErrorData as McpError,
32    model::{
33        CallToolResult, DetailedTask, InputRequest, InputRequests, JsonObject, Task, TaskPayload,
34        TaskStatus,
35    },
36};
37
38/// Default TTL (5 minutes, in milliseconds) applied when none is specified.
39pub const DEFAULT_TASK_TTL_MS: u64 = 300_000;
40
41/// Default suggested polling interval, in milliseconds.
42pub const DEFAULT_POLL_INTERVAL_MS: u64 = 1_000;
43
44/// Helper to generate an ISO 8601 timestamp for task metadata.
45pub fn current_timestamp() -> String {
46    chrono::Utc::now().to_rfc3339()
47}
48
49/// Handle passed to a running task operation, allowing it to surface
50/// server-to-client requests (elicitation, sampling, roots) mid-task and
51/// await the client's `tasks/update` response.
52#[derive(Clone)]
53pub struct TaskContext {
54    task_id: String,
55    inner: Arc<Mutex<TaskManagerInner>>,
56}
57
58impl TaskContext {
59    /// The id of the task this context belongs to.
60    pub fn task_id(&self) -> &str {
61        &self.task_id
62    }
63
64    /// Surface a server-to-client request under `key` and wait for the
65    /// client's response delivered via `tasks/update`.
66    ///
67    /// While at least one request is outstanding the task reports
68    /// `input_required` from `tasks/get`, with all outstanding requests in
69    /// `inputRequests`. Keys must be unique over the lifetime of the task;
70    /// reusing a key returns an error.
71    pub async fn request_input(
72        &self,
73        key: impl Into<String>,
74        request: InputRequest,
75    ) -> Result<serde_json::Value, TaskExit> {
76        let key = key.into();
77        let (tx, rx) = oneshot::channel();
78        {
79            let mut inner = self.inner.lock().expect("task manager lock poisoned");
80            let entry = inner.tasks.get_mut(&self.task_id).ok_or_else(|| {
81                TaskExit::Error(McpError::internal_error(
82                    "task no longer exists".to_string(),
83                    None,
84                ))
85            })?;
86            if !entry.used_input_keys.insert(key.clone()) {
87                return Err(TaskExit::Error(McpError::internal_error(
88                    format!("inputRequests key {key:?} was already used for this task"),
89                    None,
90                )));
91            }
92            entry.pending_inputs.insert(key.clone(), (request, tx));
93            entry.touch();
94        }
95        // The sender is dropped when `tasks/cancel` clears pending inputs.
96        rx.await.map_err(|_| TaskExit::Cancelled)
97    }
98
99    /// Update the task's human-readable status message.
100    pub fn set_status_message(&self, message: impl Into<String>) {
101        let mut inner = self.inner.lock().expect("task manager lock poisoned");
102        if let Some(entry) = inner.tasks.get_mut(&self.task_id) {
103            entry.task.status_message = Some(message.into());
104            entry.touch();
105        }
106    }
107
108    /// Returns `true` if `tasks/cancel` has been received for this task.
109    /// Cooperative: operations should check this and stop when set.
110    pub fn is_cancel_requested(&self) -> bool {
111        let inner = self.inner.lock().expect("task manager lock poisoned");
112        inner
113            .tasks
114            .get(&self.task_id)
115            .is_some_and(|e| e.cancel_requested)
116    }
117
118    /// Resolves once `tasks/cancel` has been received for this task (or
119    /// immediately, if it already has). Cooperative: pair with
120    /// `tokio::select!` around long-running work to implement a cancellation
121    /// exit path.
122    ///
123    /// An operation that stops in response should return
124    /// [`TaskExit::Cancelled`] so the task settles as `cancelled`. Returning
125    /// [`TaskExit::Error`] settles as `failed`, and finishing the work
126    /// anyway settles as `completed` — per SEP-2663 cancellation is
127    /// cooperative and a task may reach a non-`cancelled` terminal status.
128    pub async fn cancelled(&self) {
129        let mut rx = {
130            let inner = self.inner.lock().expect("task manager lock poisoned");
131            let Some(entry) = inner.tasks.get(&self.task_id) else {
132                return;
133            };
134            if entry.cancel_requested {
135                return;
136            }
137            entry.cancel_signal.subscribe()
138        };
139        // Wait until the watch flips to true; a closed channel means the
140        // manager dropped the entry, which also unblocks the operation.
141        while !*rx.borrow_and_update() {
142            if rx.changed().await.is_err() {
143                return;
144            }
145        }
146    }
147}
148
149/// How a task operation finished without producing a result.
150#[expect(
151    clippy::exhaustive_enums,
152    reason = "error variant for task exit may only be due to error or cancellation"
153)]
154#[derive(Debug)]
155pub enum TaskExit {
156    /// The operation is exiting in response to a cancellation request;
157    /// the task settles as terminal `cancelled`.
158    Cancelled,
159    /// A real failure; the task settles as terminal `failed` with the
160    /// error inlined, even after `tasks/cancel` was received.
161    Error(McpError),
162}
163
164impl From<McpError> for TaskExit {
165    fn from(error: McpError) -> Self {
166        TaskExit::Error(error)
167    }
168}
169
170/// Boxed future representing the async operation backing a task.
171pub type TaskFuture = Pin<Box<dyn Future<Output = Result<CallToolResult, TaskExit>> + Send>>;
172
173struct TaskEntry {
174    task: Task,
175    /// Terminal payload, if the task has finished.
176    terminal: Option<TaskPayload>,
177    /// When the task reached its terminal state; drives retention eviction.
178    terminal_at: Option<Instant>,
179    /// Outstanding input requests keyed by their unique identifier.
180    pending_inputs: HashMap<String, (InputRequest, oneshot::Sender<serde_json::Value>)>,
181    /// Every key ever used, to enforce uniqueness across the task lifetime.
182    used_input_keys: std::collections::HashSet<String>,
183    cancel_requested: bool,
184    /// Signals the running operation that cancellation was requested
185    /// (`true` once `tasks/cancel` arrives). Cooperative: the operation
186    /// decides whether and how to stop.
187    cancel_signal: tokio::sync::watch::Sender<bool>,
188    created: Instant,
189    join_handle: Option<tokio::task::JoinHandle<()>>,
190}
191
192impl TaskEntry {
193    fn touch(&mut self) {
194        self.task.last_updated_at = current_timestamp();
195    }
196
197    fn current_status(&self) -> TaskStatus {
198        match &self.terminal {
199            Some(payload) => payload.status(),
200            None if !self.pending_inputs.is_empty() => TaskStatus::InputRequired,
201            None => TaskStatus::Working,
202        }
203    }
204
205    fn detailed(&self) -> DetailedTask {
206        let payload = match &self.terminal {
207            Some(p) => p.clone(),
208            None if !self.pending_inputs.is_empty() => TaskPayload::InputRequired {
209                input_requests: self
210                    .pending_inputs
211                    .iter()
212                    .map(|(k, (req, _))| (k.clone(), req.clone()))
213                    .collect::<InputRequests>(),
214            },
215            None => TaskPayload::Working,
216        };
217        DetailedTask::new(self.task.clone(), payload)
218    }
219}
220
221#[derive(Default)]
222struct TaskManagerInner {
223    tasks: HashMap<String, TaskEntry>,
224}
225
226/// Options controlling a spawned task.
227#[derive(Debug, Clone)]
228#[non_exhaustive]
229pub struct TaskOptions {
230    /// TTL in milliseconds; `None` means unlimited retention.
231    pub ttl_ms: Option<u64>,
232    /// Suggested polling interval in milliseconds.
233    pub poll_interval_ms: Option<u64>,
234    /// Initial status message.
235    pub status_message: Option<String>,
236}
237
238impl Default for TaskOptions {
239    fn default() -> Self {
240        Self {
241            ttl_ms: Some(DEFAULT_TASK_TTL_MS),
242            poll_interval_ms: Some(DEFAULT_POLL_INTERVAL_MS),
243            status_message: None,
244        }
245    }
246}
247
248impl TaskOptions {
249    pub fn new() -> Self {
250        Self::default()
251    }
252
253    /// Set the TTL in milliseconds. `None` means unlimited retention.
254    pub fn with_ttl_ms(mut self, ttl_ms: impl Into<Option<u64>>) -> Self {
255        self.ttl_ms = ttl_ms.into();
256        self
257    }
258
259    /// Set the suggested polling interval in milliseconds.
260    pub fn with_poll_interval_ms(mut self, poll_interval_ms: u64) -> Self {
261        self.poll_interval_ms = Some(poll_interval_ms);
262        self
263    }
264
265    /// Set the initial status message.
266    pub fn with_status_message(mut self, message: impl Into<String>) -> Self {
267        self.status_message = Some(message.into());
268        self
269    }
270}
271
272/// Server-side task store and executor for the SEP-2663 Tasks extension.
273///
274/// Cheaply cloneable; all clones share the same state.
275///
276/// # Retention
277///
278/// Entries are swept opportunistically on every `spawn` / `get_task` /
279/// `update_task` / `cancel_task` / `running_task_count` call: non-terminal
280/// tasks whose `ttl_ms` has elapsed are marked `failed` (their operation is
281/// aborted), and terminal tasks are evicted after being retained for one
282/// further `ttl_ms` window past their terminal transition so pollers can
283/// observe the final state.
284///
285/// Note that the retention window intentionally extends past the
286/// creation-based lifetime that `ttl_ms` advertises on the wire: a task that
287/// runs to its TTL deadline is marked `failed` around `created + ttl_ms` and
288/// stays observable until roughly `created + 2 × ttl_ms`. This is compliant —
289/// SEP-2663 lets servers delete expired tasks *at any time* after the TTL,
290/// so retaining them longer as an observation grace period is a server-side
291/// policy choice, not a wire-contract change. Clients may treat the task as
292/// unusable after `createdAt + ttlMs` regardless.
293///
294/// Tasks with `ttl_ms: None` are retained for the lifetime of the manager
295/// (spec: unlimited retention) — bound task creation or call
296/// [`Self::shutdown`] yourself if you spawn such tasks in a long-lived
297/// server. There is no background sweeper; an idle manager holds its entries
298/// until the next call.
299#[derive(Clone, Default)]
300pub struct TaskManager {
301    inner: Arc<Mutex<TaskManagerInner>>,
302}
303
304impl TaskManager {
305    pub fn new() -> Self {
306        Self::default()
307    }
308
309    /// Spawn an operation as a task and return its seed [`Task`] state for a
310    /// `CreateTaskResult`. The task is durably observable via
311    /// [`Self::get_task`] before this method returns.
312    ///
313    /// `make_future` receives a [`TaskContext`] for mid-task input requests,
314    /// status messages, and cooperative cancellation checks.
315    pub fn spawn<F>(&self, options: TaskOptions, make_future: F) -> Task
316    where
317        F: FnOnce(TaskContext) -> TaskFuture,
318    {
319        let task_id = uuid::Uuid::new_v4().to_string();
320        let now = current_timestamp();
321        let mut task = Task::new(task_id.clone(), TaskStatus::Working, now.clone(), now);
322        task.ttl_ms = options.ttl_ms;
323        task.poll_interval_ms = options.poll_interval_ms;
324        task.status_message = options.status_message;
325
326        let entry = TaskEntry {
327            task: task.clone(),
328            terminal: None,
329            terminal_at: None,
330            pending_inputs: HashMap::new(),
331            used_input_keys: std::collections::HashSet::new(),
332            cancel_requested: false,
333            cancel_signal: tokio::sync::watch::channel(false).0,
334            created: Instant::now(),
335            join_handle: None,
336        };
337        {
338            let mut inner = self.inner.lock().expect("task manager lock poisoned");
339            // Opportunistic TTL sweep on every task creation, so terminal
340            // entries are evicted even if clients never poll again.
341            Self::sweep_expired(&mut inner);
342            inner.tasks.insert(task_id.clone(), entry);
343        }
344
345        let context = TaskContext {
346            task_id: task_id.clone(),
347            inner: self.inner.clone(),
348        };
349        let future = make_future(context);
350        let inner = self.inner.clone();
351        let id_for_task = task_id.clone();
352        let originating_request = crate::service::ORIGINATING_REQUEST
353            .try_with(|id| id.clone())
354            .ok();
355        let handle = tokio::spawn(async move {
356            let result = run_task_operation(originating_request, future).await;
357            let mut inner = inner.lock().expect("task manager lock poisoned");
358            if let Some(entry) = inner.tasks.get_mut(&id_for_task) {
359                if entry.terminal.is_none() {
360                    entry.terminal = Some(match result {
361                        Ok(result) => TaskPayload::Completed {
362                            result: result_to_object(&result),
363                        },
364                        Err(TaskExit::Cancelled) => TaskPayload::Cancelled,
365                        Err(TaskExit::Error(error)) => TaskPayload::Failed {
366                            error: error_to_object(&error),
367                        },
368                    });
369                    entry.terminal_at = Some(Instant::now());
370                    entry.pending_inputs.clear();
371                    entry.touch();
372                    entry.task.status = entry.current_status();
373                }
374                // The operation has finished; drop the JoinHandle so it is
375                // not retained for the rest of the retention window.
376                entry.join_handle = None;
377            }
378        });
379        match self
380            .inner
381            .lock()
382            .expect("task manager lock poisoned")
383            .tasks
384            .get_mut(&task_id)
385        {
386            // Only store the handle while the operation is still running: if
387            // it already settled, the completion path above ran first and a
388            // stored handle would never be cleared.
389            Some(entry) => {
390                if entry.terminal.is_none() {
391                    entry.join_handle = Some(handle);
392                }
393            }
394            // The entry is gone: shutdown() drained the map between the
395            // insert and here. Abort rather than leak a detached operation.
396            None => handle.abort(),
397        }
398        task
399    }
400
401    /// Handle `tasks/get`: return the current [`DetailedTask`] state.
402    pub fn get_task(&self, task_id: &str) -> Result<DetailedTask, McpError> {
403        let mut inner = self.inner.lock().expect("task manager lock poisoned");
404        Self::sweep_expired(&mut inner);
405        let entry = inner
406            .tasks
407            .get_mut(task_id)
408            .ok_or_else(|| unknown_task(task_id))?;
409        entry.task.status = entry.current_status();
410        Ok(entry.detailed())
411    }
412
413    /// Handle `tasks/update`: deliver `inputResponses` to the running
414    /// operation. Unknown, already-answered, or superseded keys are ignored
415    /// per spec; a partial set of responses is accepted.
416    pub fn update_task(
417        &self,
418        task_id: &str,
419        input_responses: impl IntoIterator<Item = (String, serde_json::Value)>,
420    ) -> Result<(), McpError> {
421        let mut inner = self.inner.lock().expect("task manager lock poisoned");
422        Self::sweep_expired(&mut inner);
423        let entry = inner
424            .tasks
425            .get_mut(task_id)
426            .ok_or_else(|| unknown_task(task_id))?;
427        for (key, value) in input_responses {
428            if let Some((_, tx)) = entry.pending_inputs.remove(&key) {
429                // Receiver dropped means the operation moved on; ignore.
430                let _ = tx.send(value);
431            }
432        }
433        entry.touch();
434        entry.task.status = entry.current_status();
435        Ok(())
436    }
437
438    /// Handle `tasks/cancel`: cooperative cancellation (SEP-2663).
439    ///
440    /// Records the cancellation *intent* and acknowledges immediately, but
441    /// does **not** abort the underlying future or force a terminal state.
442    /// The operation observes cancellation via
443    /// [`TaskContext::is_cancel_requested`] / [`TaskContext::cancelled`], or
444    /// via the error returned from a pending [`TaskContext::request_input`]
445    /// call (whose response channel is dropped here), and decides its own
446    /// terminal status:
447    ///
448    /// - stops with [`TaskExit::Cancelled`] → recorded as `cancelled`,
449    /// - stops with [`TaskExit::Error`] → recorded as `failed` with the
450    ///   error inlined (a real failure after a cancel request is not masked),
451    /// - finishes its work anyway → recorded as `completed` — per the spec,
452    ///   "the task may still reach a non-`cancelled` terminal status".
453    pub fn cancel_task(&self, task_id: &str) -> Result<(), McpError> {
454        let mut inner = self.inner.lock().expect("task manager lock poisoned");
455        Self::sweep_expired(&mut inner);
456        let entry = inner
457            .tasks
458            .get_mut(task_id)
459            .ok_or_else(|| unknown_task(task_id))?;
460        entry.cancel_requested = true;
461        let _ = entry.cancel_signal.send(true);
462        if entry.terminal.is_none() {
463            // Wake any operation parked on `request_input`: dropping the
464            // response senders resolves those awaits with an error, giving
465            // parked operations a cooperative exit path. The task leaves
466            // `input_required` and reports `working` until it settles.
467            entry.pending_inputs.clear();
468            entry.touch();
469            entry.task.status = entry.current_status();
470        }
471        Ok(())
472    }
473
474    /// Number of tasks currently in a non-terminal state.
475    pub fn running_task_count(&self) -> usize {
476        let mut inner = self.inner.lock().expect("task manager lock poisoned");
477        Self::sweep_expired(&mut inner);
478        inner
479            .tasks
480            .values()
481            .filter(|e| e.terminal.is_none())
482            .count()
483    }
484
485    /// Abort all running tasks and clear all task state.
486    pub fn shutdown(&self) {
487        let mut inner = self.inner.lock().expect("task manager lock poisoned");
488        for (_, mut entry) in inner.tasks.drain() {
489            if let Some(handle) = entry.join_handle.take() {
490                handle.abort();
491            }
492        }
493    }
494
495    /// TTL sweep, run from every `TaskManager` entry point (SEP-2663: servers
496    /// MAY mark a task `failed` any time after its TTL elapses, and
497    /// subsequently delete it at any time; `ttl_ms: None` means unlimited
498    /// retention).
499    ///
500    /// Two phases per entry:
501    /// 1. A non-terminal task whose TTL has elapsed is marked `failed` (its
502    ///    operation is aborted — the TTL is the SDK's hard-stop safety valve,
503    ///    unlike cooperative `tasks/cancel`).
504    /// 2. A *terminal* task is evicted once it has been retained for a full
505    ///    TTL window after reaching its terminal state, so well-behaved
506    ///    pollers get a chance to observe the final status before late
507    ///    `tasks/get` calls start returning `-32602`.
508    fn sweep_expired(inner: &mut TaskManagerInner) {
509        // Phase 1: fail overdue non-terminal tasks.
510        for entry in inner.tasks.values_mut() {
511            if entry.terminal.is_none()
512                && let Some(ttl_ms) = entry.task.ttl_ms
513                && entry.created.elapsed().as_millis() >= u128::from(ttl_ms)
514            {
515                if let Some(handle) = entry.join_handle.take() {
516                    handle.abort();
517                }
518                entry.terminal = Some(TaskPayload::Failed {
519                    error: error_to_object(&McpError::internal_error(
520                        "task expired: TTL elapsed before completion".to_string(),
521                        None,
522                    )),
523                });
524                entry.terminal_at = Some(Instant::now());
525                entry.pending_inputs.clear();
526                entry.touch();
527                entry.task.status = TaskStatus::Failed;
528            }
529        }
530        // Phase 2: evict terminal tasks whose retention window has passed.
531        inner.tasks.retain(|_, entry| {
532            let (Some(ttl_ms), Some(terminal_at)) = (entry.task.ttl_ms, entry.terminal_at) else {
533                return true;
534            };
535            terminal_at.elapsed().as_millis() < u128::from(ttl_ms)
536        });
537    }
538}
539
540fn unknown_task(task_id: &str) -> McpError {
541    McpError::invalid_params(format!("unknown task: {task_id}"), None)
542}
543
544async fn run_task_operation(
545    originating_request: Option<crate::model::RequestId>,
546    future: TaskFuture,
547) -> Result<CallToolResult, TaskExit> {
548    match originating_request {
549        Some(id) => crate::service::ORIGINATING_REQUEST.scope(id, future).await,
550        None => future.await,
551    }
552}
553
554fn result_to_object(result: &CallToolResult) -> JsonObject {
555    match serde_json::to_value(result) {
556        Ok(serde_json::Value::Object(map)) => map,
557        _ => JsonObject::new(),
558    }
559}
560
561fn error_to_object(error: &McpError) -> JsonObject {
562    match serde_json::to_value(error) {
563        Ok(serde_json::Value::Object(map)) => map,
564        _ => JsonObject::new(),
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use crate::model::ContentBlock;
572
573    fn ok_result(text: &str) -> CallToolResult {
574        CallToolResult::success(vec![ContentBlock::text(text.to_string())])
575    }
576
577    #[tokio::test]
578    async fn task_completes_and_result_is_inlined() {
579        let manager = TaskManager::new();
580        let task = manager.spawn(TaskOptions::default(), |_ctx| {
581            Box::pin(async { Ok(ok_result("42")) })
582        });
583        assert_eq!(task.status, TaskStatus::Working);
584
585        // Durable immediately.
586        manager.get_task(&task.task_id).unwrap();
587
588        // Wait for completion.
589        for _ in 0..100 {
590            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
591            let detailed = manager.get_task(&task.task_id).unwrap();
592            if detailed.status() == TaskStatus::Completed {
593                match detailed.payload {
594                    TaskPayload::Completed { result } => {
595                        assert!(result.contains_key("content"));
596                        return;
597                    }
598                    other => panic!("unexpected payload: {other:?}"),
599                }
600            }
601        }
602        panic!("task did not complete");
603    }
604
605    #[tokio::test]
606    async fn cancel_settles_to_cancelled_when_operation_honors_it() {
607        let manager = TaskManager::new();
608        let task = manager.spawn(TaskOptions::default(), |ctx| {
609            Box::pin(async move {
610                tokio::select! {
611                    _ = ctx.cancelled() => Err(TaskExit::Cancelled),
612                    _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
613                        Ok(ok_result("never"))
614                    }
615                }
616            })
617        });
618        manager.cancel_task(&task.task_id).unwrap();
619
620        // The ack is immediate but the terminal state is set by the
621        // operation; poll until it settles.
622        for _ in 0..100 {
623            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
624            let detailed = manager.get_task(&task.task_id).unwrap();
625            if detailed.status().is_terminal() {
626                assert_eq!(detailed.status(), TaskStatus::Cancelled);
627                return;
628            }
629        }
630        panic!("task did not settle after cancel");
631    }
632
633    #[tokio::test]
634    async fn post_cancel_unrelated_error_settles_as_failed() {
635        let manager = TaskManager::new();
636        let task = manager.spawn(TaskOptions::default(), |ctx| {
637            Box::pin(async move {
638                // Fail for an unrelated reason after observing the cancel:
639                // must be recorded as `failed`, not masked as `cancelled`.
640                ctx.cancelled().await;
641                Err(TaskExit::Error(McpError::internal_error(
642                    "database write failed",
643                    None,
644                )))
645            })
646        });
647        manager.cancel_task(&task.task_id).unwrap();
648
649        for _ in 0..100 {
650            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
651            let detailed = manager.get_task(&task.task_id).unwrap();
652            if detailed.status().is_terminal() {
653                assert_eq!(detailed.status(), TaskStatus::Failed);
654                match detailed.payload {
655                    TaskPayload::Failed { error } => {
656                        assert!(
657                            error.get("message").is_some_and(|m| m
658                                .as_str()
659                                .is_some_and(|s| s.contains("database write failed"))),
660                            "error payload should be preserved: {error:?}"
661                        );
662                    }
663                    other => panic!("unexpected payload: {other:?}"),
664                }
665                return;
666            }
667        }
668        panic!("task did not settle after cancel");
669    }
670
671    #[tokio::test]
672    async fn cancel_is_cooperative_and_lets_the_operation_clean_up() {
673        let manager = TaskManager::new();
674        let (cleanup_tx, cleanup_rx) = oneshot::channel::<&'static str>();
675        let task = manager.spawn(TaskOptions::default(), |ctx| {
676            Box::pin(async move {
677                // Wait for cancellation, then run cleanup and finish the
678                // work anyway (spec: a task may still reach a
679                // non-`cancelled` terminal status).
680                ctx.cancelled().await;
681                let _ = cleanup_tx.send("cleaned up");
682                Ok(ok_result("finished despite cancel"))
683            })
684        });
685
686        manager.cancel_task(&task.task_id).unwrap();
687
688        // The ack is immediate and does not force a terminal state.
689        let detailed = manager.get_task(&task.task_id).unwrap();
690        assert!(
691            !detailed.status().is_terminal(),
692            "cancel must not force terminal state"
693        );
694
695        // The operation observes the cancel and performs cleanup.
696        let cleanup = tokio::time::timeout(std::time::Duration::from_secs(5), cleanup_rx)
697            .await
698            .expect("cleanup should not time out")
699            .expect("cleanup channel should not be dropped");
700        assert_eq!(cleanup, "cleaned up");
701
702        // The operation chose to complete: the task settles as `completed`.
703        for _ in 0..100 {
704            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
705            let detailed = manager.get_task(&task.task_id).unwrap();
706            if detailed.status().is_terminal() {
707                assert_eq!(detailed.status(), TaskStatus::Completed);
708                return;
709            }
710        }
711        panic!("task did not settle after cancel");
712    }
713
714    #[tokio::test]
715    async fn cancel_wakes_parked_input_requests() {
716        let manager = TaskManager::new();
717        let (exit_tx, exit_rx) = oneshot::channel::<&'static str>();
718        let task = manager.spawn(TaskOptions::default(), |ctx| {
719            Box::pin(async move {
720                let request: InputRequest = serde_json::from_value(serde_json::json!({
721                    "method": "elicitation/create",
722                    "params": {
723                        "message": "Waiting forever",
724                        "requestedSchema": {"type": "object", "properties": {}}
725                    }
726                }))
727                .map_err(|e| McpError::internal_error(e.to_string(), None))?;
728                // Parked on input; cancel must wake this await with an error.
729                let err = ctx.request_input("k1", request).await.unwrap_err();
730                let _ = exit_tx.send("woken");
731                Err(err)
732            })
733        });
734
735        // Wait until the task is parked on the input request.
736        for _ in 0..100 {
737            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
738            if manager.get_task(&task.task_id).unwrap().status() == TaskStatus::InputRequired {
739                break;
740            }
741        }
742
743        manager.cancel_task(&task.task_id).unwrap();
744        let woken = tokio::time::timeout(std::time::Duration::from_secs(5), exit_rx)
745            .await
746            .expect("parked operation should be woken by cancel")
747            .expect("exit channel should not be dropped");
748        assert_eq!(woken, "woken");
749        assert_eq!(
750            manager.get_task(&task.task_id).unwrap().status(),
751            TaskStatus::Cancelled
752        );
753    }
754
755    #[tokio::test]
756    async fn unknown_task_is_invalid_params() {
757        // SEP-2663 §Protocol Errors: invalid or nonexistent taskId is -32602
758        // (Invalid params) — MUST for tasks/get, SHOULD for update/cancel.
759        let manager = TaskManager::new();
760        for err in [
761            manager.get_task("nope").unwrap_err(),
762            manager.cancel_task("nope").unwrap_err(),
763            manager.update_task("nope", []).unwrap_err(),
764        ] {
765            assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS);
766        }
767    }
768
769    #[tokio::test]
770    async fn terminal_tasks_are_evicted_after_retention_window() {
771        let manager = TaskManager::new();
772        let task = manager.spawn(TaskOptions::new().with_ttl_ms(50), |_ctx| {
773            Box::pin(async { Ok(ok_result("fast")) })
774        });
775
776        // Wait for completion; the terminal state stays observable during
777        // the retention window.
778        let mut completed = false;
779        for _ in 0..100 {
780            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
781            if manager.get_task(&task.task_id).unwrap().status() == TaskStatus::Completed {
782                completed = true;
783                break;
784            }
785        }
786        assert!(completed, "task should have completed");
787
788        // After a full TTL window past terminal, the entry is evicted and
789        // late polls get -32602.
790        tokio::time::sleep(std::time::Duration::from_millis(120)).await;
791        let err = manager.get_task(&task.task_id).unwrap_err();
792        assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS);
793        assert_eq!(manager.running_task_count(), 0);
794    }
795
796    #[tokio::test]
797    async fn abandoned_tasks_are_swept_by_other_entry_points() {
798        // A task nobody ever polls again must still be failed + evicted; the
799        // sweep runs from spawn() too, so activity on *other* tasks is enough.
800        let manager = TaskManager::new();
801        let abandoned = manager.spawn(TaskOptions::new().with_ttl_ms(10), |_ctx| {
802            Box::pin(async {
803                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
804                Ok(ok_result("never"))
805            })
806        });
807
808        // Let TTL elapse (fails the task), then a second full window
809        // (evicts it), without ever calling get_task on the abandoned id.
810        tokio::time::sleep(std::time::Duration::from_millis(40)).await;
811        let _ = manager.spawn(TaskOptions::default(), |_ctx| {
812            Box::pin(async { Ok(ok_result("other")) })
813        });
814        tokio::time::sleep(std::time::Duration::from_millis(40)).await;
815        let _ = manager.spawn(TaskOptions::default(), |_ctx| {
816            Box::pin(async { Ok(ok_result("other2")) })
817        });
818
819        let err = manager.get_task(&abandoned.task_id).unwrap_err();
820        assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS);
821    }
822
823    #[tokio::test]
824    async fn running_task_count_sweeps_expired_tasks() {
825        let manager = TaskManager::new();
826        let _task = manager.spawn(TaskOptions::new().with_ttl_ms(10), |_ctx| {
827            Box::pin(async {
828                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
829                Ok(ok_result("never"))
830            })
831        });
832        assert_eq!(manager.running_task_count(), 1);
833        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
834        // The count itself must sweep: the overdue task is failed, not
835        // reported as running.
836        assert_eq!(manager.running_task_count(), 0);
837    }
838
839    #[tokio::test]
840    async fn unlimited_ttl_tasks_are_retained() {
841        let manager = TaskManager::new();
842        let task = manager.spawn(TaskOptions::new().with_ttl_ms(None), |_ctx| {
843            Box::pin(async { Ok(ok_result("kept")) })
844        });
845        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
846        // Sweeps triggered by other entry points must not evict it.
847        let _ = manager.spawn(TaskOptions::default(), |_ctx| {
848            Box::pin(async { Ok(ok_result("other")) })
849        });
850        assert_eq!(
851            manager.get_task(&task.task_id).unwrap().status(),
852            TaskStatus::Completed
853        );
854    }
855
856    #[tokio::test]
857    async fn ttl_expiry_fails_task() {
858        let manager = TaskManager::new();
859        let task = manager.spawn(
860            TaskOptions {
861                ttl_ms: Some(10),
862                ..Default::default()
863            },
864            |_ctx| {
865                Box::pin(async {
866                    tokio::time::sleep(std::time::Duration::from_secs(60)).await;
867                    Ok(ok_result("never"))
868                })
869            },
870        );
871        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
872        let detailed = manager.get_task(&task.task_id).unwrap();
873        assert_eq!(detailed.status(), TaskStatus::Failed);
874    }
875
876    #[tokio::test]
877    async fn input_required_roundtrip() {
878        let manager = TaskManager::new();
879        let task = manager.spawn(TaskOptions::default(), |ctx| {
880            Box::pin(async move {
881                let request: InputRequest = serde_json::from_value(serde_json::json!({
882                    "method": "elicitation/create",
883                    "params": {
884                        "message": "What is your name?",
885                        "requestedSchema": {"type": "object", "properties": {}}
886                    }
887                }))
888                .map_err(|e| McpError::internal_error(e.to_string(), None))?;
889                let response = ctx.request_input("name-1", request).await?;
890                let name = response
891                    .get("content")
892                    .and_then(|c| c.get("name"))
893                    .and_then(|v| v.as_str())
894                    .unwrap_or("unknown")
895                    .to_string();
896                Ok(ok_result(&format!("hello {name}")))
897            })
898        });
899
900        // Wait for the task to surface the input request.
901        let mut saw_input_required = false;
902        for _ in 0..100 {
903            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
904            let detailed = manager.get_task(&task.task_id).unwrap();
905            if let TaskPayload::InputRequired { input_requests } = &detailed.payload {
906                assert!(input_requests.contains_key("name-1"));
907                saw_input_required = true;
908                break;
909            }
910        }
911        assert!(saw_input_required, "task never reached input_required");
912
913        // Respond via tasks/update.
914        manager
915            .update_task(
916                &task.task_id,
917                [(
918                    "name-1".to_string(),
919                    serde_json::json!({"action": "accept", "content": {"name": "Ada"}}),
920                )],
921            )
922            .unwrap();
923
924        for _ in 0..100 {
925            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
926            let detailed = manager.get_task(&task.task_id).unwrap();
927            if detailed.status() == TaskStatus::Completed {
928                return;
929            }
930        }
931        panic!("task did not complete after input response");
932    }
933
934    #[tokio::test]
935    async fn task_operation_reestablishes_request_association_scope() {
936        use crate::{
937            model::RequestId,
938            service::{ORIGINATING_REQUEST, in_request_handler_scope},
939        };
940
941        let manager = TaskManager::new();
942        let observed = Arc::new(Mutex::new(None::<bool>));
943        let observed_in_task = observed.clone();
944
945        ORIGINATING_REQUEST
946            .scope(RequestId::Number(7), async {
947                manager.spawn(TaskOptions::default(), move |_ctx| {
948                    let observed_in_task = observed_in_task.clone();
949                    Box::pin(async move {
950                        *observed_in_task.lock().unwrap() = Some(in_request_handler_scope());
951                        Ok(ok_result("done"))
952                    })
953                })
954            })
955            .await;
956
957        for _ in 0..100 {
958            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
959            if let Some(scoped) = *observed.lock().unwrap() {
960                assert!(
961                    scoped,
962                    "task operation must run inside the originating request's association scope"
963                );
964                return;
965            }
966        }
967        panic!("task operation did not run");
968    }
969
970    #[tokio::test]
971    async fn task_operation_without_originating_request_is_unscoped() {
972        use crate::service::in_request_handler_scope;
973
974        let manager = TaskManager::new();
975        let observed = Arc::new(Mutex::new(None::<bool>));
976        let observed_in_task = observed.clone();
977
978        manager.spawn(TaskOptions::default(), move |_ctx| {
979            let observed_in_task = observed_in_task.clone();
980            Box::pin(async move {
981                *observed_in_task.lock().unwrap() = Some(in_request_handler_scope());
982                Ok(ok_result("done"))
983            })
984        });
985
986        for _ in 0..100 {
987            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
988            if let Some(scoped) = *observed.lock().unwrap() {
989                assert!(
990                    !scoped,
991                    "task operation started without an originating request must remain unscoped"
992                );
993                return;
994            }
995        }
996        panic!("task operation did not run");
997    }
998}