ai-ml

MCP Tasks Extension: A Working Python Server (2026)

September 22, 2026

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 discriminatorresultType: "task" on every task resultNo result_type field at all
Task shapeFlat — taskId, status, ttlMs sit directly on the resultNested — CreateTaskResult.task wraps a separate Task object
Retention fieldttlMs (milliseconds, camelCase)ttl
tasks/listExplicitly 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 anotherListTasksRequest / TasksListCapability are still fully defined
Result deliveryInlined into GetTaskResult.resultA 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:

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:

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:

    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:

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.0No — removed the old experimental version, nothing replaced it—
@modelcontextprotocol/sdk (TS, official) 1.30.0No — tasks still ships under /experimental—
@modelcontextprotocol/ext-tasks (TS) 0.1.0Requester-side only — no ratified-shape receiver/server helper published8Official TS SDK v2 (@modelcontextprotocol/client)
fastmcp-tasks (Python) 4.0.5Yesfastmcp-slim (PrefectHQ FastMCP 2.0) — not the official SDK
This post's extensionYes, for the three core verbsmcp 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.

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 covers the same extension against the official TypeScript SDK. MCP's July 28, 2026 spec covers the rest of what changed in the same revision. Build a production MCP server in TypeScript covers OAuth and Streamable HTTP for a server going to production.

Footnotes

  1. Tasks — MCP Tasks Extension specification, 2026-07-28, fetched 2026-09-22 (content confirmed identical to the living Draft revision as of this date). ↩ ↩2 ↩3 ↩4

  2. What's new in v2 — MCP Python SDK, fetched 2026-09-22. ↩ ↩2

  3. mcp 2.2.0 — PyPI, release metadata fetched 2026-09-22 (uploaded 2026-09-07). ↩

  4. fastmcp-tasks — PyPI, release metadata fetched 2026-09-22 (4.0.5 uploaded 2026-09-17). ↩

  5. fastmcp-slim — PyPI, fetched 2026-09-22. ↩

  6. PrefectHQ/fastmcp — GitHub, fetched 2026-09-22 (27.9k stars; the repository jlowin/fastmcp now redirects here). ↩

  7. @modelcontextprotocol/sdk — npm registry, fetched 2026-09-22 (1.30.0). ↩

  8. MCP Tasks Extension: A Working TypeScript Server, NerdLevelTech, 2026-09-18. ↩ ↩2

  9. SEP-2663: Tasks Extension — Model Context Protocol, fetched 2026-09-22. ↩

Frequently Asked Questions

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.