docs/development/v4-notes/background-tasks.mdx
Status: Shipped (#4602, #4603). This page is the approved design for rebuilding FastMCP's background-task support on the io.modelcontextprotocol/tasks extension. It supersedes the earlier "delete the task machinery" direction recorded during the SDK v2 migration. The Feature Program carries the one-line status; user-facing usage is documented at Background Tasks and Background Tasks (client).
Background tasks live on. The MCP spec moved them out of core and into a Final, merged extension — io.modelcontextprotocol/tasks (SEP-2663) — that keeps the polling model FastMCP already implements. No SDK, in any language, ships a runtime for it yet. FastMCP owns the only production-shaped execution engine (Docket/Redis) built for a near-identical protocol.
The plan: rebuild task support on SEP-2663 as fastmcp-tasks, an in-repo optional package, gated by task=True exactly as MCP Apps is gated by app=True. Remove the SEP-1686 wire layer; keep and re-home the execution engine. Along the way, introduce a FastMCP-native server extension API so tasks (and later Apps) plug in through one documented mechanism instead of bespoke surgery on core.
Net effect: a server that already uses @mcp.tool(task=True) needs no code change, and FastMCP plausibly becomes the first runtime implementation of the tasks extension anywhere.
FastMCP 3 shipped background tasks against SEP-1686, the task protocol that briefly lived in the core MCP spec. The implementation is ~4,000 lines across server, client, CLI, and an SDK shim, split into two very different halves:
tasks/get|result|list|cancel handlers, a CreateTaskResult on augmented tools/call, and a Redis-backed push relay that lets a worker reach a client to deliver notifications and elicitation requests.memory:// or redis:// backends) plus FastMCP-built durability: auth-scoped compound keys that isolate task access by caller, request-context snapshot/restore across worker processes, argument-coercion parity with the sync path, and the fastmcp tasks worker CLI.The SDK v2 migration removed SEP-1686 from the core spec. The v4 design notes, until now, recorded the consequence as "delete the task machinery; users who need tasks stay on FastMCP 3." That was the right call given the information at the time — the assumption was that the successor protocol either didn't exist or wasn't implementable. Both halves of that assumption turned out to be wrong.
Tasks were reworked, not removed. SEP-2663 ("Tasks Extension") is Final and was merged upstream on 2026-05-15, superseding SEP-1686. It defines the io.modelcontextprotocol/tasks extension, a capability-negotiated feature layered on the SEP-2133 extensions mechanism. It keeps SEP-1686's polling core and tightens it.
The wire shape:
_meta). This is consent — "I can handle a task result" — not a request to run one.tools/call. The server decides whether to run it as a task.CreateTaskResult (a claimed result shape carrying resultType: "task") with a server-generated taskId.tasks/get until the status is terminal; the result is inlined into that response.input_required, outstanding requests appear in an inputRequests map, and the client answers via tasks/update.tasks/cancel is cooperative. Optional push exists (notifications/tasks over subscriptions/listen) but servers need not send it.Delta from SEP-1686 — and the striking thing is that most of it is deletion, because the spec moved toward what FastMCP already built:
| Dimension | SEP-1686 (old) | SEP-2663 (new) | FastMCP today |
|---|---|---|---|
| Task-id generation | Client-generated | Server-generated | Already server-generated |
tasks/list | Present | Removed (enumeration risk) | Already a stub returning [] |
| Result retrieval | Separate tasks/result | Inlined into tasks/get | Merge two handlers into one |
tasks/delete | Present | Removed (rely on TTL) | TTL is Docket-native |
| Creation race | notifications/tasks/created | Durable-creation MUST | One read-your-writes check away |
| In-task input | Push relay + _meta tagging | Poll: input_required + tasks/update | Replaces the hairiest module |
| Statuses | 7 (incl. submitted, unknown) | 5 | Shrinks a mapping table |
| Augmentable requests | Any | tools/call only | Tools-only surface (see scope) |
| LB routing | Unspecified | Mcp-Name: <taskId> header | Moot with shared Redis |
Critically: no runtime exists. The ext-tasks repo is schema + prose only. The TypeScript and Python SDKs carry the wire types and conformance fixtures — no client/server implementation. The field is open.
Build it. Two facts flip the earlier "delete and wait" call:
Maintaining the SEP-1686 machinery through the migration is dead weight (it's the sole reason for the _sdk_patches.py shim, the TaskNotificationHandler, and a cluster of protocol-era xfails). Rebuilding on SEP-2663 clears that debt and produces a flagship v4 capability with a zero-code-change migration story.
The existing code already separates cleanly along this line; the rebuild makes the boundary a package boundary.
server/tasks/elicitation.py, notifications.py), which existed only because SEP-1686 had no poll-based in-task input channel. SEP-2663's input_required/tasks/update replaces it; the request/response store survives, the push envelope does not.tasks/get/update/cancel — stronger than the spec's "taskIds may be bearer tokens"), context snapshot/restore, argument coercion, and the worker CLI. All of it is wire-agnostic.tasks/get/update/cancel methods, and a tools/call interceptor that decides-and-tasks.fastmcp-tasks becomes an in-repo uv workspace member on the fastmcp_remote template (own pyproject.toml, lockstep-versioned, re-exported through the fastmcp metapackage). The DX parallel with MCP Apps is exact:
| Concern | MCP Apps | Background tasks |
|---|---|---|
| Authoring flag (core) | @mcp.tool(app=True) | @mcp.tool(task=True) |
| Optional package | prefab-ui | fastmcp-tasks |
| Extra | fastmcp[apps] | fastmcp[tasks] |
| Missing-package behavior | Loud install hint | Loud install hint at server build |
Core keeps only the declaration: task=True / TaskConfig is metadata on a component, with no engine import. Everything else — engine and wire adapter — lives in the fastmcp-tasks package. The existing [tasks] extra re-points from the SEP-1686 machinery to fastmcp-tasks, so pip install fastmcp[tasks] and task=True keep working with modern wire underneath.
Activation stays implicit-but-loud (the existing require_docket() pattern, not silent degradation): task=True anywhere triggers a lazy import of fastmcp-tasks at build time; a missing install raises immediately. A tool the author marked as a task silently running inline would be a correctness bug, not a graceful fallback.
MCP extensions (SEP-2133) are a genuinely new abstraction in SDK v2 — they did not exist in v1. So MCP Apps hand-rolling its integration wasn't a wrong choice; it predates the tool. Today FastMCP's server bypasses the SDK's Extension class entirely (it hand-splices the ui capability onto the low-level server and walks tool metadata directly), while the client forwards ClientExtension natively. Every new protocol extension currently means bespoke core surgery.
Tasks is the forcing function to fix that. The design adds a single registration point:
from fastmcp import FastMCP
from fastmcp_tasks import TasksExtension
mcp = FastMCP("Server")
mcp.add_extension(TasksExtension(url="redis://...")) # required to enable tasks
@mcp.tool(task=True) # intent: this tool CAN run as a task
async def crunch(dataset: str) -> str:
...
add_extension is required for task=True to work — it is not autodetected from the presence of task=True flags. This is deliberate. The extension needs configuration that has to live somewhere (backend URL, worker concurrency, TTL defaults), and add_extension(TasksExtension(...)) is its natural home; autodetection would only scatter that config into settings/env and hide the moment of enablement. Requiring it also keeps capability advertisement honest — the server advertises the tasks capability iff the extension is registered — and removes the worst footgun, a tool silently running on an in-memory backend in production because nobody configured Redis. The two concerns stay cleanly separated: task=True is per-component intent ("this tool can be a task"); add_extension is server-wide enablement and config ("this server runs tasks, here's how"). Using task=True with no extension registered is a loud build-time error.
The extension API contributes a negotiated capability, additive request methods, and a tools/call interceptor — with access to FastMCP-level constructs the SDK's Extension withholds (the component registry, Context, auth scope). It is designed against tasks because tasks exercises the full surface (capability + methods + interception + client claims + notifications), where Apps exercises only a subset. Apps migrates onto the extension API as a fast-follow, deleting the hand-rolled splices and confirming the design generalizes.
Extension vs. middleware — the discriminator, so we do not over-apply this: an extension is a negotiated contract change the client must understand; middleware is unilateral server behavior the client never sees. PII detection, auth, rate limiting → middleware. Tasks, Apps → extensions. Litmus test: delete the capability advertisement — if nothing about the client's behavior changes, it was middleware.
SEP-2663 removed the client-side "make this a task" flag — the server decides. That maps onto FastMCP's existing two-tier client surface, the friendly call_tool vs the low-level call_tool_mcp, so there is almost no new API:
call_tool(name, args) (friendly) — advertises the capability and, if the server tasks the call, transparently drives the poll loop and returns the finished result. Whether the server tasked it is invisible. The machinery already exists: the migration wired claim-resolution through call_tool_mcp's allow_claimed path, so a returned CreateTaskResult is finished into an ordinary CallToolResult. In-task input_required routes through the client's existing elicitation handler, answered via tasks/update — so background elicitation looks identical to foreground elicitation, with zero new client API.call_tool_mcp(...) (low-level) — hands back the raw CreateTaskResult claimed shape for callers managing the task themselves.Task handle (.status(), .wait(), .cancel(), awaitable) without blocking — the escape hatch for progress and cancellation.Server-side, TaskConfig modes translate directly: required → always task (-32003 for non-declaring clients), optional → task iff the client declared, forbidden → never.
fastmcp-tasks — extract the engine from the removed SEP-1686 layer, write the SEP-2663 adapter, port the client half.Tasks leads because only it exercises the full API surface; leading with the Apps subset would design us into a corner. Apps becomes the second consumer that confirms generality.
notifications/tasks push and subscriptions/listen integration are deferred to a later fastmcp-tasks version. This lets the second Redis notification queue die rather than be ported.tools/call only — do not lead the spec. SEP-2663 augments tools/call only. FastMCP 3 offered task=True on prompts and resources ahead of the SDK under SEP-1686, and that was a mistake: it produced wire-inexpressible capability, a permanent xfail cluster, and the sdk-feedback #3 gap. The rebuild does not repeat it — task= is a tools-only surface, and the generic prompt/resource task spine is dropped rather than carried. If the spec extends augmentation later, the surface grows with it.ext-tasks schema is labeled experimental with no releases; fastmcp-tasks ships labeled experimental initially and revs on its own cadence when the schema moves.| Risk | Mitigation |
|---|---|
| Spec churn (extension is experimental) | Thin wire adapter over a wire-agnostic engine; ship experimental; SEP itself is Final, so the polling model is stable even if field names move. |
Era gating — SDK strips capabilities.extensions at pre-2026 negotiated versions (sdk-feedback #2) | Advertisement effectively requires the 2026-07-28 era. FastMCP 3 covers legacy tasks. #2 now gates a flagship feature → escalate upstream. |
| Co-developing a new abstraction + greenfield feature | Build and unit-test the extension API in isolation first (step 1) before tasks logic lands on it. |
Naming confusion — [tasks] extra re-points under the same name | Deliberate changelog note; user code and the extra name are unchanged, only the wire modernizes. |
These were the open forks; the maintainer has settled them. Recorded here so the direction is unambiguous going into implementation.
fastmcp-tasks package. The engine and the SEP-2663 wire adapter live in the package; core carries only the task=True declaration. This isolates the experimental schema's churn from core, at the cost of diverging from the Apps precedent (where the ui wire glue lives in core today — Apps will converge onto this model when it migrates to the extension API).mcp.add_extension(), required to enable tasks. Chosen over a thin pass-through to the SDK's MCPServer(extensions=...) because the FastMCP-native API can hand extensions the Context, component registry, and auth scope the SDK's Extension withholds. add_extension is required for task=True (not autodetected) — it is the single home for backend config and the honest source of capability advertisement. See The extension API.call_tool drives the poll loop and returns the finished result; call_tool_mcp exposes the raw CreateTaskResult; a "return quickly" flag yields the Task handle. See Client experience.fastmcp-tasks ships labeled experimental for at least one minor cycle, tracking the experimental ext-tasks schema.