fastmcp/tests/tasks/server/test_wire_models.py
Jeremiah Lowin 5fa2883670
Implement SEP-2663 tasks extension: TasksExtension, poll-based task lifecycle
TasksExtension serves io.modelcontextprotocol/tasks on the extension API:
a decide-and-task tools/call interceptor (era-gated to modern connections),
tasks/get with inlined results and inputRequests, tasks/update delivering
poll-based in-task elicitation, tasks/cancel, durable creation, and
auth-scoped task isolation. Wire models validate against the vendored
ext-tasks schema. Worker-side Context hooks are refcounted so sibling
servers cannot strand each other's workers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 23:00:38 -04:00

123 lines
3.7 KiB
Python

"""Validate the SEP-2663 wire models against the vendored draft JSON schema.
The models in `fastmcp_tasks.models` serialize to the `io.modelcontextprotocol/tasks`
extension shapes. This suite validates a serialized instance of each result shape
against the corresponding `$defs` entry in the vendored draft schema
(`tests/fixtures/ext-tasks-schema-draft.json`), so wire drift is caught here.
The vendored schema composes results as `allOf[Result, Task]` where the Task arm
carries `additionalProperties: false`; a stray `_meta` therefore fails
validation. The models omit `_meta` and the runner's `exclude_none` dump keeps it
out, which is exactly what these assertions check.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
from fastmcp_tasks.models import (
CancelTaskResult,
CreateTaskResult,
GetTaskResult,
UpdateTaskResult,
)
from jsonschema import Draft202012Validator
_SCHEMA = json.loads(
(Path(__file__).parents[2] / "fixtures" / "ext-tasks-schema-draft.json").read_text()
)
_DEFS = _SCHEMA["$defs"]
_ISO = "2026-07-21T12:00:00+00:00"
def _validate(def_name: str, instance: dict[str, Any]) -> None:
schema = {"$defs": _DEFS, **_DEFS[def_name]}
Draft202012Validator(schema).validate(instance)
def _dump(model: Any) -> dict[str, Any]:
return model.model_dump(by_alias=True, mode="json", exclude_none=True)
def test_create_task_result_matches_schema():
result = CreateTaskResult(
task_id="t1",
status="working",
created_at=_ISO,
last_updated_at=_ISO,
ttl_ms=900000,
poll_interval_ms=5000,
)
_validate("CreateTaskResult", _dump(result))
@pytest.mark.parametrize(
("status", "payload"),
[
("working", {}),
("completed", {"result": {"content": [], "isError": False}}),
("failed", {"error": {"code": -32603, "message": "boom"}}),
(
"input_required",
{
"input_requests": {
"k1": {"method": "elicitation/create", "params": {"message": "?"}}
}
},
),
("cancelled", {}),
],
)
def test_get_task_result_matches_schema(status: str, payload: dict[str, Any]):
result = GetTaskResult(
task_id="t1",
status=status, # type: ignore[arg-type]
created_at=_ISO,
last_updated_at=_ISO,
ttl_ms=900000,
poll_interval_ms=5000,
**payload,
)
_validate("GetTaskResult", _dump(result))
def test_get_task_result_completed_omits_error_and_inputs():
"""A completed result carries only `result` (the union arm forbids the rest)."""
result = GetTaskResult(
task_id="t1",
status="completed",
created_at=_ISO,
last_updated_at=_ISO,
ttl_ms=900000,
result={"content": [], "isError": False},
)
dumped = _dump(result)
assert "error" not in dumped
assert "inputRequests" not in dumped
def test_null_ttl_is_permitted_by_schema():
"""`ttlMs` is required-but-nullable; a null TTL still validates."""
result = CreateTaskResult(
task_id="t1",
status="working",
created_at=_ISO,
last_updated_at=_ISO,
ttl_ms=None,
)
dumped = result.model_dump(by_alias=True, mode="json", exclude_none=False)
# Drop the other None optionals the runner would also drop, keeping ttlMs=null.
dumped = {
k: v for k, v in dumped.items() if v is not None or k == "ttlMs"
}
_validate("CreateTaskResult", dumped)
@pytest.mark.parametrize("model", [UpdateTaskResult(), CancelTaskResult()])
def test_ack_results_match_schema(model: Any):
def_name = type(model).__name__
_validate(def_name, _dump(model))