mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
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>
163 lines
5.5 KiB
Python
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))
|