Skip to content

Commit 9f166f6

Browse files
committed
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.
1 parent 0f440b1 commit 9f166f6

9 files changed

Lines changed: 674 additions & 20 deletions

File tree

docs/migration.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -424,10 +424,21 @@ from mcp.server.apps import Apps
424424
mcp = MCPServer("demo", extensions=[Apps()])
425425
```
426426

427-
The reference extension is `mcp.server.apps.Apps` (`io.modelcontextprotocol/ui`):
428-
it binds a tool to a `ui://` UI resource via `_meta.ui.resourceUri`, and
429-
`client_supports_apps(ctx)` gates the SEP-2133 text-only fallback (checking the
430-
client advertised the `text/html;profile=mcp-app` MIME type).
427+
Two reference extensions ship in their own modules:
428+
429+
- `mcp.server.apps.Apps` (`io.modelcontextprotocol/ui`) binds a tool to a `ui://`
430+
UI resource via `_meta.ui.resourceUri`, and `client_supports_apps(ctx)` gates the
431+
SEP-2133 text-only fallback (checking the client advertised the
432+
`text/html;profile=mcp-app` MIME type).
433+
- `mcp.server.tasks.Tasks` (`io.modelcontextprotocol/tasks`, SEP-2663) defers a
434+
`tools/call` as a task: for a client that declared the extension on a modern
435+
connection, the server may return a `CreateTaskResult` (`resultType: "task"`)
436+
instead of the `CallToolResult`, and the client polls `tasks/get` /
437+
`tasks/cancel`. The server decides augmentation (the legacy `params.task` field
438+
is ignored); a `tasks/*` call from a non-declaring client is rejected with
439+
`-32003`. This is the conformant core; `tasks/update` + the MRTR input loop,
440+
`ToolExecution.taskSupport` gating, `notifications/tasks`, and task routing
441+
headers are deferred.
431442

432443
A `MethodBinding` may set `protocol_versions` to scope an extension method to
433444
specific wire versions; a request at any other version is `METHOD_NOT_FOUND`. An

examples/stories/apps/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,5 @@ uv run python -m stories.apps.client --http
3737

3838
## See also
3939

40+
`tasks/` (the interceptive half of the extension API),
4041
`custom_methods/` (registering a non-spec method without an extension).

examples/stories/manifest.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ lowlevel = false
5656
transports = ["in-memory", "http-asgi"]
5757
era = "dual-in-body"
5858

59+
[story.tasks]
60+
# SEP-2663 tasks extension; server-decided augmentation + tasks/get drop to client.session.
61+
# extensions ride server/discover (modern-only), so the connection is pinned to "auto".
62+
lowlevel = false
63+
transports = ["in-memory", "http-asgi"]
64+
era = "dual-in-body"
65+
5966
[story.schema_validators]
6067

6168
[story.middleware]
@@ -150,6 +157,5 @@ fixed_port = 8000 # issuer/PRM metadata bake in :8
150157
[deferred]
151158
caching = "client honouring + per-result override unlanded"
152159
subscriptions = "#2901 — Client.listen / ServerEventBus"
153-
tasks = "SEP-2663 — tasks extension runtime (server-decided augmentation, CreateTaskResult)"
154160
skills = "#2896 — SEP-2640"
155161
events = "#2901 + #2896"

examples/stories/tasks/README.md

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,48 @@
11
# tasks
22

3-
Task-augmented execution: a requestor augments a `tools/call` with a `task`, the
4-
receiver returns a `CreateTaskResult` immediately, and the requestor polls
5-
`tasks/get` and retrieves the deferred result.
6-
7-
**Status: deferred.** Tasks ship in 2026-07-28 as
8-
[SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2663-tasks-extension.md),
9-
an `io.modelcontextprotocol/tasks` extension that is wire-incompatible with the
10-
2025-11-25 in-core design still carried (types-only) in `mcp_types`. The runtime
11-
needs to be built to the SEP — server-decided augmentation (ignoring the legacy
12-
`params.task`), the `{tasks/get, tasks/update, tasks/cancel}` method set, the
13-
`resultType: "task"` envelope, `execution.taskSupport` gating, and `ttlMs`
14-
fields — so it lands in a separate PR with the conformance `tasks-*` scenarios
15-
wired in.
3+
Task-augmented execution (SEP-2663). A client declares the
4+
`io.modelcontextprotocol/tasks` extension; the server may then answer a
5+
`tools/call` with a `CreateTaskResult` (carrying a task id) instead of blocking,
6+
and the client polls `tasks/get` for status and the eventual result.
7+
8+
## Run it
9+
10+
```bash
11+
# stdio (default — the client spawns the server as a subprocess)
12+
uv run python -m stories.tasks.client
13+
14+
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
15+
uv run python -m stories.tasks.client --http
16+
```
17+
18+
## What to look at
19+
20+
- `server.py` `MCPServer("tasks-example", extensions=[Tasks(default_ttl_ms=...)])`
21+
opt in at construction. The extension advertises `io.modelcontextprotocol/tasks`
22+
and serves `tasks/get` and `tasks/cancel`.
23+
- `mcp.server.tasks.Tasks.intercept_tool_call` — the server DECIDES augmentation;
24+
the legacy `params.task` field is ignored. It augments only for a client that
25+
declared the extension on the request, returning a flat `CreateTaskResult`
26+
(`resultType: "task"`).
27+
- `client.py` `Client(target, extensions={EXTENSION_ID: {}})` — declaring the
28+
extension is what lets the server defer; `main` then reads the `CreateTaskResult`
29+
and polls `tasks/get`, whose completed `DetailedTask` inlines the original
30+
`CallToolResult`.
31+
32+
## Scope
33+
34+
This is the SEP-2663 conformant *core*. The tool runs to completion inline (so a
35+
task is observed as `completed` immediately), and the store is in-memory. Deferred
36+
to follow-ups, each needing deeper SDK plumbing: `tasks/update` + the MRTR
37+
`input_required` loop, `ToolExecution.taskSupport` gating with the `-32021`
38+
required-task error, `notifications/tasks`, and SEP-2243 task routing headers.
1639

1740
## Spec
1841

19-
[SEP-2663 — Tasks extension](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2663-tasks-extension.md)
42+
[SEP-2663 — Tasks extension](https://modelcontextprotocol.io/seps/2663-tasks-extension.md)
2043
· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)
2144

2245
## See also
2346

24-
`apps/` (the additive half of the extension API).
47+
`apps/` (the additive half of the extension API),
48+
`custom_methods/` (a non-spec method without an extension).

examples/stories/tasks/__init__.py

Whitespace-only changes.

examples/stories/tasks/client.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Declare the tasks extension, let the server defer a tool call, then poll tasks/get.
2+
3+
The client declares `io.modelcontextprotocol/tasks` (via `Client(extensions=...)`),
4+
so the server is free to answer `tools/call` with a `CreateTaskResult`. `Client`
5+
exposes only spec verbs, so the augmented call and `tasks/get` drop to
6+
`client.session`; the thin `_send` helper keeps that out of the story below.
7+
"""
8+
9+
from typing import Any, Literal, cast
10+
11+
import mcp_types as types
12+
from pydantic import TypeAdapter
13+
14+
from mcp.client import Client, ClientSession
15+
from mcp.server.tasks import EXTENSION_ID, GetTaskRequestParams
16+
from stories._harness import Target, run_client
17+
18+
_RAW: TypeAdapter[dict[str, Any]] = TypeAdapter(dict)
19+
20+
21+
class _GetTaskRequest(types.Request[GetTaskRequestParams, Literal["tasks/get"]]):
22+
method: Literal["tasks/get"] = "tasks/get"
23+
params: GetTaskRequestParams
24+
25+
26+
async def _send(session: ClientSession, request: types.Request[Any, Any]) -> dict[str, Any]:
27+
"""Send a request whose result has a non-spec (extension) shape; return the raw dict."""
28+
return await session.send_request(cast("types.ClientRequest", request), cast("Any", _RAW))
29+
30+
31+
async def main(target: Target, *, mode: str = "auto") -> None:
32+
async with Client(target, mode=mode, extensions={EXTENSION_ID: {}}) as client:
33+
# The extension is a modern-only capability negotiated over server/discover.
34+
# A legacy connection (today's stdio) cannot carry it, and the server then
35+
# must not augment, so the task flow only runs once it is negotiated.
36+
if client.server_capabilities.extensions is None:
37+
return
38+
assert client.server_capabilities.extensions == {EXTENSION_ID: {}}
39+
40+
# The server augments this tools/call into a task because we declared the extension.
41+
call = types.CallToolRequest(
42+
params=types.CallToolRequestParams(name="render_report", arguments={"title": "Q3", "sections": 2})
43+
)
44+
created = await _send(client.session, call)
45+
assert created["resultType"] == "task", created
46+
task_id = created["taskId"]
47+
48+
task = await _send(client.session, _GetTaskRequest(params=GetTaskRequestParams(task_id=task_id)))
49+
assert task["status"] == "completed", task
50+
assert task["result"]["content"][0]["text"].startswith("# Q3"), task
51+
52+
53+
if __name__ == "__main__":
54+
run_client(main)

examples/stories/tasks/server.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Tasks (SEP-2663): the server defers a tool call as a task the client polls.
2+
3+
`Tasks` is an opt-in `Extension`. The server decides, per request, to return a
4+
`CreateTaskResult` instead of a `CallToolResult` for a client that declared the
5+
`io.modelcontextprotocol/tasks` extension; the client then polls `tasks/get` for
6+
status and the eventual result. `render_report` is the kind of slower, multi-step
7+
tool a caller would rather run as a task than block on.
8+
"""
9+
10+
from mcp.server.mcpserver import MCPServer
11+
from mcp.server.tasks import Tasks
12+
from stories._hosting import run_server_from_args
13+
14+
15+
def build_server() -> MCPServer:
16+
mcp = MCPServer("tasks-example", extensions=[Tasks(default_ttl_ms=60_000)])
17+
18+
@mcp.tool(description="Render a multi-section report for the given title.", structured_output=False)
19+
def render_report(title: str, sections: int) -> str:
20+
body = "\n".join(f"## Section {n}\n(generated)" for n in range(1, sections + 1))
21+
return f"# {title}\n\n{body}"
22+
23+
return mcp
24+
25+
26+
if __name__ == "__main__":
27+
run_server_from_args(build_server)

0 commit comments

Comments
 (0)