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
6 changes: 5 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 Expand Up @@ -176,6 +177,9 @@ from mcp.client import advertise
client = Client(mcp, extensions=[advertise("com.example/search")])
```

For an extension with client-side behaviour, construct its object instead; the
built-in example is `mcp.client.TasksExtension` ([Tasks](tasks.md)).

## Writing a client extension

Subclass `ClientExtension` and override only what you need. Three contribution
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](../handlers/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.
2 changes: 1 addition & 1 deletion docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1568,7 +1568,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, except the `TaskExecutionMode` alias, whose literal is now inlined on `ToolExecution.task_support`.

The 2026-07-28 revision reintroduces Tasks as an official extension: [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), `io.modelcontextprotocol/tasks`, redesigned around polling (`tasks/get`) instead of a blocking `tasks/result`. This SDK does not implement the extension yet.
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 [Extensions](advanced/extensions.md) and [Tasks](advanced/tasks.md) for the new design.

## Transports

Expand Down
2 changes: 1 addition & 1 deletion docs/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ The renames announce themselves. These do not:
Each of these is a section in the **[Migration Guide](migration.md)**:

* The **WebSocket transport**, both sides, and the `mcp[ws]` extra. It was never part of the MCP specification.
* The **experimental Tasks** API (`mcp.*.experimental`). 2026-07-28 moves tasks out of the core protocol and into an official extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), which this SDK does not implement yet.
* The **experimental Tasks** API (`mcp.*.experimental`). 2026-07-28 moves tasks out of the core protocol and into an official extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), which this SDK ships as the built-in [Tasks](advanced/tasks.md) extension.
* `mcp.types`, `mcp.shared.version`, and `mcp.shared.progress` as import paths.
* The deprecated `streamablehttp_client` spelling, and the `get_session_id` callback from `streamable_http_client` (which now yields exactly two streams).
* `McpError`, renamed **`MCPError`** with a direct `(code, message, data)` constructor.
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