Skip to content

Commit a97a474

Browse files
committed
Add cancel_task helper and terminal status transition validation
Add spec-compliant task cancellation and status transition handling: - Add cancel_task() helper that validates task state before cancellation, returning -32602 (Invalid params) for nonexistent or terminal tasks - Add terminal status transition validation in InMemoryTaskStore.update_task() to prevent transitions from completed/failed/cancelled states - Export cancel_task from the tasks module for easy access - Add comprehensive tests for both features Per spec: "Receivers MUST reject cancellation of terminal status tasks with -32602 (Invalid params)" and "Terminal states MUST NOT transition to any other status"
1 parent 0c311d6 commit a97a474

File tree

5 files changed

+233
-4
lines changed

5 files changed

+233
-4
lines changed

src/mcp/shared/experimental/tasks/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
- InMemoryTaskStore: Reference implementation for testing/development
88
- TaskMessageQueue: FIFO queue for task messages delivered via tasks/result
99
- InMemoryTaskMessageQueue: Reference implementation for message queue
10-
- Helper functions: run_task, is_terminal, create_task_state, generate_task_id
10+
- Helper functions: run_task, is_terminal, create_task_state, generate_task_id, cancel_task
1111
1212
Architecture:
1313
- TaskStore is pure storage - it doesn't know about execution
@@ -20,6 +20,7 @@
2020

2121
from mcp.shared.experimental.tasks.context import TaskContext
2222
from mcp.shared.experimental.tasks.helpers import (
23+
cancel_task,
2324
create_task_state,
2425
generate_task_id,
2526
is_terminal,
@@ -53,4 +54,5 @@
5354
"is_terminal",
5455
"create_task_state",
5556
"generate_task_id",
57+
"cancel_task",
5658
]

src/mcp/shared/experimental/tasks/helpers.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,19 @@
1010

1111
from anyio.abc import TaskGroup
1212

13+
from mcp.shared.exceptions import McpError
1314
from mcp.shared.experimental.tasks.context import TaskContext
1415
from mcp.shared.experimental.tasks.store import TaskStore
15-
from mcp.types import CreateTaskResult, Result, Task, TaskMetadata, TaskStatus
16+
from mcp.types import (
17+
INVALID_PARAMS,
18+
CancelTaskResult,
19+
CreateTaskResult,
20+
ErrorData,
21+
Result,
22+
Task,
23+
TaskMetadata,
24+
TaskStatus,
25+
)
1626

1727
if TYPE_CHECKING:
1828
from mcp.server.session import ServerSession
@@ -33,6 +43,58 @@ def is_terminal(status: TaskStatus) -> bool:
3343
return status in ("completed", "failed", "cancelled")
3444

3545

46+
async def cancel_task(
47+
store: TaskStore,
48+
task_id: str,
49+
) -> CancelTaskResult:
50+
"""
51+
Cancel a task with spec-compliant validation.
52+
53+
Per spec: "Receivers MUST reject cancellation of terminal status tasks
54+
with -32602 (Invalid params)"
55+
56+
This helper validates that the task exists and is not in a terminal state
57+
before setting it to "cancelled".
58+
59+
Args:
60+
store: The task store
61+
task_id: The task identifier to cancel
62+
63+
Returns:
64+
CancelTaskResult with the cancelled task state
65+
66+
Raises:
67+
McpError: With INVALID_PARAMS (-32602) if:
68+
- Task does not exist
69+
- Task is already in a terminal state (completed, failed, cancelled)
70+
71+
Example:
72+
@server.experimental.cancel_task()
73+
async def handle_cancel(request: CancelTaskRequest) -> CancelTaskResult:
74+
return await cancel_task(store, request.params.taskId)
75+
"""
76+
task = await store.get_task(task_id)
77+
if task is None:
78+
raise McpError(
79+
ErrorData(
80+
code=INVALID_PARAMS,
81+
message=f"Task not found: {task_id}",
82+
)
83+
)
84+
85+
if is_terminal(task.status):
86+
raise McpError(
87+
ErrorData(
88+
code=INVALID_PARAMS,
89+
message=f"Cannot cancel task in terminal state '{task.status}'",
90+
)
91+
)
92+
93+
# Update task to cancelled status
94+
cancelled_task = await store.update_task(task_id, status="cancelled")
95+
return CancelTaskResult(**cancelled_task.model_dump())
96+
97+
3698
def generate_task_id() -> str:
3799
"""Generate a unique task ID."""
38100
return str(uuid4())

src/mcp/shared/experimental/tasks/in_memory_task_store.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ async def update_task(
114114
if stored is None:
115115
raise ValueError(f"Task with ID {task_id} not found")
116116

117+
# Per spec: Terminal states MUST NOT transition to any other status
118+
if status is not None and status != stored.task.status and is_terminal(stored.task.status):
119+
raise ValueError(f"Cannot transition from terminal status '{stored.task.status}'")
120+
117121
status_changed = False
118122
if status is not None and stored.task.status != status:
119123
stored.task.status = status

src/mcp/shared/experimental/tasks/store.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ async def update_task(
6969
7070
Raises:
7171
ValueError: If task not found
72+
ValueError: If attempting to transition from a terminal status
73+
(completed, failed, cancelled). Per spec, terminal states
74+
MUST NOT transition to any other status.
7275
"""
7376

7477
@abstractmethod

tests/experimental/tasks/server/test_store.py

Lines changed: 160 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55
import pytest
66

7-
from mcp.shared.experimental.tasks import InMemoryTaskStore
8-
from mcp.types import CallToolResult, TaskMetadata, TextContent
7+
from mcp.shared.exceptions import McpError
8+
from mcp.shared.experimental.tasks import InMemoryTaskStore, cancel_task
9+
from mcp.types import INVALID_PARAMS, CallToolResult, TaskMetadata, TextContent
910

1011

1112
@pytest.mark.anyio
@@ -339,3 +340,160 @@ async def test_terminal_task_ttl_reset() -> None:
339340
assert new_expiry >= initial_expiry
340341

341342
store.cleanup()
343+
344+
345+
@pytest.mark.anyio
346+
async def test_terminal_status_transition_rejected() -> None:
347+
"""Test that transitions from terminal states are rejected.
348+
349+
Per spec: Terminal states (completed, failed, cancelled) MUST NOT
350+
transition to any other status.
351+
"""
352+
store = InMemoryTaskStore()
353+
354+
# Test each terminal status
355+
for terminal_status in ("completed", "failed", "cancelled"):
356+
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
357+
358+
# Move to terminal state
359+
await store.update_task(task.taskId, status=terminal_status)
360+
361+
# Attempting to transition to any other status should raise
362+
with pytest.raises(ValueError, match="Cannot transition from terminal status"):
363+
await store.update_task(task.taskId, status="working")
364+
365+
# Also test transitioning to another terminal state
366+
other_terminal = "failed" if terminal_status != "failed" else "completed"
367+
with pytest.raises(ValueError, match="Cannot transition from terminal status"):
368+
await store.update_task(task.taskId, status=other_terminal)
369+
370+
store.cleanup()
371+
372+
373+
@pytest.mark.anyio
374+
async def test_terminal_status_allows_same_status() -> None:
375+
"""Test that setting the same terminal status doesn't raise.
376+
377+
This is not a transition, so it should be allowed (no-op).
378+
"""
379+
store = InMemoryTaskStore()
380+
381+
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
382+
await store.update_task(task.taskId, status="completed")
383+
384+
# Setting the same status should not raise
385+
updated = await store.update_task(task.taskId, status="completed")
386+
assert updated.status == "completed"
387+
388+
# Updating just the message should also work
389+
updated = await store.update_task(task.taskId, status_message="Updated message")
390+
assert updated.statusMessage == "Updated message"
391+
392+
store.cleanup()
393+
394+
395+
# =============================================================================
396+
# cancel_task helper function tests
397+
# =============================================================================
398+
399+
400+
@pytest.mark.anyio
401+
async def test_cancel_task_succeeds_for_working_task() -> None:
402+
"""Test cancel_task helper succeeds for a working task."""
403+
store = InMemoryTaskStore()
404+
405+
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
406+
assert task.status == "working"
407+
408+
result = await cancel_task(store, task.taskId)
409+
410+
assert result.taskId == task.taskId
411+
assert result.status == "cancelled"
412+
413+
# Verify store is updated
414+
retrieved = await store.get_task(task.taskId)
415+
assert retrieved is not None
416+
assert retrieved.status == "cancelled"
417+
418+
store.cleanup()
419+
420+
421+
@pytest.mark.anyio
422+
async def test_cancel_task_rejects_nonexistent_task() -> None:
423+
"""Test cancel_task raises McpError with INVALID_PARAMS for nonexistent task."""
424+
store = InMemoryTaskStore()
425+
426+
with pytest.raises(McpError) as exc_info:
427+
await cancel_task(store, "nonexistent-task-id")
428+
429+
assert exc_info.value.error.code == INVALID_PARAMS
430+
assert "not found" in exc_info.value.error.message
431+
432+
store.cleanup()
433+
434+
435+
@pytest.mark.anyio
436+
async def test_cancel_task_rejects_completed_task() -> None:
437+
"""Test cancel_task raises McpError with INVALID_PARAMS for completed task."""
438+
store = InMemoryTaskStore()
439+
440+
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
441+
await store.update_task(task.taskId, status="completed")
442+
443+
with pytest.raises(McpError) as exc_info:
444+
await cancel_task(store, task.taskId)
445+
446+
assert exc_info.value.error.code == INVALID_PARAMS
447+
assert "terminal state 'completed'" in exc_info.value.error.message
448+
449+
store.cleanup()
450+
451+
452+
@pytest.mark.anyio
453+
async def test_cancel_task_rejects_failed_task() -> None:
454+
"""Test cancel_task raises McpError with INVALID_PARAMS for failed task."""
455+
store = InMemoryTaskStore()
456+
457+
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
458+
await store.update_task(task.taskId, status="failed")
459+
460+
with pytest.raises(McpError) as exc_info:
461+
await cancel_task(store, task.taskId)
462+
463+
assert exc_info.value.error.code == INVALID_PARAMS
464+
assert "terminal state 'failed'" in exc_info.value.error.message
465+
466+
store.cleanup()
467+
468+
469+
@pytest.mark.anyio
470+
async def test_cancel_task_rejects_already_cancelled_task() -> None:
471+
"""Test cancel_task raises McpError with INVALID_PARAMS for already cancelled task."""
472+
store = InMemoryTaskStore()
473+
474+
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
475+
await store.update_task(task.taskId, status="cancelled")
476+
477+
with pytest.raises(McpError) as exc_info:
478+
await cancel_task(store, task.taskId)
479+
480+
assert exc_info.value.error.code == INVALID_PARAMS
481+
assert "terminal state 'cancelled'" in exc_info.value.error.message
482+
483+
store.cleanup()
484+
485+
486+
@pytest.mark.anyio
487+
async def test_cancel_task_succeeds_for_input_required_task() -> None:
488+
"""Test cancel_task helper succeeds for a task in input_required status."""
489+
store = InMemoryTaskStore()
490+
491+
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
492+
await store.update_task(task.taskId, status="input_required")
493+
494+
result = await cancel_task(store, task.taskId)
495+
496+
assert result.taskId == task.taskId
497+
assert result.status == "cancelled"
498+
499+
store.cleanup()

0 commit comments

Comments
 (0)