Skip to main content

rmcp/model/
task.rs

1//! Task types for the MCP Tasks extension (SEP-2663).
2//!
3//! Tasks are defined by the official `io.modelcontextprotocol/tasks` extension.
4//! A server may respond to a supported request (currently `tools/call`) with a
5//! [`CreateTaskResult`] (`resultType: "task"`) instead of the standard result.
6//! The client then polls `tasks/get`, answers in-task server-to-client requests
7//! via `tasks/update`, and may signal cancellation via `tasks/cancel`.
8
9use serde::{Deserialize, Serialize};
10
11use super::{InputRequests, JsonObject, MetaObject, ResultType};
12
13/// Extension identifier for the MCP Tasks extension (SEP-2663).
14pub const TASKS_EXTENSION_ID: &str = "io.modelcontextprotocol/tasks";
15
16/// Canonical task lifecycle status (SEP-2663).
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "snake_case")]
19#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
20#[non_exhaustive]
21pub enum TaskStatus {
22    /// The request is currently being processed.
23    #[default]
24    Working,
25    /// The server needs input from the client before the task can proceed.
26    InputRequired,
27    /// The request completed successfully and the result is available.
28    /// This includes tool calls that returned results with `isError: true`.
29    Completed,
30    /// The request failed due to a JSON-RPC error during execution.
31    Failed,
32    /// The request was cancelled before completion.
33    Cancelled,
34}
35
36impl TaskStatus {
37    /// Returns `true` for terminal statuses (`completed`, `failed`, `cancelled`).
38    pub fn is_terminal(&self) -> bool {
39        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
40    }
41}
42
43/// Operational metadata about ongoing work (spec `Task`, SEP-2663).
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
45#[serde(rename_all = "camelCase")]
46#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
47#[non_exhaustive]
48pub struct Task {
49    /// Stable identifier for this task, generated by the server.
50    pub task_id: String,
51    /// Current task status.
52    pub status: TaskStatus,
53    /// Optional message describing the current task state.
54    /// This MAY be exposed to the end-user or model.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub status_message: Option<String>,
57    /// ISO 8601 timestamp when the task was created.
58    pub created_at: String,
59    /// ISO 8601 timestamp when the task was last updated.
60    pub last_updated_at: String,
61    /// Time-to-live duration from creation in integer milliseconds; `None`
62    /// (serialized as `null`) means unlimited. The server may discard the task
63    /// after the TTL elapses. This value MAY change over the lifetime of a task.
64    pub ttl_ms: Option<u64>,
65    /// Suggested polling interval in integer milliseconds. Clients SHOULD honor
66    /// this value to avoid overwhelming the server. This value MAY change over
67    /// the lifetime of a task.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub poll_interval_ms: Option<u64>,
70}
71
72impl Task {
73    /// Create a new task with required fields.
74    pub fn new(
75        task_id: impl Into<String>,
76        status: TaskStatus,
77        created_at: impl Into<String>,
78        last_updated_at: impl Into<String>,
79    ) -> Self {
80        Self {
81            task_id: task_id.into(),
82            status,
83            status_message: None,
84            created_at: created_at.into(),
85            last_updated_at: last_updated_at.into(),
86            ttl_ms: None,
87            poll_interval_ms: None,
88        }
89    }
90
91    /// Set the status message.
92    pub fn with_status_message(mut self, status_message: impl Into<String>) -> Self {
93        self.status_message = Some(status_message.into());
94        self
95    }
96
97    /// Set the TTL in milliseconds. `None` means unlimited retention.
98    pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self {
99        self.ttl_ms = Some(ttl_ms);
100        self
101    }
102
103    /// Set the suggested poll interval in milliseconds.
104    pub fn with_poll_interval_ms(mut self, poll_interval_ms: u64) -> Self {
105        self.poll_interval_ms = Some(poll_interval_ms);
106        self
107    }
108}
109
110/// Status-specific payload carried alongside the base [`Task`] fields in a
111/// [`DetailedTask`].
112///
113/// Mirrors the spec's `WorkingTask` / `InputRequiredTask` / `CompletedTask` /
114/// `FailedTask` / `CancelledTask` union: the variant is discriminated by the
115/// `status` field on the wire, with the payload fields inlined at the top level.
116#[derive(Debug, Clone, PartialEq)]
117#[non_exhaustive]
118pub enum TaskPayload {
119    /// `status: "working"` — no additional payload.
120    Working,
121    /// `status: "input_required"` — outstanding server-to-client requests.
122    InputRequired {
123        /// Server-to-client requests that need to be fulfilled during task
124        /// execution. Keys are arbitrary identifiers for matching requests
125        /// to responses, unique over the lifetime of the task.
126        input_requests: InputRequests,
127    },
128    /// `status: "completed"` — the final result of the task. The structure
129    /// matches the result type of the original request (e.g. `CallToolResult`).
130    Completed {
131        /// The final result of the original request.
132        result: JsonObject,
133    },
134    /// `status: "failed"` — the JSON-RPC error that caused the task to fail.
135    Failed {
136        /// The JSON-RPC error object.
137        error: JsonObject,
138    },
139    /// `status: "cancelled"` — no additional payload.
140    Cancelled,
141}
142
143impl TaskPayload {
144    /// The [`TaskStatus`] this payload corresponds to.
145    pub fn status(&self) -> TaskStatus {
146        match self {
147            Self::Working => TaskStatus::Working,
148            Self::InputRequired { .. } => TaskStatus::InputRequired,
149            Self::Completed { .. } => TaskStatus::Completed,
150            Self::Failed { .. } => TaskStatus::Failed,
151            Self::Cancelled => TaskStatus::Cancelled,
152        }
153    }
154}
155
156/// A task with its status-specific payload inlined (spec `DetailedTask`).
157///
158/// Used by `tasks/get` responses ([`GetTaskResult`]) and `notifications/tasks`
159/// ([`TaskStatusNotificationParams`](crate::model::TaskStatusNotificationParams)).
160/// On the wire, the payload fields (`inputRequests` / `result` / `error`) are
161/// flattened at the top level next to the base [`Task`] fields, and `status`
162/// discriminates the variant.
163#[derive(Debug, Clone, PartialEq)]
164#[non_exhaustive]
165pub struct DetailedTask {
166    /// Base task metadata. Its `status` always agrees with the payload.
167    pub task: Task,
168    /// Status-specific payload.
169    pub payload: TaskPayload,
170}
171
172impl DetailedTask {
173    /// Build a `DetailedTask`, forcing `task.status` to match the payload.
174    pub fn new(mut task: Task, payload: TaskPayload) -> Self {
175        task.status = payload.status();
176        Self { task, payload }
177    }
178
179    /// The current status.
180    pub fn status(&self) -> TaskStatus {
181        self.task.status
182    }
183}
184
185// Wire shape helper: base Task fields + optional payload fields, all flattened.
186#[derive(Serialize, Deserialize)]
187#[serde(rename_all = "camelCase")]
188#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
189struct DetailedTaskWire {
190    #[serde(flatten)]
191    task: Task,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    input_requests: Option<InputRequests>,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    result: Option<JsonObject>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    error: Option<JsonObject>,
198}
199
200impl From<DetailedTask> for DetailedTaskWire {
201    fn from(value: DetailedTask) -> Self {
202        let DetailedTask { task, payload } = value;
203        let (input_requests, result, error) = match payload {
204            TaskPayload::Working | TaskPayload::Cancelled => (None, None, None),
205            TaskPayload::InputRequired { input_requests } => (Some(input_requests), None, None),
206            TaskPayload::Completed { result } => (None, Some(result), None),
207            TaskPayload::Failed { error } => (None, None, Some(error)),
208        };
209        Self {
210            task,
211            input_requests,
212            result,
213            error,
214        }
215    }
216}
217
218impl TryFrom<DetailedTaskWire> for DetailedTask {
219    type Error = String;
220    fn try_from(wire: DetailedTaskWire) -> Result<Self, String> {
221        let payload = match wire.task.status {
222            TaskStatus::Working => TaskPayload::Working,
223            TaskStatus::Cancelled => TaskPayload::Cancelled,
224            TaskStatus::InputRequired => TaskPayload::InputRequired {
225                input_requests: wire.input_requests.ok_or_else(|| {
226                    "task with status \"input_required\" is missing `inputRequests`".to_owned()
227                })?,
228            },
229            TaskStatus::Completed => TaskPayload::Completed {
230                result: wire.result.ok_or_else(|| {
231                    "task with status \"completed\" is missing `result`".to_owned()
232                })?,
233            },
234            TaskStatus::Failed => TaskPayload::Failed {
235                error: wire
236                    .error
237                    .ok_or_else(|| "task with status \"failed\" is missing `error`".to_owned())?,
238            },
239        };
240        Ok(DetailedTask {
241            task: wire.task,
242            payload,
243        })
244    }
245}
246
247impl Serialize for DetailedTask {
248    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
249        DetailedTaskWire::from(self.clone()).serialize(serializer)
250    }
251}
252
253impl<'de> Deserialize<'de> for DetailedTask {
254    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
255        let wire = DetailedTaskWire::deserialize(deserializer)?;
256        Self::try_from(wire).map_err(serde::de::Error::custom)
257    }
258}
259
260#[cfg(feature = "schemars")]
261impl schemars::JsonSchema for DetailedTask {
262    fn schema_name() -> std::borrow::Cow<'static, str> {
263        "DetailedTask".into()
264    }
265    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
266        // The actual wire shape: base Task fields plus the optional
267        // status-specific payload fields (inputRequests / result / error).
268        <DetailedTaskWire as schemars::JsonSchema>::json_schema(generator)
269    }
270}
271
272/// Result returned in lieu of a standard result to indicate the request will
273/// be processed asynchronously (spec `CreateTaskResult`, `resultType: "task"`).
274///
275/// The embedded task is the seed state for the task; the client uses
276/// `task.task_id` for all subsequent `tasks/get`, `tasks/update`, and
277/// `tasks/cancel` calls.
278#[derive(Debug, Clone, PartialEq, Serialize)]
279#[serde(rename_all = "camelCase")]
280#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
281#[non_exhaustive]
282pub struct CreateTaskResult {
283    /// Always `"task"`.
284    pub result_type: ResultType,
285    /// Seed state of the newly created task, flattened at the top level.
286    #[serde(flatten)]
287    pub task: Task,
288    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
289    pub meta: Option<MetaObject>,
290}
291
292// Custom deserializer that requires `resultType: "task"`. Without this,
293// `CreateTaskResult` would greedily match other task-shaped results (e.g.
294// `tasks/get` responses, which also carry `taskId`/`status` at the top level
295// but use `resultType: "complete"`) inside `#[serde(untagged)]` unions such
296// as `ServerResult`.
297impl<'de> Deserialize<'de> for CreateTaskResult {
298    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
299    where
300        D: serde::Deserializer<'de>,
301    {
302        #[derive(Deserialize)]
303        #[serde(rename_all = "camelCase")]
304        struct Helper {
305            result_type: ResultType,
306            #[serde(flatten)]
307            task: Task,
308            #[serde(rename = "_meta", default)]
309            meta: Option<MetaObject>,
310        }
311        let helper = Helper::deserialize(deserializer)?;
312        if !helper.result_type.is_task() {
313            return Err(serde::de::Error::custom(
314                "CreateTaskResult requires resultType to be \"task\"",
315            ));
316        }
317        Ok(CreateTaskResult {
318            result_type: helper.result_type,
319            task: helper.task,
320            meta: helper.meta,
321        })
322    }
323}
324
325impl CreateTaskResult {
326    /// Create a new `CreateTaskResult` from the seed task state.
327    pub fn new(task: Task) -> Self {
328        Self {
329            result_type: ResultType::TASK,
330            task,
331            meta: None,
332        }
333    }
334
335    /// Sets the protocol-level metadata for this result.
336    pub fn with_meta(mut self, meta: MetaObject) -> Self {
337        self.meta = Some(meta);
338        self
339    }
340}
341
342/// Response to a `tasks/get` request (spec `GetTaskResult = Result & DetailedTask`).
343///
344/// `resultType` is `"complete"` — this is the standard result shape for
345/// `tasks/get`, not a task handle.
346#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
347#[serde(rename_all = "camelCase")]
348#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
349#[non_exhaustive]
350pub struct GetTaskResult {
351    /// Result type discriminator. `tasks/get` responses are standard results:
352    /// `"complete"` (SEP-2322). Absent values deserialize as `"complete"`.
353    #[serde(default)]
354    pub result_type: ResultType,
355    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
356    pub meta: Option<MetaObject>,
357    /// The task with status-specific payload inlined.
358    #[serde(flatten)]
359    pub task: DetailedTask,
360}
361
362impl GetTaskResult {
363    pub fn new(task: DetailedTask) -> Self {
364        Self {
365            result_type: ResultType::COMPLETE,
366            meta: None,
367            task,
368        }
369    }
370}
371
372/// Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663).
373///
374/// The spec requires these acks to be empty results carrying the SEP-2322
375/// `resultType: "complete"` discriminator; task state changes are observed
376/// via the next `tasks/get`.
377#[derive(Debug, Clone, PartialEq, Serialize)]
378#[serde(rename_all = "camelCase")]
379#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
380#[non_exhaustive]
381pub struct TaskAckResult {
382    /// Always `"complete"`.
383    pub result_type: ResultType,
384    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
385    pub meta: Option<MetaObject>,
386}
387
388// Custom deserializer that requires `resultType: "complete"` and rejects any
389// other fields. Without this, `TaskAckResult` would greedily match arbitrary
390// result objects carrying a `resultType` key inside `#[serde(untagged)]`
391// unions such as `ServerResult`, shadowing `CustomResult` and losing data.
392impl<'de> Deserialize<'de> for TaskAckResult {
393    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
394    where
395        D: serde::Deserializer<'de>,
396    {
397        #[derive(Deserialize)]
398        #[serde(rename_all = "camelCase", deny_unknown_fields)]
399        struct Helper {
400            result_type: ResultType,
401            #[serde(rename = "_meta", default)]
402            meta: Option<MetaObject>,
403        }
404        let helper = Helper::deserialize(deserializer)?;
405        if !helper.result_type.is_complete() {
406            return Err(serde::de::Error::custom(
407                "TaskAckResult requires resultType to be \"complete\"",
408            ));
409        }
410        Ok(TaskAckResult {
411            result_type: helper.result_type,
412            meta: helper.meta,
413        })
414    }
415}
416
417impl Default for TaskAckResult {
418    fn default() -> Self {
419        Self {
420            result_type: ResultType::COMPLETE,
421            meta: None,
422        }
423    }
424}
425
426impl TaskAckResult {
427    pub fn new() -> Self {
428        Self::default()
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use serde_json::json;
435
436    use super::*;
437
438    fn base_task(status: TaskStatus) -> Task {
439        Task::new(
440            "task-1",
441            status,
442            "2025-11-25T10:30:00Z",
443            "2025-11-25T10:40:00Z",
444        )
445        .with_ttl_ms(60000)
446        .with_poll_interval_ms(5000)
447    }
448
449    #[test]
450    fn create_task_result_wire_shape() {
451        let result = CreateTaskResult::new(base_task(TaskStatus::Working));
452        let value = serde_json::to_value(&result).unwrap();
453        assert_eq!(
454            value,
455            json!({
456                "resultType": "task",
457                "taskId": "task-1",
458                "status": "working",
459                "createdAt": "2025-11-25T10:30:00Z",
460                "lastUpdatedAt": "2025-11-25T10:40:00Z",
461                "ttlMs": 60000,
462                "pollIntervalMs": 5000
463            })
464        );
465        let roundtrip: CreateTaskResult = serde_json::from_value(value).unwrap();
466        assert_eq!(roundtrip, result);
467    }
468
469    #[test]
470    fn ttl_ms_null_means_unlimited() {
471        let mut task = base_task(TaskStatus::Working);
472        task.ttl_ms = None;
473        let value = serde_json::to_value(&task).unwrap();
474        assert_eq!(value["ttlMs"], serde_json::Value::Null);
475        let roundtrip: Task = serde_json::from_value(value).unwrap();
476        assert_eq!(roundtrip.ttl_ms, None);
477    }
478
479    #[test]
480    fn detailed_task_completed_roundtrip() {
481        let detailed = DetailedTask::new(
482            base_task(TaskStatus::Working),
483            TaskPayload::Completed {
484                result: serde_json::from_value(json!({
485                    "content": [{"type": "text", "text": "ok"}],
486                    "isError": false
487                }))
488                .unwrap(),
489            },
490        );
491        // Status is forced to match the payload.
492        assert_eq!(detailed.status(), TaskStatus::Completed);
493        let value = serde_json::to_value(&detailed).unwrap();
494        assert_eq!(value["status"], "completed");
495        assert_eq!(value["result"]["isError"], false);
496        let roundtrip: DetailedTask = serde_json::from_value(value).unwrap();
497        assert_eq!(roundtrip, detailed);
498    }
499
500    #[test]
501    fn detailed_task_input_required_requires_input_requests() {
502        let err = serde_json::from_value::<DetailedTask>(json!({
503            "taskId": "task-1",
504            "status": "input_required",
505            "createdAt": "2025-11-25T10:30:00Z",
506            "lastUpdatedAt": "2025-11-25T10:40:00Z",
507            "ttlMs": null
508        }))
509        .unwrap_err();
510        assert!(err.to_string().contains("inputRequests"));
511    }
512
513    #[test]
514    fn detailed_task_failed_roundtrip() {
515        let detailed = DetailedTask::new(
516            base_task(TaskStatus::Failed),
517            TaskPayload::Failed {
518                error: serde_json::from_value(json!({
519                    "code": -32603,
520                    "message": "boom"
521                }))
522                .unwrap(),
523            },
524        );
525        let value = serde_json::to_value(&detailed).unwrap();
526        assert_eq!(value["status"], "failed");
527        assert_eq!(value["error"]["code"], -32603);
528        let roundtrip: DetailedTask = serde_json::from_value(value).unwrap();
529        assert_eq!(roundtrip, detailed);
530    }
531
532    #[test]
533    fn get_task_result_flattens_detailed_task() {
534        let result = GetTaskResult::new(DetailedTask::new(
535            base_task(TaskStatus::Working),
536            TaskPayload::Working,
537        ));
538        let value = serde_json::to_value(&result).unwrap();
539        assert_eq!(value["taskId"], "task-1");
540        assert_eq!(value["status"], "working");
541        let roundtrip: GetTaskResult = serde_json::from_value(value).unwrap();
542        assert_eq!(roundtrip, result);
543    }
544}