From 9f166f6fcdff8c9c6a949a16c0322ee269a42fd8 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 23:07:02 +0200 Subject: [PATCH] Add the SEP-2663 Tasks extension (core) Implement io.modelcontextprotocol/tasks per SEP-2663 (Final), wire-incompatible with the 2025-11-25 in-core design still carried (types-only) in mcp_types, so the extension defines its own SEP-2663-shaped models: - The server decides task augmentation per request; the legacy params.task field is ignored. Only a client that declared the extension on a modern (2026-07-28) connection is augmented - a legacy handshake cannot carry the capability, so it is never augmented. - A task-augmented tools/call returns a flat CreateTaskResult (resultType: "task", taskId/status/createdAt/lastUpdatedAt/ttlMs). - tasks/get returns a DetailedTask (resultType: "complete"); a completed task inlines the original CallToolResult. isError: true is a completed task (failed is reserved for JSON-RPC errors). - tasks/cancel is an empty ack. tasks/result is not registered, so it returns -32601. A tasks/* call from a non-declaring client returns -32003 with a requiredCapabilities payload. Task ids are entropy-bearing. Ships a runnable tasks story (server-decided augmentation + tasks/get polling) and a migration note. Deferred to follow-ups (each needs deeper SDK plumbing): tasks/update + the MRTR input_required loop, ToolExecution.taskSupport gating with -32021, notifications/tasks, and SEP-2243 task routing headers. --- docs/migration.md | 19 +- examples/stories/apps/README.md | 1 + examples/stories/manifest.toml | 8 +- examples/stories/tasks/README.md | 54 ++++-- examples/stories/tasks/__init__.py | 0 examples/stories/tasks/client.py | 54 ++++++ examples/stories/tasks/server.py | 27 +++ src/mcp/server/tasks.py | 232 ++++++++++++++++++++++ tests/server/test_tasks.py | 299 +++++++++++++++++++++++++++++ 9 files changed, 674 insertions(+), 20 deletions(-) create mode 100644 examples/stories/tasks/__init__.py create mode 100644 examples/stories/tasks/client.py create mode 100644 examples/stories/tasks/server.py create mode 100644 src/mcp/server/tasks.py create mode 100644 tests/server/test_tasks.py diff --git a/docs/migration.md b/docs/migration.md index c19ed2f31c..00fbf703dd 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -424,10 +424,21 @@ from mcp.server.apps import Apps mcp = MCPServer("demo", extensions=[Apps()]) ``` -The reference extension is `mcp.server.apps.Apps` (`io.modelcontextprotocol/ui`): -it binds a tool to a `ui://` UI resource via `_meta.ui.resourceUri`, and -`client_supports_apps(ctx)` gates the SEP-2133 text-only fallback (checking the -client advertised the `text/html;profile=mcp-app` MIME type). +Two reference extensions ship in their own modules: + +- `mcp.server.apps.Apps` (`io.modelcontextprotocol/ui`) binds a tool to a `ui://` + UI resource via `_meta.ui.resourceUri`, and `client_supports_apps(ctx)` gates the + SEP-2133 text-only fallback (checking the client advertised the + `text/html;profile=mcp-app` MIME type). +- `mcp.server.tasks.Tasks` (`io.modelcontextprotocol/tasks`, SEP-2663) defers a + `tools/call` as a task: for a client that declared the extension on a modern + connection, the server may return a `CreateTaskResult` (`resultType: "task"`) + instead of the `CallToolResult`, and the client polls `tasks/get` / + `tasks/cancel`. The server decides augmentation (the legacy `params.task` field + is ignored); a `tasks/*` call from a non-declaring client is rejected with + `-32003`. This is the conformant core; `tasks/update` + the MRTR input loop, + `ToolExecution.taskSupport` gating, `notifications/tasks`, and task routing + headers are deferred. A `MethodBinding` may set `protocol_versions` to scope an extension method to specific wire versions; a request at any other version is `METHOD_NOT_FOUND`. An diff --git a/examples/stories/apps/README.md b/examples/stories/apps/README.md index dc180a0d3d..b384a2f5ac 100644 --- a/examples/stories/apps/README.md +++ b/examples/stories/apps/README.md @@ -37,4 +37,5 @@ uv run python -m stories.apps.client --http ## See also +`tasks/` (the interceptive half of the extension API), `custom_methods/` (registering a non-spec method without an extension). diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index 9816b568b8..a1f3b71ab9 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -56,6 +56,13 @@ lowlevel = false transports = ["in-memory", "http-asgi"] era = "dual-in-body" +[story.tasks] +# SEP-2663 tasks extension; server-decided augmentation + tasks/get drop to client.session. +# extensions ride server/discover (modern-only), so the connection is pinned to "auto". +lowlevel = false +transports = ["in-memory", "http-asgi"] +era = "dual-in-body" + [story.schema_validators] [story.middleware] @@ -150,6 +157,5 @@ fixed_port = 8000 # issuer/PRM metadata bake in :8 [deferred] caching = "client honouring + per-result override unlanded" subscriptions = "#2901 — Client.listen / ServerEventBus" -tasks = "SEP-2663 — tasks extension runtime (server-decided augmentation, CreateTaskResult)" skills = "#2896 — SEP-2640" events = "#2901 + #2896" diff --git a/examples/stories/tasks/README.md b/examples/stories/tasks/README.md index d1956d1e33..4c988949c7 100644 --- a/examples/stories/tasks/README.md +++ b/examples/stories/tasks/README.md @@ -1,24 +1,48 @@ # tasks -Task-augmented execution: a requestor augments a `tools/call` with a `task`, the -receiver returns a `CreateTaskResult` immediately, and the requestor polls -`tasks/get` and retrieves the deferred result. - -**Status: deferred.** Tasks ship in 2026-07-28 as -[SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2663-tasks-extension.md), -an `io.modelcontextprotocol/tasks` extension that is wire-incompatible with the -2025-11-25 in-core design still carried (types-only) in `mcp_types`. The runtime -needs to be built to the SEP — server-decided augmentation (ignoring the legacy -`params.task`), the `{tasks/get, tasks/update, tasks/cancel}` method set, the -`resultType: "task"` envelope, `execution.taskSupport` gating, and `ttlMs` -fields — so it lands in a separate PR with the conformance `tasks-*` scenarios -wired in. +Task-augmented execution (SEP-2663). A client declares the +`io.modelcontextprotocol/tasks` extension; the server may then answer a +`tools/call` with a `CreateTaskResult` (carrying a task id) instead of blocking, +and the client polls `tasks/get` for status and the eventual result. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.tasks.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.tasks.client --http +``` + +## What to look at + +- `server.py` `MCPServer("tasks-example", extensions=[Tasks(default_ttl_ms=...)])` — + opt in at construction. The extension advertises `io.modelcontextprotocol/tasks` + and serves `tasks/get` and `tasks/cancel`. +- `mcp.server.tasks.Tasks.intercept_tool_call` — the server DECIDES augmentation; + the legacy `params.task` field is ignored. It augments only for a client that + declared the extension on the request, returning a flat `CreateTaskResult` + (`resultType: "task"`). +- `client.py` `Client(target, extensions={EXTENSION_ID: {}})` — declaring the + extension is what lets the server defer; `main` then reads the `CreateTaskResult` + and polls `tasks/get`, whose completed `DetailedTask` inlines the original + `CallToolResult`. + +## Scope + +This is the SEP-2663 conformant *core*. The tool runs to completion inline (so a +task is observed as `completed` immediately), and the store is in-memory. Deferred +to follow-ups, each needing deeper SDK plumbing: `tasks/update` + the MRTR +`input_required` loop, `ToolExecution.taskSupport` gating with the `-32021` +required-task error, `notifications/tasks`, and SEP-2243 task routing headers. ## Spec -[SEP-2663 — Tasks extension](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2663-tasks-extension.md) +[SEP-2663 — Tasks extension](https://modelcontextprotocol.io/seps/2663-tasks-extension.md) · [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) ## See also -`apps/` (the additive half of the extension API). +`apps/` (the additive half of the extension API), +`custom_methods/` (a non-spec method without an extension). diff --git a/examples/stories/tasks/__init__.py b/examples/stories/tasks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/tasks/client.py b/examples/stories/tasks/client.py new file mode 100644 index 0000000000..64253da787 --- /dev/null +++ b/examples/stories/tasks/client.py @@ -0,0 +1,54 @@ +"""Declare the tasks extension, let the server defer a tool call, then poll tasks/get. + +The client declares `io.modelcontextprotocol/tasks` (via `Client(extensions=...)`), +so the server is free to answer `tools/call` with a `CreateTaskResult`. `Client` +exposes only spec verbs, so the augmented call and `tasks/get` drop to +`client.session`; the thin `_send` helper keeps that out of the story below. +""" + +from typing import Any, Literal, cast + +import mcp_types as types +from pydantic import TypeAdapter + +from mcp.client import Client, ClientSession +from mcp.server.tasks import EXTENSION_ID, GetTaskRequestParams +from stories._harness import Target, run_client + +_RAW: TypeAdapter[dict[str, Any]] = TypeAdapter(dict) + + +class _GetTaskRequest(types.Request[GetTaskRequestParams, Literal["tasks/get"]]): + method: Literal["tasks/get"] = "tasks/get" + params: GetTaskRequestParams + + +async def _send(session: ClientSession, request: types.Request[Any, Any]) -> dict[str, Any]: + """Send a request whose result has a non-spec (extension) shape; return the raw dict.""" + return await session.send_request(cast("types.ClientRequest", request), cast("Any", _RAW)) + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode, extensions={EXTENSION_ID: {}}) as client: + # The extension is a modern-only capability negotiated over server/discover. + # A legacy connection (today's stdio) cannot carry it, and the server then + # must not augment, so the task flow only runs once it is negotiated. + if client.server_capabilities.extensions is None: + return + assert client.server_capabilities.extensions == {EXTENSION_ID: {}} + + # The server augments this tools/call into a task because we declared the extension. + call = types.CallToolRequest( + params=types.CallToolRequestParams(name="render_report", arguments={"title": "Q3", "sections": 2}) + ) + created = await _send(client.session, call) + assert created["resultType"] == "task", created + task_id = created["taskId"] + + task = await _send(client.session, _GetTaskRequest(params=GetTaskRequestParams(task_id=task_id))) + assert task["status"] == "completed", task + assert task["result"]["content"][0]["text"].startswith("# Q3"), task + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/tasks/server.py b/examples/stories/tasks/server.py new file mode 100644 index 0000000000..1129e3c8fc --- /dev/null +++ b/examples/stories/tasks/server.py @@ -0,0 +1,27 @@ +"""Tasks (SEP-2663): the server defers a tool call as a task the client polls. + +`Tasks` is an opt-in `Extension`. The server decides, per request, to return a +`CreateTaskResult` instead of a `CallToolResult` for a client that declared the +`io.modelcontextprotocol/tasks` extension; the client then polls `tasks/get` for +status and the eventual result. `render_report` is the kind of slower, multi-step +tool a caller would rather run as a task than block on. +""" + +from mcp.server.mcpserver import MCPServer +from mcp.server.tasks import Tasks +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("tasks-example", extensions=[Tasks(default_ttl_ms=60_000)]) + + @mcp.tool(description="Render a multi-section report for the given title.", structured_output=False) + def render_report(title: str, sections: int) -> str: + body = "\n".join(f"## Section {n}\n(generated)" for n in range(1, sections + 1)) + return f"# {title}\n\n{body}" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/src/mcp/server/tasks.py b/src/mcp/server/tasks.py new file mode 100644 index 0000000000..889eeb6e45 --- /dev/null +++ b/src/mcp/server/tasks.py @@ -0,0 +1,232 @@ +"""Tasks extension (`io.modelcontextprotocol/tasks`, SEP-2663). + +Tasks let a server defer the result of a `tools/call`: instead of blocking for the +`CallToolResult`, the server immediately returns a `CreateTaskResult` carrying a +task id, and the client polls `tasks/get` for status and the eventual result. + +SEP-2663 (https://modelcontextprotocol.io/seps/2663-tasks-extension.md) is an +opt-in extension, wire-incompatible with the 2025-11-25 in-core Tasks design that +still ships (types-only) in `mcp_types`. This module therefore defines its own +SEP-2663-shaped models rather than reusing `mcp_types.{Task, CreateTaskResult, ...}`. + +Key SEP-2663 rules implemented here: + + - The SERVER decides task augmentation, per request, at its discretion. The + legacy `params.task` field is ignored (it is not the opt-in). + - A `CreateTaskResult` is only returned to a client that declared the extension + on the request; a `tasks/*` call from a client that did not declare it is + rejected with `-32003` (missing required client capability). + - `CreateTaskResult` is `Result & Task` flat, with `resultType: "task"`. + - `tasks/get` returns a `DetailedTask` (`resultType: "complete"`): `working`, + `completed` (inlines the original `CallToolResult`), or `cancelled` here. + - A tool result with `isError: true` is a `completed` task, not `failed` + (`failed` is reserved for JSON-RPC errors). + - `tasks/cancel` is an empty acknowledgement. + +Scope: this is the conformant *core*. Deferred to follow-ups (each needs deeper +SDK plumbing): `tasks/update` + the MRTR `input_required` loop, +`ToolExecution.taskSupport` gating with the `-32021` required-task error, +`notifications/tasks`, and SEP-2243 task routing headers. The task runs to +completion inline, so it is observed as `completed` immediately; the store is +in-memory and per-server. +""" + +from __future__ import annotations + +import secrets +from collections.abc import Callable, Sequence +from typing import Any, Literal + +from mcp_types import INVALID_PARAMS, RequestParams, Result +from mcp_types.version import MODERN_PROTOCOL_VERSIONS +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.extension import Extension, MethodBinding +from mcp.shared.exceptions import MCPError + +EXTENSION_ID = "io.modelcontextprotocol/tasks" +"""The Tasks extension identifier (SEP-2663).""" + +MISSING_REQUIRED_CLIENT_CAPABILITY = -32003 +"""JSON-RPC error code: a `tasks/*` call from a client that did not declare the extension.""" + +TaskStatus = Literal["working", "input_required", "completed", "failed", "cancelled"] + +Clock = Callable[[], str] +"""Returns the current time as an ISO-8601 string (injectable for determinism).""" + + +def _fixed_clock() -> str: + return "1970-01-01T00:00:00Z" + + +class _TasksModel(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class Task(_TasksModel): + """SEP-2663 task snapshot (note the `*Ms` field names, unlike the 2025 design).""" + + task_id: str + status: TaskStatus + status_message: str | None = None + created_at: str + last_updated_at: str + ttl_ms: int | None = None + poll_interval_ms: int | None = None + + +class CreateTaskResult(Result): + """`Result & Task` flat, discriminated by `result_type: "task"` (SEP-2663). + + Inherits `Result`'s camelCase alias generator, so snake_case fields serialize + to `resultType`/`taskId`/`ttlMs`/... on the wire. + """ + + result_type: Literal["task"] = "task" + task_id: str + status: TaskStatus + status_message: str | None = None + created_at: str + last_updated_at: str + ttl_ms: int | None = None + poll_interval_ms: int | None = None + + +def _task_envelope(task: Task) -> dict[str, Any]: + return task.model_dump(by_alias=True, exclude_none=True) + + +class GetTaskRequestParams(RequestParams): + task_id: str + + +class CancelTaskRequestParams(RequestParams): + task_id: str + + +class TaskStore: + """In-memory record of tasks and their completed `CallToolResult` payloads.""" + + def __init__(self) -> None: + self._tasks: dict[str, Task] = {} + self._results: dict[str, dict[str, Any]] = {} + + def create(self, now: str, ttl_ms: int | None) -> Task: + # Task IDs are bearer capabilities for tasks/get|cancel, so they need + # entropy a third party cannot guess or enumerate (SEP-2663 security). + task_id = f"task_{secrets.token_urlsafe(16)}" + task = Task(task_id=task_id, status="working", created_at=now, last_updated_at=now, ttl_ms=ttl_ms) + self._tasks[task_id] = task + return task + + def complete(self, task_id: str, now: str, result: dict[str, Any]) -> None: + task = self._tasks[task_id] + self._tasks[task_id] = task.model_copy(update={"status": "completed", "last_updated_at": now}) + self._results[task_id] = result + + def cancel(self, task_id: str, now: str) -> None: + task = self._tasks[task_id] + self._tasks[task_id] = task.model_copy(update={"status": "cancelled", "last_updated_at": now}) + + def get(self, task_id: str) -> Task | None: + return self._tasks.get(task_id) + + def result(self, task_id: str) -> dict[str, Any] | None: + return self._results.get(task_id) + + +class Tasks(Extension): + """The Tasks extension: server-decided task-augmented `tools/call` plus `tasks/*`.""" + + identifier = EXTENSION_ID + + def __init__(self, *, clock: Clock = _fixed_clock, default_ttl_ms: int | None = None) -> None: + self._store = TaskStore() + self._clock = clock + self._default_ttl_ms = default_ttl_ms + + def methods(self) -> Sequence[MethodBinding]: + return [ + MethodBinding("tasks/get", GetTaskRequestParams, self._handle_get), + MethodBinding("tasks/cancel", CancelTaskRequestParams, self._handle_cancel), + ] + + async def intercept_tool_call( + self, + params: Any, + ctx: ServerRequestContext[Any, Any], + call_next: CallNext, + ) -> HandlerResult: + # SEP-2663: the server decides augmentation; the legacy `params.task` field + # is ignored. Only augment for a client that declared the extension on the + # request, and never alter a plain (non-declaring) client's call. + if not _client_declared_tasks(ctx): + return await call_next(ctx) + now = self._clock() + task = self._store.create(now, self._default_ttl_ms) + result = await call_next(ctx) + payload = result if isinstance(result, dict) else {} + # A tool result (even isError: true) is a completed task; `failed` is for + # JSON-RPC errors, which surface as a raised MCPError, not a result here. + self._store.complete(task.task_id, self._clock(), payload) + created = self._store.get(task.task_id) + assert created is not None + return CreateTaskResult( + task_id=created.task_id, + status=created.status, + created_at=created.created_at, + last_updated_at=created.last_updated_at, + ttl_ms=created.ttl_ms, + ) + + async def _handle_get(self, ctx: ServerRequestContext[Any, Any], params: GetTaskRequestParams) -> dict[str, Any]: + _require_tasks_capability(ctx) + task = self._require(params.task_id) + detailed = _task_envelope(task) + detailed["resultType"] = "complete" + if task.status == "completed": + # DetailedTask: a completed task inlines the original CallToolResult. + result = self._store.result(task.task_id) + assert result is not None + detailed["result"] = result + return detailed + + async def _handle_cancel( + self, ctx: ServerRequestContext[Any, Any], params: CancelTaskRequestParams + ) -> dict[str, Any]: + _require_tasks_capability(ctx) + self._require(params.task_id) + self._store.cancel(params.task_id, self._clock()) + # An empty acknowledgement; cancellation is cooperative. + return {"resultType": "complete"} + + def _require(self, task_id: str) -> Task: + task = self._store.get(task_id) + if task is None: + raise MCPError(code=INVALID_PARAMS, message=f"unknown task {task_id!r}") + return task + + +def _client_declared_tasks(ctx: ServerRequestContext[Any, Any]) -> bool: + # The extension only exists on the modern (2026-07-28+) wire: a legacy + # `initialize` cannot carry `capabilities.extensions` back to the client, so a + # legacy connection must never be augmented even if the client's recorded + # capabilities happen to include the identifier. + if ctx.protocol_version not in MODERN_PROTOCOL_VERSIONS: + return False + client_params = ctx.session.client_params + declared = client_params.capabilities.extensions if client_params else None + return bool(declared and EXTENSION_ID in declared) + + +def _require_tasks_capability(ctx: ServerRequestContext[Any, Any]) -> None: + """Reject a `tasks/*` call from a client that did not declare the extension (-32003).""" + if not _client_declared_tasks(ctx): + raise MCPError( + code=MISSING_REQUIRED_CLIENT_CAPABILITY, + message="Client did not declare the io.modelcontextprotocol/tasks extension", + data={"requiredCapabilities": {"extensions": {EXTENSION_ID: {}}}}, + ) diff --git a/tests/server/test_tasks.py b/tests/server/test_tasks.py new file mode 100644 index 0000000000..8d43394296 --- /dev/null +++ b/tests/server/test_tasks.py @@ -0,0 +1,299 @@ +"""Tests for the SEP-2663 Tasks extension (`io.modelcontextprotocol/tasks`). + +These drive the conformant core in `mcp.server.tasks` end-to-end through an +in-memory `Client`. `Client` exposes only spec verbs, so task-augmented calls and +the `tasks/*` methods go through `client.session.send_request`; `CreateTaskResult` +and `DetailedTask` have non-spec shapes, so the raw wire dict is read with a +permissive `dict` result type. Determinism comes from an injected fixed `clock`; +task ids are random `task_` bearer capabilities, so they are captured and +reused for identity rather than snapshotted. +""" + +from typing import Literal, cast + +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import INVALID_PARAMS, METHOD_NOT_FOUND, Result +from pydantic import BaseModel, TypeAdapter + +from mcp.client.client import Client +from mcp.server.mcpserver import MCPServer +from mcp.server.tasks import ( + EXTENSION_ID, + MISSING_REQUIRED_CLIENT_CAPABILITY, + CancelTaskRequestParams, + GetTaskRequestParams, + Tasks, +) +from mcp.shared.exceptions import MCPError + +pytestmark = pytest.mark.anyio + +_RAW: TypeAdapter[dict[str, object]] = TypeAdapter(dict[str, object]) + + +async def _send_raw(client: Client, request: BaseModel) -> dict[str, object]: + """Read the raw wire dict for a non-spec `tasks/*` shape (bypasses the typed result model).""" + result_type = cast("type[Result]", _RAW) + result = await client.session.send_request(cast("types.ClientRequest", request), result_type) + return cast("dict[str, object]", result) + + +class _GetTaskRequest(types.Request[GetTaskRequestParams, Literal["tasks/get"]]): + method: Literal["tasks/get"] = "tasks/get" + params: GetTaskRequestParams + + +class _CancelTaskRequest(types.Request[CancelTaskRequestParams, Literal["tasks/cancel"]]): + method: Literal["tasks/cancel"] = "tasks/cancel" + params: CancelTaskRequestParams + + +class _TasksResultRequest(types.Request[GetTaskRequestParams, Literal["tasks/result"]]): + method: Literal["tasks/result"] = "tasks/result" + params: GetTaskRequestParams + + +def _tasks_server(*, default_ttl_ms: int | None = None) -> MCPServer: + """A server exposing `echo` under the Tasks extension with a fixed clock.""" + tasks = Tasks(clock=lambda: "2026-01-01T00:00:00Z", default_ttl_ms=default_ttl_ms) + mcp = MCPServer("demo", extensions=[tasks]) + + @mcp.tool(structured_output=False) + def echo(text: str) -> str: + return text + + return mcp + + +def _call_echo() -> types.CallToolRequest: + return types.CallToolRequest(params=types.CallToolRequestParams(name="echo", arguments={"text": "hi"})) + + +async def _augmented_call(client: Client) -> dict[str, object]: + return await _send_raw(client, _call_echo()) + + +async def _get_task(client: Client, task_id: str) -> dict[str, object]: + return await _send_raw(client, _GetTaskRequest(params=GetTaskRequestParams(task_id=task_id))) + + +async def _cancel_task(client: Client, task_id: str) -> dict[str, object]: + return await _send_raw(client, _CancelTaskRequest(params=CancelTaskRequestParams(task_id=task_id))) + + +async def test_tasks_capability_advertised_under_extensions_on_modern_path() -> None: + """SEP-2663: the Tasks extension rides `server/discover`, so a `mode='auto'` client + sees `EXTENSION_ID` under `server_capabilities.extensions`.""" + async with Client(_tasks_server(), mode="auto", extensions={EXTENSION_ID: {}}) as client: + assert client.server_capabilities.extensions == snapshot({"io.modelcontextprotocol/tasks": {}}) + + +async def test_tasks_capability_dropped_on_legacy_handshake() -> None: + """Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions` field, + so a `mode='legacy'` handshake cannot carry the Tasks capability even though the + modern `auto` path does.""" + async with Client(_tasks_server(), mode="legacy", extensions={EXTENSION_ID: {}}) as client: + assert client.server_capabilities.extensions is None + + +async def test_augmented_tools_call_returns_create_task_result_for_declaring_client() -> None: + """SEP-2663: the server decides augmentation; a declaring client's `tools/call` + returns a flat `Result & Task` envelope discriminated by `resultType: "task"`, + observed as `completed` because the tool runs inline (documented core scope).""" + captured: list[str] = [] + async with Client(_tasks_server(), extensions={EXTENSION_ID: {}}) as client: + created = await _augmented_call(client) + assert isinstance(created["taskId"], str) + captured.append(created["taskId"]) + + assert created == snapshot( + { + "resultType": "task", + "taskId": captured[0], + "status": "completed", + "createdAt": "2026-01-01T00:00:00Z", + "lastUpdatedAt": "2026-01-01T00:00:00Z", + } + ) + + +async def test_create_task_result_carries_ttl_when_configured() -> None: + """SEP-2663: a server with a default TTL stamps `ttlMs` on the `CreateTaskResult`.""" + captured: list[str] = [] + async with Client(_tasks_server(default_ttl_ms=60000), extensions={EXTENSION_ID: {}}) as client: + created = await _augmented_call(client) + assert isinstance(created["taskId"], str) + captured.append(created["taskId"]) + + assert created == snapshot( + { + "resultType": "task", + "taskId": captured[0], + "status": "completed", + "createdAt": "2026-01-01T00:00:00Z", + "lastUpdatedAt": "2026-01-01T00:00:00Z", + "ttlMs": 60000, + } + ) + + +async def test_create_task_result_uses_default_clock_when_none_injected() -> None: + """SDK-defined: with no `clock` injected, `Tasks` falls back to its fixed default clock, + stamping the SDK's epoch sentinel timestamps on the task.""" + tasks = Tasks() + mcp = MCPServer("demo", extensions=[tasks]) + + @mcp.tool(structured_output=False) + def echo(text: str) -> str: + return text + + captured: list[str] = [] + async with Client(mcp, extensions={EXTENSION_ID: {}}) as client: + created = await _augmented_call(client) + assert isinstance(created["taskId"], str) + captured.append(created["taskId"]) + + assert created == snapshot( + { + "resultType": "task", + "taskId": captured[0], + "status": "completed", + "createdAt": "1970-01-01T00:00:00Z", + "lastUpdatedAt": "1970-01-01T00:00:00Z", + } + ) + + +async def test_plain_tools_call_is_untouched_for_non_declaring_client() -> None: + """SEP-2663: the server never augments a client that did not declare the extension; + a plain `call_tool` returns the ordinary `CallToolResult` with no task `_meta`.""" + async with Client(_tasks_server()) as client: + result = await client.call_tool("echo", {"text": "x"}) + + assert result == snapshot(types.CallToolResult(content=[types.TextContent(text="x")])) + assert result.meta is None + + +async def test_get_task_inlines_completed_call_tool_result_without_related_task_meta() -> None: + """SEP-2663: `tasks/get` returns a `DetailedTask` (`resultType: "complete"`); a + completed task inlines the original `CallToolResult`, which must NOT carry an + `io.modelcontextprotocol/related-task` `_meta` key.""" + captured: list[str] = [] + async with Client(_tasks_server(), extensions={EXTENSION_ID: {}}) as client: + created = await _augmented_call(client) + assert isinstance(created["taskId"], str) + captured.append(created["taskId"]) + detailed = await _get_task(client, created["taskId"]) + + assert detailed == snapshot( + { + "taskId": captured[0], + "status": "completed", + "createdAt": "2026-01-01T00:00:00Z", + "lastUpdatedAt": "2026-01-01T00:00:00Z", + "resultType": "complete", + "result": { + "content": [{"text": "hi", "type": "text"}], + "isError": False, + "resultType": "complete", + }, + } + ) + inlined = detailed["result"] + assert isinstance(inlined, dict) + assert "_meta" not in inlined + + +async def test_cancel_task_returns_empty_ack_and_marks_task_cancelled() -> None: + """SEP-2663: `tasks/cancel` is an empty acknowledgement (`resultType: "complete"`), + and a subsequent `tasks/get` reports the task as `cancelled`.""" + captured: list[str] = [] + async with Client(_tasks_server(), extensions={EXTENSION_ID: {}}) as client: + created = await _augmented_call(client) + assert isinstance(created["taskId"], str) + captured.append(created["taskId"]) + ack = await _cancel_task(client, created["taskId"]) + after = await _get_task(client, created["taskId"]) + + assert ack == snapshot({"resultType": "complete"}) + assert after == snapshot( + { + "taskId": captured[0], + "status": "cancelled", + "createdAt": "2026-01-01T00:00:00Z", + "lastUpdatedAt": "2026-01-01T00:00:00Z", + "resultType": "complete", + } + ) + + +async def test_tasks_result_method_is_method_not_found() -> None: + """SEP-2663 core scope: `tasks/result` is not part of the conformant core, so it is + rejected with METHOD_NOT_FOUND.""" + async with Client(_tasks_server(), extensions={EXTENSION_ID: {}}) as client: + created = await _augmented_call(client) + task_id = created["taskId"] + assert isinstance(task_id, str) + with pytest.raises(MCPError) as exc_info: + await _send_raw(client, _TasksResultRequest(params=GetTaskRequestParams(task_id=task_id))) + + assert exc_info.value.code == METHOD_NOT_FOUND + + +async def test_tasks_get_from_non_declaring_client_is_missing_required_capability() -> None: + """SEP-2663: a `tasks/*` call from a client that did not declare the extension is + rejected with -32003, carrying the required-capabilities data.""" + async with Client(_tasks_server()) as client: + with pytest.raises(MCPError) as exc_info: + await _get_task(client, "task_anything") + + assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert exc_info.value.error.data == snapshot( + {"requiredCapabilities": {"extensions": {"io.modelcontextprotocol/tasks": {}}}} + ) + + +async def test_get_unknown_task_id_is_invalid_params() -> None: + """SEP-2663: a declaring client requesting an unknown `taskId` gets INVALID_PARAMS.""" + async with Client(_tasks_server(), extensions={EXTENSION_ID: {}}) as client: + with pytest.raises(MCPError) as exc_info: + await _get_task(client, "task_does_not_exist") + + assert exc_info.value.code == INVALID_PARAMS + + +async def test_cancel_unknown_task_id_is_invalid_params() -> None: + """SEP-2663: a declaring client cancelling an unknown `taskId` gets INVALID_PARAMS.""" + async with Client(_tasks_server(), extensions={EXTENSION_ID: {}}) as client: + with pytest.raises(MCPError) as exc_info: + await _cancel_task(client, "task_does_not_exist") + + assert exc_info.value.code == INVALID_PARAMS + + +async def test_task_ids_are_prefixed_and_unique_per_creation() -> None: + """SEP-2663 security: task ids are unguessable bearer capabilities, so each creation + yields a distinct `task_`-prefixed id.""" + async with Client(_tasks_server(), extensions={EXTENSION_ID: {}}) as client: + first = await _augmented_call(client) + second = await _augmented_call(client) + + assert isinstance(first["taskId"], str) + assert isinstance(second["taskId"], str) + assert first["taskId"].startswith("task_") + assert second["taskId"].startswith("task_") + assert first["taskId"] != second["taskId"] + + +async def test_legacy_connection_is_not_augmented_even_when_client_declares_tasks() -> None: + """SEP-2663: the extension is modern-only. On a legacy handshake the server cannot + carry `capabilities.extensions` back, so it must not augment - a `tools/call` + returns a normal `CallToolResult`, never a `CreateTaskResult`.""" + async with Client(_tasks_server(), mode="legacy", extensions={EXTENSION_ID: {}}) as client: + result = await client.call_tool("echo", {"text": "hi"}) + + assert isinstance(result.content[0], types.TextContent) + assert result.content[0].text == "hi" + assert result.meta is None