|
| 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 |
0 commit comments