pmcp/types/tasks.rs
1//! MCP Task protocol types (2025-11-25).
2//!
3//! This module contains the wire types for MCP Tasks as defined
4//! in the 2025-11-25 protocol version.
5
6use serde::{Deserialize, Serialize};
7
8/// Related task metadata key per MCP spec.
9pub const RELATED_TASK_META_KEY: &str = "io.modelcontextprotocol/related-task";
10
11/// Task status (5-value enum).
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum TaskStatus {
15 /// Task is actively being worked on
16 #[default]
17 Working,
18 /// Task requires user input to continue
19 InputRequired,
20 /// Task completed successfully
21 Completed,
22 /// Task failed
23 Failed,
24 /// Task was cancelled
25 Cancelled,
26}
27
28impl std::fmt::Display for TaskStatus {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match self {
31 Self::Working => write!(f, "working"),
32 Self::InputRequired => write!(f, "input_required"),
33 Self::Completed => write!(f, "completed"),
34 Self::Failed => write!(f, "failed"),
35 Self::Cancelled => write!(f, "cancelled"),
36 }
37 }
38}
39
40impl TaskStatus {
41 /// Returns `true` if this status is terminal (no further transitions allowed).
42 ///
43 /// Terminal states are `Completed`, `Failed`, and `Cancelled`.
44 pub fn is_terminal(&self) -> bool {
45 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
46 }
47
48 /// Returns `true` if transitioning from this status to `next` is valid.
49 ///
50 /// The MCP spec defines these valid transitions:
51 /// - `Working` -> `InputRequired`, `Completed`, `Failed`, `Cancelled`
52 /// - `InputRequired` -> `Working`, `Completed`, `Failed`, `Cancelled`
53 /// - Terminal states -> no transitions allowed
54 ///
55 /// Self-transitions (e.g., `Working` -> `Working`) are rejected per spec.
56 pub fn can_transition_to(&self, next: &Self) -> bool {
57 if self == next {
58 return false;
59 }
60
61 match self {
62 Self::Working => matches!(
63 next,
64 Self::InputRequired | Self::Completed | Self::Failed | Self::Cancelled
65 ),
66 Self::InputRequired => matches!(
67 next,
68 Self::Working | Self::Completed | Self::Failed | Self::Cancelled
69 ),
70 Self::Completed | Self::Failed | Self::Cancelled => false,
71 }
72 }
73}
74
75/// The classification of a polled [`Task`], derived purely from its
76/// already-deserialized [`TaskStatus`] — the single decision primitive every
77/// task poller consumes so the branch logic cannot drift.
78///
79/// Produced by [`Task::poll_decision`]. It answers the one question a poll loop
80/// asks each tick: *stop, ask the user, or sleep and poll again?* — without a
81/// network round-trip, a [`CallToolResult`](crate::types::CallToolResult)
82/// fetch, or any I/O. It is a pure, replay-deterministic function of the polled
83/// `Task`, so it is safe to call inside a memoized durable/replay step (D-01,
84/// D-03).
85///
86/// # Non-exhaustive
87///
88/// This enum is `#[non_exhaustive]` for future-proofing (D-04): adding a variant
89/// later is a non-breaking change, so external `match` sites must carry a
90/// wildcard `_ =>` arm. This is a distinct claim from [`TaskStatus`], which is
91/// deliberately **exhaustive** today (D-15): `#[non_exhaustive]` here does NOT
92/// imply that unknown or future wire statuses are handled gracefully at runtime.
93/// An unknown status fails at serde deserialization during `tasks/get`, BEFORE
94/// classification ever runs — `poll_decision()` only ever sees one of the five
95/// known `TaskStatus` values.
96///
97/// Unlike every other type in this module, `TaskPollDecision` intentionally
98/// derives neither `Serialize` nor `Deserialize`: it is a returned classifier
99/// value consumed in-process, never a wire type.
100#[derive(Debug, Clone, PartialEq, Eq)]
101#[non_exhaustive]
102pub enum TaskPollDecision {
103 /// The task has reached a terminal status ([`TaskStatus::Completed`],
104 /// [`TaskStatus::Failed`], or [`TaskStatus::Cancelled`]) and will not
105 /// transition further — the poll loop should stop.
106 ///
107 /// The classifier carries the terminal [`TaskStatus`] by value (it is
108 /// `Copy`) but deliberately does NOT carry the final
109 /// [`CallToolResult`](crate::types::CallToolResult): to retrieve the
110 /// result the caller still issues a **separate `tasks/result`** call
111 /// (D-06/D-16). This keeps classification a pure, I/O-free decision.
112 Terminal {
113 /// The terminal status that ended the task.
114 status: TaskStatus,
115 },
116 /// The task is still running ([`TaskStatus::Working`]) — the poll loop
117 /// should sleep and poll again.
118 ///
119 /// `poll_hint` is the raw server-reported `pollInterval` in **milliseconds**
120 /// ([`Task::poll_interval`]), passed through verbatim including `None`
121 /// (D-07). Feed it to [`resolve_poll_interval`] to obtain the concrete sleep
122 /// duration honoring the caller override, this hint, the default, and the
123 /// hot-loop floor.
124 InProgress {
125 /// The raw server-reported `pollInterval` in ms, verbatim (`None` when
126 /// the server suggested no interval).
127 poll_hint: Option<u64>,
128 },
129 /// The task is blocked on user input ([`TaskStatus::InputRequired`]).
130 ///
131 /// This is a unit variant carrying no payload (D-05): the caller already
132 /// holds the polled `Task`, and a blocking poller cannot supply the input,
133 /// so it must route to elicitation rather than continue spinning.
134 InputRequired,
135}
136
137/// The default poll interval, in **milliseconds**, used when neither the caller
138/// nor the server-reported `pollInterval` specifies one.
139///
140/// This is a **stable, supported public default** — the documented fallback in
141/// the poll-interval policy (1000 ms), not an internal tunable. Its value is a
142/// public API contract: changing it is a semver-relevant change, not a silent
143/// implementation detail. It is the single source of truth shared by
144/// [`resolve_poll_interval`] and every task poller (D-08).
145pub const DEFAULT_POLL_MS: u64 = 1000;
146
147/// The floor, in **milliseconds**, applied to any resolved poll interval so a
148/// zero or very small value cannot hot-spin the poll loop.
149///
150/// This is a **stable, supported public default** — the documented 50 ms
151/// hot-loop-protection floor in the poll-interval policy, not an internal
152/// tunable. Its value is a public API contract: changing it is a
153/// semver-relevant change. It is the single source of truth shared by
154/// [`resolve_poll_interval`] and the budget clamp in blocking pollers (D-08,
155/// T-105-01 mitigation).
156pub const MIN_POLL_MS: u64 = 50;
157
158/// Resolve the concrete poll interval, in **milliseconds**, from a caller
159/// override and a server-reported hint, applying the documented precedence and
160/// the hot-loop-protection floor.
161///
162/// Precedence: `caller_override` wins if present, else the server `hint`, else
163/// [`DEFAULT_POLL_MS`] (1000 ms); the result is then floored to at least
164/// [`MIN_POLL_MS`] (50 ms) so a zero or tiny value cannot busy-spin the poll
165/// loop (T-105-01 mitigation). This is the single source of truth for interval
166/// resolution, consumed by every task poller so the policy cannot drift (D-08).
167///
168/// Returns `u64` milliseconds — NOT a [`Duration`](std::time::Duration) — to
169/// stay symmetric with the `Option<u64>` inputs and consistent with
170/// [`Task::poll_interval`] and [`TaskMetadata::poll_interval`] (D-12). Callers
171/// wrap with `Duration::from_millis` at the sleep site.
172///
173/// # Examples
174///
175/// ```rust
176/// use pmcp::types::tasks::resolve_poll_interval;
177///
178/// // Caller override always wins.
179/// assert_eq!(resolve_poll_interval(Some(200), Some(999)), 200);
180/// // Server hint used when there is no override.
181/// assert_eq!(resolve_poll_interval(None, Some(300)), 300);
182/// // Falls back to the 1000 ms default when neither is set.
183/// assert_eq!(resolve_poll_interval(None, None), 1000);
184/// // A zero (or tiny) value is floored to 50 ms so it cannot hot-spin.
185/// assert_eq!(resolve_poll_interval(Some(0), None), 50);
186/// ```
187pub fn resolve_poll_interval(caller_override: Option<u64>, hint: Option<u64>) -> u64 {
188 caller_override
189 .or(hint)
190 .unwrap_or(DEFAULT_POLL_MS)
191 .max(MIN_POLL_MS)
192}
193
194/// A task resource representing an in-progress or completed operation.
195///
196/// # Backward Compatibility
197///
198/// This struct is `#[non_exhaustive]`. Use the constructor to remain
199/// forward-compatible:
200///
201/// ```rust
202/// use pmcp::types::tasks::{Task, TaskStatus};
203///
204/// let task = Task::new("t-123", TaskStatus::Working)
205/// .with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z")
206/// .with_ttl(60000)
207/// .with_poll_interval(5000)
208/// .with_status_message("Processing...");
209/// ```
210#[derive(Debug, Clone, Default, Serialize, Deserialize)]
211#[non_exhaustive]
212#[serde(rename_all = "camelCase")]
213pub struct Task {
214 /// Unique task identifier
215 pub task_id: String,
216 /// Current task status
217 pub status: TaskStatus,
218 /// Time-to-live in milliseconds. Required but nullable per MCP spec:
219 /// `None` serializes as `null` (unlimited TTL), `Some(ms)` as a number.
220 pub ttl: Option<u64>,
221 /// ISO 8601 creation timestamp
222 pub created_at: String,
223 /// ISO 8601 last-updated timestamp
224 pub last_updated_at: String,
225 /// Suggested polling interval in milliseconds
226 #[serde(skip_serializing_if = "Option::is_none")]
227 pub poll_interval: Option<u64>,
228 /// Human-readable status message
229 #[serde(skip_serializing_if = "Option::is_none")]
230 pub status_message: Option<String>,
231 /// Full operator-facing diagnostic detail (step ids, URLs, internal error
232 /// text) for this task.
233 ///
234 /// **PMCP EXTENSION — not an MCP-spec field (D-17).** The MCP `Task` type
235 /// carries a single [`status_message`](Task::status_message) voice, which
236 /// PMCP treats as the business-friendly, user-facing voice (D-17/D-18).
237 /// `diagnostic_detail` is a second, separate voice this SDK adds for
238 /// operator/developer consumption — full detail that would be
239 /// inappropriate to show a business user by default (see the
240 /// Information-Disclosure disposition on this field: producers MUST
241 /// redact secrets/tokens before setting it). Consuming UIs typically
242 /// render it behind an expandable "details" affordance.
243 ///
244 /// Because `Task` has no `deny_unknown_fields` (see the module's serde
245 /// round-trip and consumer-tolerance tests), existing/strict consumers
246 /// that don't know this field simply ignore the extra `diagnosticDetail`
247 /// key on the wire — this is additive and non-breaking. When absent
248 /// (`None`), it is skip-serialized, so callers that never set it produce
249 /// byte-identical JSON to before this field existed.
250 ///
251 /// **Future migration note:** if/when the MCP spec grows a `_meta`
252 /// extension slot on `Task` (mirroring `CallToolResult._meta` /
253 /// [`RELATED_TASK_META_KEY`]), this field is a candidate to migrate under
254 /// that slot instead of a top-level struct field, to keep `Task` itself
255 /// spec-pure. Until then, this is the pragmatic wire slot.
256 #[serde(skip_serializing_if = "Option::is_none")]
257 pub diagnostic_detail: Option<String>,
258}
259
260impl Task {
261 /// Create a task with the given ID and status.
262 ///
263 /// Timestamps default to empty strings (caller should set via
264 /// [`Task::with_timestamps`]). Optional fields default to `None`.
265 pub fn new(task_id: impl Into<String>, status: TaskStatus) -> Self {
266 Self {
267 task_id: task_id.into(),
268 status,
269 ttl: None,
270 created_at: String::new(),
271 last_updated_at: String::new(),
272 poll_interval: None,
273 status_message: None,
274 diagnostic_detail: None,
275 }
276 }
277
278 /// Set the time-to-live in milliseconds.
279 pub fn with_ttl(mut self, ttl: u64) -> Self {
280 self.ttl = Some(ttl);
281 self
282 }
283
284 /// Set both creation and last-updated timestamps.
285 pub fn with_timestamps(
286 mut self,
287 created_at: impl Into<String>,
288 last_updated_at: impl Into<String>,
289 ) -> Self {
290 self.created_at = created_at.into();
291 self.last_updated_at = last_updated_at.into();
292 self
293 }
294
295 /// Set the suggested polling interval in milliseconds.
296 pub fn with_poll_interval(mut self, interval: u64) -> Self {
297 self.poll_interval = Some(interval);
298 self
299 }
300
301 /// Set a human-readable status message.
302 pub fn with_status_message(mut self, message: impl Into<String>) -> Self {
303 self.status_message = Some(message.into());
304 self
305 }
306
307 /// Set the full operator-facing diagnostic detail (PMCP extension — D-17;
308 /// see the field doc comment on [`Task::diagnostic_detail`]).
309 pub fn with_diagnostic_detail(mut self, detail: impl Into<String>) -> Self {
310 self.diagnostic_detail = Some(detail.into());
311 self
312 }
313
314 /// Classify this already-polled task into a [`TaskPollDecision`] without any
315 /// I/O — the single decision primitive every task poller consumes.
316 ///
317 /// This is a pure, total function of the polled `Task`'s [`TaskStatus`]:
318 /// there is no `_` wildcard arm because `TaskStatus` is exhaustive (five
319 /// known variants), so the mapping cannot silently drift. Because it touches
320 /// nothing but the in-hand `Task`, it is replay-deterministic and safe to
321 /// call inside a memoized durable/replay step — provided the `tasks/get`
322 /// network call and its serde decode sit INSIDE the memoized step, so an
323 /// unknown/future status fails at deserialization before this runs (D-01,
324 /// D-04, D-14, D-15).
325 ///
326 /// Mapping:
327 /// - [`TaskStatus::Working`] → [`TaskPollDecision::InProgress`] carrying the
328 /// task's `poll_interval` verbatim as `poll_hint` (D-07).
329 /// - [`TaskStatus::InputRequired`] → [`TaskPollDecision::InputRequired`].
330 /// - [`TaskStatus::Completed`] / [`TaskStatus::Failed`] /
331 /// [`TaskStatus::Cancelled`] → [`TaskPollDecision::Terminal`] carrying the
332 /// terminal status. The final
333 /// [`CallToolResult`](crate::types::CallToolResult) is NOT fetched here —
334 /// the caller issues a separate `tasks/result` call (D-06/D-16).
335 ///
336 /// # Examples
337 ///
338 /// ```rust
339 /// use pmcp::types::tasks::{Task, TaskStatus, TaskPollDecision};
340 ///
341 /// let task = Task::new("t-123", TaskStatus::Working).with_poll_interval(2000);
342 /// match task.poll_decision() {
343 /// TaskPollDecision::InProgress { poll_hint } => assert_eq!(poll_hint, Some(2000)),
344 /// // `TaskPollDecision` is `#[non_exhaustive]`, so external callers
345 /// // need a wildcard arm.
346 /// _ => panic!("a Working task must classify as InProgress"),
347 /// }
348 /// ```
349 pub fn poll_decision(&self) -> TaskPollDecision {
350 match self.status {
351 TaskStatus::Working => TaskPollDecision::InProgress {
352 poll_hint: self.poll_interval,
353 },
354 TaskStatus::InputRequired => TaskPollDecision::InputRequired,
355 TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled => {
356 TaskPollDecision::Terminal {
357 status: self.status,
358 }
359 },
360 }
361 }
362}
363
364/// Parameters for task creation (augments tools/call).
365#[derive(Debug, Clone, Default, Serialize, Deserialize)]
366#[non_exhaustive]
367#[serde(rename_all = "camelCase")]
368pub struct TaskCreationParams {
369 /// Time-to-live in milliseconds. Required but nullable per MCP spec:
370 /// `None` serializes as `null` (unlimited TTL), `Some(ms)` as a number.
371 pub ttl: Option<u64>,
372 /// Suggested polling interval in milliseconds
373 #[serde(skip_serializing_if = "Option::is_none")]
374 pub poll_interval: Option<u64>,
375}
376
377impl TaskCreationParams {
378 /// Create empty task creation parameters.
379 pub fn new() -> Self {
380 Self::default()
381 }
382
383 /// Set the time-to-live in milliseconds.
384 pub fn with_ttl(mut self, ttl: u64) -> Self {
385 self.ttl = Some(ttl);
386 self
387 }
388
389 /// Set the suggested polling interval in milliseconds.
390 pub fn with_poll_interval(mut self, interval: u64) -> Self {
391 self.poll_interval = Some(interval);
392 self
393 }
394}
395
396/// Task metadata for related-task references.
397#[derive(Debug, Clone, Serialize, Deserialize)]
398#[serde(rename_all = "camelCase")]
399pub struct RelatedTaskMetadata {
400 /// The referenced task ID
401 pub task_id: String,
402}
403
404/// Typed metadata carried on a `CallToolResult` under
405/// [`RELATED_TASK_META_KEY`], linking a synchronous tool result to the async
406/// task that produced (or is producing) it (SEP-1686).
407///
408/// This is the richer twin of [`RelatedTaskMetadata`]: it carries the optional
409/// polling hints a client needs to drive
410/// [`Client::wait_for_task`](crate::Client::wait_for_task) without hand-copying
411/// fields. Server code builds it via
412/// [`CallToolResult::with_related_task`](crate::types::CallToolResult::with_related_task);
413/// client code reads it via
414/// [`CallToolResult::related_task`](crate::types::CallToolResult::related_task).
415///
416/// The two `Option` polling fields default to `None`, so the minimal native
417/// emit shape `{ "taskId": "t1" }` deserializes cleanly (extra fields absent).
418///
419/// # Backward Compatibility
420///
421/// This struct is `#[non_exhaustive]`. Construct it via [`TaskMetadata::new`]
422/// and the builder methods to stay forward-compatible:
423///
424/// ```rust
425/// use pmcp::types::tasks::TaskMetadata;
426///
427/// let meta = TaskMetadata::new("t-123")
428/// .with_poll_interval(5000)
429/// .with_max_poll_duration_secs(300);
430/// let json = serde_json::to_value(&meta).unwrap();
431/// assert_eq!(json["taskId"], "t-123");
432/// assert_eq!(json["pollInterval"], 5000);
433/// ```
434#[derive(Debug, Clone, Serialize, Deserialize)]
435#[non_exhaustive]
436#[serde(rename_all = "camelCase")]
437pub struct TaskMetadata {
438 /// The referenced task ID.
439 pub task_id: String,
440 /// Suggested polling interval, in **milliseconds**.
441 ///
442 /// This is the same unit as [`Task::poll_interval`]. A client poller uses
443 /// it as the delay between `tasks/get` calls.
444 #[serde(skip_serializing_if = "Option::is_none")]
445 pub poll_interval: Option<u64>,
446 /// Maximum total time to poll before giving up, in **seconds**.
447 ///
448 /// Note the unit differs from [`TaskMetadata::poll_interval`] (which is
449 /// milliseconds): this is a coarse overall budget expressed in seconds.
450 #[serde(skip_serializing_if = "Option::is_none")]
451 pub max_poll_duration_secs: Option<u64>,
452}
453
454impl TaskMetadata {
455 /// Create related-task metadata referencing `task_id`.
456 ///
457 /// Both polling hints default to `None`; set them via
458 /// [`TaskMetadata::with_poll_interval`] and
459 /// [`TaskMetadata::with_max_poll_duration_secs`].
460 pub fn new(task_id: impl Into<String>) -> Self {
461 Self {
462 task_id: task_id.into(),
463 poll_interval: None,
464 max_poll_duration_secs: None,
465 }
466 }
467
468 /// Set the suggested polling interval, in **milliseconds**.
469 pub fn with_poll_interval(mut self, interval_ms: u64) -> Self {
470 self.poll_interval = Some(interval_ms);
471 self
472 }
473
474 /// Set the maximum total polling duration, in **seconds**.
475 pub fn with_max_poll_duration_secs(mut self, secs: u64) -> Self {
476 self.max_poll_duration_secs = Some(secs);
477 self
478 }
479}
480
481/// Result of creating a task.
482#[derive(Debug, Clone, Serialize, Deserialize)]
483#[non_exhaustive]
484#[serde(rename_all = "camelCase")]
485pub struct CreateTaskResult {
486 /// The created task
487 pub task: Task,
488}
489
490impl CreateTaskResult {
491 /// Create a task creation result.
492 pub fn new(task: Task) -> Self {
493 Self { task }
494 }
495}
496
497/// Task status notification.
498#[derive(Debug, Clone, Serialize, Deserialize)]
499#[serde(rename_all = "camelCase")]
500pub struct TaskStatusNotification {
501 /// Task with updated status
502 pub task: Task,
503}
504
505/// Get task request.
506#[derive(Debug, Clone, Serialize, Deserialize)]
507#[serde(rename_all = "camelCase")]
508pub struct GetTaskRequest {
509 /// Task ID to retrieve
510 pub task_id: String,
511}
512
513/// Get task result.
514#[derive(Debug, Clone, Serialize, Deserialize)]
515#[non_exhaustive]
516#[serde(rename_all = "camelCase")]
517pub struct GetTaskResult {
518 /// The requested task
519 pub task: Task,
520}
521
522impl GetTaskResult {
523 /// Create a get task result.
524 pub fn new(task: Task) -> Self {
525 Self { task }
526 }
527}
528
529/// Get task payload request.
530#[derive(Debug, Clone, Serialize, Deserialize)]
531#[serde(rename_all = "camelCase")]
532pub struct GetTaskPayloadRequest {
533 /// Task ID whose payload to retrieve
534 pub task_id: String,
535}
536
537/// List tasks request (paginated).
538#[derive(Debug, Clone, Default, Serialize, Deserialize)]
539#[serde(rename_all = "camelCase")]
540pub struct ListTasksRequest {
541 /// Pagination cursor
542 #[serde(skip_serializing_if = "Option::is_none")]
543 pub cursor: Option<String>,
544}
545
546/// List tasks result.
547#[derive(Debug, Clone, Default, Serialize, Deserialize)]
548#[non_exhaustive]
549#[serde(rename_all = "camelCase")]
550pub struct ListTasksResult {
551 /// List of tasks
552 pub tasks: Vec<Task>,
553 /// Pagination cursor for next page
554 #[serde(skip_serializing_if = "Option::is_none")]
555 pub next_cursor: Option<String>,
556}
557
558impl ListTasksResult {
559 /// Create a list tasks result.
560 pub fn new(tasks: Vec<Task>) -> Self {
561 Self {
562 tasks,
563 next_cursor: None,
564 }
565 }
566
567 /// Set the pagination cursor for the next page.
568 pub fn with_next_cursor(mut self, cursor: impl Into<String>) -> Self {
569 self.next_cursor = Some(cursor.into());
570 self
571 }
572}
573
574/// Cancel task request.
575///
576/// When `result` is `Some`, the task transitions to `Completed` status
577/// (workflow completion). When `result` is `None`, the task transitions to
578/// `Cancelled` status (standard cancel).
579#[derive(Debug, Clone, Serialize, Deserialize)]
580#[serde(rename_all = "camelCase")]
581pub struct CancelTaskRequest {
582 /// Task ID to cancel
583 pub task_id: String,
584 /// Optional result value for workflow completion.
585 ///
586 /// When present, completes the task (transitions to `Completed` status)
587 /// instead of cancelling it.
588 #[serde(skip_serializing_if = "Option::is_none")]
589 pub result: Option<serde_json::Value>,
590}
591
592/// Cancel task result.
593#[derive(Debug, Clone, Serialize, Deserialize)]
594#[non_exhaustive]
595#[serde(rename_all = "camelCase")]
596pub struct CancelTaskResult {
597 /// The cancelled task
598 pub task: Task,
599}
600
601impl CancelTaskResult {
602 /// Create a cancel task result.
603 pub fn new(task: Task) -> Self {
604 Self { task }
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use serde_json::json;
612
613 #[test]
614 fn task_status_serialization() {
615 assert_eq!(
616 serde_json::to_value(TaskStatus::Working).unwrap(),
617 "working"
618 );
619 assert_eq!(
620 serde_json::to_value(TaskStatus::InputRequired).unwrap(),
621 "input_required"
622 );
623 assert_eq!(
624 serde_json::to_value(TaskStatus::Completed).unwrap(),
625 "completed"
626 );
627 assert_eq!(serde_json::to_value(TaskStatus::Failed).unwrap(), "failed");
628 assert_eq!(
629 serde_json::to_value(TaskStatus::Cancelled).unwrap(),
630 "cancelled"
631 );
632 }
633
634 #[test]
635 fn task_roundtrip() {
636 let task = Task::new("t-123", TaskStatus::Working)
637 .with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z")
638 .with_ttl(60000)
639 .with_poll_interval(5000)
640 .with_status_message("Processing...");
641 let json = serde_json::to_value(&task).unwrap();
642 assert_eq!(json["taskId"], "t-123");
643 assert_eq!(json["status"], "working");
644 assert_eq!(json["ttl"], 60000);
645 assert_eq!(json["createdAt"], "2025-11-25T00:00:00Z");
646 assert_eq!(json["pollInterval"], 5000);
647
648 let roundtrip: Task = serde_json::from_value(json).unwrap();
649 assert_eq!(roundtrip.task_id, "t-123");
650 assert_eq!(roundtrip.status, TaskStatus::Working);
651 }
652
653 #[test]
654 fn create_task_result_roundtrip() {
655 let result = CreateTaskResult::new(
656 Task::new("t-456", TaskStatus::Completed)
657 .with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:05:00Z"),
658 );
659 let json = serde_json::to_value(&result).unwrap();
660 assert_eq!(json["task"]["taskId"], "t-456");
661 assert_eq!(json["task"]["status"], "completed");
662
663 let roundtrip: CreateTaskResult = serde_json::from_value(json).unwrap();
664 assert_eq!(roundtrip.task.status, TaskStatus::Completed);
665 }
666
667 #[test]
668 fn task_ts_format_interop() {
669 // Test deserialization from TypeScript-format JSON
670 let ts_json = json!({
671 "taskId": "task-abc",
672 "status": "input_required",
673 "createdAt": "2025-11-25T12:00:00.000Z",
674 "lastUpdatedAt": "2025-11-25T12:01:00.000Z",
675 "pollInterval": 3000,
676 "statusMessage": "Waiting for user input"
677 });
678 let task: Task = serde_json::from_value(ts_json).unwrap();
679 assert_eq!(task.task_id, "task-abc");
680 assert_eq!(task.status, TaskStatus::InputRequired);
681 assert_eq!(task.poll_interval, Some(3000));
682 }
683
684 #[test]
685 fn task_metadata_serde_round_trip() {
686 let meta = TaskMetadata {
687 task_id: "t1".to_string(),
688 poll_interval: Some(5000),
689 max_poll_duration_secs: None,
690 };
691 let json = serde_json::to_value(&meta).unwrap();
692 assert_eq!(json["taskId"], "t1");
693 assert_eq!(json["pollInterval"], 5000);
694 // maxPollDurationSecs omitted (skip_serializing_if = None)
695 assert!(
696 json.get("maxPollDurationSecs").is_none(),
697 "maxPollDurationSecs should be omitted when None"
698 );
699
700 let roundtrip: TaskMetadata = serde_json::from_value(json).unwrap();
701 assert_eq!(roundtrip.task_id, "t1");
702 assert_eq!(roundtrip.poll_interval, Some(5000));
703 assert_eq!(roundtrip.max_poll_duration_secs, None);
704 }
705
706 #[test]
707 fn task_metadata_minimal_shape_deserializes() {
708 // The minimal native emit shape carries only taskId.
709 let meta: TaskMetadata = serde_json::from_value(json!({ "taskId": "t1" })).unwrap();
710 assert_eq!(meta.task_id, "t1");
711 assert_eq!(meta.poll_interval, None);
712 assert_eq!(meta.max_poll_duration_secs, None);
713 }
714
715 #[test]
716 fn related_task_meta_key_value() {
717 assert_eq!(
718 RELATED_TASK_META_KEY,
719 "io.modelcontextprotocol/related-task"
720 );
721 }
722
723 #[test]
724 fn task_status_is_terminal() {
725 assert!(!TaskStatus::Working.is_terminal());
726 assert!(!TaskStatus::InputRequired.is_terminal());
727 assert!(TaskStatus::Completed.is_terminal());
728 assert!(TaskStatus::Failed.is_terminal());
729 assert!(TaskStatus::Cancelled.is_terminal());
730 }
731
732 #[test]
733 fn task_status_can_transition_to() {
734 // Working can transition to all except itself
735 assert!(TaskStatus::Working.can_transition_to(&TaskStatus::InputRequired));
736 assert!(TaskStatus::Working.can_transition_to(&TaskStatus::Completed));
737 assert!(TaskStatus::Working.can_transition_to(&TaskStatus::Failed));
738 assert!(TaskStatus::Working.can_transition_to(&TaskStatus::Cancelled));
739
740 // InputRequired can transition to all except itself
741 assert!(TaskStatus::InputRequired.can_transition_to(&TaskStatus::Working));
742 assert!(TaskStatus::InputRequired.can_transition_to(&TaskStatus::Completed));
743 assert!(TaskStatus::InputRequired.can_transition_to(&TaskStatus::Failed));
744 assert!(TaskStatus::InputRequired.can_transition_to(&TaskStatus::Cancelled));
745 }
746
747 #[test]
748 fn task_status_self_transition_rejected() {
749 assert!(!TaskStatus::Working.can_transition_to(&TaskStatus::Working));
750 assert!(!TaskStatus::InputRequired.can_transition_to(&TaskStatus::InputRequired));
751 assert!(!TaskStatus::Completed.can_transition_to(&TaskStatus::Completed));
752 assert!(!TaskStatus::Failed.can_transition_to(&TaskStatus::Failed));
753 assert!(!TaskStatus::Cancelled.can_transition_to(&TaskStatus::Cancelled));
754 }
755
756 #[test]
757 fn task_status_terminal_rejects_all() {
758 for terminal in [
759 TaskStatus::Completed,
760 TaskStatus::Failed,
761 TaskStatus::Cancelled,
762 ] {
763 for target in [
764 TaskStatus::Working,
765 TaskStatus::InputRequired,
766 TaskStatus::Completed,
767 TaskStatus::Failed,
768 TaskStatus::Cancelled,
769 ] {
770 assert!(
771 !terminal.can_transition_to(&target),
772 "{terminal:?} should not transition to {target:?}"
773 );
774 }
775 }
776 }
777
778 #[test]
779 fn task_ttl_null_serialization() {
780 let task = Task::new("test-null-ttl", TaskStatus::Working)
781 .with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z");
782 let json = serde_json::to_value(&task).unwrap();
783 // ttl MUST be present as null, not omitted (MCP spec: number | null)
784 assert!(json.get("ttl").is_some(), "ttl must be present");
785 assert!(json["ttl"].is_null(), "ttl must be null when None");
786 // pollInterval SHOULD be omitted when None
787 assert!(
788 json.get("pollInterval").is_none(),
789 "pollInterval should be omitted when None"
790 );
791 }
792
793 #[test]
794 fn task_ttl_present_serialization() {
795 let task = Task::new("test-present-ttl", TaskStatus::Working)
796 .with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z")
797 .with_ttl(60000);
798 let json = serde_json::to_value(&task).unwrap();
799 assert_eq!(json["ttl"], 60000);
800 }
801
802 #[test]
803 fn task_diagnostic_detail_absent_when_none() {
804 // D-17 PMCP extension: diagnostic_detail defaults to None and MUST be
805 // skip-serialized — byte-identical to pre-field JSON for callers that
806 // never set it.
807 let task = Task::new("t-diag-none", TaskStatus::Working)
808 .with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z");
809 assert_eq!(task.diagnostic_detail, None);
810 let json = serde_json::to_value(&task).unwrap();
811 assert!(
812 json.get("diagnosticDetail").is_none(),
813 "diagnosticDetail must be omitted from JSON when None"
814 );
815 }
816
817 #[test]
818 fn task_diagnostic_detail_round_trip() {
819 // D-17 PMCP extension serde round-trip: Some(...) serializes under the
820 // camelCase `diagnosticDetail` key and deserializes back unchanged.
821 let task = Task::new("t-diag-some", TaskStatus::Failed)
822 .with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z")
823 .with_status_message("The AI service was temporarily unavailable")
824 .with_diagnostic_detail(
825 "step=call_tool op=propose_schema url=https://api.example/v1/x error=timeout",
826 );
827 let json = serde_json::to_value(&task).unwrap();
828 assert_eq!(
829 json["diagnosticDetail"],
830 "step=call_tool op=propose_schema url=https://api.example/v1/x error=timeout"
831 );
832 // Both voices ride independently — statusMessage stays the friendly one.
833 assert_eq!(
834 json["statusMessage"],
835 "The AI service was temporarily unavailable"
836 );
837
838 let roundtrip: Task = serde_json::from_value(json).unwrap();
839 assert_eq!(
840 roundtrip.diagnostic_detail.as_deref(),
841 Some("step=call_tool op=propose_schema url=https://api.example/v1/x error=timeout")
842 );
843 assert_eq!(
844 roundtrip.status_message.as_deref(),
845 Some("The AI service was temporarily unavailable")
846 );
847 }
848
849 #[test]
850 fn task_diagnostic_detail_consumer_tolerance() {
851 // Review concern #3 / T-162-05-COMPAT: a Task JSON carrying the NEW
852 // diagnosticDetail key (plus a hypothetical extra unknown key a
853 // strict/older consumer wouldn't recognize) deserializes cleanly into
854 // `Task` — proving Task does NOT use deny_unknown_fields and existing
855 // consumers tolerate additive wire fields.
856 let wire_json = json!({
857 "taskId": "task-tolerant",
858 "status": "failed",
859 "ttl": null,
860 "createdAt": "2025-11-25T12:00:00.000Z",
861 "lastUpdatedAt": "2025-11-25T12:01:00.000Z",
862 "statusMessage": "The AI service was temporarily unavailable",
863 "diagnosticDetail": "step=call_tool op=propose_schema error=timeout",
864 "someFutureUnknownField": { "nested": "value" }
865 });
866 let task: Task = serde_json::from_value(wire_json)
867 .expect("Task must tolerate diagnosticDetail + an unrelated unknown field");
868 assert_eq!(task.task_id, "task-tolerant");
869 assert_eq!(task.status, TaskStatus::Failed);
870 assert_eq!(
871 task.diagnostic_detail.as_deref(),
872 Some("step=call_tool op=propose_schema error=timeout")
873 );
874 assert_eq!(
875 task.status_message.as_deref(),
876 Some("The AI service was temporarily unavailable")
877 );
878 }
879
880 #[test]
881 fn task_status_display() {
882 assert_eq!(TaskStatus::Working.to_string(), "working");
883 assert_eq!(TaskStatus::InputRequired.to_string(), "input_required");
884 assert_eq!(TaskStatus::Completed.to_string(), "completed");
885 assert_eq!(TaskStatus::Failed.to_string(), "failed");
886 assert_eq!(TaskStatus::Cancelled.to_string(), "cancelled");
887 }
888
889 /// All five `TaskStatus` values, for exhaustive table tests.
890 const ALL_STATUSES: [TaskStatus; 5] = [
891 TaskStatus::Working,
892 TaskStatus::InputRequired,
893 TaskStatus::Completed,
894 TaskStatus::Failed,
895 TaskStatus::Cancelled,
896 ];
897
898 /// The expected `poll_decision()` classification for a status, given the
899 /// task's `poll_interval` (only consulted for the `Working` case).
900 fn expected_decision(status: TaskStatus, poll_interval: Option<u64>) -> TaskPollDecision {
901 match status {
902 TaskStatus::Working => TaskPollDecision::InProgress {
903 poll_hint: poll_interval,
904 },
905 TaskStatus::InputRequired => TaskPollDecision::InputRequired,
906 TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled => {
907 TaskPollDecision::Terminal { status }
908 },
909 }
910 }
911
912 #[test]
913 fn poll_decision_maps_every_status() {
914 // Working -> InProgress carrying the poll_interval verbatim.
915 let working = Task::new("t-w", TaskStatus::Working).with_poll_interval(2500);
916 assert_eq!(
917 working.poll_decision(),
918 TaskPollDecision::InProgress {
919 poll_hint: Some(2500)
920 }
921 );
922
923 // Working with no poll_interval -> InProgress { poll_hint: None }.
924 let working_none = Task::new("t-wn", TaskStatus::Working);
925 assert_eq!(
926 working_none.poll_decision(),
927 TaskPollDecision::InProgress { poll_hint: None }
928 );
929
930 // InputRequired -> unit variant.
931 let waiting = Task::new("t-i", TaskStatus::InputRequired);
932 assert_eq!(waiting.poll_decision(), TaskPollDecision::InputRequired);
933
934 // Each terminal status -> Terminal carrying that status.
935 for status in [
936 TaskStatus::Completed,
937 TaskStatus::Failed,
938 TaskStatus::Cancelled,
939 ] {
940 let task = Task::new("t-term", status);
941 assert_eq!(
942 task.poll_decision(),
943 TaskPollDecision::Terminal { status },
944 "{status:?} must classify as Terminal"
945 );
946 }
947 }
948
949 #[test]
950 fn poll_decision_covers_all_statuses_exhaustively() {
951 // Table drive across EVERY TaskStatus value (guards D-15 exhaustiveness).
952 for status in ALL_STATUSES {
953 let task = Task::new("t-x", status).with_poll_interval(1234);
954 assert_eq!(
955 task.poll_decision(),
956 expected_decision(status, Some(1234)),
957 "poll_decision() drifted for {status:?}"
958 );
959 }
960 }
961
962 #[test]
963 fn resolve_poll_interval_precedence() {
964 // Caller override wins over the server hint.
965 assert_eq!(resolve_poll_interval(Some(200), Some(999)), 200);
966 // Server hint used when there is no caller override.
967 assert_eq!(resolve_poll_interval(None, Some(300)), 300);
968 // Falls back to DEFAULT_POLL_MS when neither is specified.
969 assert_eq!(resolve_poll_interval(None, None), DEFAULT_POLL_MS);
970 assert_eq!(resolve_poll_interval(None, None), 1000);
971 }
972
973 #[test]
974 fn resolve_poll_interval_floors_zero() {
975 // A zero override cannot hot-spin — floored to MIN_POLL_MS.
976 assert_eq!(resolve_poll_interval(Some(0), None), MIN_POLL_MS);
977 assert_eq!(resolve_poll_interval(Some(0), None), 50);
978 // The floor also applies to a low server hint.
979 assert_eq!(resolve_poll_interval(None, Some(10)), 50);
980 // And to a zero hint.
981 assert_eq!(resolve_poll_interval(None, Some(0)), 50);
982 }
983
984 proptest::proptest! {
985 /// For every TaskStatus and any poll_interval, poll_decision() returns
986 /// exactly the mapped variant — the classifier never drifts.
987 #[test]
988 fn poll_decision_matches_expected_map(
989 status_idx in 0usize..ALL_STATUSES.len(),
990 poll_interval in proptest::option::of(proptest::prelude::any::<u64>()),
991 ) {
992 let status = ALL_STATUSES[status_idx];
993 let mut task = Task::new("t-prop", status);
994 task.poll_interval = poll_interval;
995 proptest::prop_assert_eq!(
996 task.poll_decision(),
997 expected_decision(status, poll_interval)
998 );
999 }
1000
1001 /// The 50 ms floor holds for ALL caller/hint inputs, not just the
1002 /// tabled cases (T-105-01 invariant).
1003 #[test]
1004 fn resolve_poll_interval_never_below_floor(
1005 caller in proptest::option::of(proptest::prelude::any::<u64>()),
1006 hint in proptest::option::of(proptest::prelude::any::<u64>()),
1007 ) {
1008 proptest::prop_assert!(resolve_poll_interval(caller, hint) >= MIN_POLL_MS);
1009 }
1010 }
1011}