Skip to content

Commit 51ad100

Browse files
committed
Add the Tasks extension (io.modelcontextprotocol/tasks)
`Tasks` is an interceptive `Extension`: `intercept_tool_call` records a task-augmented `tools/call` and stamps the task id into `_meta[io.modelcontextprotocol/related-task]`, while `methods()` serves `tasks/get`, `tasks/result`, `tasks/cancel`, and `tasks/list` over an in-memory store. It demonstrates the interceptive seam; the augmented call returns a `CallToolResult` rather than `CreateTaskResult` because the `tools/call` result schema admits only `CallToolResult | InputRequiredResult` (TODO L56). Also add the negotiation-plumbing tests shared by both extensions.
1 parent c8684e9 commit 51ad100

3 files changed

Lines changed: 604 additions & 0 deletions

File tree

src/mcp/server/tasks.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""Tasks extension (`io.modelcontextprotocol/tasks`).
2+
3+
Tasks let a client request *task-augmented* execution of a tool call: instead of
4+
blocking for the `CallToolResult`, the client sends `tools/call` with a `task`
5+
field and immediately gets back a `CreateTaskResult` carrying a task id. It then
6+
polls `tasks/get` for status and `tasks/result` for the payload, and may
7+
`tasks/cancel` or `tasks/list`. Tasks were part of the core spec in 2025-11-25
8+
and now continue as an extension. See SEP-2133 for the extension framework.
9+
10+
This module demonstrates the *interceptive* half of the extension API. A `Tasks`
11+
instance:
12+
13+
- overrides `intercept_tool_call` to branch on `params.task`: a plain call
14+
passes through untouched; a task-augmented call still runs the tool, but its
15+
result is recorded under a task id and returned with that id stamped into
16+
`_meta["io.modelcontextprotocol/related-task"]`, and
17+
- overrides `methods` to serve `tasks/get`, `tasks/result`, `tasks/cancel`,
18+
and `tasks/list` so a client can poll status and fetch the payload.
19+
20+
mcp = MCPServer("demo", extensions=[Tasks()])
21+
22+
Scope: this is a reference implementation for the extension API, not a
23+
production task runtime. Two deliberate simplifications keep it self-contained:
24+
25+
- The tool runs to completion inline, so a task is observed as `completed`
26+
immediately (no detached/background execution, no TTL eviction).
27+
- A task-augmented `tools/call` returns a normal `CallToolResult` (with the
28+
task id in `_meta`) rather than the spec's `CreateTaskResult`. The wire
29+
schema for `tools/call` only admits `CallToolResult | InputRequiredResult`
30+
(even at 2026-07-28; see the `TODO(L56)` in `mcp.server.runner`), so
31+
returning `CreateTaskResult` would require extending the methods-layer
32+
validation maps. Driving the lifecycle through the dedicated `tasks/*`
33+
methods stays within the schema while still exercising the interceptor.
34+
35+
The store is in-memory and per-server.
36+
"""
37+
38+
from __future__ import annotations
39+
40+
from collections.abc import Callable, Sequence
41+
from typing import Any
42+
43+
import mcp_types as types
44+
45+
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
46+
from mcp.server.mcpserver.extension import Extension, MethodBinding
47+
from mcp.shared.exceptions import MCPError
48+
49+
EXTENSION_ID = "io.modelcontextprotocol/tasks"
50+
"""The Tasks extension identifier."""
51+
52+
RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task"
53+
"""`_meta` key associating a `CallToolResult` with the task that produced it."""
54+
55+
Clock = Callable[[], str]
56+
"""Returns the current time as an ISO-8601 string (injectable for determinism)."""
57+
58+
59+
def _fixed_clock() -> str:
60+
return "1970-01-01T00:00:00Z"
61+
62+
63+
class TaskStore:
64+
"""In-memory record of tasks and their completed payloads."""
65+
66+
def __init__(self) -> None:
67+
self._tasks: dict[str, types.Task] = {}
68+
self._results: dict[str, dict[str, Any]] = {}
69+
self._counter = 0
70+
71+
def create(self, now: str, ttl: int | None) -> types.Task:
72+
self._counter += 1
73+
task_id = f"task-{self._counter}"
74+
task = types.Task(
75+
task_id=task_id,
76+
status="working",
77+
created_at=now,
78+
last_updated_at=now,
79+
ttl=ttl,
80+
)
81+
self._tasks[task_id] = task
82+
return task
83+
84+
def complete(self, task_id: str, now: str, result: dict[str, Any]) -> None:
85+
task = self._tasks[task_id]
86+
self._tasks[task_id] = task.model_copy(update={"status": "completed", "last_updated_at": now})
87+
self._results[task_id] = result
88+
89+
def fail(self, task_id: str, now: str) -> None:
90+
task = self._tasks[task_id]
91+
self._tasks[task_id] = task.model_copy(update={"status": "failed", "last_updated_at": now})
92+
93+
def cancel(self, task_id: str, now: str) -> types.Task:
94+
task = self._tasks[task_id]
95+
cancelled = task.model_copy(update={"status": "cancelled", "last_updated_at": now})
96+
self._tasks[task_id] = cancelled
97+
return cancelled
98+
99+
def get(self, task_id: str) -> types.Task | None:
100+
return self._tasks.get(task_id)
101+
102+
def result(self, task_id: str) -> dict[str, Any] | None:
103+
return self._results.get(task_id)
104+
105+
def list(self) -> list[types.Task]:
106+
return list(self._tasks.values())
107+
108+
109+
class Tasks(Extension):
110+
"""The Tasks extension: task-augmented tool execution plus the `tasks/*` methods."""
111+
112+
identifier = EXTENSION_ID
113+
114+
def __init__(self, *, clock: Clock = _fixed_clock) -> None:
115+
self._store = TaskStore()
116+
self._clock = clock
117+
118+
def settings(self) -> dict[str, Any]:
119+
# Advertise list + cancel support (per ServerTasksCapability).
120+
return {"list": {}, "cancel": {}}
121+
122+
def methods(self) -> Sequence[MethodBinding]:
123+
return [
124+
MethodBinding("tasks/get", types.GetTaskRequestParams, self._handle_get),
125+
MethodBinding("tasks/result", types.GetTaskPayloadRequestParams, self._handle_result),
126+
MethodBinding("tasks/cancel", types.CancelTaskRequestParams, self._handle_cancel),
127+
MethodBinding("tasks/list", types.PaginatedRequestParams, self._handle_list),
128+
]
129+
130+
async def intercept_tool_call(
131+
self,
132+
params: types.CallToolRequestParams,
133+
ctx: ServerRequestContext[Any, Any],
134+
call_next: CallNext,
135+
) -> HandlerResult:
136+
if params.task is None:
137+
return await call_next(ctx)
138+
now = self._clock()
139+
task = self._store.create(now, params.task.ttl)
140+
# `call_next` runs the real tool; its already-serialized `CallToolResult`
141+
# dict is what we record and return (with the task id stamped on `_meta`).
142+
result = await call_next(ctx)
143+
payload = result if isinstance(result, dict) else {}
144+
if payload.get("isError"):
145+
self._store.fail(task.task_id, self._clock())
146+
else:
147+
self._store.complete(task.task_id, self._clock(), payload)
148+
existing_meta: dict[str, Any] = payload.get("_meta") or {}
149+
meta = {**existing_meta, RELATED_TASK_META_KEY: {"taskId": task.task_id}}
150+
return {**payload, "_meta": meta}
151+
152+
async def _handle_get(
153+
self, ctx: ServerRequestContext[Any, Any], params: types.GetTaskRequestParams
154+
) -> types.GetTaskResult:
155+
task = self._require(params.task_id)
156+
return types.GetTaskResult.model_validate(task.model_dump(by_alias=True))
157+
158+
async def _handle_result(
159+
self, ctx: ServerRequestContext[Any, Any], params: types.GetTaskPayloadRequestParams
160+
) -> dict[str, Any]:
161+
self._require(params.task_id)
162+
payload = self._store.result(params.task_id)
163+
if payload is None:
164+
raise MCPError(code=types.INVALID_PARAMS, message=f"task {params.task_id!r} has no result")
165+
return payload
166+
167+
async def _handle_cancel(
168+
self, ctx: ServerRequestContext[Any, Any], params: types.CancelTaskRequestParams
169+
) -> types.CancelTaskResult:
170+
self._require(params.task_id)
171+
cancelled = self._store.cancel(params.task_id, self._clock())
172+
return types.CancelTaskResult.model_validate(cancelled.model_dump(by_alias=True))
173+
174+
async def _handle_list(
175+
self, ctx: ServerRequestContext[Any, Any], params: types.PaginatedRequestParams
176+
) -> types.ListTasksResult:
177+
return types.ListTasksResult(tasks=self._store.list())
178+
179+
def _require(self, task_id: str) -> types.Task:
180+
task = self._store.get(task_id)
181+
if task is None:
182+
raise MCPError(code=types.INVALID_PARAMS, message=f"unknown task {task_id!r}")
183+
return task
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""Tests for the SEP-2133 extensions capability negotiation plumbing.
2+
3+
The extension-map negotiation is independent of any concrete extension (Apps,
4+
Tasks): the lowlevel `Server` advertises `self.extensions` under
5+
`ServerCapabilities.extensions`, a client mirrors its own support under
6+
`ClientCapabilities.extensions`, and `Connection.check_capability` resolves the
7+
server-side query. These tests pin that plumbing end-to-end and at the unit
8+
level. Per-extension contribution wiring lives in `test_extension.py`; this file
9+
covers only the capability advertisement and negotiation.
10+
"""
11+
12+
import mcp_types as types
13+
import pytest
14+
from inline_snapshot import snapshot
15+
16+
from mcp.client.client import Client
17+
from mcp.server import Server, ServerRequestContext
18+
from mcp.server.mcpserver import MCPServer
19+
from mcp.server.mcpserver.extension import Extension
20+
21+
pytestmark = pytest.mark.anyio
22+
23+
_EXTENSION_ID = "com.example/x"
24+
_OTHER_EXTENSION_ID = "com.example/other"
25+
26+
27+
class _Extension(Extension):
28+
identifier = _EXTENSION_ID
29+
30+
def settings(self) -> dict[str, object]:
31+
return {"k": 1}
32+
33+
34+
def test_get_capabilities_omits_extensions_when_none_registered() -> None:
35+
"""SDK-defined: a lowlevel `Server` with an empty `extensions` map advertises
36+
`ServerCapabilities.extensions` as `None`, not an empty map."""
37+
server = Server("bare")
38+
assert server.get_capabilities().extensions is None
39+
40+
41+
def test_get_capabilities_advertises_populated_self_extensions() -> None:
42+
"""SDK-defined: `get_capabilities` reads `self.extensions` (the map higher
43+
layers populate) and advertises it under `ServerCapabilities.extensions`."""
44+
server = Server("with-ext")
45+
settings = {"k": 1}
46+
server.extensions = {_EXTENSION_ID: settings}
47+
assert server.get_capabilities().extensions == {_EXTENSION_ID: settings}
48+
49+
50+
async def test_modern_connection_carries_the_advertised_extensions_map() -> None:
51+
"""SDK-defined: over a modern (`server/discover`) connection the client reads
52+
the server's advertised extension map from `server_capabilities`."""
53+
server = MCPServer("host", extensions=[_Extension()])
54+
async with Client(server, mode="auto") as client:
55+
assert client.server_capabilities.extensions == snapshot({"com.example/x": {"k": 1}})
56+
57+
58+
async def test_legacy_handshake_drops_the_extensions_map() -> None:
59+
"""Pinned gap: the handshake-era `initialize` result is serialized against the
60+
2025 wire schema, which has no `extensions` field, so a legacy handshake cannot
61+
carry it; the client sees `None` even though the server advertised one."""
62+
server = MCPServer("host", extensions=[_Extension()])
63+
async with Client(server, mode="legacy") as client:
64+
assert client.server_capabilities.extensions is None
65+
66+
67+
async def test_server_accepts_capability_for_client_advertised_extension() -> None:
68+
"""SDK-defined: a client advertising `extensions={id: ...}` makes the
69+
server-side `check_client_capability` return True when queried for that id.
70+
Observed inside a tool handler."""
71+
queried = types.ClientCapabilities(extensions={_EXTENSION_ID: {}})
72+
supported: list[bool] = []
73+
74+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
75+
assert params.name == "probe"
76+
supported.append(ctx.session.check_client_capability(queried))
77+
return types.CallToolResult(content=[])
78+
79+
async def list_tools(
80+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
81+
) -> types.ListToolsResult:
82+
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
83+
84+
server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools)
85+
async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client:
86+
await client.call_tool("probe", {})
87+
88+
assert supported == [True]
89+
90+
91+
async def test_server_rejects_capability_for_undeclared_extension() -> None:
92+
"""SDK-defined: when the client advertises one extension, a server query for a
93+
*different* identifier returns False - presence, not value, is the check."""
94+
queried = types.ClientCapabilities(extensions={_OTHER_EXTENSION_ID: {}})
95+
supported: list[bool] = []
96+
97+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
98+
assert params.name == "probe"
99+
supported.append(ctx.session.check_client_capability(queried))
100+
return types.CallToolResult(content=[])
101+
102+
async def list_tools(
103+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
104+
) -> types.ListToolsResult:
105+
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
106+
107+
server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools)
108+
async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client:
109+
await client.call_tool("probe", {})
110+
111+
assert supported == [False]
112+
113+
114+
async def test_server_rejects_capability_when_client_advertises_no_extensions() -> None:
115+
"""SDK-defined: a client that declares no extensions makes any server
116+
`check_client_capability` query for an extension return False."""
117+
queried = types.ClientCapabilities(extensions={_EXTENSION_ID: {}})
118+
supported: list[bool] = []
119+
120+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
121+
assert params.name == "probe"
122+
supported.append(ctx.session.check_client_capability(queried))
123+
return types.CallToolResult(content=[])
124+
125+
async def list_tools(
126+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
127+
) -> types.ListToolsResult:
128+
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
129+
130+
server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools)
131+
async with Client(server) as client:
132+
await client.call_tool("probe", {})
133+
134+
assert supported == [False]

0 commit comments

Comments
 (0)