---
title: 'MCP Tasks Extension: A Working Python Server (2026)'
description: The ratified MCP Tasks extension needs three new methods. The official Python SDK v2.2.0 still doesn't implement them. Here's a tested server that does.
url: https://nerdleveltech.com/mcp-tasks-extension-python-tutorial
language: en
date: '2026-09-22'
category: ai-ml
tags:
  - mcp python sdk
  - model context protocol
  - tasks extension
  - python
  - sep-2663
  - fastmcp
  - agent tooling
markdown_url: https://nerdleveltech.com/mcp-tasks-extension-python-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/mcp-tasks-extension-python-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# MCP Tasks Extension: A Working Python Server (2026)

**TL;DR:** The ratified `io.modelcontextprotocol/tasks` extension (SEP-2663) gives MCP servers a way to hand back a durable task handle instead of blocking on a slow tool call. As of `mcp` 2.2.0 (released September 7, 2026), the official Python SDK still doesn't implement it — it deleted the old experimental version and left nothing in its place.

The one PyPI package that does implement the ratified spec, `fastmcp-tasks`, isn't built on the official SDK at all. This post builds a working implementation on top of `MCPServer` using the SDK's own extension framework, runs it end to end, and catches two real bugs along the way.

## What you'll learn

- Why `mcp` 2.2.0 has no Tasks implementation, and what it deleted to get there
- Why `mcp.types.CreateTaskResult` exists in the SDK but will not interoperate with a ratified-spec client
- How `fastmcp-tasks`, the one package that does implement SEP-2663, differs from the official SDK
- How to build the extension yourself with the v2 SDK's `Extension` framework
- Why the client's own `call_tool()` helper can't parse your server's task response
- A real cooperative-cancellation bug this build produced, and the one-line fix

## The official SDK went from "experimental" to "nothing"

MCP's July 28, 2026 specification revision changed a lot at once: it dropped the connection handshake, removed every server-initiated request, and moved the old, informal Tasks design out of the core spec entirely — as its own extension, `io.modelcontextprotocol/tasks`, proposed as SEP-2663 on April 27, 2026 and ratified separately on its own Extensions Track before shipping with the July 28 revision.[^1]

The Python SDK's v2 line (`mcp` 2.0.0 and later) implements that July 28 revision. Its own changelog is explicit about what happened to Tasks along the way: the old `mcp.*.experimental` Tasks API was removed outright, and the SDK does not implement its replacement.[^2]

I installed `mcp` 2.2.0 fresh from PyPI (the current release as of this writing, shipped September 7, 2026) to confirm it firsthand.[^3] `mcp.server`'s own submodule list has no `tasks` module at all:

```
['__main__', '_otel', '_streamable_http_modern', 'apps', 'auth', 'caching',
 'connection', 'context', 'elicitation', 'extension', 'fastmcp', 'lowlevel',
 'mcpserver', 'models', 'request_state', 'runner', 'session', 'sse', 'stdio',
 'streamable_http', 'streamable_http_manager', 'subscriptions',
 'transport_security', 'validation']
```

No server-side handler, no client-side result type, nothing.

## The trap: `mcp.types.CreateTaskResult` still exists

Here's the part that isn't in the changelog. `mcp.types` — the SDK's wire-type package — still ships a full family of Task-shaped Pydantic models: `CreateTaskResult`, `GetTaskRequest`, `GetTaskResult`, `CancelTaskRequest`, `TaskStatusNotification`, even `ListTasksRequest`.

Import any of them and they work. Nothing warns you they're the wrong shape.

The package's own source comment explains why, sitting directly above the class definitions:

> "Tasks: introduced in 2025-11-25, removed from the core spec in 2026-07-28 (continuing as an extension). Defined here types-only; their methods are not in the request/notification unions below, so they are never dispatched."

These are the *abandoned* 2025-11-25 experimental design, kept only as inert type definitions. They do not match the ratified extension, in five concrete ways I checked field by field:

| | Ratified spec (SEP-2663) | Leftover `mcp.types` models |
|---|---|---|
| Result discriminator | `resultType: "task"` on every task result | No `result_type` field at all |
| Task shape | Flat — `taskId`, `status`, `ttlMs` sit directly on the result | Nested — `CreateTaskResult.task` wraps a separate `Task` object |
| Retention field | `ttlMs` (milliseconds, camelCase) | `ttl` |
| `tasks/list` | Explicitly does not exist — the spec calls this out as a deliberate security fix over the 2025-11-25 design, since a poorly-scoped list could leak one caller's task IDs to another | `ListTasksRequest` / `TasksListCapability` are still fully defined |
| Result delivery | Inlined into `GetTaskResult.result` | A separate `tasks/result` request (`GetTaskPayloadRequest`) |

Build a server against `mcp.types.CreateTaskResult` today and you'll get something that talks to no ratified-spec client anywhere, because that client-side type doesn't exist in this SDK either.

## Who actually ships SEP-2663 in Python

One PyPI package does: `fastmcp-tasks`, at version 4.0.5 as of September 17, 2026 — five days before this post, and under three weeks after its 4.0.0 stable release on August 31.[^4] Its own description is unambiguous: "Background task execution for FastMCP servers via the `io.modelcontextprotocol/tasks` extension (SEP-2663)."

The detail worth catching: it is not built on the official `mcp` package. Among its hard dependencies is `fastmcp-slim`, pinned to the exact same version (4.0.5).[^5] `fastmcp-slim`, and `fastmcp-tasks` alongside it, live in the same monorepo as FastMCP itself — originally created by Jeremiah Lowin, now maintained by Prefect under the `PrefectHQ` GitHub org.[^6] It is a real, officially maintained project. It just isn't the official MCP SDK.

That project has a real but easily confused relationship to `modelcontextprotocol/python-sdk`. FastMCP 1.0's high-level server design was folded into the official SDK in 2024, and survives there today as what v2 renamed from `FastMCP` to `MCPServer`.

FastMCP 2.0 is a separate, still-independently-developed codebase by the same team, and it's what `fastmcp-slim` (and therefore `fastmcp-tasks`) actually runs on — not `mcp.server.mcpserver.MCPServer`. Two different projects share a name and a common ancestor; only one of them ships the ratified Tasks extension, and it is the one outside `modelcontextprotocol/python-sdk`.

## Building it yourself on `MCPServer`

The v2 SDK does give you what you need to build this: a first-class `Extension` framework, added the same release as the protocol rewrite (SEP-2133).[^2] An extension can register new request methods and intercept `tools/call` — which is exactly the shape SEP-2663 needs.

Everything below ran against a real `mcp` 2.2.0 install in a clean virtualenv — no mocked transport, no hand-waved output.

Start with the task record types. `types.Result` and `types.RequestParams` auto-convert `snake_case` Python attributes to `camelCase` on the wire, so `task_id` becomes `taskId` without any manual aliasing:

```python
import mcp.types as types
from mcp import MCPError

class TaskGetParams(types.RequestParams):
    task_id: str

class TaskResult(types.Result):
    result_type: str = "complete"
    task_id: str
    status: str  # working | input_required | completed | cancelled | failed
    created_at: str
    last_updated_at: str
    ttl_ms: int | None = None
    poll_interval_ms: int | None = None
    result: dict | None = None
    error: dict | None = None

class TaskAck(types.Result):
    result_type: str = "complete"
```

Register the three verbs with `MethodBinding`, and hook task creation with `intercept_tool_call`:

```python
from mcp.server.extension import Extension, MethodBinding
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext

_TASKS: dict[str, dict] = {}

class TasksExtension(Extension):
    identifier = "io.modelcontextprotocol/tasks"
    TASK_TOOLS = {"slow_report"}

    def methods(self):
        return [
            MethodBinding("tasks/get", TaskGetParams, _get_task),
            MethodBinding("tasks/cancel", TaskCancelParams, _cancel_task),
            MethodBinding("tasks/update", TaskUpdateParams, _update_task),
        ]

    async def intercept_tool_call(self, params, ctx, call_next) -> HandlerResult:
        if params.name not in self.TASK_TOOLS:
            return await call_next(ctx)

        task_id = str(uuid.uuid4())
        now = _now_iso()
        _TASKS[task_id] = {
            "task_id": task_id, "status": "working",
            "created_at": now, "last_updated_at": now,
            "ttl_ms": 300_000, "poll_interval_ms": 500,
            "result": None, "error": None,
        }
        asyncio.create_task(self._run(task_id, ctx, call_next))

        # Raw dict, matching the spec's flat CreateTaskResult shape exactly.
        return {
            "resultType": "task", "taskId": task_id, "status": "working",
            "createdAt": now, "lastUpdatedAt": now,
            "ttlMs": 300_000, "pollIntervalMs": 500,
        }
```

`_run` does the actual work in the background, after `intercept_tool_call` has already returned the task handle to the client:

```python
    async def _run(self, task_id, ctx, call_next) -> None:
        record = _TASKS[task_id]
        try:
            await asyncio.sleep(2)  # stand-in for real slow work
            result = await call_next(ctx)
            if record["status"] == "cancelled":
                return  # see "the cancellation bug" below
            record["status"] = "completed"
            record["result"] = result.model_dump(by_alias=True, exclude_none=True)
        except Exception as exc:
            if record["status"] != "cancelled":
                record["status"] = "failed"
                record["error"] = {"code": -32603, "message": str(exc)}
        record["last_updated_at"] = _now_iso()
```

`tasks/get`, `tasks/cancel`, and `tasks/update` are ordinary handlers reading and writing that same dict — `tasks/get` raises `MCPError(types.INVALID_PARAMS, ...)` for an unknown `task_id`, matching the spec's required `-32602`.[^1]

## The client can't call its own server

This is the finding I didn't expect going in. `Client.call_tool()` — the SDK's own high-level convenience method — validates every response against a hard-coded union of `CallToolResult` and `InputRequiredResult`. It has no branch for `resultType: "task"`, because the SDK doesn't know the extension exists.

Calling `client.call_tool("slow_report", {...})` against my own server, with the extension fully wired up server-side, raised this immediately:

```
pydantic_core._pydantic_core.ValidationError: 2 validation errors for
union[CallToolResult,function-after[_require_one_field(), InputRequiredResult]]
CallToolResult.content
  Field required [type=missing, input_value={'resultType': 'task', ...
function-after[_require_one_field(), InputRequiredResult].resultType
  Input should be 'input_required' [type=literal_error, input_value='task', ...]
```

A perfectly spec-compliant server response, rejected by the official client's own convenience method. The fix is to drop one layer, to `client.session.send_request()`, with your own result type:

```python
from typing import Literal, Union
from pydantic import TypeAdapter

class CreateTaskResult(types.Result):
    result_type: Literal["task"] = "task"
    task_id: str
    status: Literal["working"]
    created_at: str
    last_updated_at: str
    ttl_ms: int | None = None
    poll_interval_ms: int | None = None

CallToolOrTask = TypeAdapter(Union[types.CallToolResult, CreateTaskResult])

call_req = types.CallToolRequest(
    params=types.CallToolRequestParams(name="slow_report", arguments={"topic": "Q3 agent adoption"})
)
created = await client.session.send_request(call_req, CallToolOrTask)
```

That call succeeds and returns a `CreateTaskResult` with a real `task_id`. Polling is the same pattern against `tasks/get`, using a `types.Request` subclass with `name_param = "taskId"`.

That field tells the SDK's client session to mirror `taskId` into the `Mcp-Name` header on Streamable HTTP, which the spec requires so a load balancer can route a task's follow-up calls back to the worker holding its state.[^1] An in-memory transport like the one this post tests against never sends HTTP headers, so `name_param` is inert here — but it's not optional for a real deployment behind more than one server process.

## The cancellation bug

The first version of `_run` had no cancelled-status guard. It looked correct: I created a task, cancelled it, and `tasks/get` immediately reported `status: "cancelled"`. It wasn't correct.

`tasks/cancel` only *signals* cancellation — the spec is explicit that a server "is not obligated to actually stop the work" and eventual transition to `cancelled` "is not guaranteed."[^1] My background `asyncio.create_task` kept running regardless. When its simulated 2-second sleep finished, it wrote `status: "completed"` straight over the top of `cancelled`.

I caught it by polling again after the work would have finished, instead of only checking right after cancelling:

```
status right after cancel: cancelled
status 2.5s after cancel: completed | result: {'content': [{'type': 'text', ...
```

A cancelled task silently un-cancelling itself is worse than the spec's "may not honor cancellation" — it's actively wrong. The fix is one guard clause: check `record["status"] == "cancelled"` before writing a terminal result, and discard the late-arriving work instead.

## Real output, full run

This is the actual terminal output from `python3 client.py` against the fixed server — tool call, task creation, polling, cancellation, and an unknown-`task_id` error, in one process:

```
server_capabilities.extensions: {'io.modelcontextprotocol/tasks': {}}
quick_echo -> [TextContent(type='text', text='echo: hi', annotations=None, meta=None)]
[server] created task c3e2abd5-d9ec-4447-a555-abc61448fd4b, scheduling background run
[server] task c3e2abd5-d9ec-4447-a555-abc61448fd4b starting slow work
slow_report raw result -> meta={'io.modelcontextprotocol/serverInfo': {'name': 'task-demo', 'version': ''}} result_type='task' task_id='c3e2abd5-d9ec-4447-a555-abc61448fd4b' status='working' created_at='2026-09-22T04:09:44Z' last_updated_at='2026-09-22T04:09:44Z' ttl_ms=300000 poll_interval_ms=500
task_id -> c3e2abd5-d9ec-4447-a555-abc61448fd4b
poll 0: status=working
poll 1: status=working
poll 2: status=working
poll 3: status=working
[server] task c3e2abd5-d9ec-4447-a555-abc61448fd4b completed
poll 4: status=completed
final result: {'content': [{'type': 'text', 'text': "Report on 'Q3 agent adoption': 3 findings, 0 blockers."}], 'structuredContent': {'result': "Report on 'Q3 agent adoption': 3 findings, 0 blockers."}, 'isError': False, 'resultType': 'complete'}
[server] created task 3ec43169-4b96-4150-82aa-b8c52b4a5bb8, scheduling background run
[server] task 3ec43169-4b96-4150-82aa-b8c52b4a5bb8 starting slow work
cancel ack -> meta={'io.modelcontextprotocol/serverInfo': {'name': 'task-demo', 'version': ''}} result_type='complete'
status after cancel -> cancelled
unknown task_id error -> Failed to retrieve task: Task not found
```

Four polls at a 500ms interval against a 2-second task, then a clean completion, then a cancel that stays cancelled, then the spec-required error for a bogus ID. This is the literal stdout, not a trimmed or reconstructed version of it.

## Where this leaves the ecosystem

The Python SDK isn't uniquely behind. I checked the TypeScript SDK's current published package fresh for this post: `@modelcontextprotocol/sdk` is at 1.30.0, and its own `package.json` still exports an `./experimental/tasks` entry point — the pre-ratification name, not the extension.[^7]

Our own hands-on build against that SDK, published four days before this post, found a related gap in the same package: the low-level `Server`'s handler-registration capability check has no case for `tasks/update` at all, so registering a handler for it is silently ungated by the `tasks` capability declaration the other three task methods require.[^8]

Two languages, two different SDK teams, the same outcome: a spec extension proposed in April, ratified ahead of the core spec's July release, and still absent from both official runtimes in late September.

| | Implements ratified `io.modelcontextprotocol/tasks`? | Built on |
|---|---|---|
| `mcp` (Python, official) 2.2.0 | No — removed the old experimental version, nothing replaced it | — |
| `@modelcontextprotocol/sdk` (TS, official) 1.30.0 | No — `tasks` still ships under `/experimental` | — |
| `@modelcontextprotocol/ext-tasks` (TS) 0.1.0 | Requester-side only — no ratified-shape receiver/server helper published[^8] | Official TS SDK v2 (`@modelcontextprotocol/client`) |
| `fastmcp-tasks` (Python) 4.0.5 | Yes | `fastmcp-slim` (PrefectHQ FastMCP 2.0) — not the official SDK |
| This post's extension | Yes, for the three core verbs | `mcp` 2.2.0's own `Extension` framework |

## Verification

Every code sample in this post ran, unedited, against `mcp` 2.2.0 installed fresh from PyPI in a clean Python 3.10 virtual environment on September 22, 2026. No Anthropic or OpenAI API key was used or needed — an MCP task handle is JSON-RPC state, not a model call, so the client and server in this post talk to each other in-process with `mcp.Client(server_object)`, never touching the network.

The "leftover types" claim was checked by importing `mcp.types.CreateTaskResult` and its siblings directly and reading `model_fields`, then reading the source comment sitting above their definitions in the installed package.

The "who ships it" claim was checked with `pip install fastmcp-tasks` and `pip show` against both it and its `fastmcp-slim` dependency. The TypeScript comparison was checked fresh against the current `@modelcontextprotocol/sdk` npm registry entry, not carried over from an earlier post.

## FAQ

**Does the official MCP Python SDK support the Tasks extension?**
No. `mcp` 2.2.0 removed the old experimental Tasks API in its v2 rewrite and has not added the ratified `io.modelcontextprotocol/tasks` extension (SEP-2663) in its place, as of September 2026.

**What is SEP-2663?**
The MCP enhancement proposal that moved Tasks out of the core protocol and into an official, separately versioned extension. It was proposed on April 27, 2026, reached Final status, and shipped as part of the July 28, 2026 spec revision.[^9]

**Is `mcp.types.CreateTaskResult` the same as the ratified Tasks extension?**
No. It's a leftover type from the abandoned 2025-11-25 design — the SDK's own source comment says its methods "are never dispatched." It has no `resultType` discriminator, nests the task under a `task` key instead of a flat shape, and includes a `tasks/list` method the ratified spec deliberately omits.

**Is `fastmcp-tasks` part of the official MCP SDK?**
No. It depends on `fastmcp-slim`, part of the separately maintained FastMCP 2.0 project (Jeremiah Lowin / PrefectHQ), not on the `mcp` package from `modelcontextprotocol/python-sdk`.

**Why can't `client.call_tool()` read a task response?**
Its result-type union only recognizes `resultType: "complete"` and `"input_required"`. A `"task"` result raises a `pydantic.ValidationError`. Use `client.session.send_request()` with your own result type instead.

**What happens if I cancel an MCP task that's still running?**
Per spec, cancellation is a request, not a guarantee — the server may finish the work anyway. A naive implementation can let that finished work silently overwrite the cancelled status; the fix is checking the task's status before writing a terminal result.

## Bottom line

SEP-2663 was proposed in April 2026, and the spec revision that shipped it went out almost two months ago. Neither official SDK implements the extension yet, and the Python SDK's own wire-types package still exposes a type-compatible-looking but functionally incompatible leftover from the design it replaced.

The one package that does implement it comes from outside the official SDK entirely. The v2 extension framework is expressive enough to close that gap — this post's server and client together run under 300 lines — but nothing ships it for you today.

---

*Related reading: [MCP Tasks Extension: A Working TypeScript Server](https://nerdleveltech.com/mcp-tasks-extension-tutorial) covers the same extension against the official TypeScript SDK. [MCP's July 28, 2026 spec](https://nerdleveltech.com/mcp-stateless-protocol-enterprise-authorization) covers the rest of what changed in the same revision. [Build a production MCP server in TypeScript](https://nerdleveltech.com/mcp-server-typescript-oauth-streamable-http-production-tutorial) covers OAuth and Streamable HTTP for a server going to production.*

[^1]: [Tasks — MCP Tasks Extension specification, 2026-07-28](https://tasks.extensions.modelcontextprotocol.io/specification/2026-07-28/tasks.html), fetched 2026-09-22 (content confirmed identical to the living Draft revision as of this date).
[^2]: [What's new in v2 — MCP Python SDK](https://py.sdk.modelcontextprotocol.io/whats-new/), fetched 2026-09-22.
[^3]: [mcp 2.2.0 — PyPI](https://pypi.org/project/mcp/), release metadata fetched 2026-09-22 (uploaded 2026-09-07).
[^4]: [fastmcp-tasks — PyPI](https://pypi.org/project/fastmcp-tasks/), release metadata fetched 2026-09-22 (4.0.5 uploaded 2026-09-17).
[^5]: [fastmcp-slim — PyPI](https://pypi.org/project/fastmcp-slim/), fetched 2026-09-22.
[^6]: [PrefectHQ/fastmcp — GitHub](https://github.com/PrefectHQ/fastmcp), fetched 2026-09-22 (27.9k stars; the repository jlowin/fastmcp now redirects here).
[^7]: [@modelcontextprotocol/sdk — npm registry](https://registry.npmjs.org/@modelcontextprotocol/sdk/latest), fetched 2026-09-22 (1.30.0).
[^8]: [MCP Tasks Extension: A Working TypeScript Server](https://nerdleveltech.com/mcp-tasks-extension-tutorial), NerdLevelTech, 2026-09-18.
[^9]: [SEP-2663: Tasks Extension — Model Context Protocol](https://modelcontextprotocol.io/seps/2663-tasks-extension), fetched 2026-09-22.
