Skip to main content

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