mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
- tasks/get|update|cancel now return -32003 when the client did not declare the tasks extension for the request (SEP-2663 MUST). - A task tool that raises is a completed task with an is_error result, not a failed task; failed is reserved for protocol faults, matching a live tools/call. - A per-task lock serializes concurrent tasks/update so two racing answers cannot each enqueue a next leg (double execution). Co-Authored-By: Claude <noreply@anthropic.com>
130 lines
4.2 KiB
Python
130 lines
4.2 KiB
Python
"""Task protocol methods for SEP-2663: tasks/get, tasks/cancel, tasks/update.
|
|
|
|
SEP-1686's `tasks/result` and `tasks/list` are removed — `tasks/get` inlines the
|
|
completed result. This suite covers the surviving methods, driven in-process via
|
|
the task helpers because there is no client task-submission API until Phase 4.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
from fastmcp_tasks.models import UpdateTaskResult
|
|
from mcp.shared.exceptions import MCPError
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.exceptions import ToolError
|
|
from fastmcp_tasks import TasksExtension
|
|
from tests.tasks.task_helpers import (
|
|
cancel_task,
|
|
get_task,
|
|
run_task,
|
|
running_task_server,
|
|
submit_task,
|
|
update_task,
|
|
wait_for_task,
|
|
)
|
|
|
|
|
|
def _methods_server() -> FastMCP:
|
|
mcp = FastMCP("endpoint-test-server")
|
|
mcp.add_extension(TasksExtension())
|
|
|
|
@mcp.tool(task=True)
|
|
async def quick_tool(value: int) -> int:
|
|
return value * 2
|
|
|
|
@mcp.tool(task=True)
|
|
async def error_tool() -> str:
|
|
raise ToolError("Task failed!")
|
|
|
|
return mcp
|
|
|
|
|
|
async def test_tasks_get_returns_status_and_inlined_result():
|
|
"""`tasks/get` reports status and inlines the completed tool result."""
|
|
mcp = _methods_server()
|
|
async with running_task_server(mcp):
|
|
created = await submit_task(mcp, "quick_tool", {"value": 21})
|
|
got = await get_task(mcp, created.task_id)
|
|
assert got.task_id == created.task_id
|
|
assert got.status in {"working", "completed"}
|
|
|
|
final = await wait_for_task(mcp, created.task_id)
|
|
assert final.status == "completed"
|
|
assert final.result is not None
|
|
assert final.result["structuredContent"] == {"result": 42}
|
|
assert final.result is not None
|
|
assert final.result["isError"] is False
|
|
|
|
|
|
async def test_tasks_get_includes_poll_interval():
|
|
"""`tasks/get` includes the poll-interval hint."""
|
|
mcp = _methods_server()
|
|
async with running_task_server(mcp):
|
|
created = await submit_task(mcp, "quick_tool", {"value": 42})
|
|
got = await get_task(mcp, created.task_id)
|
|
assert got.poll_interval_ms == 5000
|
|
|
|
|
|
async def test_tasks_get_returns_is_error_result_for_raised_tool():
|
|
"""A raised tool error is a completed task with an is_error result (SEP-2663)."""
|
|
mcp = _methods_server()
|
|
async with running_task_server(mcp):
|
|
final = await run_task(mcp, "error_tool", {})
|
|
assert final.status == "completed"
|
|
assert final.error is None
|
|
assert final.result is not None
|
|
assert final.result["isError"] is True
|
|
assert "Task failed!" in final.result["content"][0]["text"]
|
|
|
|
|
|
async def test_tasks_get_unknown_id_raises_not_found():
|
|
"""`tasks/get` for an unknown id raises a not-found error (-32602)."""
|
|
mcp = _methods_server()
|
|
async with running_task_server(mcp):
|
|
with pytest.raises(MCPError, match="not found"):
|
|
await get_task(mcp, "nonexistent-task-id")
|
|
|
|
|
|
async def test_tasks_cancel_transitions_to_cancelled():
|
|
"""`tasks/cancel` transitions a running task to cancelled."""
|
|
mcp = FastMCP("cancel-test")
|
|
mcp.add_extension(TasksExtension())
|
|
release = asyncio.Event()
|
|
|
|
@mcp.tool(task=True)
|
|
async def slow_tool() -> str:
|
|
await release.wait()
|
|
return "done"
|
|
|
|
async with running_task_server(mcp):
|
|
created = await submit_task(mcp, "slow_tool", {})
|
|
await cancel_task(mcp, created.task_id)
|
|
# Release so the worker unwinds whether or not it observed the cancel first.
|
|
release.set()
|
|
final = await wait_for_task(
|
|
mcp,
|
|
created.task_id,
|
|
target_states=frozenset({"cancelled", "completed"}),
|
|
)
|
|
assert final.status in {"cancelled", "completed"}
|
|
|
|
|
|
async def test_tasks_update_acks_empty():
|
|
"""`tasks/update` returns an empty ack."""
|
|
mcp = FastMCP("update-test")
|
|
mcp.add_extension(TasksExtension())
|
|
release = asyncio.Event()
|
|
|
|
@mcp.tool(task=True)
|
|
async def waiter() -> str:
|
|
await release.wait()
|
|
return "done"
|
|
|
|
async with running_task_server(mcp):
|
|
created = await submit_task(mcp, "waiter", {})
|
|
ack = await update_task(mcp, created.task_id, {})
|
|
assert isinstance(ack, UpdateTaskResult)
|
|
release.set()
|