{"slug": "mcp-tasks-long-running-work-without-holding-a-connection", "title": "MCP Tasks: long-running work without holding a connection", "summary": "The Model Context Protocol's Tasks extension, identified as io.modelcontextprotocol/tasks and defined by Specification Enhancement Proposal (SEP) 2663, lets an MCP server answer a tools/call with a task handle that the client polls via tasks/get instead of holding an HTTP stream open, according to a part-26 write-up of the 2026-07-28 MCP specification. The server alone decides per request whether to return a CreateTaskResult marked resultType \"task\", must not return a task to a client that did not declare the extension (returning error -32021, Missing Required Client Capability), and only tools/call supports tasks today. The official Python SDK, mcp 2.3.0, does not yet implement the extension; pull request #3005 adding it was still open on 9 October 2026, so the article defines the Task type in plain Pydantic.", "body_md": "# MCP Tasks: long-running work without holding a connection\n\nIn today’s Model Context Protocol (MCP), a dropped HTTP stream cancels the request, and nothing can resume it. That’s fine for a three-second call and fatal for a 20-minute report. What I like about the Tasks extension is how little it asks of the connection: the server hands back a handle, the client polls, and nothing stays connected while the work runs.\n\nThis is part 26 of my series on what an MCP server does under the 2026-07-28 specification, after [building an MCP App in part 25](https://pournasserian.com/writing/mcp-2026-apps-building). The facts are as I read them in October 2026.\n\n## In brief\n\n1. **A task is a handle in place of a result.** The server answers`tools/call` with a task, and the client polls`tasks/get` until it ends.\n2. **The server decides, one request at a time, whether to return a task,** and it may return one only to a`tools/call` that declared the extension.\n3. **A tool result with `isError: true` makes a `completed` task, not a `failed` one.**`failed` is reserved for JSON-RPC 2.0 errors.\n4. **Store the task durably before you return it,** give it an unguessable ID, and check authorization on every task call.\n5. **The official Python software development kit (SDK) doesn’t implement Tasks yet,** so the Task type below is plain Pydantic.\n\n## Why Tasks exist, and who decides\n\nThe Tasks extension’s identifier is `io.modelcontextprotocol/tasks`. It’s defined by [Specification Enhancement Proposal (SEP) 2663](https://modelcontextprotocol.io/seps/2663-tasks-extension) and negotiated like the other extensions in [part 23](https://pournasserian.com/writing/mcp-2026-extensions). It fits expensive computations, batch processing and work that wraps an external job application programming interface (API). Blocking isn’t a real alternative ([part 16](https://pournasserian.com/writing/mcp-2026-progress-and-cancellation)): the [Tasks overview](https://modelcontextprotocol.io/extensions/tasks/overview) warns that many clients and transport intermediaries impose timeouts that make it impractical beyond a few seconds.\n\nThe extension also fixes the experimental tasks of the 2025-11-25 specification: a fragile opt-in at both the method and the tool level, a blocking `tasks/result` that forced long-lived connections, and a `tasks/list` that couldn’t be scoped to one caller once sessions were gone.\n\nNegotiation leaves the choice with the server:\n\n- The client declares the extension in its per-request capabilities, and the server advertises it in [`server/discover`](https://pournasserian.com/writing/mcp-2026-server-discover) .\n- The server alone decides, request by request, whether to return a task (a `CreateTaskResult` , marked`resultType: \"task\"` ) or a normal result. Declaring support doesn’t mean the client requires a task.\n- A server MUST NOT return a task to a request that didn’t declare the extension, and a declaring client MUST handle either shape. If the work can’t be done without a task and the client didn’t opt in, the server returns `-32021` (Missing Required Client Capability).\n- Only `tools/call` supports tasks today, and implementations SHOULD expect more methods later.\n\n## The task, its states and its methods\n\n### The task object in Python\n\nThe official Python SDK, `mcp` 2.3.0, doesn’t implement the extension yet: [pull request #3005](https://github.com/modelcontextprotocol/python-sdk/pull/3005), which adds it, was still open on 9 October 2026. The SDK’s own `mcp.types.Task` is documented as 2025-11-25 only, with `ttl` and `poll_interval`, so it isn’t SEP-2663’s task. Here is that task in plain Pydantic, with the wire’s camelCase names as aliases:\n\n``` python\nfrom datetime import datetime\nfrom typing import Any, Literal\n\nfrom pydantic import BaseModel, ConfigDict\nfrom pydantic.alias_generators import to_camel\n\nTaskStatus = Literal[\"working\", \"input_required\", \"completed\", \"cancelled\", \"failed\"]\n\nclass Task(BaseModel):\n    \"\"\"The Task object of the Tasks extension (SEP-2663).\"\"\"\n\n    # JSON in and out uses the wire names: taskId, ttlMs\n    model_config = ConfigDict(\n        alias_generator=to_camel, validate_by_name=True, serialize_by_alias=True\n    )\n\n    task_id: str                         # stable, server-generated, unguessable\n    status: TaskStatus\n    status_message: str | None = None    # human-readable progress or reason\n    created_at: datetime                 # ISO 8601\n    last_updated_at: datetime            # ISO 8601\n    ttl_ms: int | None                   # required; None means unlimited\n    poll_interval_ms: int | None = None  # suggested polling cadence\n\nclass TaskState(Task):\n    \"\"\"A tasks/get result: the task plus the field its status adds.\"\"\"\n\n    input_requests: dict[str, Any] | None = None  # input_required\n    result: dict[str, Any] | None = None          # completed\n    error: dict[str, Any] | None = None           # failed\n```\n\nHere it parses the final `tasks/get` reply of the flow drawn further down:\n\n```\nreply = \"\"\"{\n  \"resultType\": \"complete\", \"taskId\": \"786512e2-9e0d-44bd-8f29-789f320fe840\",\n  \"status\": \"completed\", \"createdAt\": \"2026-10-09T10:30:00Z\",\n  \"lastUpdatedAt\": \"2026-10-09T10:50:00Z\", \"ttlMs\": 3600000, \"pollIntervalMs\": 5000,\n  \"result\": {\"content\": [{\"type\": \"text\", \"text\": \"Hello, Luca!\"}], \"isError\": false}\n}\"\"\"\n\nstate = TaskState.model_validate_json(reply)\nprint(state.status, state.ttl_ms, state.result[\"content\"][0][\"text\"])\n```\n\nIt printed:\n\n```\ncompleted 3600000 Hello, Luca!\n```\n\nBoth `ttlMs` and `pollIntervalMs` may change during a task’s life.\n\n### Five statuses, three of them final\n\nA task starts `working`, may wait at `input_required` for an answer, and ends `completed`, `failed` or `cancelled`:\n\n| Status | `tasks/get` also returns | \n|---|---|\n| `working` | Just the task | \n| `input_required` | `inputRequests` : all outstanding server-to-client requests | \n| `completed` | `result` : exactly what the original request would have returned | \n| `failed` | `error` : the JSON-RPC error, only for protocol-level failures | \n| `cancelled` | Just the task | \n\n### Three methods and a notification\n\n| Method | Purpose | Response | \n|---|---|---|\n| `tasks/get` | Poll the current state | The full task with its status-specific field; a pure, idempotent read | \n| `tasks/update` | Supply `inputResponses` for outstanding`inputRequests` | An empty acknowledgement, eventually consistent | \n| `tasks/cancel` | Signal the intent to cancel | An empty acknowledgement, cooperative and eventually consistent | \n| `notifications/tasks` | Optional server push of the full task state | Opt in through [`subscriptions/listen`](https://pournasserian.com/writing/mcp-2026-subscriptions-listen) with` taskIds` | \n\nReads and writes are separate on purpose: `tasks/get` stays cacheable and safe to retry, and all write semantics live in `tasks/update`. Here is a complete flow, with a question for the user midway:\n\n## Running tasks on the server\n\n### What the extension asks of the server\n\n- **Durable before acknowledging.** Don’t return the task until a`tasks/get` for it would succeed; in an eventually consistent store, wait for consistency.\n- **Unique keys.** An`inputRequests` key is unique for the task’s whole life and never reused. Outstanding requests reappear on every poll, so clients SHOULD deduplicate.\n- **Eventual consistency.** An update or a cancellation may be acknowledged before`tasks/get` shows it. Cancellation is cooperative: the server needn’t stop, and the task may still end`completed` .\n- **Expiry.** Once`ttlMs` has elapsed, a server may mark the task`failed` and delete it; “task not found” (`-32602` ) for a purged task is compliant.\n- **Routing.** Over Streamable HTTP, clients MUST set the[`Mcp-Name` header](https://pournasserian.com/writing/mcp-2026-transports) to the`taskId` on every`tasks/*` call, beyond the three core methods part 7 lists, so load balancers can route to the instance or partition holding the task’s state.\n- **No progress or log notifications.** Tasks support neither`notifications/progress` nor`notifications/message` ; use`statusMessage` .\n- **No old methods.**`tasks/result` returns`-32601` , and there’s no`tasks/list` .\n\nIf you need input before deciding to start, resolve those rounds of Multi Round-Trip Requests (MRTR, [part 13](https://pournasserian.com/writing/mcp-2026-multi-round-trip-requests)) synchronously, then return the task; the two phases keep separate `inputRequests` key spaces. I recommend this for work that is both long and needs a confirmation, with the rule from [part 14](https://pournasserian.com/writing/mcp-2026-request-state): no side effects before the final input round.\n\n### Security\n\nA task ID may work as a bearer token, so give it enough entropy that it can’t be enumerated, and check authentication and authorization on every task-related request. Without `tasks/list`, no caller can learn that another’s tasks exist. And `inputRequests` arriving through a task get the same trust and user-experience treatment as direct [elicitation](https://pournasserian.com/writing/mcp-2026-elicitation).\n\n### A durable implementation\n\nThe rule above asks that `tasks/get` can find a task before you return it. My advice goes further: back the task store with something durable, not memory, or a restart loses every task. Separate request handling from execution with a queue and workers, and make the workers idempotent, since queues redeliver. Have them write `statusMessage` as they go, because it’s your only progress channel. Put together:\n\nFinally, ask clients to persist task IDs, as the SEP says they SHOULD, so polling can resume after a crash.\n\n## When to use a task\n\n| Situation | Use | \n|---|---|\n| Finishes in seconds | Plain `tools/call` | \n| Quick, but needs a confirmation or a missing value first | `tools/call` with MRTR | \n| Minutes or hours, batch jobs, wraps an external job API | Tasks | \n| Long and needs input before starting | MRTR rounds, then return a task | \n| Long and needs input midway | Tasks with `input_required` , answered by`tasks/update` | \n\nBecause the server decides, you can return a direct result when the work is fast or cached and a task when it isn’t, without changing the tool’s contract. Client SDKs can hide the polling and expose only the final result. In [part 12](https://pournasserian.com/writing/mcp-2026-choosing-a-primitive) I drew the line at about a minute, as a rule of thumb; the overview’s timeout warning suggests a lower bar.\n\n## Method and caveats\n\n- Built from my guide’s chapter on Tasks and one pattern from its design-patterns chapter, written against the 2026-07-28 specification and SEP-2663.\n- The Python sample is plain Pydantic 2.14, installed with `mcp` 2.3.0, not SDK code. It printed the output shown on 9 October 2026, the day I also checked pull request #3005.", "url": "https://wpnews.pro/news/mcp-tasks-long-running-work-without-holding-a-connection", "canonical_source": "https://pournasserian.com/writing/mcp-2026-tasks", "published_at": "2026-10-24 00:00:00+00:00", "updated_at": "2026-10-24 12:43:16.676780+00:00", "lang": "en", "topics": ["agent-protocols", "ai-agents", "developer-tools", "ai-tools"], "entities": ["Model Context Protocol", "Specification Enhancement Proposal 2663", "mcp 2.3.0", "Python SDK", "pull request #3005", "CreateTaskResult", "Pydantic"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/mcp-tasks-long-running-work-without-holding-a-connection", "markdown": "https://wpnews.pro/news/mcp-tasks-long-running-work-without-holding-a-connection.md", "text": "https://wpnews.pro/news/mcp-tasks-long-running-work-without-holding-a-connection.txt", "jsonld": "https://wpnews.pro/news/mcp-tasks-long-running-work-without-holding-a-connection.jsonld"}}