|
4 | 4 |
|
5 | 5 | import pytest |
6 | 6 |
|
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 |
9 | 10 |
|
10 | 11 |
|
11 | 12 | @pytest.mark.anyio |
@@ -339,3 +340,160 @@ async def test_terminal_task_ttl_reset() -> None: |
339 | 340 | assert new_expiry >= initial_expiry |
340 | 341 |
|
341 | 342 | 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