Skip to main content

rust_web_server/mcp/
tasks.rs

1//! The MCP Tasks extension (`io.modelcontextprotocol/tasks`, SEP-2663) on
2//! MCP 2026-07-28: a tool hands long work to a task with
3//! [`McpContext::start_task`] and answers at once with a handle; the client
4//! polls `tasks/get`, answers the task's input requests with `tasks/update`
5//! and may `tasks/cancel` it. Tasks live in memory for their TTL (an hour
6//! by default, [`McpServer::task_ttl`]) and belong to the caller that
7//! started them.
8
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Arc, Condvar, Mutex};
12use std::time::Duration;
13
14use super::{json_escape, json_rpc, McpContent, McpContext, McpServer};
15use crate::mcp::modern::InputRequired;
16
17/// The extension's identifier.
18pub(crate) const EXTENSION: &str = "io.modelcontextprotocol/tasks";
19/// At most this many tasks are kept; the oldest finished ones go first.
20const MAX_TASKS: usize = 10_000;
21const POLL_INTERVAL_MS: u64 = 1000;
22/// Tasks whose work may run at once, by default: each holds a thread.
23pub const DEFAULT_MAX_RUNNING_TASKS: usize = 256;
24
25/// Releases a running-task slot when the work ends, however it ends.
26struct RunningSlot(Arc<std::sync::atomic::AtomicUsize>);
27
28impl Drop for RunningSlot {
29    fn drop(&mut self) {
30        self.0.fetch_sub(1, Ordering::AcqRel);
31    }
32}
33
34/// Why a task's work ended without a result.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum TaskError {
37    /// The tool ran and reports an error: the task completes with a result
38    /// marked `isError`, as a synchronous call would.
39    Tool(String),
40    /// A protocol-level failure: the task's status is `failed`, with this
41    /// JSON-RPC error.
42    Failed { code: i32, message: String },
43    /// The client cancelled the task.
44    Cancelled,
45}
46
47impl From<String> for TaskError {
48    fn from(message: String) -> Self {
49        TaskError::Tool(message)
50    }
51}
52
53impl From<&str> for TaskError {
54    fn from(message: &str) -> Self {
55        TaskError::Tool(message.to_string())
56    }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60enum Status {
61    Working,
62    InputRequired,
63    Completed,
64    Failed,
65    Cancelled,
66}
67
68impl Status {
69    fn as_str(self) -> &'static str {
70        match self {
71            Status::Working => "working",
72            Status::InputRequired => "input_required",
73            Status::Completed => "completed",
74            Status::Failed => "failed",
75            Status::Cancelled => "cancelled",
76        }
77    }
78
79    fn is_terminal(self) -> bool {
80        matches!(self, Status::Completed | Status::Failed | Status::Cancelled)
81    }
82}
83
84#[derive(Debug)]
85struct State {
86    status: Status,
87    status_message: Option<String>,
88    updated_ms: u64,
89    /// Outstanding input requests: `(key, request JSON)`.
90    pending: Vec<(String, String)>,
91    /// Answers to the current input round.
92    answers: HashMap<String, String>,
93    /// Every key ever issued: a key is never reused.
94    issued: Vec<String>,
95    /// `result` (completed) or `error` (failed), JSON.
96    payload: Option<String>,
97}
98
99/// Lets [`McpContext::start_task`] reach the server's tasks, for the caller.
100#[derive(Clone)]
101pub(crate) struct TaskHandle {
102    pub(crate) server: McpServer,
103    pub(crate) principal: String,
104}
105
106impl std::fmt::Debug for TaskHandle {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        f.debug_struct("TaskHandle").field("principal", &self.principal).finish()
109    }
110}
111
112/// One task.
113#[derive(Debug)]
114pub(crate) struct Task {
115    id: String,
116    principal: String,
117    created_ms: u64,
118    ttl_ms: u64,
119    state: Mutex<State>,
120    changed: Condvar,
121    cancelled: Arc<AtomicBool>,
122}
123
124/// The tasks of one [`McpServer`] (shared by its clones).
125#[derive(Debug, Default)]
126pub(crate) struct TaskStore {
127    tasks: Mutex<HashMap<String, Arc<Task>>>,
128}
129
130fn now_ms() -> u64 {
131    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64)
132}
133
134/// `2026-07-28T10:30:00.123Z`.
135pub(crate) fn iso8601(ms: u64) -> String {
136    let (days, rest) = (ms / 86_400_000, ms % 86_400_000);
137    let z = days as i64 + 719_468;
138    let era = z.div_euclid(146_097);
139    let doe = z - era * 146_097;
140    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
141    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
142    let mp = (5 * doy + 2) / 153;
143    let day = doy - (153 * mp + 2) / 5 + 1;
144    let month = if mp < 10 { mp + 3 } else { mp - 9 };
145    let year = yoe + era * 400 + i64::from(month <= 2);
146    let secs = rest / 1000;
147    format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z", secs / 3600, secs % 3600 / 60, secs % 60, rest % 1000)
148}
149
150fn task_id() -> String {
151    super::modern::random_key()[..16].iter().map(|b| format!("{b:02x}")).collect()
152}
153
154impl Task {
155    /// The `DetailedTask` fields: status, metadata and the status's payload.
156    fn detail_json(&self) -> String {
157        let state = self.state.lock().unwrap_or_else(|e| e.into_inner());
158        let mut json = format!(
159            r#""taskId":"{}","status":"{}","createdAt":"{}","lastUpdatedAt":"{}","ttlMs":{},"pollIntervalMs":{POLL_INTERVAL_MS}"#,
160            self.id,
161            state.status.as_str(),
162            iso8601(self.created_ms),
163            iso8601(state.updated_ms),
164            self.ttl_ms,
165        );
166        if let Some(message) = &state.status_message {
167            json.push_str(&format!(r#","statusMessage":"{}""#, json_escape(message)));
168        }
169        match state.status {
170            Status::InputRequired => {
171                let requests: Vec<String> = state.pending.iter().map(|(key, request)| format!(r#""{}":{request}"#, json_escape(key))).collect();
172                json.push_str(&format!(r#","inputRequests":{{{}}}"#, requests.join(",")));
173            }
174            Status::Completed => json.push_str(&format!(r#","result":{}"#, state.payload.as_deref().unwrap_or("{}"))),
175            Status::Failed => json.push_str(&format!(r#","error":{}"#, state.payload.as_deref().unwrap_or(r#"{"code":-32603,"message":"failed"}"#))),
176            Status::Working | Status::Cancelled => {}
177        }
178        json
179    }
180
181    fn set(&self, store: &McpServer, change: impl FnOnce(&mut State)) {
182        {
183            let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
184            if state.status.is_terminal() {
185                return;
186            }
187            change(&mut state);
188            state.updated_ms = now_ms();
189        }
190        self.changed.notify_all();
191        let params = format!("{{{}}}", self.detail_json());
192        store.deliver_task_notification(&self.id, &params);
193    }
194}
195
196/// What a task's work runs with: input from the client through
197/// `tasks/update`, cancellation, a status message.
198pub struct TaskContext {
199    task: Option<(Arc<Task>, McpServer)>,
200}
201
202impl TaskContext {
203    /// Whether this work runs as a task (else inline, the client not having
204    /// declared the extension).
205    pub fn is_task(&self) -> bool {
206        self.task.is_some()
207    }
208
209    /// Whether the client cancelled the task.
210    pub fn is_cancelled(&self) -> bool {
211        self.task.as_ref().is_some_and(|(task, _)| task.cancelled.load(Ordering::Relaxed))
212    }
213
214    /// Show `message` as the task's `statusMessage`.
215    pub fn status_message(&self, message: &str) {
216        if let Some((task, server)) = &self.task {
217            task.set(server, |state| state.status_message = Some(message.to_string()));
218        }
219    }
220
221    /// Ask the client for input and wait for all of it: the task shows
222    /// `input_required` with `request`'s requests until `tasks/update` has
223    /// answered every key; returns the answers `(key, raw JSON)`. Keys must
224    /// be new for the task. `Err(Cancelled)` when the task is cancelled;
225    /// an error when the work runs inline (no tasks extension).
226    pub fn input(&self, request: InputRequired) -> Result<Vec<(String, String)>, TaskError> {
227        let Some((task, server)) = &self.task else {
228            return Err(TaskError::Tool("this tool asks for input during a task, which needs the client's io.modelcontextprotocol/tasks extension".to_string()));
229        };
230        let requests = request.requests_json();
231        {
232            let state = task.state.lock().unwrap_or_else(|e| e.into_inner());
233            if let Some((key, _)) = requests.iter().find(|(key, _)| state.issued.contains(key)) {
234                return Err(TaskError::Failed { code: json_rpc::INTERNAL_ERROR, message: format!("input key {key:?} was already used by this task") });
235            }
236        }
237        let keys: Vec<String> = requests.iter().map(|(key, _)| key.clone()).collect();
238        task.set(server, |state| {
239            state.issued.extend(keys.iter().cloned());
240            state.answers.clear();
241            state.pending = requests;
242            state.status = Status::InputRequired;
243        });
244        let mut state = task.state.lock().unwrap_or_else(|e| e.into_inner());
245        loop {
246            if task.cancelled.load(Ordering::Relaxed) || state.status == Status::Cancelled {
247                return Err(TaskError::Cancelled);
248            }
249            if state.pending.is_empty() && state.status == Status::Working {
250                return Ok(keys.iter().map(|key| (key.clone(), state.answers.get(key).cloned().unwrap_or_default())).collect());
251            }
252            let (next, _) = task.changed.wait_timeout(state, Duration::from_secs(1)).unwrap_or_else(|e| e.into_inner());
253            state = next;
254        }
255    }
256}
257
258impl McpContext {
259    /// Run `work` as a task (MCP Tasks extension): when the client declared
260    /// `io.modelcontextprotocol/tasks` on this MCP 2026-07-28 request, the
261    /// call answers at once with the task's handle and `work` runs on a
262    /// thread of its own; the client polls `tasks/get` for its result.
263    /// Otherwise `work` runs now and its result is the call's (a
264    /// [`TaskError::Failed`] then becomes a tool error). With
265    /// [`McpServer::max_running_tasks`] tasks already running, it's an
266    /// error instead. Return this from a tool handler:
267    ///
268    /// ```rust
269    /// use rust_web_server::mcp::{McpContent, McpServer};
270    ///
271    /// let server = McpServer::new("ci", "1.0").tool_with_context("build", "Builds the project", "{}", |ctx, _args| {
272    ///     ctx.start_task(|task| {
273    ///         task.status_message("compiling");
274    ///         std::thread::sleep(std::time::Duration::from_secs(2));
275    ///         Ok(McpContent::text("build ok"))
276    ///     })
277    /// });
278    /// ```
279    pub fn start_task<F>(&self, work: F) -> Result<McpContent, String>
280    where
281        F: FnOnce(TaskContext) -> Result<McpContent, TaskError> + Send + 'static,
282    {
283        let Some(TaskHandle { server, principal }) = self.tasks.clone() else {
284            return match work(TaskContext { task: None }) {
285                Ok(content) => Ok(content),
286                Err(TaskError::Tool(message)) | Err(TaskError::Failed { message, .. }) => Err(message),
287                Err(TaskError::Cancelled) => Err("cancelled".to_string()),
288            };
289        };
290        // A slot first: every task's work holds a thread.
291        let running = Arc::clone(&server.running_tasks);
292        if running.fetch_add(1, Ordering::AcqRel) >= server.max_running_tasks {
293            running.fetch_sub(1, Ordering::AcqRel);
294            return Err(format!("too many tasks running (at most {}); try again later", server.max_running_tasks));
295        }
296        let slot = RunningSlot(running);
297        let now = now_ms();
298        let task = Arc::new(Task {
299            id: task_id(),
300            principal,
301            created_ms: now,
302            ttl_ms: server.task_ttl_ms,
303            state: Mutex::new(State {
304                status: Status::Working,
305                status_message: None,
306                updated_ms: now,
307                pending: Vec::new(),
308                answers: HashMap::new(),
309                issued: Vec::new(),
310                payload: None,
311            }),
312            changed: Condvar::new(),
313            cancelled: Arc::new(AtomicBool::new(false)),
314        });
315        server.task_store.insert(Arc::clone(&task));
316        let id = task.id.clone();
317        std::thread::spawn(move || {
318            let _slot = slot;
319            let context = TaskContext { task: Some((Arc::clone(&task), server.clone())) };
320            let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(context)));
321            task.set(&server, |state| {
322                state.pending.clear();
323                let (status, payload) = match outcome {
324                    Ok(Ok(content)) if content.kind == "input_required" || content.kind == "task" => (
325                        Status::Failed,
326                        format!(r#"{{"code":{},"message":"a task's work can't return {}"}}"#, json_rpc::INTERNAL_ERROR, content.kind),
327                    ),
328                    Ok(Ok(content)) => (Status::Completed, McpServer::format_tool_result(Ok(content))),
329                    Ok(Err(TaskError::Tool(message))) => (Status::Completed, McpServer::format_tool_result(Err(message))),
330                    Ok(Err(TaskError::Failed { code, message })) => {
331                        state.status_message = Some(message.clone());
332                        (Status::Failed, format!(r#"{{"code":{code},"message":"{}"}}"#, json_escape(&message)))
333                    }
334                    Ok(Err(TaskError::Cancelled)) => (Status::Cancelled, String::new()),
335                    Err(_) => {
336                        state.status_message = Some("the task's work panicked".to_string());
337                        (Status::Failed, format!(r#"{{"code":{},"message":"the task's work panicked"}}"#, json_rpc::INTERNAL_ERROR))
338                    }
339                };
340                state.status = status;
341                state.payload = (!payload.is_empty()).then_some(payload);
342            });
343        });
344        Ok(McpContent { kind: "task", text: id, mime_type: None, uri: None })
345    }
346
347    /// Whether this request's client declared the tasks extension.
348    pub fn supports_tasks(&self) -> bool {
349        self.tasks.is_some()
350    }
351}
352
353impl TaskStore {
354    fn insert(&self, task: Arc<Task>) {
355        let mut tasks = self.tasks.lock().unwrap_or_else(|e| e.into_inner());
356        let now = now_ms();
357        tasks.retain(|_, t| now <= t.created_ms + t.ttl_ms);
358        if tasks.len() >= MAX_TASKS {
359            // The oldest finished task, else the oldest.
360            let finished = |t: &&Arc<Task>| t.state.lock().map(|s| s.status.is_terminal()).unwrap_or(true);
361            let oldest = tasks
362                .values()
363                .filter(finished)
364                .min_by_key(|t| t.created_ms)
365                .or_else(|| tasks.values().min_by_key(|t| t.created_ms))
366                .map(|t| t.id.clone());
367            if let Some(id) = oldest {
368                tasks.remove(&id);
369            }
370        }
371        tasks.insert(task.id.clone(), task);
372    }
373
374    /// The task `id` of `principal`, if it exists and hasn't expired.
375    fn get(&self, id: &str, principal: &str) -> Option<Arc<Task>> {
376        let tasks = self.tasks.lock().unwrap_or_else(|e| e.into_inner());
377        tasks.get(id).filter(|t| t.principal == principal && now_ms() <= t.created_ms + t.ttl_ms).cloned()
378    }
379}
380
381impl McpServer {
382    /// How long a task is kept after it starts (default an hour).
383    pub fn task_ttl(mut self, ttl: Duration) -> Self {
384        self.task_ttl_ms = ttl.as_millis() as u64;
385        self
386    }
387
388    /// How many tasks' work may run at once (default
389    /// [`DEFAULT_MAX_RUNNING_TASKS`]): each holds a thread. Past it,
390    /// [`McpContext::start_task`] answers with an error.
391    pub fn max_running_tasks(mut self, n: usize) -> Self {
392        self.max_running_tasks = n;
393        self
394    }
395
396    /// Mark the tool `name` as usable only as a task: a client that didn't
397    /// declare the tasks extension gets `-32021` instead of the tool running.
398    pub fn task_required(self, name: &str) -> Self {
399        self.task_required_tools.write().unwrap().push(name.to_string());
400        self
401    }
402
403    pub(crate) fn is_task_required(&self, name: &str) -> bool {
404        self.task_required_tools.read().unwrap().iter().any(|n| n == name)
405    }
406
407    /// `CreateTaskResult`'s fields for the task `id`.
408    pub(crate) fn create_task_fields(&self, id: &str, principal: &str) -> Option<String> {
409        self.task_store.get(id, principal).map(|task| task.detail_json())
410    }
411
412    /// `tasks/get`: the task's `DetailedTask` fields.
413    pub(crate) fn task_get(&self, id: &str, principal: &str) -> Result<String, (i32, String)> {
414        self.task_store.get(id, principal).map(|task| task.detail_json()).ok_or_else(not_found)
415    }
416
417    /// `tasks/update`: record answers to outstanding requests (others are
418    /// ignored); the task resumes once every one is answered.
419    pub(crate) fn task_update(&self, id: &str, principal: &str, responses: &str) -> Result<(), (i32, String)> {
420        let task = self.task_store.get(id, principal).ok_or_else(not_found)?;
421        let answers = json_rpc::object_members(responses).ok_or((json_rpc::INVALID_PARAMS, "inputResponses must be an object".to_string()))?;
422        task.set(self, |state| {
423            for (key, answer) in answers {
424                if let Some(at) = state.pending.iter().position(|(k, _)| *k == key) {
425                    state.pending.remove(at);
426                    state.answers.insert(key, answer);
427                }
428            }
429            if state.pending.is_empty() && state.status == Status::InputRequired {
430                state.status = Status::Working;
431            }
432        });
433        Ok(())
434    }
435
436    /// `tasks/cancel`: a running task becomes `cancelled` at once (its work
437    /// sees [`TaskContext::is_cancelled`]); a finished one stays as it is.
438    pub(crate) fn task_cancel(&self, id: &str, principal: &str) -> Result<(), (i32, String)> {
439        let task = self.task_store.get(id, principal).ok_or_else(not_found)?;
440        task.cancelled.store(true, Ordering::Relaxed);
441        task.set(self, |state| {
442            state.status = Status::Cancelled;
443            state.pending.clear();
444            state.status_message = Some("cancelled by the client".to_string());
445        });
446        Ok(())
447    }
448}
449
450fn not_found() -> (i32, String) {
451    (json_rpc::INVALID_PARAMS, "Failed to retrieve task: Task not found".to_string())
452}
453
454#[cfg(test)]
455mod tests;