fastmcp/tests/tasks/server/test_wire_models.py
Jeremiah Lowin ef29b731ea
Add server-side claim production for tasks; emit resultType discriminator
Widen the tools/call result serialization (via a refcounted, modern-gated wrap
installed by TasksExtension) so a CreateTaskResult reaches the client instead of
being stripped by the CallToolResult|InputRequiredResult surface — the SDK ships
claim consumption but no production. Emit the resultType discriminator the
protocol requires (task on CreateTaskResult, complete on the tasks/* results);
the draft schema forbids it (additionalProperties:false), a contradiction
reported upstream. Closes compliance gaps G1/G4/G5.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 08:03:32 -04:00

163 lines
5.5 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.
**Known schema-vs-protocol contradiction:** the modern `tools/call` result union
carries a required `resultType` discriminator, and the SDK's client-side
`ResultClaim` requires `CreateTaskResult` to pin `resultType: "task"` — so we
emit it. The draft schema's Task arm, however, forbids `resultType` (its
`additionalProperties: false` does not list it). We validate the task *fields*
against the schema with the discriminator stripped, and assert separately that
the discriminator is present on the wire. This contradiction is reported
upstream (the schema forbids a field the base protocol requires).
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
from fastmcp_tasks.models import (
CancelTaskResult,
CreateTaskResult,
GetTaskResult,
TaskStatus,
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 _dump_task_fields(model: Any) -> dict[str, Any]:
"""Dump without the `resultType` discriminator the draft schema omits.
`resultType` is required by the protocol's result union but forbidden by the
schema's Task arm; strip it so the remaining task fields can be validated
against the schema. `test_create_task_result_emits_result_type_discriminator`
covers the discriminator itself.
"""
dumped = _dump(model)
dumped.pop("resultType", None)
return dumped
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_task_fields(result))
def test_create_task_result_emits_result_type_discriminator():
"""The protocol requires `resultType: "task"` to distinguish a tasked result.
The modern `tools/call` union discriminates on `resultType`, and the SDK's
`ResultClaim` for tasks pins the model to `Literal["task"]`; without it a
client cannot tell a task result from a `CallToolResult`.
"""
result = CreateTaskResult(
task_id="t1",
status="working",
created_at=_ISO,
last_updated_at=_ISO,
ttl_ms=900000,
)
assert _dump(result)["resultType"] == "task"
@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: TaskStatus, payload: dict[str, Any]):
result = GetTaskResult(
task_id="t1",
status=status,
created_at=_ISO,
last_updated_at=_ISO,
ttl_ms=900000,
poll_interval_ms=5000,
**payload,
)
_validate("GetTaskResult", _dump_task_fields(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,
# and the resultType the draft schema omits (see module docstring).
dumped = {k: v for k, v in dumped.items() if v is not None or k == "ttlMs"}
dumped.pop("resultType", None)
_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))