Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 18 additions & 14 deletions .github/actions/conformance/expected-failures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,26 @@
client: []

server:
# SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the
# tasks extension yet. These extension-tagged scenarios are selected only by
# the bare `--suite all` leg — extension scenarios never match a
# --spec-version filter and the active/draft suites exclude them — so these
# entries are inert for the other legs that read this file.
# SEP-2663 (io.modelcontextprotocol/tasks): the SDK implements the extension
# with tasks born terminal — the tool runs to completion inside the
# interceptor, so there is no background execution and no in-task
# `input_required` parking (see the deferred follow-ups in
# src/mcp/server/tasks.py). tasks-mrtr-input needs exactly that surface: a
# task that parks with `status:"input_required"` + a non-empty
# `inputRequests` map on tasks/get, resumes via the tasks/update
# inputResponses loop, and supports partial fulfillment across two pending
# keys. The everything-server's `confirm_delete`/`multi_input` fixtures
# therefore resolve their elicitation on the original `tools/call` (SEP-2322
# MRTR) and never mint a task, so all three checks fail with "did not create
# a task". Remove this entry when background task execution and the in-task
# input_required/tasks/update resume loop land. Extension-tagged scenarios
# are selected only by the bare `--suite all` leg — they never match a
# --spec-version filter and the active/draft suites exclude them — so this
# entry is inert for the other legs that read this file.
#
# `tasks-status-notifications` is intentionally NOT listed: the harness
# skips it unconditionally (pending its rewrite against subscriptions/
# listen), and a baseline entry for a scenario with no failing checks is
# flagged stale.
- tasks-lifecycle
- tasks-capability-negotiation
- tasks-wire-fields
- tasks-request-state-removal
# flagged stale. The other eight tasks-* scenarios pass against the
# everything-server's Tasks registration and are not listed either.
- tasks-mrtr-input
- tasks-request-headers
- tasks-dispatch-and-envelope
- tasks-required-task-error
- tasks-mrtr-composition
3 changes: 2 additions & 1 deletion docs/advanced/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ Pass instances at construction:
Done. The server now advertises `io.modelcontextprotocol/ui` under
`capabilities.extensions` and serves everything the extension contributes.

`Apps` is the built-in reference extension, and it gets its own page: **[MCP Apps](apps.md)**.
Two built-in reference extensions ship with the SDK, and each gets its own page:
**[MCP Apps](apps.md)** and **[Tasks](tasks.md)**.

!!! note
Extensions are fixed at construction. There is no `add_extension` to call later:
Expand Down
159 changes: 159 additions & 0 deletions docs/advanced/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# Tasks

A **task** is a `tools/call` answered by reference: instead of the `CallToolResult`,
the server returns a `CreateTaskResult` carrying a task id, and the client fetches
the outcome with `tasks/get`. That is
[SEP-2663](https://modelcontextprotocol.io/seps/2663-tasks-extension.md), and the SDK
ships it as the built-in `Tasks` extension (`io.modelcontextprotocol/tasks`).
If [Extensions](extensions.md) are new to you, skim that page first. One minute,
then come back.

## Opting in, both sides

```python title="server.py" hl_lines="6 16 17"
--8<-- "docs_src/tasks/tutorial001.py"
```

* `extensions=[Tasks()]`: the server advertises `io.modelcontextprotocol/tasks`
under `capabilities.extensions` and serves `tasks/get`, `tasks/update`, and
`tasks/cancel`.
* `Client(mcp, extensions=[TasksExtension()])`: the client declares the extension
back — `TasksExtension` (from `mcp.client`) is the client half, a
`ClientExtension` whose result claim admits and resolves the `task`
resultType on `tools/call`. Only a declaring client's `tools/call` may be
answered with a task.
* `client.call_tool(...)` does not change. When the answer comes back as a
`CreateTaskResult`, the client polls `tasks/get` — honoring the server's
`pollIntervalMs` hint, one second between polls in its absence — and surfaces
only the final `CallToolResult`. A `failed` task raises the typed
`TaskFailedError` carrying the inlined JSON-RPC error; a `cancelled` one raises
`TaskCancelledError`; an `input_required` one raises `TaskInputRequiredError` —
the automatic in-task input loop is not implemented yet, so drive that task
manually (below). All three subclass `TaskError`, so one `except TaskError`
catches any non-completion.

Degradation is built in. A modern client that does not declare the extension is
never augmented: it keeps getting plain `CallToolResult`s. And a legacy
(2025-11-25) connection cannot negotiate the extension at all — the capability
rides `server/discover`, which a legacy handshake doesn't have — so for that
client the feature does not exist. Your tools don't change either way.

## The server decides

Augmentation is the server's call, per request: the client's declaration is
permission, not a trigger. `Tasks()` augments every call from a declaring client;
pass `augment=` to be choosier:

```python title="server.py" hl_lines="9-10 13"
--8<-- "docs_src/tasks/tutorial002.py"
```

* `augment` sees the validated `CallToolRequestParams` for each call. Return
`False` and the call passes through untouched, exactly as for a non-declaring
client — errors included. Here `transcode` becomes a task; `ping` never does.
* `default_ttl_ms` bounds retention. It is stamped on the wire as `ttlMs`, and the
record is dropped once the deadline passes. The default `None` retains records
for the store's lifetime.
* `clock` (not shown) injects the source of time behind the wire timestamps and
TTL deadlines. Inject a fixed clock for deterministic tests.

## Where tasks live

Records persist through a `TaskStore`, a two-method protocol:

```python
class TaskStore(Protocol):
async def put(self, record: TaskRecord) -> None: ...
async def get(self, task_id: str) -> TaskRecord | None: ...
```

The default `InMemoryTaskStore` is **per-process**: right for stdio servers and
single-process development, wrong for a multi-worker HTTP deployment. SEP-2663
requires a task to be durably recorded before its `CreateTaskResult` is returned,
and a poll routed to another worker must find it — back `Tasks(store=...)` with
shared storage there. `get` returning `None` is the whole expiry contract: unknown
and expired ids look identical, and both answer `-32602` on the wire.

Task ids are unguessable bearer capabilities (at least 128 bits of entropy): any
caller presenting a valid id may poll the task, which is what lets a reconnecting
client resume. Need stricter scoping or audited access? That is a custom store's
job.

## What execution actually looks like

In this SDK the tool still runs **inline**, to completion, inside the `tools/call`
request. A task is therefore born terminal:

* The tool produced a result → a `completed` task, with the `CallToolResult`
inlined on `tasks/get`. A result with `isError: true` is still `completed`;
tool-level failure is a result, not a protocol error.
* The call raised a JSON-RPC error (an `MCPError`) → a `failed` task, with the
error inlined on `tasks/get`. The declaring client receives a failed
`CreateTaskResult` instead of the JSON-RPC error, and the transparent driver
turns it into `TaskFailedError`.
* `tasks/cancel` and `tasks/update` acknowledge and change nothing: cancellation
is cooperative in SEP-2663, and here the work has always finished before a
`tasks/*` request can arrive.

A [multi-round-trip](multi-round-trip.md) interim (`input_required`) passes through
un-augmented: the exchange resolves on the original `tools/call`, and only the leg
that produces the final result becomes a task.

What augmentation buys today is the wire shape and the retention: the result of an
expensive call outlives the request that computed it, fetchable by id for `ttlMs`.
Background execution is on the roadmap (below).

## Driving the task yourself

The transparent flow is a convenience, not a requirement. Drop one layer to get the
`CreateTaskResult`, then drive `tasks/*` with the typed functions in
`mcp.client.tasks`:

```python title="client.py" hl_lines="19 24 29"
--8<-- "docs_src/tasks/tutorial003.py"
```

* `session.call_tool(..., allow_claimed=True)` returns the typed
`CreateTaskResult` instead of polling. Without the flag, an unexpected
`CreateTaskResult` raises `RuntimeError` rather than leaking the widened union
into code that expected a `CallToolResult`.
* `get_task` is one `tasks/get`: it returns the `GetTaskResult` snapshot —
`result` is set on a `completed` task, `error` on a `failed` one, never both.
* `wait_task` polls to a terminal status and returns the final `CallToolResult`,
raising the same typed errors as the transparent flow. Pass the
`CreateTaskResult` and its `pollIntervalMs` hint seeds the cadence — or pass a
bare task id: task ids are bearer capabilities (above), so a client that
reconnected, or restarted with nothing but the persisted id, can resume a task
it no longer holds the `CreateTaskResult` for.
* `update_task` answers a task's in-task `inputRequests`, and `cancel_task` asks
the server to stop one. Both hide the empty acknowledgement and return `None`.
Cancellation is cooperative in SEP-2663 — it may never take effect, and in this
SDK the work has always finished already — so follow with `get_task` for the
status that actually resulted.
Comment on lines +128 to +132

@cubic-dev-ai cubic-dev-ai Bot Jul 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This overstates update_task: the SDK currently treats tasks/update as a no-op and does not expose inputRequests, so users following this doc cannot actually answer in-task input through this surface.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/advanced/tasks.md, line 128:

<comment>This overstates `update_task`: the SDK currently treats `tasks/update` as a no-op and does not expose `inputRequests`, so users following this doc cannot actually answer in-task input through this surface.</comment>

<file context>
@@ -105,20 +106,30 @@ Background execution is on the roadmap (below).
+  bare task id: task ids are bearer capabilities (above), so a client that
+  reconnected, or restarted with nothing but the persisted id, can resume a task
+  it no longer holds the `CreateTaskResult` for.
+* `update_task` answers a task's in-task `inputRequests`, and `cancel_task` asks
+  the server to stop one. Both hide the empty acknowledgement and return `None`.
+  Cancellation is cooperative in SEP-2663 — it may never take effect, and in this
</file context>
Suggested change
* `update_task` answers a task's in-task `inputRequests`, and `cancel_task` asks
the server to stop one. Both hide the empty acknowledgement and return `None`.
Cancellation is cooperative in SEP-2663 — it may never take effect, and in this
SDK the work has always finished already — so follow with `get_task` for the
status that actually resulted.
* `update_task` sends an `inputResponses` payload for servers that implement
in-task input; this SDK's `Tasks` server currently has no outstanding
`inputRequests`, so its handler acknowledges the update as a no-op.
`cancel_task` asks the server to stop one. Both hide the empty acknowledgement
and return `None`. Cancellation is cooperative in SEP-2663 — it may never take
effect, and in this SDK the work has always finished already — so follow with
`get_task` for the status that actually resulted.
Fix with cubic


## Who sees what

| Caller | `tools/call` | `tasks/*` |
|---|---|---|
| Declaring 2026-07-28 client | may be augmented into a task | served |
| Non-declaring 2026-07-28 client | plain `CallToolResult`, always | `-32021` missing required client capability |
| Legacy (2025-11-25) connection | plain `CallToolResult`, always | `-32601` method not found |

The split on `tasks/*` is deliberate. A modern client could fix its declaration, so
it gets the capability error with the machine-readable `requiredCapabilities`
payload; a legacy client could not, so for it the methods simply don't exist. A
declaring client naming an unknown — or expired — task id gets `-32602` (invalid
params).

Over Streamable HTTP, every `tasks/*` request carries the `Mcp-Name: <taskId>`
routing header (SEP-2663 via SEP-2243) so intermediaries can route the poll to the
instance holding the task's state. The SDK stamps it client-side and validates it
server-side; you never touch it.

## Roadmap

This is the core SEP-2663 surface. Background execution (tasks created `working`
and resolved later), the in-task `input_required` loop over `tasks/update`, and
`notifications/tasks` over `subscriptions/listen` build on it as planned
follow-ups — each needs deeper SDK plumbing, and the wire contract above is
already shaped for them.
53 changes: 41 additions & 12 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,23 +457,52 @@ 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 — `True` only
when the client's ui-extension settings list the `text/html;profile=mcp-app`
MIME type, per the Apps spec's required `mimeTypes` field. Every
`@apps.tool(resource_uri=...)` must have a matching resource registered on the
same `Apps` instance (`add_html_resource` for inline HTML, `add_resource` for a
pre-built `Resource`); a tool bound to an unregistered URI raises at
`MCPServer(...)` construction rather than 404ing on `resources/read` at runtime.
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 — `True` only when the client's ui-extension
settings list the `text/html;profile=mcp-app` MIME type, per the Apps spec's
required `mimeTypes` field. Every `@apps.tool(resource_uri=...)` must have a
matching resource registered on the same `Apps` instance (`add_html_resource`
for inline HTML, `add_resource` for a pre-built `Resource`); a tool bound to an
unregistered URI raises at `MCPServer(...)` construction rather than 404ing on
`resources/read` at runtime.
Comment thread
claude[bot] marked this conversation as resolved.
- `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 fetches the result via
`tasks/get` (`tasks/update` and `tasks/cancel` are empty acknowledgements).
The server decides augmentation (the legacy `params.task` field is ignored),
passes multi round-trip `input_required` interims through un-augmented, and
keeps finished (completed or failed) tasks in a pluggable `TaskStore` (`Tasks(store=...)`,
in-memory default) that enforces `default_ttl_ms`. A `tasks/*` call from a
non-declaring modern client is rejected with `-32021` (missing required
client capability); legacy calls get `METHOD_NOT_FOUND`. The client half is a
`ClientExtension` result claim: constructing `TasksExtension()` into
`Client(extensions=[...])` declares the extension and claims the `task`
resultType on `tools/call`, so `Client.call_tool` admits the
`CreateTaskResult`, polls `tasks/get` (honoring `pollIntervalMs`), and
returns the final `CallToolResult` unchanged, while `failed`/`cancelled`
tasks surface as the typed `TaskFailedError`/`TaskCancelledError` (all task
errors share the `TaskError` base). Manual driving stays available —
`client.session.call_tool(..., allow_claimed=True)` returns the typed
`CreateTaskResult`, and the typed `mcp.client.tasks` functions
(`get_task`/`wait_task`/`update_task`/`cancel_task`) drive
`tasks/get`/`tasks/update`/`tasks/cancel` over the session, with
the `Mcp-Name` routing header stamped automatically over Streamable HTTP.
This is the core SEP-2663 surface — see [Tasks](advanced/tasks.md);
background execution (`working` tasks), the in-task `input_required` loop
over `tasks/update`, and `notifications/tasks` are deferred.

Extension methods are strictly additive: a `MethodBinding` cannot name a
spec-defined request method, and registering one whose method collides with
another handler raises at construction. A `MethodBinding` may set
`protocol_versions` to scope an extension method to specific wire versions
(`frozenset()` is rejected — use `None` to admit every version); a request at
any other version is `METHOD_NOT_FOUND`. An
extension handler can call `mcp.server.mcpserver.require_client_extension(ctx, identifier)`
extension handler can call `mcp.server.extension.require_client_extension(ctx, identifier)`
(also re-exported from `mcp.server.mcpserver`)
to reject a request with the `-32021` (missing required client capability) error
when the client did not declare the extension.

Expand Down Expand Up @@ -510,7 +539,7 @@ client = Client(server, extensions=[advertise("com.example/ui", {"mimeTypes": [.
```

`advertise()` is only for identifiers with no client-side behaviour. For a
behavioural extension (e.g. tasks, once its extension ships), construct that
behavioural extension (e.g. tasks — `mcp.client.TasksExtension`), construct that
extension's object instead; advertising an identifier you do not implement
asserts wire support you don't have.

Expand Down Expand Up @@ -1510,7 +1539,7 @@ Behavior changes:

Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. The corresponding `Task*` types remain in `mcp_types` as types-only definitions.

Tasks are expected to return as a separate MCP extension in a future release.
Tasks have since returned as the built-in `Tasks` extension ([SEP-2663](https://modelcontextprotocol.io/seps/2663-tasks-extension.md)), with a different wire shape than the experimental SEP-1686 surface — see [Server extensions API](#server-extensions-api-sep-2133) above and [Tasks](advanced/tasks.md).

## Deprecations

Expand Down
Empty file added docs_src/tasks/__init__.py
Empty file.
19 changes: 19 additions & 0 deletions docs_src/tasks/tutorial001.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from mcp import Client
from mcp.client import TasksExtension
from mcp.server.mcpserver import MCPServer
from mcp.server.tasks import Tasks

mcp = MCPServer("bakery", extensions=[Tasks()])


@mcp.tool()
def bake(flavor: str) -> str:
"""Bake a cake."""
return f"One {flavor} cake, ready."


async def main() -> None:
async with Client(mcp, extensions=[TasksExtension()]) as client:
result = await client.call_tool("bake", {"flavor": "lemon"})
print(result.content)
# [TextContent(text='One lemon cake, ready.')]
25 changes: 25 additions & 0 deletions docs_src/tasks/tutorial002.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from mcp_types import CallToolRequestParams

from mcp.server.mcpserver import MCPServer
from mcp.server.tasks import Tasks

SLOW_TOOLS = {"transcode"}


def augment(params: CallToolRequestParams) -> bool:
return params.name in SLOW_TOOLS


mcp = MCPServer("studio", extensions=[Tasks(augment=augment, default_ttl_ms=60_000)])


@mcp.tool()
def transcode(clip: str) -> str:
"""Transcode a clip to the house format."""
return f"{clip} transcoded."


@mcp.tool()
def ping() -> str:
"""Liveness probe."""
return "pong"
31 changes: 31 additions & 0 deletions docs_src/tasks/tutorial003.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from mcp import Client
from mcp.client import TasksExtension
from mcp.client.tasks import get_task, wait_task
from mcp.server.mcpserver import MCPServer
from mcp.server.tasks import Tasks
from mcp.shared.tasks import CreateTaskResult

mcp = MCPServer("bakery", extensions=[Tasks()])


@mcp.tool()
def bake(flavor: str) -> str:
"""Bake a cake."""
return f"One {flavor} cake, ready."


async def main() -> None:
async with Client(mcp, extensions=[TasksExtension()]) as client:
created = await client.session.call_tool("bake", {"flavor": "mocha"}, allow_claimed=True)
assert isinstance(created, CreateTaskResult)
print(created.status)
# completed

polled = await get_task(client.session, created.task_id)
assert polled.result is not None
print(polled.result["content"])
# [{'text': 'One mocha cake, ready.', 'type': 'text'}]

result = await wait_task(client.session, created)
print(result.content)
# [TextContent(text='One mocha cake, ready.')]
Loading
Loading