Merge pull request #4603 from PrefectHQ/feat/tasks-sep2663

Add background tasks via the io.modelcontextprotocol/tasks extension (SEP-2663)
This commit is contained in:
Jeremiah Lowin 2026-07-23 20:16:11 -04:00 committed by GitHub
commit 39148870af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
181 changed files with 10998 additions and 14322 deletions

View file

@ -0,0 +1,87 @@
name: Publish fastmcp-tasks to PyPI
on:
workflow_run:
workflows: ["Publish fastmcp-slim to PyPI"]
types: [completed]
workflow_dispatch:
permissions:
contents: read
id-token: write
jobs:
pypi-publish:
name: Upload fastmcp-tasks to PyPI
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'release')
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Build fastmcp-tasks
run: uv build --package fastmcp-tasks
- name: Verify matching fastmcp-slim is published
run: |
SLIM_VERSION=$(python - <<'PY'
import email.parser
import re
import zipfile
from pathlib import Path
wheel = next(Path("dist").glob("fastmcp_tasks-*.whl"))
metadata_name = next(
name for name in zipfile.ZipFile(wheel).namelist()
if name.endswith(".dist-info/METADATA")
)
metadata = email.parser.Parser().parsestr(
zipfile.ZipFile(wheel).read(metadata_name).decode()
)
for value in metadata.get_all("Requires-Dist", []):
requirement, _, marker = value.partition(";")
if marker.strip():
continue
match = re.fullmatch(
r"fastmcp-slim(?:\[[^\]]+\])?==([^;\s]+)",
requirement.strip(),
)
if match:
print(match.group(1))
break
else:
raise RuntimeError("Could not find the base fastmcp-slim dependency")
PY
)
for attempt in {1..12}; do
if python - "$SLIM_VERSION" <<'PY'
import json
import sys
import urllib.request
version = sys.argv[1]
url = f"https://pypi.org/pypi/fastmcp-slim/{version}/json"
with urllib.request.urlopen(url, timeout=30) as response:
json.load(response)
PY
then
exit 0
fi
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI yet; retrying (${attempt}/12)."
sleep 10
done
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI; refusing to publish fastmcp-tasks." >&2
exit 1
- name: Publish fastmcp-tasks to PyPI
run: uv publish -v dist/fastmcp_tasks-*.tar.gz dist/fastmcp_tasks-*.whl

View file

@ -115,6 +115,58 @@ jobs:
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI; refusing to publish fastmcp." >&2
exit 1
- name: Verify matching fastmcp-tasks is published
run: |
TASKS_VERSION=$(python - <<'PY'
import email.parser
import re
import zipfile
from pathlib import Path
wheel = next(Path("dist").glob("fastmcp-*.whl"))
metadata_name = next(
name for name in zipfile.ZipFile(wheel).namelist()
if name.endswith(".dist-info/METADATA")
)
metadata = email.parser.Parser().parsestr(
zipfile.ZipFile(wheel).read(metadata_name).decode()
)
# fastmcp-tasks is pinned via the optional `tasks` extra, so its
# Requires-Dist entry carries an `extra == "tasks"` marker — unlike the
# base slim dependency, do not skip marked entries here.
for value in metadata.get_all("Requires-Dist", []):
requirement, _, _marker = value.partition(";")
match = re.fullmatch(r"fastmcp-tasks==([^;\s]+)", requirement.strip())
if match:
print(match.group(1))
break
else:
raise RuntimeError("Could not find the fastmcp-tasks extra dependency")
PY
)
for attempt in {1..12}; do
if python - "$TASKS_VERSION" <<'PY'
import json
import sys
import urllib.request
version = sys.argv[1]
url = f"https://pypi.org/pypi/fastmcp-tasks/{version}/json"
with urllib.request.urlopen(url, timeout=30) as response:
json.load(response)
PY
then
exit 0
fi
echo "fastmcp-tasks ${TASKS_VERSION} is not available on PyPI yet; retrying (${attempt}/12)."
sleep 10
done
echo "fastmcp-tasks ${TASKS_VERSION} is not available on PyPI; refusing to publish fastmcp (the [tasks] extra would be uninstallable)." >&2
exit 1
- name: Publish fastmcp to PyPI
run: uv publish -v dist/fastmcp-*.tar.gz dist/fastmcp-*.whl

View file

@ -190,9 +190,10 @@ Legacy mode is also what you need for the capabilities that depend on a live ses
- **[Sampling](/clients/sampling)** — server-initiated LLM completion requests
- **[Roots](/clients/roots)** — server-initiated requests for the client's roots
- **[Elicitation](/clients/elicitation)** — server-initiated requests for user input, which modern connections replace with [input-required rounds](/clients/elicitation#input-required-rounds)
- **[Background tasks](/clients/tasks)** — submitting an operation with `task=True`
- `client.ping()` and `transport.get_session_id()`
Conversely, [background tasks](/clients/tasks) are **modern-only**: the tasks capability is negotiated over `2026-07-28` connections, so `mode="legacy"` never triggers one and a task-enabled tool just runs synchronously.
A FastMCP server serves both eras, so a default client negotiates the modern one and these raise an era-specific error. Pinning the handshake restores them.
You can also pin a specific modern protocol version to adopt it directly, without a discovery probe:
@ -288,7 +289,7 @@ from myproject.extensions import AppsExtension
client = Client("https://example.com/mcp", extensions=[AppsExtension()])
```
Each extension's contributions are threaded into the underlying session. Notification bindings compose with FastMCP's own internal task-status binding rather than replacing it, so an extension that observes a custom notification and FastMCP's task tracking both work on the same connection. When a tool returns a shape an extension claims, `client.call_tool()` resolves it transparently through the owning claim's resolver and hands you back an ordinary result. Result claims and their advertisements are honored only on modern-era connections, so they are inert on a legacy handshake.
Each extension's contributions are threaded into the underlying session. FastMCP folds in its own internal extension for [background tasks](/clients/tasks) automatically, and your own extensions *compose* with it rather than replacing it — pass your own tasks extension with the same identifier if you need to override it. When a tool returns a shape an extension claims, `client.call_tool()` resolves it transparently through the owning claim's resolver and hands you back an ordinary result. Result claims and their advertisements are honored only on modern-era connections, so they are inert on a legacy handshake.
For the rare case where you need to register additional result claims against an extension that is already advertised, pass them through `result_claims=`, keyed by the extension's identifier. Prefer declaring claims on the extension itself; this parameter merges extra claims with an extension's own.

View file

@ -1,184 +1,136 @@
---
title: Background Tasks
sidebarTitle: Tasks
description: Execute operations asynchronously and track their progress.
description: Call long-running tools without blocking, and answer questions they ask mid-run.
icon: clock
tag: "NEW"
---
import { VersionBadge } from "/snippets/version-badge.mdx"
<VersionBadge version="2.14.0" />
<VersionBadge version="4.0.0" />
Use this when you need to run long operations asynchronously while doing other work.
The MCP task protocol lets you request operations to run in the background. The call returns a Task object immediately, letting you track progress, cancel operations, or await results.
Some tool calls take a while. The MCP background tasks extension lets a server run one in the background instead of holding the request open, and FastMCP's client drives the whole thing for you — most of the time you don't need to know a call was tasked at all.
<Note>
**Background tasks require the older MCP protocol.** FastMCP submits a task over the session that the `initialize` handshake opens, and protocol version `2026-07-28` has no equivalent. Clients default to `mode="auto"`, which negotiates the newest version both sides support, so the examples on this page pass `mode="legacy"`. See [protocol negotiation](/clients/client#protocol-negotiation).
**Client task support is opt-in.** Install the `fastmcp-tasks` package (`pip install "fastmcp[tasks]"`) and import it — importing `fastmcp_tasks` anywhere (which you do to use `call_tool_task`) enables task support for every `Client` in the process. Without it, a `Client` never advertises the tasks capability, so the server runs its calls synchronously and background tasks simply don't happen.
**Tasks also require the modern protocol.** The capability is negotiated over `2026-07-28` connections. `mode="auto"` (the client default) negotiates it automatically; `mode="legacy"` never does. See [protocol negotiation](/clients/client#protocol-negotiation).
</Note>
## Requesting Background Execution
## Transparent Calls
Pass `task=True` to run an operation as a background task:
With task support enabled, just call the tool. If the server runs it as a background task, `call_tool` polls it to completion under the hood and returns the same result you'd get from a synchronous call — the task is invisible.
```python
import fastmcp_tasks # enables client task support
from fastmcp import Client
async with Client(server, mode="auto") as client:
result = await client.call_tool("slow_computation", {"duration": 10})
print(result.data)
```
This is the right default for most code: it works whether or not the server actually tasks the call, so you can write ordinary tool-calling code without checking server capabilities.
## Driving a Task Explicitly
When you want to do other work while a task runs — or check on it, or cancel it — use `call_tool_task` instead. It returns a `ToolTask` handle immediately rather than waiting for completion.
```python
from fastmcp import Client
from fastmcp_tasks import call_tool_task
async with Client(server, mode="legacy") as client:
# Start a background task
task = await client.call_tool("slow_computation", {"duration": 10}, task=True)
async with Client(server, mode="auto") as client:
task = await call_tool_task(client, "slow_computation", {"duration": 10})
print(f"Task started: {task.task_id}")
# Do other work while it runs...
# Get the result when ready
result = await task.result()
```
This works with tools, resources, and prompts:
```python
tool_task = await client.call_tool("my_tool", args, task=True)
resource_task = await client.read_resource("file://large.txt", task=True)
prompt_task = await client.get_prompt("my_prompt", args, task=True)
```
## Task API
All task types share a common interface.
### Getting Results
Call `await task.result()` or simply `await task` to block until the task completes:
```python
task = await client.call_tool("analyze", {"text": "hello"}, task=True)
# Wait for result (blocking)
result = await task.result()
# or: result = await task
```
`call_tool_task` requires the server to actually run the call as a task — if the tool isn't `task=True`, or the server doesn't have the tasks extension registered, it raises `ToolError`. Use it when you specifically need the handle; use `call_tool` when you just want the result.
### Checking Status
Check the current status without blocking:
```python
status = await task.status()
print(f"{status.status}: {status.statusMessage}")
print(f"{status.status}: {status.status_message}")
# status.status is "working", "input_required", "completed", "failed", or "cancelled"
```
### Waiting with Control
Use `task.wait()` for more control over waiting. With no `state`, it returns when the task leaves `working`; pass a specific state when you need to wait for a particular transition:
`task.wait()` polls until a terminal state (or a specific one you name), without answering any input the task asks for — use it when you want to observe an `input_required` pause yourself rather than have it answered automatically.
```python
# Wait up to 30 seconds for completion
status = await task.wait(timeout=30.0)
# Wait for a specific state
status = await task.wait(state="completed", timeout=30.0)
status = await task.wait(state="input_required", timeout=30.0)
```
### Cancellation
### Getting the Result
Cancel a running task:
`task.result()` drives the task the rest of the way — including answering any input it asks for — and returns the finished result, same as `client.call_tool` would. Awaiting the task directly is shorthand for this.
```python
result = await task.result()
# or: result = await task
```
By default a failed or cancelled task raises `ToolError`. Pass `raise_on_error=False` to `call_tool_task` to get an error result back instead.
### Cancellation
```python
await task.cancel()
```
## Status Updates
Cancellation is cooperative — the task may still finish before the server notices the request.
Register callbacks to receive real-time status updates as the server reports progress:
## Answering Questions Mid-Task
```python
def on_status_change(status):
print(f"Task {status.taskId}: {status.status} - {status.statusMessage}")
task.on_status_change(on_status_change)
# Async callbacks work too
async def on_status_async(status):
await log_status(status)
task.on_status_change(on_status_async)
```
### Handler Template
A task can pause partway through to ask a question, the same way a foreground [multi-round-trip](/clients/elicitation#input-required-rounds) tool does. Pass an `elicitation_handler` and both `call_tool` and `task.result()` answer it automatically as part of driving the task to completion:
```python
from fastmcp import Client
def status_handler(status):
"""
Handle task status updates.
async def handle_elicitation(message, response_type, params, context):
return {"cuisine": "Thai", "vegetarian": True}
Args:
status: Task status object with:
- taskId: Unique task identifier
- status: "working", "input_required", "completed", "failed", or "cancelled"
- statusMessage: Optional progress message from server
"""
if status.status == "working":
print(f"Progress: {status.statusMessage}")
elif status.status == "completed":
print("Task completed")
elif status.status == "failed":
print(f"Task failed: {status.statusMessage}")
task.on_status_change(status_handler)
async with Client(server, mode="auto", elicitation_handler=handle_elicitation) as client:
result = await client.call_tool("plan_dinner", {})
print(result.data)
```
## Graceful Degradation
You can always pass `task=True` regardless of whether the server supports background tasks. Per the MCP specification, servers without task support execute the operation immediately and return the result inline.
```python
task = await client.call_tool("my_tool", args, task=True)
if task.returned_immediately:
print("Server executed immediately (no background support)")
else:
print("Running in background")
# Either way, this works
result = await task.result()
```
This lets you write task-aware client code without worrying about server capabilities.
Without an `elicitation_handler`, a task that asks for input raises `ToolError` rather than hanging. See [server-side background tasks](/servers/tasks#gathering-input-mid-task) for how a tool asks a question in the first place.
## Example
```python
import asyncio
from fastmcp import Client
from fastmcp_tasks import call_tool_task
async def main():
async with Client(server, mode="legacy") as client:
# Start background task
task = await client.call_tool(
"slow_computation",
{"duration": 10},
task=True,
)
async with Client(server, mode="auto") as client:
# Return immediately and drive the task yourself
task = await call_tool_task(client, "slow_computation", {"duration": 10})
print(f"Task started: {task.task_id}")
# Subscribe to updates
def on_update(status):
print(f"Progress: {status.statusMessage}")
# Do other work while the task runs
while True:
status = await task.status()
if status.status in ("completed", "failed", "cancelled"):
break
print(f"Still working... ({status.status})")
await asyncio.sleep(1)
task.on_status_change(on_update)
# Do other work while task runs
print("Doing other work...")
await asyncio.sleep(2)
# Wait for completion and get result
result = await task.result()
print(f"Result: {result.content}")
print(f"Result: {result.data}")
asyncio.run(main())
```

View file

@ -939,7 +939,7 @@ v3.0 implements MCP SEP-1686 for background task execution via Docket integratio
**Configuration** (`fastmcp_slim/fastmcp/server/tasks/config.py`):
```python
from fastmcp.server.tasks import TaskConfig
from fastmcp.utilities.tasks import TaskConfig
@mcp.tool(task=TaskConfig(mode="required"))
async def long_running_task():

View file

@ -2,7 +2,7 @@
title: Background Tasks (SEP-2663)
---
**Status: Designed — approved for implementation.** 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. Implementation is sequenced behind the [extension API](#the-extension-api); the [Feature Program](/development/v4-notes/feature-program#background-tasks-sep-2663) carries the one-line status.
**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](/development/v4-notes/feature-program#background-tasks-sep-2663) carries the one-line status; user-facing usage is documented at [Background Tasks](/servers/tasks) and [Background Tasks (client)](/clients/tasks).
## TL;DR

View file

@ -115,7 +115,7 @@ A cluster of protocol features tracked for v4. Their statuses have diverged:
## FastMCP-native extension API
**Status: Designed.**
**Status: Shipped (#4602).**
MCP extensions (SEP-2133) are optional, capability-negotiated protocol features identified by a reverse-DNS string — `io.modelcontextprotocol/ui` (MCP Apps), `io.modelcontextprotocol/tasks` (SEP-2663). They are a genuinely new abstraction in SDK v2; they did not exist in v1. The SDK exposes them through an `Extension` server class that contributes a capability, additive request methods, and a `tools/call` interceptor, plus a symmetric `ClientExtension` with result claims and notification bindings.
@ -125,7 +125,7 @@ The Designed work is a FastMCP-native server extension API — a single registra
## Background tasks (SEP-2663)
**Status: Designed — approved for implementation.**
**Status: Shipped (#4603).**
Background tasks return to the modern era as `fastmcp-tasks`, an in-repo optional package rebuilt on the `io.modelcontextprotocol/tasks` extension (SEP-2663, Final, merged upstream 2026-05-15). SEP-2663 supersedes SEP-1686 but keeps its polling core: a client that advertises the tasks capability issues an augmented `tools/call`; the server decides whether to run it as a task and returns a `CreateTaskResult` carrying a server-generated task id; the client polls `tasks/get` until terminal and reads the result inlined there. FastMCP's existing SEP-1686 wire layer is removed while the Docket/Redis execution engine underneath moves into `fastmcp-tasks` intact — the spec moved toward what FastMCP already built, so the rebuild is mostly deletion plus a thin wire adapter. `task=True` stays the authoring surface (gated by the `fastmcp[tasks]` extra and an explicit `mcp.add_extension(TasksExtension(...))`, the first consumer of the [extension API](#fastmcp-native-extension-api) above), so a server that already uses tasks needs no code change. Scope for v1 is polling-only and `tools/call`-only.

View file

@ -5,7 +5,7 @@ title: v4.0 Development Notes
This directory is the working map of FastMCP v4.0: the complete register of user-facing changes from the MCP Python SDK v2 migration ([PR #4437](https://github.com/PrefectHQ/fastmcp/pull/4437)), plus the forward v4 feature program. It plays three roles at once.
1. **A change register.** Every user-visible change from the migration, organized by subsystem, with a note on how FastMCP handles it (absorbed, bridged, breaking, or deprecated) and where to find it in the diff. This is the [Change Register](/development/v4-notes/change-register).
2. **A feature program.** The forward v4 work — sampling removal, multi-round-trip elicitation, the first-class 2026 client, a FastMCP-native extension API, the SEP-2663 background-tasks rebuild, and the SDK-delegation round-two convergence — now a mix of shipped, designed, and pending. Multi-round-trip guard tools (#4544) and the client's `mode="auto"` default with a partial SDK-composition (#4572/#4574, full composition blocked upstream) have shipped; sampling removal, the extension API, tasks, and SDK delegation remain ahead. Each carries an explicit status in the [Feature Program](/development/v4-notes/feature-program). The shipped side — what a v4 deployment provides on the modern protocol today, including the complete server-side SEP-990 identity assertion implementation — is cataloged in [2026-07-28 Protocol Support](/development/v4-notes/protocol-2026).
2. **A feature program.** The forward v4 work — sampling removal, multi-round-trip elicitation, the first-class 2026 client, a FastMCP-native extension API, the SEP-2663 background-tasks rebuild, and the SDK-delegation round-two convergence — now a mix of shipped, designed, and pending. Multi-round-trip guard tools (#4544), the client's `mode="auto"` default with a partial SDK-composition (#4572/#4574, full composition blocked upstream), the extension API (#4602), and background tasks on SEP-2663 (#4603) have shipped; sampling removal and SDK delegation remain ahead. Each carries an explicit status in the [Feature Program](/development/v4-notes/feature-program). The shipped side — what a v4 deployment provides on the modern protocol today, including the complete server-side SEP-990 identity assertion implementation — is cataloged in [2026-07-28 Protocol Support](/development/v4-notes/protocol-2026).
3. **A review lens.** Because the migration PR is too large to review line by line, the change register is organized so a reviewer can take one subsystem, read its claimed changes, and verify each against the diff. The [Known Gaps](/development/v4-notes/known-gaps) page collects the deliberate xfails and the upstream dependencies that gate the follow-up work.
## Why v4 exists

View file

@ -46,11 +46,8 @@ The complete picture of what a FastMCP v4 server and client provide on the `2026
| **Composition** | `mount()`, providers, proxying, and tool transforms compose servers dynamically at runtime, with lifespans and middleware driven through the SDK session manager. |
| **Pagination** | Declarative `FastMCP(list_page_size=...)` paginates all list operations in the high-level server; the client auto-paginates with cycle detection. |
| **Telemetry** | OpenTelemetry spans on by default (no-op without an exporter), SDK-aligned attributes (`mcp.method.name`, `mcp.protocol.version`, `gen_ai.*`), plus auth and provider-delegation spans; `FASTMCP_ENABLE_TELEMETRY=false` disables cleanly. |
**Background tasks are not yet in the table because their modern-era support is being rebuilt.** The current `@mcp.tool(task=True)` runtime implements the 2025 task wire protocol (SEP-1686), which left the core MCP spec. Tasks did not disappear — they were reworked into the `io.modelcontextprotocol/tasks` extension (SEP-2663, Final, merged upstream 2026-05-15), a capability-negotiated feature layered on the extensions mechanism. So on `2026-07-28` the current SEP-1686 wire layer does not apply, and `task=True` completes only on handshake-era connections today.
The plan is to rebuild task support on SEP-2663 as an in-repo optional package, `fastmcp-tasks`, gated by `task=True` exactly as `app=True` gates `prefab-ui`. The SEP-1686 wire layer is removed, but the Docket/Redis execution engine underneath it is extracted and re-adapted to the SEP-2663 wire shape — a polling protocol (augmented `tools/call` → `CreateTaskResult` → poll `tasks/get`, resolve in-task input via `tasks/update`) that the durable engine already fits. `task=True` stays the authoring surface, so a server that opts into tasks needs no code change when the wire underneath modernizes. This is a Designed feature — see [Background Tasks (SEP-2663)](/development/v4-notes/background-tasks) for the full design, and [Known Gaps](/development/v4-notes/known-gaps#the-xfail-register) for the SEP-1686-layer removal tracking.
| **Background tasks (SEP-2663)** | `fastmcp-tasks` implements the `io.modelcontextprotocol/tasks` extension end to end: `mcp.add_extension(TasksExtension())` plus `task=True` runs a tool as a background task, driven by the same Docket engine FastMCP 3 used. A client transparently completes a tasked call; gathering input mid-task uses the same guard pattern as foreground multi-round-trip tools, so a tool is written once and works either way. Modern-protocol only — the `task=True` runtime this replaced (SEP-1686) is gone entirely, not bridged. See [Background Tasks (SEP-2663)](/development/v4-notes/background-tasks) for the design and [servers/tasks](/servers/tasks) for usage. |
## Still in the program
Elicitation on the modern protocol is now shipped in its **guard form** — a tool returns an `InputRequiredResult` and re-runs per round to gather user input via multi-round trips (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). The declarative `Resolve(...)` layer over that primitive remains staged, tracked in the [Feature Program](/development/v4-notes/feature-program), along with the unified `subscriptions/listen` stream and the SEP-2663 tasks extension. The [Known Gaps](/development/v4-notes/known-gaps) page tracks the upstream dependencies that gate them.
Elicitation on the modern protocol is now shipped in its **guard form** — a tool returns an `InputRequiredResult` and re-runs per round to gather user input via multi-round trips (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). The declarative `Resolve(...)` layer over that primitive remains staged, tracked in the [Feature Program](/development/v4-notes/feature-program), along with the unified `subscriptions/listen` stream. The [Known Gaps](/development/v4-notes/known-gaps) page tracks the upstream dependencies that gate them.

View file

@ -81,7 +81,7 @@ BREAKING CHANGES (will crash at import or runtime):
12. REPO MOVE: GitHub repository moved from jlowin/fastmcp to PrefectHQ/fastmcp. Update git remotes and dependency URLs that reference the old location.
13. BACKGROUND TASKS: FastMCP's background task system (SEP-1686) is now an optional dependency. If the code uses task=True or TaskConfig, add pip install "fastmcp[tasks]".
13. BACKGROUND TASKS: FastMCP's background task system is now an optional dependency. If the code uses task=True or TaskConfig, add pip install "fastmcp[tasks]".
DEPRECATIONS (still work but emit warnings):
@ -321,7 +321,7 @@ If you have code that treats the decorated result as a `FunctionTool` (e.g., acc
**Background tasks require optional dependency**
FastMCP's background task system (SEP-1686) is now behind an optional extra. If your server uses background tasks, install with:
FastMCP's background task system is now behind an optional extra. If your server uses background tasks, install with:
```bash
pip install "fastmcp[tasks]"

View file

@ -4,7 +4,7 @@ description: Configure FastMCP behavior through environment variables or a .env
icon: gear
---
FastMCP uses [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) for configuration. Every setting is available as an environment variable with a `FASTMCP_` prefix. Settings are loaded from environment variables and from a `.env` file (see the [Tasks (Docket)](#tasks-docket) section for a caveat about nested settings in `.env` files).
FastMCP uses [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) for configuration. Every setting is available as an environment variable with a `FASTMCP_` prefix. Settings are loaded from environment variables and from a `.env` file.
```bash
# Set via environment
@ -63,7 +63,7 @@ These control how the server listens when running with an HTTP transport.
|---|---|---|---|
| `FASTMCP_CLIENT_INIT_TIMEOUT` | `float \| None` | None | Timeout in seconds for the client initialization handshake. Set to `0` or leave unset to disable. |
| `FASTMCP_CLIENT_DISCONNECT_TIMEOUT` | `float` | `5` | Maximum time in seconds to wait for a clean disconnect before giving up. |
| `FASTMCP_CLIENT_TASK_POLL_INTERVAL` | `float` | `0.5` | Ceiling in seconds for the fallback poll backoff while waiting on a [background task](/servers/tasks). Applies **only** when the server does not advertise its own `pollInterval`: in that case `Task.wait()` starts polling fast (~20ms) and doubles up to this ceiling rather than polling at a fixed cadence. When the server advertises a `pollInterval`, that interval is honored exactly and this setting is ignored. |
| `FASTMCP_TASKS_CLIENT_POLL_INTERVAL` | `float` | `0.5` | Ceiling in seconds for the fallback poll backoff while waiting on a [background task](/servers/tasks). Requires the `fastmcp-tasks` package. Applies **only** when the server does not advertise its own `pollInterval`: in that case `Task.wait()` starts polling fast (~20ms) and doubles up to this ceiling rather than polling at a fixed cadence. When the server advertises a `pollInterval`, that interval is honored exactly and this setting is ignored. |
| `FASTMCP_CLIENT_RAISE_FIRST_EXCEPTIONGROUP_ERROR` | `bool` | `true` | When an `ExceptionGroup` is raised, re-raise the first error directly instead of the group. Simplifies debugging but may mask secondary errors. |
## CLI & Display
@ -81,21 +81,7 @@ These control how the server listens when running with an HTTP transport.
## Tasks (Docket)
These configure the [Docket](https://github.com/prefecthq/docket) task queue used by [server tasks](/servers/tasks). All use the `FASTMCP_DOCKET_` prefix.
<Warning>
When setting Docket values in a `.env` file, use a **double** underscore: `FASTMCP_DOCKET__URL` (not `FASTMCP_DOCKET_URL`). This is because `.env` values are resolved through the parent `Settings` class, which uses `__` as its nested delimiter. As regular environment variables (e.g., `export`), the single-underscore form `FASTMCP_DOCKET_URL` works fine.
</Warning>
| Environment Variable | Type | Default | Description |
|---|---|---|---|
| `FASTMCP_DOCKET_NAME` | `str` | `fastmcp` | Queue name. Servers and workers sharing the same name and backend URL share a task queue. |
| `FASTMCP_DOCKET_URL` | `str` | `memory://` | Backend URL. Use `memory://` for single-process or `redis://host:port/db` for distributed workers. |
| `FASTMCP_DOCKET_WORKER_NAME` | `str \| None` | None | Worker name. Auto-generated if unset. |
| `FASTMCP_DOCKET_CONCURRENCY` | `int` | `10` | Maximum concurrent tasks per worker. |
| `FASTMCP_DOCKET_REDELIVERY_TIMEOUT` | `timedelta` | `300s` | If a worker doesn't complete a task within this time, it's redelivered to another worker. |
| `FASTMCP_DOCKET_RECONNECTION_DELAY` | `timedelta` | `5s` | Delay between reconnection attempts when the worker loses its backend connection. |
| `FASTMCP_DOCKET_MINIMUM_CHECK_INTERVAL` | `timedelta` | `50ms` | How frequently the worker polls for new tasks. Lower values reduce latency at the cost of more CPU usage. |
Task settings (the `FASTMCP_DOCKET_` variables) moved to the optional `fastmcp-tasks` package. See [server tasks](/servers/tasks) for configuration.
## Security

View file

@ -160,10 +160,9 @@ def get_client_ip() -> str:
```
<Note>
Both raise `RuntimeError` when called outside an HTTP context (e.g., STDIO transport).
For background tasks created from an HTTP request, FastMCP restores a minimal request
backed by the originating request's snapshotted headers. Use HTTP Headers if you need
graceful fallback.
Both raise `RuntimeError` when called outside an HTTP context (e.g., STDIO transport,
or inside a background task — there is no live request object to reconstruct there).
Use HTTP Headers below if you need graceful fallback, including inside background tasks.
</Note>
### HTTP Headers
@ -282,7 +281,8 @@ For background task execution, FastMCP provides dependencies that integrate with
```python
from fastmcp import FastMCP
from fastmcp.dependencies import CurrentDocket, CurrentWorker, Progress
from fastmcp.dependencies import Progress
from fastmcp_tasks.dependencies import CurrentDocket, CurrentWorker
mcp = FastMCP("Task Demo")

View file

@ -1,58 +1,59 @@
---
title: Background Tasks
sidebarTitle: Background Tasks
description: Run long-running operations asynchronously with progress tracking
description: Run long-running tools asynchronously with progress tracking
icon: clock
tag: "NEW"
---
import { VersionBadge } from "/snippets/version-badge.mdx"
<VersionBadge version="2.14.0" />
<VersionBadge version="4.0.0" />
<Tip>
Background tasks require the `tasks` optional extra. See [installation instructions](#enabling-background-tasks) below.
Background tasks require the `fastmcp-tasks` package. See [enabling background tasks](#enabling-background-tasks) below.
</Tip>
FastMCP implements the MCP background task protocol ([SEP-1686](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)), giving your servers a production-ready distributed task scheduler with a single decorator change.
FastMCP implements the MCP background tasks extension ([`io.modelcontextprotocol/tasks`](https://modelcontextprotocol.io/extensions/tasks/overview), SEP-2663), giving your servers a production-ready distributed task scheduler with one extension registration and a decorator change.
<Tip>
**What is Docket?** FastMCP's task system is powered by [Docket](https://github.com/chrisguidry/docket), originally built by [Prefect](https://prefect.io) to power [Prefect Cloud](https://www.prefect.io/prefect/cloud)'s managed task scheduling and execution service, where it processes millions of concurrent tasks every day. Docket is now open-sourced for the community.
</Tip>
## What Are MCP Background Tasks?
In MCP, all component interactions are blocking by default. When a client calls a tool, reads a resource, or fetches a prompt, it sends a request and waits for the response. For operations that take seconds or minutes, this creates a poor user experience.
In MCP, a tool call is blocking by default. When a client calls a tool, it sends a request and waits for the response. For operations that take seconds or minutes, this creates a poor user experience.
The MCP background task protocol solves this by letting clients:
1. **Start** an operation and receive a task ID immediately
2. **Track** progress as the operation runs
3. **Retrieve** the result when ready
Background tasks solve this by letting a server tell a supporting client:
1. **Start** the tool and return a task ID immediately
2. **Poll** for status as the tool runs
3. **Retrieve** the result when ready — or answer a question the tool asks mid-run
FastMCP handles all of this for you. Add `task=True` to your decorator, and your function gains full background execution with progress reporting, distributed processing, and horizontal scaling.
FastMCP handles all of this for you. Add `task=True` to a tool decorator and register the tasks extension, and your function gains background execution with progress reporting, distributed processing, and horizontal scaling.
### MCP Background Tasks vs Python Concurrency
You can always use Python's concurrency primitives (asyncio, threads, multiprocessing) or external task queues in your FastMCP servers. FastMCP is just Python—run code however you like.
MCP background tasks are different: they're **protocol-native**. This means MCP clients that support the task protocol can start operations, receive progress updates, and retrieve results through the standard MCP interface. The coordination happens at the protocol level, not inside your application code.
MCP background tasks are different: they're **protocol-native**. This means MCP clients that support the tasks extension can start a call, poll it, and retrieve its result through the standard MCP interface. The coordination happens at the protocol level, not inside your application code.
## Enabling Background Tasks
<VersionBadge version="3.0.0" /> Background tasks require the `tasks` extra:
Background tasks require the `fastmcp-tasks` package:
```bash
pip install "fastmcp[tasks]"
```
Add `task=True` to any tool, resource, resource template, or prompt decorator. This marks the component as capable of background execution.
Register `TasksExtension` on your server, then add `task=True` to a tool decorator. `task=True` marks the tool as *capable* of background execution; the extension is what actually runs it — a `task=True` tool on a server with no tasks extension registered raises at server startup.
```python {6}
```python {5,8}
import asyncio
from fastmcp import FastMCP
from fastmcp_tasks import TasksExtension
mcp = FastMCP("MyServer")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
async def slow_computation(duration: int) -> str:
@ -62,34 +63,38 @@ async def slow_computation(duration: int) -> str:
return f"Completed in {duration} seconds"
```
When a client requests background execution, the call returns immediately with a task ID. The work executes in a background worker, and the client can poll for status or wait for the result.
Whether a given call actually runs as a task depends on the client: it opts in per request, and the *server* decides based on the tool's execution mode (below). When it does run as a task, the call returns immediately with a task ID; the work executes in a background worker, and the client polls for the result. A [FastMCP client](/clients/tasks) does all of this transparently — `client.call_tool(...)` looks the same either way.
Background tasks are a modern-protocol feature: the tasks capability is negotiated over `2026-07-28` connections, so a client pinned to `mode="legacy"` never triggers one — the tool always runs synchronously for it.
<Warning>
Background tasks require async functions. Attempting to use `task=True` with a sync function raises a `ValueError` at registration time.
Background tasks require async functions. Attempting to use `task=True` with a sync function raises a `ValueError` at registration time. Only tools can be task-enabled; resources, resource templates, and prompts do not carry `task=`.
</Warning>
## Execution Modes
For fine-grained control over task execution behavior, use `TaskConfig` instead of the boolean shorthand. The MCP task protocol defines three execution modes:
For fine-grained control over task execution behavior, use `TaskConfig` instead of the boolean shorthand. The tasks extension defines three execution modes:
| Mode | Client calls without task | Client calls with task |
| Mode | Client calls without the tasks capability | Client calls with the tasks capability |
|------|--------------------------|------------------------|
| `"forbidden"` | Executes synchronously | Error: task not supported |
| `"optional"` | Executes synchronously | Executes as background task |
| `"required"` | Error: task required | Executes as background task |
| `"forbidden"` | Executes synchronously | Executes synchronously (never tasked) |
| `"optional"` | Executes synchronously | Executes as a background task |
| `"required"` | Error: task required | Executes as a background task |
```python
from fastmcp import FastMCP
from fastmcp.server.tasks import TaskConfig
from fastmcp.utilities.tasks import TaskConfig
from fastmcp_tasks import TasksExtension
mcp = FastMCP("MyServer")
mcp.add_extension(TasksExtension())
# Supports both sync and background execution (default when task=True)
@mcp.tool(task=TaskConfig(mode="optional"))
async def flexible_task() -> str:
return "Works either way"
# Requires background execution - errors if client doesn't request task
# Requires background execution - errors if the client didn't opt in
@mcp.tool(task=TaskConfig(mode="required"))
async def must_be_background() -> str:
return "Only runs as a background task"
@ -104,18 +109,20 @@ The boolean shortcuts map to these modes:
- `task=True` → `TaskConfig(mode="optional")`
- `task=False` → `TaskConfig(mode="forbidden")`
When a `mode="required"` tool is called by a client that didn't opt in, FastMCP returns a "missing required capability" error rather than running it synchronously.
### Poll Interval
<VersionBadge version="2.15.0" />
When clients poll for task status, the server tells them how frequently to check back. By default, FastMCP suggests a 5-second interval, but you can customize this per component:
When a client polls for task status, the server can suggest how frequently to check back:
```python
from datetime import timedelta
from fastmcp import FastMCP
from fastmcp.server.tasks import TaskConfig
from fastmcp.utilities.tasks import TaskConfig
from fastmcp_tasks import TasksExtension
mcp = FastMCP("MyServer")
mcp.add_extension(TasksExtension())
# Poll every 2 seconds for a fast-completing task
@mcp.tool(task=TaskConfig(mode="optional", poll_interval=timedelta(seconds=2)))
@ -128,31 +135,33 @@ async def slow_task() -> str:
return "Eventually done"
```
Shorter intervals give clients faster feedback but increase server load. Longer intervals reduce load but delay status updates. FastMCP clients honor the advertised interval exactly, so this is a real load control — but note that status notifications still wake a waiting client immediately, so the interval only governs how quickly a *missed* notification is noticed.
Shorter intervals give clients faster feedback but increase server load. The interval is a ceiling, not an exact cadence — the FastMCP client starts polling quickly and backs off toward it, so a fast task is still observed as done almost immediately.
### Server-Wide Default
To enable background task support for all components by default, pass `tasks=True` to the constructor. Individual decorators can still override this with `task=False`.
To enable background task support for all tools by default, pass `tasks=True` to the constructor. Individual decorators can still override this with `task=False`.
```python
mcp = FastMCP("MyServer", tasks=True)
```
<Warning>
If your server defines any synchronous tools, resources, or prompts, you will need to explicitly set `task=False` on their decorators to avoid an error.
If your server defines any synchronous tools, you will need to explicitly set `task=False` on their decorators to avoid an error.
</Warning>
### Graceful Degradation
When a client requests background execution but the component has `mode="forbidden"`, FastMCP rejects the task-augmented request with a `METHOD_NOT_FOUND` error saying the component does not support task execution. The high-level FastMCP tool client can surface this as an immediate errored `ToolTask` when you call `client.call_tool(..., task=True, raise_on_error=False)`, but the server does not run the forbidden component synchronously for that task request.
Conversely, when a component has `mode="required"` but the client doesn't request background execution, FastMCP returns an error indicating that task execution is required.
### Configuration
`TasksExtension` takes the backend configuration directly, with `FASTMCP_DOCKET_*` environment variables as defaults — so `TasksExtension()` works out of the box against an env-configured deployment:
```python
mcp.add_extension(TasksExtension(url="redis://localhost:6379/0", concurrency=20))
```
| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| `FASTMCP_DOCKET_URL` | `memory://` | Backend URL (`memory://` or `redis://host:port/db`) |
| `FASTMCP_DOCKET_NAME` | `fastmcp` | Queue name. Servers and workers sharing a name and URL share a queue. |
| `FASTMCP_DOCKET_CONCURRENCY` | `10` | Maximum concurrent tasks per worker. |
## Backends
@ -173,7 +182,11 @@ The in-memory backend (`memory://`) requires zero configuration and works out of
### Redis Backend
For production deployments, use Redis (or Valkey) as your backend by setting `FASTMCP_DOCKET_URL=redis://localhost:6379`.
For production deployments, use Redis (or Valkey) as your backend:
```python
mcp.add_extension(TasksExtension(url="redis://localhost:6379/0"))
```
**Advantages:**
- **Persistent**: Tasks survive server restarts
@ -182,19 +195,19 @@ For production deployments, use Redis (or Valkey) as your backend by setting `FA
## Workers
Every FastMCP server with task-enabled components automatically starts an **embedded worker**. You do not need to start a separate worker process for tasks to execute.
Every FastMCP server with task-enabled tools automatically starts an **embedded worker**. You do not need to start a separate worker process for tasks to execute.
To scale horizontally, add more workers using the CLI:
To scale horizontally, add more workers:
```bash
fastmcp tasks worker server.py
python -m fastmcp_tasks.worker_cli worker server.py
```
Each additional worker pulls tasks from the same queue, distributing load across processes. Configure worker concurrency via environment:
```bash
export FASTMCP_DOCKET_CONCURRENCY=20
fastmcp tasks worker server.py
python -m fastmcp_tasks.worker_cli worker server.py
```
<Note>
@ -202,7 +215,47 @@ Additional workers only work with Redis/Valkey backends. The in-memory backend i
</Note>
<Warning>
Task-enabled components must be defined at server startup to be registered with all workers. Components added dynamically after the server starts will not be available for background execution.
Task-enabled tools must be defined at server startup to be registered with all workers. Tools added dynamically after the server starts will not be available for background execution.
</Warning>
## Gathering Input Mid-Task
A tool can ask the client a question partway through — the same [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol) used for multi-round-trip input on foreground calls: instead of awaiting a response, the tool *returns* one, and FastMCP re-runs it once the client answers.
```python
from fastmcp import Context, FastMCP
from fastmcp_tasks import TasksExtension
import mcp_types
mcp = FastMCP("MyServer")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
async def plan_dinner(ctx: Context) -> str | mcp_types.InputRequiredResult:
responses = ctx.input_responses
if responses is None:
# First leg: ask a question and end here.
request = mcp_types.ElicitRequest(
params=mcp_types.ElicitRequestFormParams(
message="What are you in the mood for?",
requested_schema={"type": "object", "properties": {"cuisine": {"type": "string"}}},
)
)
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={"prefs": request},
)
# Re-entered leg: the client's answer is on ctx.input_responses.
answer = responses["prefs"]
assert isinstance(answer, mcp_types.ElicitResult)
return f"Tonight: {answer.content['cuisine']}!"
```
Run as a task, this "ends" the tool's first leg entirely rather than blocking a worker on the client's answer: the task reports `input_required`, the client answers, and FastMCP re-invokes the tool with the answer attached. No worker ever sits idle waiting on a round-trip — the same tool works identically whether it's called synchronously or as a background task, and a [FastMCP client](/clients/tasks) answers the question automatically through its elicitation handler.
<Warning>
Imperative `await ctx.elicit(...)` is not supported inside a background task — it would require blocking a worker for the length of a client round-trip. Use the guard pattern (return `InputRequiredResult`) instead; calling `ctx.elicit()` from a task-enabled tool raises with guidance toward the guard pattern.
</Warning>
## Progress Reporting
@ -241,7 +294,8 @@ FastMCP exposes Docket's full dependency injection system within your task-enabl
```python
from docket import Docket, Worker
from fastmcp import FastMCP
from fastmcp.dependencies import Progress, CurrentDocket, CurrentWorker
from fastmcp.dependencies import Progress
from fastmcp_tasks.dependencies import CurrentDocket, CurrentWorker
mcp = FastMCP("MyServer")
@ -260,4 +314,4 @@ async def my_task(
return "Done"
```
With `CurrentDocket()`, you can schedule additional background tasks, chain work together, and coordinate complex workflows. See the [Docket documentation](https://chrisguidry.github.io/docket/) for the complete API, including retry policies, timeouts, and custom dependencies.
With `CurrentDocket()`, you can schedule additional background tasks, chain work together, and coordinate complex workflows. See the [Docket documentation](https://docket.lol/) for the complete API, including retry policies, timeouts, and custom dependencies.

View file

@ -69,7 +69,7 @@ The server creates spans for each operation using [MCP semantic conventions](htt
| `tools/call {name}` | Tool execution (e.g., `tools/call get_weather`) |
| `resources/read` | Resource read (URI in `mcp.resource.uri` attribute, not span name) |
| `prompts/get {name}` | Prompt render (e.g., `prompts/get greeting`) |
| `tasks/{operation}` | Task management (`tasks/get`, `tasks/result`, `tasks/list`, or `tasks/cancel`) |
| `tasks/{operation}` | Task management (`tasks/get`, `tasks/update`, or `tasks/cancel`) |
For mounted servers, an additional `delegate {name}` span shows the delegation to the child server.
@ -100,12 +100,12 @@ tools/call remote_search (CLIENT)
Background task traces have two parts:
- Task submission and management requests use normal client-to-server context propagation. `tasks/get`, `tasks/result`, `tasks/list`, and `tasks/cancel` server spans are descendants of the corresponding FastMCP client spans.
- Task submission and management requests use normal client-to-server context propagation. `tasks/get`, `tasks/update`, and `tasks/cancel` server spans are descendants of the corresponding FastMCP client spans.
- Deferred execution runs in a Docket worker. Docket records its `CONSUMER` span as a new trace root with a span link to the submission context, rather than making it a child of the submission span. Custom spans created inside the task are children of that worker span.
Span links preserve the causal relationship without forcing worker sampling to inherit the submit trace's sampling decision. Some tracing backends do not display links prominently, so the worker trace may look disconnected even though the link is present.
Frequent status and list polling can produce more detail than you need. You can drop those client and server spans with a sampler that checks the span name before delegating to `ParentBased`:
Frequent status polling can produce more detail than you need. You can drop those client and server spans with a sampler that checks the span name before delegating to `ParentBased`:
```python
from opentelemetry import trace
@ -124,7 +124,7 @@ class DropTaskPolls(Sampler):
self._delegate = ParentBased(ALWAYS_ON)
def should_sample(self, parent_context, trace_id, name, *args, **kwargs):
if name in {"tasks/get", "tasks/list"}:
if name in {"tasks/get"}:
return SamplingResult(Decision.DROP)
return self._delegate.should_sample(
parent_context,

View file

@ -1,8 +1,16 @@
"""
Background task elicitation demo.
Background task input demo (SEP-2663 guard pattern).
A background task (Docket) that pauses mid-execution to ask the user a
question, waits for the answer, then resumes and finishes.
A background task that pauses to ask the user a question, waits for the answer,
then resumes and finishes. Under SEP-2663 a task gathers input by the *guard
pattern*: instead of awaiting `ctx.elicit()` (which would block a worker), the
tool *returns* an `InputRequiredResult`. That ends the leg; the client answers
via the tasks protocol; the framework re-runs the tool with the answer on
`ctx.input_responses`. No worker is ever blocked.
The client side is transparent: `client.call_tool(...)` drives the whole
round-trip poll, answer via the `elicitation_handler`, poll again and returns
the finished result.
Works with both in-memory and Redis backends:
@ -22,13 +30,15 @@ Requires the `docket` extra (included in dev dependencies).
import asyncio
from dataclasses import dataclass
import mcp_types
from mcp_types import TextContent
from fastmcp import Context, FastMCP
from fastmcp.client import Client
from fastmcp.server.elicitation import AcceptedElicitation
from fastmcp_tasks import TasksExtension
mcp = FastMCP("Task Elicitation Demo")
mcp.add_extension(TasksExtension())
@dataclass
@ -37,44 +47,60 @@ class DinnerPrefs:
vegetarian: bool
@mcp.tool(task=True)
async def plan_dinner(ctx: Context) -> str:
"""Plan a dinner menu, asking the user what they're in the mood for."""
await ctx.report_progress(0, 2, "Asking what you'd like...")
result = await ctx.elicit(
"What kind of dinner are you in the mood for?",
response_type=DinnerPrefs,
def _ask_dinner_prefs() -> mcp_types.InputRequiredResult:
"""Return the input request that pauses the task until the client answers."""
request = mcp_types.ElicitRequest(
params=mcp_types.ElicitRequestFormParams(
message="What kind of dinner are you in the mood for?",
requested_schema={
"type": "object",
"properties": {
"cuisine": {"type": "string"},
"vegetarian": {"type": "boolean"},
},
"required": ["cuisine", "vegetarian"],
},
)
)
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={"prefs": request},
)
if not isinstance(result, AcceptedElicitation):
@mcp.tool(task=True)
async def plan_dinner(ctx: Context) -> str | mcp_types.InputRequiredResult:
"""Plan a dinner menu, asking the user what they're in the mood for."""
responses = ctx.input_responses
if responses is None:
# First leg: ask for preferences and end the leg.
return _ask_dinner_prefs()
# Re-entered leg: the client's answer is on ctx.input_responses.
answer = responses["prefs"]
assert isinstance(answer, mcp_types.ElicitResult)
if answer.action != "accept" or answer.content is None:
return "Dinner cancelled!"
prefs = result.data
assert isinstance(prefs, DinnerPrefs)
await ctx.report_progress(1, 2, "Planning your menu...")
await asyncio.sleep(1)
await ctx.report_progress(2, 2, "Done!")
veg = "vegetarian " if prefs.vegetarian else ""
return f"Tonight's menu: a lovely {veg}{prefs.cuisine} dinner!"
await asyncio.sleep(1) # "planning the menu"
veg = "vegetarian " if answer.content["vegetarian"] else ""
return f"Tonight's menu: a lovely {veg}{answer.content['cuisine']} dinner!"
async def handle_elicitation(message, response_type, params, context):
"""Handle elicitation requests from background tasks."""
"""Answer elicitation requests raised by the background task."""
print(f" Server asks: {message}")
print(" Responding with: cuisine=Thai, vegetarian=True")
return DinnerPrefs(cuisine="Thai", vegetarian=True)
async def main():
async with Client(mcp, elicitation_handler=handle_elicitation) as client:
print("Starting background task...")
task = await client.call_tool("plan_dinner", {}, task=True)
print(f" task_id = {task.task_id}\n")
result = await task.result()
client = Client(mcp, mode="auto", elicitation_handler=handle_elicitation)
async with client:
print("Calling plan_dinner (runs as a background task)...")
# call_tool drives the whole round-trip transparently: it polls, answers
# the task's input request via handle_elicitation, and returns the result.
result = await client.call_tool("plan_dinner", {})
assert isinstance(result.content[0], TextContent)
print(f"\nResult: {result.content[0].text}")

View file

@ -1,10 +1,11 @@
# FastMCP Tasks Example Environment Configuration
# This file is loaded by direnv (https://direnv.net/) when you cd into this directory
# Run `direnv allow` to enable automatic environment loading
# Loaded by direnv (https://direnv.net/) when you cd into this directory.
# Run `direnv allow` to enable automatic loading — or just `source .envrc`.
# Configure Docket backend URL
# Use Redis backend (requires docker-compose up)
export FASTMCP_DOCKET_URL=redis://localhost:24242/0
# In-process worker on an in-memory backend: no Redis, nothing to start.
# This is the default the example runs on.
export FASTMCP_DOCKET_URL=memory://
# Or uncomment to use memory:// for single-process testing
# export FASTMCP_DOCKET_URL=memory://
# For distributed workers across separate processes (the `fastmcp tasks worker`
# CLI), point at Redis instead and run `docker compose up -d` first:
# export FASTMCP_DOCKET_URL=redis://localhost:24242/0

View file

@ -1,60 +1,75 @@
# FastMCP Tasks Example
# FastMCP Background Tasks Example
Demonstrates background task execution with Docket, including progress tracking, distributed backends, and CLI worker management.
A runnable client/server pair for SEP-2663 background tasks. The server exposes
one `task=True` tool that reports progress as it works; the client drives it
three ways — transparently, through an explicit handle, and several at once in
parallel.
## Setup
This runs on the in-memory backend by default, so there's nothing to install or
start beyond the two processes.
## Run it
In one terminal, start the server:
```bash
# From the fastmcp root directory
uv sync
uv sync # from the fastmcp root, once
python examples/tasks/server.py # listens on http://127.0.0.1:8000/mcp
```
# Start Redis
In another terminal, drive it from the client:
```bash
# Transparent — call_tool runs the background task and returns its result
python examples/tasks/client.py --duration 8
# Explicit handle — returns immediately, poll it yourself, then collect
python examples/tasks/client.py handle --duration 6
# Parallel — fire several tasks at once and watch them overlap
python examples/tasks/client.py parallel
python examples/tasks/client.py parallel 8 6 4 2
```
The `parallel` run is the one to watch: four tasks of decreasing duration all
start at once and total wall-clock tracks the *longest* task rather than the
sum, because the worker runs them concurrently.
## How it works
The server enables tasks with one line:
```python
mcp = FastMCP("Tasks Example")
mcp.add_extension(TasksExtension())
```
The client opts in by importing `fastmcp_tasks` (which it does to use
`call_tool_task`). That single import enables task support for every `Client`
in the process — without it, a `Client` never advertises the tasks capability,
so the server would run the calls synchronously.
## Distributed workers (optional)
The default `memory://` backend runs the worker in the server process. To run
workers as separate processes, point Docket at Redis and start it first:
```bash
cd examples/tasks
docker compose up -d
export FASTMCP_DOCKET_URL=redis://localhost:24242/0 # or: direnv allow
# Load environment (or source .envrc manually)
direnv allow
# Run the server
fastmcp run server.py
python server.py # in one terminal
python -m fastmcp_tasks.worker_cli worker server.py # extra worker(s) in others
```
For single-process mode without Redis, set `FASTMCP_DOCKET_URL=memory://` (note: CLI workers won't work).
| Backend | Workers |
| ------------ | ------------------------------- |
| `memory://` | in-process only (default) |
| `redis://…` | distributed across processes |
## Running the Client
## Learn more
```bash
# Background execution with progress callbacks
python examples/tasks/client.py --duration 10
# Immediate execution (blocks)
python examples/tasks/client.py immediate --duration 5
```
## Starting Additional Workers
With Redis, you can run additional workers to process tasks in parallel:
```bash
fastmcp tasks worker server.py
# Configure via environment:
export FASTMCP_DOCKET_CONCURRENCY=20
fastmcp tasks worker server.py
```
**Backend options:**
- `memory://` - Single-process only (default)
- `redis://` - Distributed, multi-process (Redis or Valkey)
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `FASTMCP_DOCKET_URL` | `memory://` | Docket backend URL |
## Learn More
- [FastMCP Tasks Documentation](https://gofastmcp.com/docs/tasks)
- [Docket Documentation](https://github.com/PrefectHQ/docket)
- [MCP Task Protocol (SEP-1686)](https://spec.modelcontextprotocol.io/specification/architecture/tasks/)
- [Server background tasks](https://gofastmcp.com/servers/tasks)
- [Client background tasks](https://gofastmcp.com/clients/tasks)
- [Docket](https://github.com/chrisguidry/docket)

View file

@ -1,159 +1,136 @@
"""
FastMCP Tasks Example Client
"""FastMCP background-tasks example client (SEP-2663).
Demonstrates calling tools both immediately and as background tasks,
with real-time progress updates via status callbacks.
Start the server first (`python examples/tasks/server.py`), then run any of the
commands below against it over HTTP.
Usage:
# Make sure environment is configured (source .envrc or use direnv)
source .envrc
# Transparent: call_tool drives the background task and returns its result
python examples/tasks/client.py --duration 8
# Background task execution with progress callbacks (default)
python client.py --duration 10
# Explicit handle: return immediately, poll it yourself, then collect
python examples/tasks/client.py handle --duration 6
# Immediate execution (blocks until complete)
python client.py immediate --duration 5
# Parallel: fire several tasks at once and watch them overlap
python examples/tasks/client.py parallel
Importing `fastmcp_tasks` (below) enables client task support for every Client
in the process without it, a Client never advertises the tasks capability and
the server runs its calls synchronously.
"""
import asyncio
import sys
from pathlib import Path
import time
from typing import Annotated
import cyclopts
from mcp_types import GetTaskResult, TextContent
from mcp_types import TextContent
from rich.console import Console
from fastmcp.client import Client
from fastmcp_tasks import call_tool_task # importing enables client task support
SERVER_URL = "http://127.0.0.1:8000/mcp"
console = Console()
app = cyclopts.App(name="tasks-client", help="FastMCP Tasks Example Client")
app = cyclopts.App(name="tasks-client", help="FastMCP background-tasks example client")
def load_server():
"""Load the example server."""
examples_dir = Path(__file__).parent.parent.parent
if str(examples_dir) not in sys.path:
sys.path.insert(0, str(examples_dir))
import examples.tasks.server as server_module
return server_module.mcp
# Track last message to deduplicate consecutive identical notifications
# Note: Docket fires separate events for progress.increment() and progress.set_message(),
# but MCP's status_message field only carries the text message (no numerical progress).
# This means we often get duplicate notifications with identical messages.
_last_notification_message = None
def print_notification(status: GetTaskResult) -> None:
"""Callback function for push notifications from server.
This is called automatically when the server sends notifications/tasks/status.
Deduplicates identical consecutive messages to keep output clean.
"""
global _last_notification_message
# Skip if this is the same message we just printed
if status.status_message == _last_notification_message:
return
_last_notification_message = status.status_message
color = {
"working": "yellow",
"completed": "green",
"failed": "red",
}.get(status.status, "yellow")
icon = {
"working": "🚀",
"completed": "",
"failed": "",
}.get(status.status, "⚠️")
console.print(
f"[{color}]📢 Notification: {status.status} {icon} - {status.status_message}[/{color}]"
)
def _text(result) -> str:
assert isinstance(result.content[0], TextContent)
return result.content[0].text
@app.default
async def task(
duration: Annotated[
int,
cyclopts.Parameter(help="Duration of computation in seconds (1-60)"),
] = 10,
async def transparent(
duration: Annotated[int, cyclopts.Parameter(help="Seconds (1-60)")] = 8,
):
"""Execute as background task with real-time progress callbacks."""
if duration < 1 or duration > 60:
console.print("[red]Error: Duration must be between 1 and 60 seconds[/red]")
sys.exit(1)
"""Call the tool transparently: the client drives the task to completion.
server = load_server()
console.print(f"\n[bold]Calling slow_computation(duration={duration})[/bold]")
console.print("Mode: [cyan]Background task[/cyan]\n")
async with Client(server) as client:
task_obj = await client.call_tool(
The server runs `slow_computation` as a background task, but `call_tool`
polls it under the hood and returns the tool's real result — the calling
code looks exactly like an ordinary synchronous tool call.
"""
async with Client(SERVER_URL, mode="auto") as client:
console.print(f"\n[bold]Transparent call[/bold] (duration={duration})\n")
started = time.perf_counter()
result = await client.call_tool(
"slow_computation",
arguments={"duration": duration},
task=True,
{"label": "transparent", "duration": duration},
)
console.print(f"Task started: [cyan]{task_obj.task_id}[/cyan]\n")
# Register callback for real-time push notifications
task_obj.on_status_change(print_notification)
console.print(
"[dim]Notifications will appear as the server sends them...[/dim]\n"
)
# Do other work while task runs in background
for i in range(3):
await asyncio.sleep(0.5)
console.print(f"[dim]Client doing other work... ({i + 1}/3)[/dim]")
console.print()
# Wait for task to complete
console.print("[dim]Waiting for final result...[/dim]")
result = await task_obj.result()
console.print("\n[bold]Result:[/bold]")
assert isinstance(result.content[0], TextContent)
console.print(f" {result.content[0].text}")
console.print(f"[green]{_text(result)}[/green]")
console.print(f"[dim]elapsed {time.perf_counter() - started:.1f}s[/dim]")
@app.command
async def immediate(
duration: Annotated[
int,
cyclopts.Parameter(help="Duration of computation in seconds (1-60)"),
] = 5,
async def handle(
duration: Annotated[int, cyclopts.Parameter(help="Seconds (1-60)")] = 6,
):
"""Execute the tool immediately (blocks until complete)."""
if duration < 1 or duration > 60:
console.print("[red]Error: Duration must be between 1 and 60 seconds[/red]")
sys.exit(1)
server = load_server()
console.print(f"\n[bold]Calling slow_computation(duration={duration})[/bold]")
console.print("Mode: [cyan]Immediate execution[/cyan]\n")
async with Client(server) as client:
result = await client.call_tool(
"slow_computation",
arguments={"duration": duration},
"""Use the explicit handle: return immediately, then drive the task yourself."""
async with Client(SERVER_URL, mode="auto") as client:
console.print(f"\n[bold]Explicit handle[/bold] (duration={duration})\n")
task = await call_tool_task(
client, "slow_computation", {"label": "handle", "duration": duration}
)
console.print(f"Task started: [cyan]{task.task_id}[/cyan]\n")
console.print("\n[bold]Result:[/bold]")
assert isinstance(result.content[0], TextContent)
console.print(f" {result.content[0].text}")
# Do other work while the task runs, checking its status as you go.
while True:
status = await task.status()
if status.status in ("completed", "failed", "cancelled"):
break
console.print(f"[dim]still {status.status}: {status.status_message}[/dim]")
await asyncio.sleep(1)
result = await task.result()
console.print(f"\n[green]{_text(result)}[/green]")
@app.command
async def parallel(
durations: Annotated[
list[int] | None,
cyclopts.Parameter(help="One task per duration (default: 5 4 3 2)"),
] = None,
):
"""Fire several background tasks at once and drive them concurrently.
Each `call_tool_task` returns immediately, so we start every task before
awaiting any of them. The worker runs them in parallel, so total wall-clock
tracks the *longest* task, not the sum proof the work actually overlaps.
"""
durations = durations or [5, 4, 3, 2]
async with Client(SERVER_URL, mode="auto") as client:
console.print(f"\n[bold]Parallel tasks[/bold]: durations={durations}\n")
started = time.perf_counter()
# Start every task up front — none of these await completion.
tasks = [
await call_tool_task(
client,
"slow_computation",
{"label": f"task-{i}({d}s)", "duration": d},
)
for i, d in enumerate(durations)
]
for task in tasks:
console.print(f" started [cyan]{task.task_id}[/cyan]")
# Await them together; results print as each task finishes.
async def collect(task):
result = await task.result()
console.print(
f"[green]✓[/green] {_text(result)} "
f"[dim](+{time.perf_counter() - started:.1f}s)[/dim]"
)
console.print()
await asyncio.gather(*(collect(task) for task in tasks))
total = time.perf_counter() - started
console.print(
f"\n[bold]All {len(tasks)} tasks done in {total:.1f}s[/bold] "
f"[dim](longest single task: {max(durations)}s)[/dim]"
)
if __name__ == "__main__":

View file

@ -1,75 +1,65 @@
"""
FastMCP Tasks Example Server
"""FastMCP background-tasks example server (SEP-2663).
Demonstrates background task execution with progress tracking using Docket.
Run this in one terminal, then drive it from `client.py` in another. It exposes
one `task=True` tool that reports progress as it works, so you can watch the
client poll a real background task over HTTP.
Setup:
1. Start Redis: docker compose up -d
2. Load environment: source .envrc
3. Run server: fastmcp run server.py
# From the fastmcp root (memory:// backend, no Redis needed):
python examples/tasks/server.py
The example uses Redis by default to demonstrate distributed task execution
and the fastmcp tasks CLI commands.
The server listens on http://localhost:8000/mcp. The tasks extension runs its
Docket worker in-process on the default `memory://` backend, so several tasks
submitted at once execute concurrently (worker concurrency defaults to 10).
Point `FASTMCP_DOCKET_URL` at Redis to distribute work across separate worker
processes instead see README.md.
"""
import asyncio
import logging
from datetime import timedelta
from typing import Annotated
from docket import Logged
from fastmcp import FastMCP
from fastmcp.dependencies import Progress
from fastmcp.utilities.tasks import TaskConfig
from fastmcp_tasks import TasksExtension
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
logger = logging.getLogger("tasks-example")
# Create server
# Enable SEP-2663 background tasks. With no arguments the extension reads the
# FASTMCP_DOCKET_* environment and falls back to an in-process memory:// worker.
mcp = FastMCP("Tasks Example")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
# A short poll interval keeps the example snappy: the client observes each
# task finishing within ~1s. The default is 5s, tuned for real workloads.
@mcp.tool(task=TaskConfig(poll_interval=timedelta(seconds=1)))
async def slow_computation(
duration: Annotated[int, Logged],
label: Annotated[str, "A name for this run, echoed back in progress logs"],
duration: Annotated[int, "How many seconds the computation should take (1-60)"],
progress: Progress = Progress(),
) -> str:
"""Spend `duration` seconds working, reporting progress once per second.
Marked `task=True`, so a task-aware client runs it in the background and
polls for progress and the final result instead of blocking on the call.
"""
Perform a slow computation that takes `duration` seconds.
if not 1 <= duration <= 60:
raise ValueError("duration must be between 1 and 60 seconds")
This tool demonstrates progress tracking with background tasks.
It logs progress every 1-2 seconds and reports progress via Docket.
Args:
duration: Number of seconds the computation should take (1-60)
Returns:
A completion message with the total duration
"""
if duration < 1 or duration > 60:
raise ValueError("Duration must be between 1 and 60 seconds")
logger.info(f"Starting slow computation for {duration} seconds")
# Set total progress units
logger.info("[%s] starting — %ds", label, duration)
await progress.set_total(duration)
# Process each second
for i in range(duration):
# Sleep for 1 second
for elapsed in range(1, duration + 1):
await asyncio.sleep(1)
# Update progress
elapsed = i + 1
remaining = duration - elapsed
await progress.increment()
await progress.set_message(
f"Working... {elapsed}/{duration}s ({remaining}s remaining)"
)
await progress.set_message(f"{label}: {elapsed}/{duration}s")
# Log every 1-2 seconds
if elapsed % 2 == 0 or elapsed == duration:
logger.info(f"Progress: {elapsed}/{duration}s")
logger.info("[%s] done", label)
return f"{label} finished in {duration}s"
logger.info(f"Completed computation in {duration} seconds")
return f"Computation completed successfully in {duration} seconds!"
if __name__ == "__main__":
mcp.run(transport="http", host="127.0.0.1", port=8000)

View file

@ -5,14 +5,10 @@ import warnings
from importlib.metadata import PackageNotFoundError, version as _version
from typing import TYPE_CHECKING
from fastmcp import _install_hints, _sdk_patches
from fastmcp import _install_hints
from fastmcp.settings import Settings
from fastmcp.utilities.logging import configure_logging as _configure_logging
# Apply temporary SDK registry patches (SEP-1686 task methods) before any
# client/server use. See fastmcp._sdk_patches for the upstream-gap rationale.
_sdk_patches.install()
if TYPE_CHECKING:
from fastmcp.client import Client as Client
from fastmcp.apps.app import FastMCPApp as FastMCPApp

View file

@ -1,131 +0,0 @@
"""Temporary in-place patches for gaps in the pinned MCP SDK.
## SEP-1686 task methods missing from the handshake-era method registries
This shim compensates for a genuine gap in the SDK's *handshake-era*
(2025-11-25 and earlier) task registry. In the 2025-11-25 SEP-1686 model, tasks
are a first-class part of the core protocol: `CallToolRequestParams` carries a
`task: TaskMetadata` field and a task-augmented `tools/call` returns a
`CreateTaskResult`. `mcp==2.0.0b1` ships those task types (`CreateTaskResult`,
`GetTaskResult`, `GetTaskPayloadResult`, `ListTasksResult`, `CancelTaskResult`)
and the `task` request field, but its `mcp_types.methods` registries were never
wired for them: there are no `tasks/*` rows, and the handshake-era `tools/call`
result rows are a plain `CallToolResult` with no `CreateTaskResult` arm.
The lowlevel server runner (`mcp.server.runner`) serializes a handler's result
through `serialize_server_result(method, version, ...)` for any method in
`SPEC_CLIENT_METHODS`. `tools/call` is such a method, so when a FastMCP tool is
submitted as a background task (`client.call_tool(..., task=True)`) the handler
returns a `CreateTaskResult`, which fails validation against the un-widened
`tools/call` surface row -> the client sees "Handler returned an invalid
result". The `tasks/*` methods themselves are NOT in `SPEC_CLIENT_METHODS`, so
their handler results already bypass serialization and reach the wire
unvalidated; we still register their result rows here for symmetry and so the
maps are consistent if a future SDK adds them to the spec method set.
## Scope: handshake-era versions only
The widening + `tasks/*` registration is gated to
`HANDSHAKE_PROTOCOL_VERSIONS` (2025-11-25 and earlier) because those are the
versions where the 2025 SEP-1686 task model actually applies and where the
SDK's registry has the genuine gap we compensate for.
The 2026-07-28 protocol is intentionally NOT patched here. Tasks left the core
protocol in 2026-07-28 and became the separate `io.modelcontextprotocol/tasks`
extension; `CreateTaskResult` and the `task` field on `CallToolRequestParams`
do not exist in that schema (a task-augmented `tools/call` was replaced by the
mutually-recursive `CallToolResult | InputRequiredResult` result). Injecting the
2025-era `CreateTaskResult` into the 2026 `tools/call` union would assert the
wrong task model onto that protocol, so we leave its rows untouched.
This module widens the registries IN PLACE (the maps are `MappingProxyType`
views over private dicts, so we reach the backing dict via `gc.get_referents`
and mutate it, which the already-bound default-argument references in
`mcp_types.methods` observe). `install()` is idempotent.
# TODO(sdk-upstream): remove when mcp>=2.0.0bX wires SEP-1686 into the
# handshake-era method registries.
"""
from __future__ import annotations
import gc
from types import MappingProxyType, UnionType
import mcp_types
from mcp_types import methods as _methods
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS
# Result type for each task method, keyed by the client request method name.
_TASK_RESULT_TYPES: dict[str, type] = {
"tasks/get": mcp_types.GetTaskResult,
"tasks/result": mcp_types.GetTaskPayloadResult,
"tasks/list": mcp_types.ListTasksResult,
"tasks/cancel": mcp_types.CancelTaskResult,
}
_installed = False
def _backing_dict(proxy: object) -> dict:
"""Return the mutable dict a MappingProxyType wraps.
The `mcp_types.methods` surface maps are `MappingProxyType` views; their
sole dict referent is the backing store the module's functions read through
their default `surface=` arguments.
"""
referents = [r for r in gc.get_referents(proxy) if isinstance(r, dict)]
if len(referents) != 1:
raise RuntimeError(
"expected exactly one backing dict for the method registry proxy, "
f"found {len(referents)}"
)
return referents[0]
def install() -> None:
"""Widen the SDK's server-result registry for SEP-1686 task methods.
Idempotent. Safe to call at import time before any client/server use.
"""
global _installed
if _installed:
return
if not isinstance(_methods.SERVER_RESULTS, MappingProxyType):
# Registry shape changed upstream; the shim no longer applies.
_installed = True
return
server_results = _backing_dict(_methods.SERVER_RESULTS)
# Gate to handshake-era versions only: the 2025 SEP-1686 task model applies
# there, and 2026-07-28 tasks are the separate io.modelcontextprotocol/tasks
# extension (see module docstring) — its rows must stay untouched.
versions_with_tools_call = {
version
for (method, version) in server_results
if method == "tools/call" and version in HANDSHAKE_PROTOCOL_VERSIONS
}
for version in versions_with_tools_call:
# (a) widen tools/call so a CreateTaskResult validates (task submission).
existing = server_results[("tools/call", version)]
arms = get_union_arms(existing)
if mcp_types.CreateTaskResult not in arms:
server_results[("tools/call", version)] = (
existing | mcp_types.CreateTaskResult
)
# (b) register the tasks/* result rows for the same versions.
for method, result_type in _TASK_RESULT_TYPES.items():
server_results.setdefault((method, version), result_type)
_installed = True
def get_union_arms(row: type | UnionType) -> tuple[type, ...]:
"""Return the member types of a result row, whether a single type or union."""
if isinstance(row, UnionType):
return tuple(row.__args__)
return (row,)

View file

@ -23,7 +23,6 @@ from fastmcp.cli.auth import auth_app
from fastmcp.cli.client import call_command, discover_command, list_command
from fastmcp.cli.generate import generate_cli_command
from fastmcp.cli.install import install_app
from fastmcp.cli.tasks import tasks_app
from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config
from fastmcp.utilities.inspect import (
InspectFormat,
@ -1126,9 +1125,6 @@ app.command(project_app)
# Add install subcommands using proper Cyclopts pattern
app.command(install_app)
# Add tasks subcommand group
app.command(tasks_app)
# Add client query commands
app.command(list_command, name="list")
app.command(call_command, name="call")

View file

@ -7,7 +7,6 @@ import hashlib
import secrets
import ssl
import uuid
import weakref
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping, Sequence
from contextlib import AsyncExitStack, asynccontextmanager, suppress
from dataclasses import dataclass, field
@ -42,11 +41,10 @@ from mcp.client.extension import (
NotificationBinding,
ResultClaim,
)
from mcp.client.session import ClientRequestContext, MessageHandlerFnT
from mcp_types import (
GetTaskResult,
TaskStatusNotification,
TaskStatusNotificationParams,
from mcp.client.session import (
ClientRequestContext,
ElicitationFnT,
MessageHandlerFnT,
)
from mcp_types.methods import validate_server_result
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
@ -58,6 +56,7 @@ from fastmcp.client.elicitation import (
ElicitationHandler,
create_elicitation_callback,
)
from fastmcp.client.extension_hooks import build_internal_client_extensions
from fastmcp.client.logging import (
LogHandler,
create_log_callback,
@ -67,7 +66,6 @@ from fastmcp.client.messages import MessageHandler, MessageHandlerT
from fastmcp.client.mixins import (
ClientPromptsMixin,
ClientResourcesMixin,
ClientTaskManagementMixin,
ClientToolsMixin,
)
from fastmcp.client.progress import ProgressHandler, default_progress_handler
@ -80,12 +78,6 @@ from fastmcp.client.sampling import (
SamplingHandler,
create_sampling_callback,
)
from fastmcp.client.tasks import (
PromptTask,
ResourceTask,
TaskNotificationHandler,
ToolTask,
)
from fastmcp.mcp_config import MCPConfig
from fastmcp.utilities.exceptions import get_catch_handlers
from fastmcp.utilities.logging import get_logger
@ -256,7 +248,6 @@ class Client(
ClientResourcesMixin,
ClientPromptsMixin,
ClientToolsMixin,
ClientTaskManagementMixin,
):
"""
MCP client that delegates connection management to a Transport instance.
@ -348,6 +339,13 @@ class Client(
```
"""
#: Whether FastMCP-internal client extensions (e.g. the tasks extension) are
#: folded in automatically at construction. `ProxyClient` overrides this to
#: `False`: a proxy forwards calls and must not advertise task support to its
#: backend, since proxied tools run synchronously (forbidden mode) and the
#: proxy has no path to drive a backend task on the front connection's behalf.
_auto_internal_extensions: bool = True
@overload
def __init__(self: Client[T], transport: T, *args: Any, **kwargs: Any) -> None: ...
@ -500,12 +498,10 @@ class Client(
cache
)
# The unwrapped base handler (default routes task notifications; a user
# handler is preserved as-is). Retained so `new()` can rebuild the clone's
# handler without unwrapping the cache-eviction wrapper below.
self._base_message_handler: MessageHandlerFnT | None = (
message_handler or TaskNotificationHandler(self)
)
# The unwrapped base handler (a user handler is preserved as-is).
# Retained so `new()` can rebuild the clone's handler without unwrapping
# the cache-eviction wrapper below.
self._base_message_handler: MessageHandlerFnT | None = message_handler
effective_message_handler = self._base_message_handler
if self._response_cache is not None:
effective_message_handler = _evicting_message_handler(
@ -520,6 +516,16 @@ class Client(
# `_build_extension_kwargs`.
self._claim_by_model: dict[type[mcp_types.Result], ResultClaim[Any]] = {}
# Build the elicitation callback up front: it is threaded both into the
# session (to answer server-initiated elicitation) and into the internal
# client extensions (so a task resolver can answer in-task input), and
# `_build_extension_kwargs` — called below — needs it.
self._elicitation_callback: ElicitationFnT | None = (
create_elicitation_callback(elicitation_handler)
if elicitation_handler is not None
else None
)
self._session_kwargs: SessionKwargs = {
"sampling_callback": None,
"list_roots_callback": None,
@ -543,10 +549,8 @@ class Client(
else mcp_types.SamplingCapability()
)
if elicitation_handler is not None:
self._session_kwargs["elicitation_callback"] = create_elicitation_callback(
elicitation_handler
)
if self._elicitation_callback is not None:
self._session_kwargs["elicitation_callback"] = self._elicitation_callback
# Maximum time to wait for a clean disconnect before giving up.
# Normally disconnects complete in <100ms; this is a safety net for
@ -557,15 +561,6 @@ class Client(
self._session_state = ClientSessionState()
self._transport_options: TransportOptions | None = None
# Track task IDs submitted by this client (for list_tasks support)
self._submitted_task_ids: set[str] = set()
# Registry for routing notifications/tasks/status to Task objects
self._task_registry: dict[
str, weakref.ref[ToolTask | PromptTask | ResourceTask]
] = {}
def _build_response_cache(
self, cache: CacheConfig | bool | None
) -> ClientResponseCache | None:
@ -713,9 +708,12 @@ class Client(
self, elicitation_callback: ElicitationHandler
) -> None:
"""Set the elicitation callback for the client."""
self._session_kwargs["elicitation_callback"] = create_elicitation_callback(
elicitation_callback
)
self._elicitation_callback = create_elicitation_callback(elicitation_callback)
self._session_kwargs["elicitation_callback"] = self._elicitation_callback
# Rebuild internal extensions (e.g. the tasks extension) so a background
# task's in-task input is answered through the newly-set handler, not the
# one captured when the client was constructed.
self._session_kwargs.update(self._build_extension_kwargs())
def is_connected(self) -> bool:
"""Check if the client is currently connected."""
@ -746,26 +744,16 @@ class Client(
new_client._session_state = ClientSessionState()
new_client._transport_options = self._transport_options
# Reset mutable task tracking state so new client is independent
new_client._task_registry = {}
new_client._submitted_task_ids = set()
# Give the clone its own response cache so cached entries are not shared
# across independent sessions, and rebuild the negotiated_version closure
# to point at the clone's session state.
new_client._response_cache = new_client._build_response_cache(self._cache_arg)
# Create a fresh session kwargs dict so the clone doesn't share
# the original's mutable dict. Rebind the task notification handler
# to the new client if the default handler is in use; preserve any
# custom message handler the user may have set.
# the original's mutable dict; preserve any custom message handler the
# user may have set, re-wrapping with the clone's own cache if one exists.
new_client._session_kwargs = {**self._session_kwargs} # type: ignore[typeddict-item]
# Recover the unwrapped base handler (never the cache-evicting wrapper): a
# default (TaskNotificationHandler) rebinds to the clone; a user handler is
# preserved. Then re-wrap with the clone's own cache if one exists.
base_handler: MessageHandlerFnT | None = self._base_message_handler
if isinstance(base_handler, TaskNotificationHandler) or base_handler is None:
base_handler = TaskNotificationHandler(new_client)
new_client._base_message_handler = base_handler
if new_client._response_cache is not None:
new_client._session_kwargs["message_handler"] = _evicting_message_handler(
@ -774,8 +762,7 @@ class Client(
else:
new_client._session_kwargs["message_handler"] = base_handler
# Rebuild the extension-contributed kwargs (capability ad, result claims,
# notification bindings) so the clone's task-status binding routes to the
# clone while user extensions still compose with it.
# notification bindings) so user extensions compose on the clone.
new_client._session_kwargs.update(new_client._build_extension_kwargs())
new_client.name += f":{secrets.token_hex(2)}"
@ -1241,47 +1228,41 @@ class Client(
max_rounds=self.input_required_max_rounds,
)
def _handle_task_status_notification(
self, notification: TaskStatusNotification
) -> None:
"""Route task status notification to appropriate Task object.
Called when notifications/tasks/status is received from server.
Updates Task object's cache and triggers events/callbacks.
"""
self._handle_task_status_params(notification.params)
def _handle_task_status_params(self, params: TaskStatusNotificationParams) -> None:
"""Route task status notification params to the matching Task object."""
task_id = params.task_id
if not task_id:
return
# Look up task in registry (weakref)
task_ref = self._task_registry.get(task_id)
if task_ref:
task = task_ref() # Dereference weakref
if task:
# Convert notification params to GetTaskResult (they share the same fields via Task)
status = GetTaskResult.model_validate(params.model_dump())
task._handle_status_notification(status)
def _build_extension_kwargs(self) -> SessionKwargs:
"""Session kwargs contributed by `extensions=` / `result_claims=`.
Folds the user's `ClientExtension` instances into the capability ad, result
claims, and notification bindings the SDK `ClientSession` consumes, then
merges in any explicitly-passed `result_claims`. The internal task-status
binding is always prepended to the folded bindings so user extensions
*compose* with it rather than clobbering it; a user extension that binds the
same `notifications/tasks/status` method surfaces a duplicate-method error
from the SDK rather than silently replacing FastMCP's routing.
merges in any explicitly-passed `result_claims`.
Also rebuilds `self._claim_by_model`, the modelclaim index the resolution
path uses to finish a claimed `tools/call` result, covering both the folded
extension claims and the explicit `result_claims` extras.
FastMCP-internal extensions (e.g. the tasks extension from `fastmcp-tasks`,
registered via `register_internal_client_extension_factory`) are folded in
automatically so an ordinary `Client` transparently drives a server's
background tasks. They lead the fold order; a user extension declaring the
same identifier wins, so the internal one is dropped rather than colliding.
"""
folded = _fold_extensions(self._extensions_arg)
user_extensions = list(self._extensions_arg or ())
user_identifiers = {
identifier
for extension in user_extensions
if (identifier := getattr(extension, "identifier", None)) is not None
}
internal_extensions = (
[
extension
for extension in build_internal_client_extensions(
self._elicitation_callback
)
if extension.identifier not in user_identifiers
]
if self._auto_internal_extensions
else []
)
folded = _fold_extensions([*internal_extensions, *user_extensions])
claims: dict[str, tuple[ResultClaim[Any], ...]] = dict(folded.claims or {})
by_model: dict[type[mcp_types.Result], ResultClaim[Any]] = dict(folded.by_model)
@ -1293,11 +1274,7 @@ class Client(
self._claim_by_model = by_model
kwargs: SessionKwargs = {
# The internal task binding must lead so user bindings extend it.
"notification_bindings": [
self._task_status_binding(),
*(folded.bindings or ()),
],
"notification_bindings": [*(folded.bindings or ())],
}
if folded.ad:
kwargs["extensions"] = folded.ad
@ -1333,26 +1310,6 @@ class Client(
await self.session.validate_tool_result(name, final)
return final
def _task_status_binding(self) -> NotificationBinding[TaskStatusNotificationParams]:
"""Build a binding routing `notifications/tasks/status` to Task objects.
SDK v2 drops notifications whose method is absent from the negotiated
version's core tables before they reach the message_handler; a binding is
the supported channel for observing such vendor notifications.
"""
client_ref = weakref.ref(self)
async def _handler(params: TaskStatusNotificationParams) -> None:
client = client_ref()
if client is not None:
client._handle_task_status_params(params)
return NotificationBinding(
method="notifications/tasks/status",
params_type=TaskStatusNotificationParams,
handler=_handler,
)
async def close(self):
await self._disconnect(force=True)
await self.transport.close()

View file

@ -0,0 +1,68 @@
"""Registry for FastMCP-internal client extensions (SEP-2133).
Core ships the client wiring for opt-in extensions but no extension of its own.
A companion package (``fastmcp-tasks``) provides an extension the ``Client``
folds in automatically once the package is imported so a caller that uses
tasks (importing ``fastmcp_tasks`` for ``call_tool_task``, or to register the
server extension) gets transparent client task support without passing anything
per ``Client``. The package cannot reach into core's ``Client`` constructor, so
core exposes this hook instead: the package registers a factory on import, and
``Client`` folds the factory's extension in alongside the user's own.
This mirrors the server-side ``set_background_context_factory`` hook: core
declares the extension point, the tasks package fills it. Task support is
opt-in with ``fastmcp_tasks`` unimported the registry is empty and ``Client``
behaves exactly as core alone, so a plain ``from fastmcp import Client`` never
advertises the tasks capability and the server never runs its calls as tasks.
A factory receives the client's elicitation callback (so a task resolver can
answer in-task input prompts) and returns a ``ClientExtension`` to register, or
``None`` to contribute nothing for this client.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from mcp.client.extension import ClientExtension
from mcp.client.session import ElicitationFnT
#: A factory that builds a FastMCP-internal client extension for one ``Client``,
#: given that client's elicitation callback (``None`` when the client has no
#: elicitation handler).
InternalClientExtensionFactory = Callable[
["ElicitationFnT | None"], "ClientExtension | None"
]
_internal_client_extension_factories: list[InternalClientExtensionFactory] = []
def register_internal_client_extension_factory(
factory: InternalClientExtensionFactory,
) -> None:
"""Register a factory whose extension every ``Client`` folds in automatically.
Idempotent: registering the same factory object twice is a no-op, so a
package importing more than once does not double-register.
"""
if factory not in _internal_client_extension_factories:
_internal_client_extension_factories.append(factory)
def build_internal_client_extensions(
elicitation_callback: ElicitationFnT | None,
) -> list[ClientExtension]:
"""Build the internal extensions to fold into a ``Client`` under construction.
Each registered factory is invoked with the client's elicitation callback;
factories that return ``None`` contribute nothing. Empty when no companion
package has registered a factory (plain core, or ``fastmcp_tasks`` unimported).
"""
extensions: list[ClientExtension] = []
for factory in _internal_client_extension_factories:
extension = factory(elicitation_callback)
if extension is not None:
extensions.append(extension)
return extensions

View file

@ -2,12 +2,10 @@
from fastmcp.client.mixins.prompts import ClientPromptsMixin
from fastmcp.client.mixins.resources import ClientResourcesMixin
from fastmcp.client.mixins.task_management import ClientTaskManagementMixin
from fastmcp.client.mixins.tools import ClientToolsMixin
__all__ = [
"ClientPromptsMixin",
"ClientResourcesMixin",
"ClientTaskManagementMixin",
"ClientToolsMixin",
]

View file

@ -2,19 +2,15 @@
from __future__ import annotations
import uuid
import weakref
from typing import TYPE_CHECKING, Any, Literal, cast, overload
from typing import TYPE_CHECKING, Any, cast
import mcp_types
import pydantic_core
from mcp.client.caching import CacheMode
from pydantic import RootModel
if TYPE_CHECKING:
from fastmcp.client.client import Client
from fastmcp.client.tasks import PromptTask
from fastmcp.client.telemetry import client_span
from fastmcp.telemetry import inject_trace_context
from fastmcp.utilities.logging import get_logger
@ -23,11 +19,6 @@ logger = get_logger(__name__)
AUTO_PAGINATION_MAX_PAGES = 250
# Type alias for task response union (SEP-1686 graceful degradation)
PromptTaskResponseUnion = RootModel[
mcp_types.CreateTaskResult | mcp_types.GetPromptResult
]
class ClientPromptsMixin:
"""Mixin providing prompt-related methods for Client."""
@ -192,7 +183,6 @@ class ClientPromptsMixin:
)
return result
@overload
async def get_prompt(
self: Client,
name: str,
@ -200,33 +190,7 @@ class ClientPromptsMixin:
*,
version: str | None = None,
meta: dict[str, Any] | None = None,
task: Literal[False] = False,
) -> mcp_types.GetPromptResult: ...
@overload
async def get_prompt(
self: Client,
name: str,
arguments: dict[str, Any] | None = None,
*,
version: str | None = None,
meta: dict[str, Any] | None = None,
task: Literal[True],
task_id: str | None = None,
ttl: int = 60000,
) -> PromptTask: ...
async def get_prompt(
self: Client,
name: str,
arguments: dict[str, Any] | None = None,
*,
version: str | None = None,
meta: dict[str, Any] | None = None,
task: bool = False,
task_id: str | None = None,
ttl: int = 60000,
) -> mcp_types.GetPromptResult | PromptTask:
) -> mcp_types.GetPromptResult:
"""Retrieve a rendered prompt message list from the server.
Args:
@ -234,13 +198,9 @@ class ClientPromptsMixin:
arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None.
version (str | None, optional): Specific prompt version to get. If None, gets highest version.
meta (dict[str, Any] | None): Optional request-level metadata.
task (bool): If True, execute as background task (SEP-1686). Defaults to False.
task_id (str | None): Optional client-provided task ID (auto-generated if not provided).
ttl (int): Time to keep results available in milliseconds (default 60s).
Returns:
mcp_types.GetPromptResult | PromptTask: The complete response object if task=False,
or a PromptTask object if task=True.
mcp_types.GetPromptResult: The complete response object.
Raises:
RuntimeError: If called while the client is not connected.
@ -254,94 +214,7 @@ class ClientPromptsMixin:
"version": version,
}
if task:
return await self._get_prompt_as_task(
name, arguments, task_id, ttl, meta=request_meta or None
)
result = await self.get_prompt_mcp(
name=name, arguments=arguments, meta=request_meta or None
)
return result
async def _get_prompt_as_task(
self: Client,
name: str,
arguments: dict[str, Any] | None = None,
task_id: str | None = None,
ttl: int = 60000,
meta: dict[str, Any] | None = None,
) -> PromptTask:
"""Get a prompt for background execution (SEP-1686).
Returns a PromptTask object that handles both background and immediate execution.
Args:
name: Prompt name to get
arguments: Prompt arguments
task_id: Optional client-provided task ID (ignored, for backward compatibility)
ttl: Time to keep results available in milliseconds (default 60s)
meta: Optional request metadata (e.g., version info)
Returns:
PromptTask: Future-like object for accessing task status and results
"""
# Per SEP-1686 final spec: client sends only ttl, server generates taskId
# Inject trace context into meta for propagation to server.
# SDK v2: request `_meta` is `RequestParamsMeta` (a TypedDict), not
# the old `RequestParams.Meta` nested model.
propagated_meta = inject_trace_context(meta)
request_meta = cast(
"mcp_types.RequestParamsMeta | None",
propagated_meta if propagated_meta else None,
)
# Serialize arguments for MCP protocol
serialized_arguments: dict[str, str] | None = None
if arguments:
serialized_arguments = {}
for key, value in arguments.items():
if isinstance(value, str):
serialized_arguments[key] = value
else:
serialized_arguments[key] = pydantic_core.to_json(value).decode(
"utf-8"
)
# SDK v2: GetPromptRequestParams has no `task` field, so this request
# cannot carry task metadata over the wire and the server graceful-
# degrades to immediate execution (sdk-feedback #3). `ttl` is retained on
# the public API but has no wire representation here.
request = mcp_types.GetPromptRequest(
params=mcp_types.GetPromptRequestParams(
name=name,
arguments=serialized_arguments,
_meta=request_meta, # type: ignore[unknown-argument] # pydantic alias
)
)
# Server returns CreateTaskResult (task accepted) or GetPromptResult (graceful degradation)
wrapped_result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type]
result_type=PromptTaskResponseUnion,
)
)
raw_result = wrapped_result.root
if isinstance(raw_result, mcp_types.CreateTaskResult):
# Task was accepted - extract task info from CreateTaskResult
server_task_id = raw_result.task.task_id
self._submitted_task_ids.add(server_task_id)
task_obj = PromptTask(
self, server_task_id, prompt_name=name, immediate_result=None
)
self._task_registry[server_task_id] = weakref.ref(task_obj)
return task_obj
else:
# Graceful degradation - server returned GetPromptResult
synthetic_task_id = task_id or str(uuid.uuid4())
return PromptTask(
self, synthetic_task_id, prompt_name=name, immediate_result=raw_result
)

View file

@ -2,18 +2,15 @@
from __future__ import annotations
import uuid
import weakref
from typing import TYPE_CHECKING, Any, Literal, cast, overload
from typing import TYPE_CHECKING, Any, cast
import mcp_types
from mcp.client.caching import CacheMode
from pydantic import AnyUrl, RootModel
from pydantic import AnyUrl
if TYPE_CHECKING:
from fastmcp.client.client import Client
from fastmcp.client.tasks import ResourceTask
from fastmcp.client.telemetry import client_span
from fastmcp.telemetry import inject_trace_context
from fastmcp.utilities.logging import get_logger
@ -22,11 +19,6 @@ logger = get_logger(__name__)
AUTO_PAGINATION_MAX_PAGES = 250
# Type alias for task response union (SEP-1686 graceful degradation)
ResourceTaskResponseUnion = RootModel[
mcp_types.CreateTaskResult | mcp_types.ReadResourceResult
]
class ClientResourcesMixin:
"""Mixin providing resource-related methods for Client."""
@ -272,54 +264,23 @@ class ClientResourcesMixin:
)
return result
@overload
async def read_resource(
self: Client,
uri: AnyUrl | str,
*,
version: str | None = None,
meta: dict[str, Any] | None = None,
task: Literal[False] = False,
) -> list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]: ...
@overload
async def read_resource(
self: Client,
uri: AnyUrl | str,
*,
version: str | None = None,
meta: dict[str, Any] | None = None,
task: Literal[True],
task_id: str | None = None,
ttl: int = 60000,
) -> ResourceTask: ...
async def read_resource(
self: Client,
uri: AnyUrl | str,
*,
version: str | None = None,
meta: dict[str, Any] | None = None,
task: bool = False,
task_id: str | None = None,
ttl: int = 60000,
) -> (
list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]
| ResourceTask
):
) -> list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]:
"""Read the contents of a resource or resolved template.
Args:
uri (AnyUrl | str): The URI of the resource to read. Can be a string or an AnyUrl object.
version (str | None): Specific version to read. If None, reads highest version.
meta (dict[str, Any] | None): Optional request-level metadata.
task (bool): If True, execute as background task (SEP-1686). Defaults to False.
task_id (str | None): Optional client-provided task ID (auto-generated if not provided).
ttl (int): Time to keep results available in milliseconds (default 60s).
Returns:
list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents] | ResourceTask:
A list of content objects if task=False, or a ResourceTask object if task=True.
list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]:
A list of content objects.
Raises:
RuntimeError: If called while the client is not connected.
@ -333,11 +294,6 @@ class ClientResourcesMixin:
"version": version,
}
if task:
return await self._read_resource_as_task(
uri, task_id, ttl, meta=request_meta or None
)
if isinstance(uri, str):
try:
uri = AnyUrl(uri) # Ensure AnyUrl
@ -347,77 +303,3 @@ class ClientResourcesMixin:
) from e
result = await self.read_resource_mcp(uri, meta=request_meta or None)
return result.contents
async def _read_resource_as_task(
self: Client,
uri: AnyUrl | str,
task_id: str | None = None,
ttl: int = 60000,
meta: dict[str, Any] | None = None,
) -> ResourceTask:
"""Read a resource for background execution (SEP-1686).
Returns a ResourceTask object that handles both background and immediate execution.
Args:
uri: Resource URI to read
task_id: Optional client-provided task ID (ignored, for backward compatibility)
ttl: Time to keep results available in milliseconds (default 60s)
meta: Optional metadata to pass with the request (e.g., version info)
Returns:
ResourceTask: Future-like object for accessing task status and results
"""
# Per SEP-1686 final spec: client sends only ttl, server generates taskId
# Inject trace context into meta for propagation to server.
# SDK v2: request `_meta` is `RequestParamsMeta` (a TypedDict), not
# the old `RequestParams.Meta` nested model.
propagated_meta = inject_trace_context(meta)
request_meta = cast(
"mcp_types.RequestParamsMeta | None",
propagated_meta if propagated_meta else None,
)
# SDK v2: ReadResourceRequestParams.uri is a plain string, but resources
# are stored under the AnyUrl-normalized form, so normalize to match.
uri_str = str(AnyUrl(uri)) if isinstance(uri, str) else str(uri)
# SDK v2: ReadResourceRequestParams has no `task` field, so this request
# cannot carry task metadata over the wire and the server graceful-
# degrades to immediate execution (sdk-feedback #3). `ttl` is retained on
# the public API but has no wire representation here.
request = mcp_types.ReadResourceRequest(
params=mcp_types.ReadResourceRequestParams(
uri=uri_str,
_meta=request_meta, # type: ignore[unknown-argument] # pydantic alias
)
)
# Server returns CreateTaskResult (task accepted) or ReadResourceResult (graceful degradation)
wrapped_result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type]
result_type=ResourceTaskResponseUnion,
)
)
raw_result = wrapped_result.root
if isinstance(raw_result, mcp_types.CreateTaskResult):
# Task was accepted - extract task info from CreateTaskResult
server_task_id = raw_result.task.task_id
self._submitted_task_ids.add(server_task_id)
task_obj = ResourceTask(
self, server_task_id, uri=str(uri), immediate_result=None
)
self._task_registry[server_task_id] = weakref.ref(task_obj)
return task_obj
else:
# Graceful degradation - server returned ReadResourceResult
synthetic_task_id = task_id or str(uuid.uuid4())
return ResourceTask(
self,
synthetic_task_id,
uri=str(uri),
immediate_result=raw_result.contents,
)

View file

@ -1,232 +0,0 @@
"""Task management methods for FastMCP Client."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
import mcp_types
from mcp import MCPError
from mcp_types import Result
from pydantic import ConfigDict
if TYPE_CHECKING:
from fastmcp.client.client import Client
from mcp_types import (
CancelTaskRequest,
CancelTaskRequestParams,
GetTaskPayloadRequest,
GetTaskPayloadRequestParams,
GetTaskRequest,
GetTaskRequestParams,
GetTaskResult,
ListTasksRequest,
PaginatedRequestParams,
)
from fastmcp.client.telemetry import client_span
from fastmcp.telemetry import inject_trace_context
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
class _RawTaskPayloadResult(Result):
"""Permissive result type for `tasks/result` responses.
Per the v2 spec, a `tasks/result` payload arrives as extra wire fields whose
shape matches the original request's result type (CallToolResult,
GetPromptResult, ReadResourceResult, ...). `GetTaskPayloadResult` is a bare
`Result` that drops those fields on validation, so this subclass retains them
with `extra="allow"`; callers re-parse the resulting dict into the concrete
result type.
"""
model_config = ConfigDict(
alias_generator=Result.model_config.get("alias_generator"),
populate_by_name=True,
extra="allow",
)
class ClientTaskManagementMixin:
"""Mixin providing task management methods for Client."""
async def get_task_status(self: Client, task_id: str) -> GetTaskResult:
"""Query the status of a background task.
Sends a 'tasks/get' MCP protocol request over the existing transport.
Args:
task_id: The task ID returned from call_tool_as_task
Returns:
GetTaskResult: Status information including taskId, status, pollInterval, etc.
Raises:
RuntimeError: If client not connected
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
with client_span(
"tasks/get",
"tasks/get",
task_id,
session_id=self.transport.get_session_id(),
):
request_meta = cast(
"mcp_types.RequestParamsMeta | None", inject_trace_context()
)
request = GetTaskRequest(
params=GetTaskRequestParams(
task_id=task_id,
_meta=request_meta, # type: ignore[unknown-argument]
)
)
return await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type]
result_type=GetTaskResult,
)
)
async def get_task_result(self: Client, task_id: str) -> Any:
"""Retrieve the raw result of a completed background task.
Sends a 'tasks/result' MCP protocol request over the existing transport.
Returns the raw result - callers should parse it appropriately.
Args:
task_id: The task ID returned from call_tool_as_task
Returns:
Any: The raw result (could be tool, prompt, or resource result)
Raises:
RuntimeError: If client not connected, task not found, or task failed
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
with client_span(
"tasks/result",
"tasks/result",
task_id,
session_id=self.transport.get_session_id(),
):
request_meta = cast(
"mcp_types.RequestParamsMeta | None", inject_trace_context()
)
request = GetTaskPayloadRequest(
params=GetTaskPayloadRequestParams(
task_id=task_id,
_meta=request_meta, # type: ignore[unknown-argument]
)
)
# Return raw result - Task classes handle type-specific parsing
result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type]
result_type=_RawTaskPayloadResult,
)
)
# Return as dict for compatibility with Task class parsing. The payload
# fields (content, structuredContent, messages, contents, ...) survive
# via the permissive result type's extra="allow".
return result.model_dump(exclude_none=True, by_alias=True)
async def list_tasks(
self: Client,
cursor: str | None = None,
limit: int = 50,
) -> dict[str, Any]:
"""List background tasks.
Sends a 'tasks/list' MCP protocol request to the server. If the server
returns an empty list (indicating client-side tracking), falls back to
querying status for locally tracked task IDs.
Args:
cursor: Optional pagination cursor
limit: Maximum number of tasks to return (default 50)
Returns:
dict: Response with structure:
- tasks: List of task status dicts with taskId, status, etc.
- nextCursor: Optional cursor for next page
Raises:
RuntimeError: If client not connected
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
with client_span(
"tasks/list",
"tasks/list",
"",
session_id=self.transport.get_session_id(),
):
request_meta = cast(
"mcp_types.RequestParamsMeta | None", inject_trace_context()
)
# Send protocol request
params = PaginatedRequestParams.model_validate(
{"cursor": cursor, "limit": limit, "_meta": request_meta}
)
request = ListTasksRequest(params=params)
server_response = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[invalid-argument-type]
result_type=mcp_types.ListTasksResult,
)
)
# If server returned tasks, use those
if server_response.tasks:
return server_response.model_dump(by_alias=True)
# Server returned empty - fall back to client-side tracking
tasks = []
for task_id in list(self._submitted_task_ids)[:limit]:
try:
status = await self.get_task_status(task_id)
tasks.append(status.model_dump(by_alias=True))
except MCPError:
# Task may have expired or been deleted, skip it
continue
return {"tasks": tasks, "nextCursor": None}
async def cancel_task(self: Client, task_id: str) -> mcp_types.CancelTaskResult:
"""Cancel a task, transitioning it to cancelled state.
Sends a 'tasks/cancel' MCP protocol request. Task will halt execution
and transition to cancelled state.
Args:
task_id: The task ID to cancel
Returns:
CancelTaskResult: The task status showing cancelled state
Raises:
RuntimeError: If task doesn't exist
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
with client_span(
"tasks/cancel",
"tasks/cancel",
task_id,
session_id=self.transport.get_session_id(),
):
request_meta = cast(
"mcp_types.RequestParamsMeta | None", inject_trace_context()
)
request = CancelTaskRequest(
params=CancelTaskRequestParams(
task_id=task_id,
_meta=request_meta, # type: ignore[unknown-argument]
)
)
return await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[invalid-argument-type]
result_type=mcp_types.CancelTaskResult,
)
)

View file

@ -2,21 +2,17 @@
from __future__ import annotations
import uuid
import weakref
from typing import TYPE_CHECKING, Any, Literal, cast, overload
from typing import TYPE_CHECKING, Any, cast
import mcp_types
from mcp.client.caching import CacheMode
from opentelemetry.trace import Status, StatusCode
from pydantic import RootModel
if TYPE_CHECKING:
import datetime
from fastmcp.client.client import CallToolResult, Client
from fastmcp.client.progress import ProgressHandler
from fastmcp.client.tasks import ToolTask
from fastmcp.client.telemetry import client_span
from fastmcp.exceptions import ToolError
from fastmcp.telemetry import inject_trace_context
@ -29,9 +25,6 @@ logger = get_logger(__name__)
AUTO_PAGINATION_MAX_PAGES = 250
# Type alias for task response union (SEP-1686 graceful degradation)
ToolTaskResponseUnion = RootModel[mcp_types.CreateTaskResult | mcp_types.CallToolResult]
class ClientToolsMixin:
"""Mixin providing tool-related methods for Client."""
@ -278,7 +271,6 @@ class ClientToolsMixin:
raise_on_error=raise_on_error,
)
@overload
async def call_tool(
self: Client,
name: str,
@ -289,39 +281,7 @@ class ClientToolsMixin:
progress_handler: ProgressHandler | None = None,
raise_on_error: bool = True,
meta: dict[str, Any] | None = None,
task: Literal[False] = False,
) -> CallToolResult: ...
@overload
async def call_tool(
self: Client,
name: str,
arguments: dict[str, Any] | None = None,
*,
version: str | None = None,
timeout: datetime.timedelta | float | int | None = None,
progress_handler: ProgressHandler | None = None,
raise_on_error: bool = True,
meta: dict[str, Any] | None = None,
task: Literal[True],
task_id: str | None = None,
ttl: int = 60000,
) -> ToolTask: ...
async def call_tool(
self: Client,
name: str,
arguments: dict[str, Any] | None = None,
*,
version: str | None = None,
timeout: datetime.timedelta | float | int | None = None,
progress_handler: ProgressHandler | None = None,
raise_on_error: bool = True,
meta: dict[str, Any] | None = None,
task: bool = False,
task_id: str | None = None,
ttl: int = 60000,
) -> CallToolResult | ToolTask:
) -> CallToolResult:
"""Call a tool on the server.
Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
@ -337,15 +297,11 @@ class ClientToolsMixin:
This is useful for passing contextual information (like user IDs, trace IDs, or preferences)
that shouldn't be tool arguments but may influence server-side processing. The server
can access this via `context.request_context.meta`. Defaults to None.
task (bool): If True, execute as background task (SEP-1686). Defaults to False.
task_id (str | None): Optional client-provided task ID (auto-generated if not provided).
ttl (int): Time to keep results available in milliseconds (default 60s).
Returns:
CallToolResult | ToolTask: The content returned by the tool if task=False,
or a ToolTask object if task=True. If the tool returns structured
outputs, they are returned as a dataclass (if an output schema
is available) or a dictionary; otherwise, a list of content
CallToolResult: The content returned by the tool. If the tool returns
structured outputs, they are returned as a dataclass (if an output
schema is available) or a dictionary; otherwise, a list of content
blocks is returned. Note: to receive both structured and
unstructured outputs, use call_tool_mcp instead and access the
raw result object.
@ -363,16 +319,6 @@ class ClientToolsMixin:
"version": version,
}
if task:
return await self._call_tool_as_task(
name,
arguments,
task_id,
ttl,
raise_on_error=raise_on_error,
meta=request_meta or None,
)
result = await self.call_tool_mcp(
name=name,
arguments=arguments or {},
@ -384,85 +330,6 @@ class ClientToolsMixin:
name, result, raise_on_error=raise_on_error
)
async def _call_tool_as_task(
self: Client,
name: str,
arguments: dict[str, Any] | None = None,
task_id: str | None = None,
ttl: int = 60000,
raise_on_error: bool = True,
meta: dict[str, Any] | None = None,
) -> ToolTask:
"""Call a tool for background execution (SEP-1686).
Returns a ToolTask object that handles both background and immediate execution.
If the server accepts background execution, ToolTask will poll for results.
If the server declines (graceful degradation), ToolTask wraps the immediate result.
Args:
name: Tool name to call
arguments: Tool arguments
task_id: Optional client-provided task ID (ignored, for backward compatibility)
ttl: Time to keep results available in milliseconds (default 60s)
raise_on_error: Whether task.result() should raise ToolError on errors
meta: Optional request metadata (e.g., version info)
Returns:
ToolTask: Future-like object for accessing task status and results
"""
# Per SEP-1686 final spec: client sends only ttl, server generates taskId
# Inject trace context into meta for propagation to server
propagated_meta = inject_trace_context(meta)
# SDK v2: request `_meta` is `RequestParamsMeta` (a TypedDict), not the
# old `RequestParams.Meta` nested model.
request_meta = cast(mcp_types.RequestParamsMeta | None, propagated_meta)
# Build request with task metadata
request = mcp_types.CallToolRequest(
params=mcp_types.CallToolRequestParams(
name=name,
arguments=arguments or {},
task=mcp_types.TaskMetadata(ttl=ttl),
_meta=request_meta, # type: ignore[unknown-argument] # pydantic alias
)
)
# Server returns CreateTaskResult (task accepted) or CallToolResult (graceful degradation)
# Use RootModel with Union to handle both response types (SDK calls model_validate)
wrapped_result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type]
result_type=ToolTaskResponseUnion,
)
)
raw_result = wrapped_result.root
if isinstance(raw_result, mcp_types.CreateTaskResult):
# Task was accepted - extract task info from CreateTaskResult
server_task_id = raw_result.task.task_id
self._submitted_task_ids.add(server_task_id)
task_obj = ToolTask(
self,
server_task_id,
tool_name=name,
immediate_result=None,
raise_on_error=raise_on_error,
)
self._task_registry[server_task_id] = weakref.ref(task_obj)
return task_obj
else:
# Graceful degradation - server returned CallToolResult
parsed_result = await self._parse_call_tool_result(name, raw_result)
synthetic_task_id = task_id or str(uuid.uuid4())
return ToolTask(
self,
synthetic_task_id,
tool_name=name,
immediate_result=parsed_result,
raise_on_error=raise_on_error,
)
async def _parse_call_tool_result(
name: str,

View file

@ -1,626 +0,0 @@
"""SEP-1686 client Task classes."""
from __future__ import annotations
import abc
import asyncio
import inspect
import time
import weakref
from collections.abc import Awaitable, Callable
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Generic, TypeVar
import mcp_types
from mcp_types import GetTaskResult, TaskStatusNotification
import fastmcp
from fastmcp.client.messages import Message, MessageHandler
from fastmcp.exceptions import ToolError
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
# Floor for the fallback poll interval in Task.wait() (seconds). When the server
# does not advertise a pollInterval, each wait() call starts its backoff ramp
# here so fast tasks resolve quickly even if a status notification is missed.
# When the server does advertise one, this is only a safety floor that keeps a
# server sending `pollInterval: 0` from spinning the client in a tight loop.
MIN_POLL_INTERVAL = 0.02
if TYPE_CHECKING:
from fastmcp.client.client import CallToolResult, Client
class TaskNotificationHandler(MessageHandler):
"""MessageHandler that routes task status notifications to Task objects."""
def __init__(self, client: Client):
super().__init__()
self._client_ref: weakref.ref[Client] = weakref.ref(client)
async def dispatch(self, message: Message) -> None:
"""Dispatch messages, including task status notifications."""
# SDK v2 delivers notifications unwrapped (no `.root` wrapper).
if isinstance(message, TaskStatusNotification):
client = self._client_ref()
if client:
client._handle_task_status_notification(message)
await super().dispatch(message)
TaskResultT = TypeVar("TaskResultT")
class Task(abc.ABC, Generic[TaskResultT]):
"""
Abstract base class for MCP background tasks (SEP-1686).
Provides a uniform API whether the server accepts background execution
or executes synchronously (graceful degradation per SEP-1686).
Subclasses:
- ToolTask: For tool calls (result type: CallToolResult)
- PromptTask: For prompts (future, result type: GetPromptResult)
- ResourceTask: For resources (future, result type: ReadResourceResult)
"""
def __init__(
self,
client: Client,
task_id: str,
immediate_result: TaskResultT | None = None,
):
"""
Create a Task wrapper.
Args:
client: The FastMCP client
task_id: The task identifier
immediate_result: If server executed synchronously, the immediate result
"""
self._client = client
self._task_id = task_id
self._immediate_result = immediate_result
self._is_immediate = immediate_result is not None
# Notification-based optimization (SEP-1686 notifications/tasks/status)
self._status_cache: GetTaskResult | None = None
self._status_event: asyncio.Event | None = None # Lazy init
self._status_callbacks: list[
Callable[[GetTaskResult], None | Awaitable[None]]
] = []
self._cached_result: TaskResultT | None = None
def _check_client_connected(self) -> None:
"""Validate that client context is still active.
Raises:
RuntimeError: If accessed outside client context (unless immediate)
"""
if self._is_immediate:
return # Already resolved, no client needed
try:
_ = self._client.session
except RuntimeError as e:
raise RuntimeError(
"Cannot access task results outside client context. "
"Task futures must be used within 'async with client:' block."
) from e
@property
def task_id(self) -> str:
"""Get the task ID."""
return self._task_id
@property
def returned_immediately(self) -> bool:
"""Check if server executed the task immediately.
Returns:
True if server executed synchronously (graceful degradation or no task support)
False if server accepted background execution
"""
return self._is_immediate
def _handle_status_notification(self, status: GetTaskResult) -> None:
"""Process incoming notifications/tasks/status (internal).
Called by Client when a notification is received for this task.
Updates cache, triggers events, and invokes user callbacks.
Args:
status: Task status from notification
"""
# Update cache for next status() call
self._status_cache = status
# Wake up any wait() calls
if self._status_event is not None:
self._status_event.set()
# Invoke user callbacks
for callback in self._status_callbacks:
try:
result = callback(status)
if inspect.isawaitable(result):
# Fire and forget async callbacks
asyncio.create_task(result) # type: ignore[arg-type] # noqa: RUF006 # ty:ignore[invalid-argument-type]
except Exception as e:
logger.warning(f"Task callback error: {e}", exc_info=True)
def on_status_change(
self,
callback: Callable[[GetTaskResult], None | Awaitable[None]],
) -> None:
"""Register callback for status change notifications.
The callback will be invoked when a notifications/tasks/status is received
for this task (optional server feature per SEP-1686 lines 436-444).
Supports both sync and async callbacks (auto-detected).
Args:
callback: Function to call with GetTaskResult when status changes.
Can return None (sync) or Awaitable[None] (async).
Example:
>>> task = await client.call_tool("slow_operation", {}, task=True)
>>>
>>> def on_update(status: GetTaskResult):
... print(f"Task {status.task_id} is now {status.status}")
>>>
>>> task.on_status_change(on_update)
>>> result = await task # Callback fires when status changes
"""
self._status_callbacks.append(callback)
async def status(self) -> GetTaskResult:
"""Get current task status.
If server executed immediately, returns synthetic completed status.
Otherwise queries the server for current status.
"""
self._check_client_connected()
if self._is_immediate:
# Return synthetic completed status. SDK v2 types the task
# timestamps as ISO 8601 strings.
now = datetime.now(timezone.utc).isoformat()
return GetTaskResult(
task_id=self._task_id,
status="completed",
created_at=now,
last_updated_at=now,
ttl=None,
poll_interval=1000,
)
# Return cached status if available (from notification)
if self._status_cache is not None:
cached = self._status_cache
# Don't clear cache - keep it for next call
return cached
# Query server and cache the result
self._status_cache = await self._client.get_task_status(self._task_id)
return self._status_cache
@abc.abstractmethod
async def result(self) -> TaskResultT:
"""Wait for and return the task result.
Must be implemented by subclasses to return the appropriate result type.
"""
...
async def wait(
self, *, state: str | None = None, timeout: float = 300.0
) -> GetTaskResult:
"""Wait for task to reach a specific state or complete.
Uses event-based waiting when notifications are available (fast),
with fallback to polling (reliable). Optimally wakes up immediately
on status changes when server sends notifications/tasks/status.
The fallback poll cadence has two modes. If the server advertises a
`pollInterval`, that interval is honored exactly (subject only to a
20ms safety floor), because it is a deliberate statement about how
much load the server wants to take. If it does not, the poll starts at
20ms and doubles up to the `client_task_poll_interval` setting.
Args:
state: Desired state ('working', 'input_required', 'completed', 'failed', 'cancelled').
If None, waits until the task exits the 'working' state (completed, failed, cancelled, input_required, etc.)
timeout: Maximum time to wait in seconds
Returns:
GetTaskResult: Final task status
Raises:
TimeoutError: If desired state not reached within timeout
"""
self._check_client_connected()
if self._is_immediate:
# Already done
return await self.status()
# Initialize event for notification wake-ups
if self._status_event is None:
self._status_event = asyncio.Event()
start = time.time()
in_progress_states = {"working"}
# Backoff state for the unadvertised-interval mode; resets per wait()
# call. Notifications still short-circuit the wait via the status event,
# so this only governs the fallback poll.
backoff = MIN_POLL_INTERVAL
while True:
# Check cached status first (updated by notifications)
if self._status_cache:
current = self._status_cache.status
if state is None:
if current not in in_progress_states:
return self._status_cache
elif current == state:
return self._status_cache
# Check timeout
elapsed = time.time() - start
if elapsed >= timeout:
raise TimeoutError(
f"Task {self._task_id} did not reach {state or 'terminal state'} within {timeout}s"
)
remaining = timeout - elapsed
interval, backoff = self._next_poll_delay(backoff)
# Wait for notification event OR poll timeout
try:
await asyncio.wait_for(
self._status_event.wait(), timeout=min(interval, remaining)
)
self._status_event.clear()
except asyncio.TimeoutError:
# Fallback: poll server (notification didn't arrive in time)
self._status_cache = await self._client.get_task_status(self._task_id)
def _next_poll_delay(self, backoff: float) -> tuple[float, float]:
"""Delay before the next fallback poll, plus the backoff for the round after.
Advertised interval -> honor it; no advertised interval -> ramp.
A server that advertises `pollInterval` (milliseconds) is making a
deliberate statement about how much load it wants to take, so that
interval is used verbatim as the delay with no backoff ramp. The only
adjustment is `MIN_POLL_INTERVAL` as a safety floor, so a server sending
a zero or negative interval cannot spin this client in a tight request
loop.
When the server advertises nothing, there is no guidance to honor, so
the poll starts at `MIN_POLL_INTERVAL` and doubles each round up to the
`client_task_poll_interval` setting.
"""
cache = self._status_cache
if cache is not None and cache.poll_interval is not None:
return max(cache.poll_interval / 1000, MIN_POLL_INTERVAL), backoff
ceiling = fastmcp.settings.client_task_poll_interval
return min(backoff, ceiling), min(backoff * 2, ceiling)
async def _wait_terminal(self, timeout: float = 300.0) -> GetTaskResult:
"""Wait until task reaches a terminal state (completed, failed, cancelled).
Unlike wait(), this will not return on input_required it continues
waiting until the task fully resolves. Used internally by result().
"""
terminal_states = {"completed", "failed", "cancelled"}
status = await self.wait(timeout=timeout)
while status.status not in terminal_states:
# Task is in a non-terminal state (e.g. input_required) — reset
# cache so the next wait() call blocks instead of returning immediately.
self._status_cache = None
status = await self.wait(timeout=timeout)
return status
async def cancel(self) -> None:
"""Cancel this task, transitioning it to cancelled state.
Sends a tasks/cancel protocol request. The server will attempt to halt
execution and move the task to cancelled state.
Note: If server executed immediately (graceful degradation), this is a no-op
as there's no server-side task to cancel.
"""
if self._is_immediate:
# No server-side task to cancel
return
self._check_client_connected()
await self._client.cancel_task(self._task_id)
# Invalidate cache to force fresh status fetch
self._status_cache = None
def __await__(self):
"""Allow 'await task' to get result."""
return self.result().__await__()
class ToolTask(Task["CallToolResult"]):
"""
Represents a tool call that may execute in background or immediately.
Provides a uniform API whether the server accepts background execution
or executes synchronously (graceful degradation per SEP-1686).
Usage:
task = await client.call_tool_as_task("analyze", args)
# Check status
status = await task.status()
# Wait for completion
await task.wait()
# Get result (waits if needed)
result = await task.result() # Returns CallToolResult
# Or just await the task directly
result = await task
"""
def __init__(
self,
client: Client,
task_id: str,
tool_name: str,
immediate_result: CallToolResult | None = None,
raise_on_error: bool = True,
):
"""
Create a ToolTask wrapper.
Args:
client: The FastMCP client
task_id: The task identifier
tool_name: Name of the tool being executed
immediate_result: If server executed synchronously, the immediate result
raise_on_error: Whether task.result() should raise ToolError on errors
"""
super().__init__(client, task_id, immediate_result)
self._tool_name = tool_name
self._raise_on_error = raise_on_error
async def result(self) -> CallToolResult:
"""Wait for and return the tool result.
If server executed immediately, returns the immediate result.
Otherwise waits for background task to complete and retrieves result.
Returns:
CallToolResult: The parsed tool result (same as call_tool returns)
"""
# Check cache first
if self._cached_result is not None:
return self._cached_result
if self._is_immediate:
assert self._immediate_result is not None # Type narrowing
result = self._immediate_result
if result.is_error and self._raise_on_error:
if result.content and isinstance(
result.content[0], mcp_types.TextContent
):
msg = result.content[0].text
else:
msg = f"Tool '{self._tool_name}' returned an error"
raise ToolError(msg)
else:
# Check client connected
self._check_client_connected()
# Wait for completion using event-based wait (respects notifications)
await self._wait_terminal()
# Get the raw result (dict or CallToolResult)
raw_result = await self._client.get_task_result(self._task_id)
# Convert to CallToolResult if needed and parse
if isinstance(raw_result, dict):
# Raw dict from get_task_result - parse as CallToolResult
mcp_result = mcp_types.CallToolResult.model_validate(raw_result)
result = await self._client._parse_call_tool_result(
self._tool_name,
mcp_result,
raise_on_error=self._raise_on_error,
)
elif isinstance(raw_result, mcp_types.CallToolResult):
# Already a CallToolResult from MCP protocol - parse it
result = await self._client._parse_call_tool_result(
self._tool_name,
raw_result,
raise_on_error=self._raise_on_error,
)
else:
# Legacy ToolResult format - convert to MCP type
if hasattr(raw_result, "content") and hasattr(
raw_result, "structured_content"
):
mcp_result = mcp_types.CallToolResult(
content=raw_result.content,
structured_content=raw_result.structured_content,
_meta=raw_result.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field
)
result = await self._client._parse_call_tool_result(
self._tool_name,
mcp_result,
raise_on_error=self._raise_on_error,
)
else:
# Unknown type - just return it
result = raw_result
# Cache before returning
self._cached_result = result
return result
class PromptTask(Task[mcp_types.GetPromptResult]):
"""
Represents a prompt call that may execute in background or immediately.
Provides a uniform API whether the server accepts background execution
or executes synchronously (graceful degradation per SEP-1686).
Usage:
task = await client.get_prompt_as_task("analyze", args)
result = await task # Returns GetPromptResult
"""
def __init__(
self,
client: Client,
task_id: str,
prompt_name: str,
immediate_result: mcp_types.GetPromptResult | None = None,
):
"""
Create a PromptTask wrapper.
Args:
client: The FastMCP client
task_id: The task identifier
prompt_name: Name of the prompt being executed
immediate_result: If server executed synchronously, the immediate result
"""
super().__init__(client, task_id, immediate_result)
self._prompt_name = prompt_name
async def result(self) -> mcp_types.GetPromptResult:
"""Wait for and return the prompt result.
If server executed immediately, returns the immediate result.
Otherwise waits for background task to complete and retrieves result.
Returns:
GetPromptResult: The prompt result with messages and description
"""
# Check cache first
if self._cached_result is not None:
return self._cached_result
if self._is_immediate:
assert self._immediate_result is not None
result = self._immediate_result
else:
# Check client connected
self._check_client_connected()
# Wait for completion using event-based wait (respects notifications)
await self._wait_terminal()
# Get the raw MCP result
mcp_result = await self._client.get_task_result(self._task_id)
# Parse as GetPromptResult
result = mcp_types.GetPromptResult.model_validate(mcp_result)
# Cache before returning
self._cached_result = result
return result
class ResourceTask(
Task[list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]]
):
"""
Represents a resource read that may execute in background or immediately.
Provides a uniform API whether the server accepts background execution
or executes synchronously (graceful degradation per SEP-1686).
Usage:
task = await client.read_resource_as_task("file://data.txt")
contents = await task # Returns list[ReadResourceContents]
"""
def __init__(
self,
client: Client,
task_id: str,
uri: str,
immediate_result: list[
mcp_types.TextResourceContents | mcp_types.BlobResourceContents
]
| None = None,
):
"""
Create a ResourceTask wrapper.
Args:
client: The FastMCP client
task_id: The task identifier
uri: URI of the resource being read
immediate_result: If server executed synchronously, the immediate result
"""
super().__init__(client, task_id, immediate_result)
self._uri = uri
async def result(
self,
) -> list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]:
"""Wait for and return the resource contents.
If server executed immediately, returns the immediate result.
Otherwise waits for background task to complete and retrieves result.
Returns:
list[ReadResourceContents]: The resource contents
"""
# Check cache first
if self._cached_result is not None:
return self._cached_result
if self._is_immediate:
assert self._immediate_result is not None
result = self._immediate_result
else:
# Check client connected
self._check_client_connected()
# Wait for completion using event-based wait (respects notifications)
await self._wait_terminal()
# Get the raw MCP result
mcp_result = await self._client.get_task_result(self._task_id)
# Parse as ReadResourceResult or extract contents
if isinstance(mcp_result, mcp_types.ReadResourceResult):
# Already parsed by TasksResponse - extract contents
result = list(mcp_result.contents)
elif isinstance(mcp_result, dict) and "contents" in mcp_result:
# Dict format - parse each content item
parsed_contents = []
for item in mcp_result["contents"]:
if isinstance(item, dict):
if "blob" in item:
parsed_contents.append(
mcp_types.BlobResourceContents.model_validate(item)
)
else:
parsed_contents.append(
mcp_types.TextResourceContents.model_validate(item)
)
else:
parsed_contents.append(item)
result = parsed_contents
else:
# Fallback - might be the list directly
result = mcp_result if isinstance(mcp_result, list) else [mcp_result]
# Cache before returning
self._cached_result = result
return result

View file

@ -8,8 +8,8 @@ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
if TYPE_CHECKING:
from fastmcp.prompts.function_prompt import PromptMeta
from fastmcp.resources.function_resource import ResourceMeta
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.function_tool import ToolMeta
from fastmcp.utilities.tasks import TaskConfig
FastMCPMeta = ToolMeta | ResourceMeta | PromptMeta

View file

@ -4,20 +4,21 @@ This module re-exports dependency injection symbols to provide a clean,
centralized import location for all dependency-related functionality.
DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket
using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket,
CurrentWorker) and background task execution require fastmcp[tasks].
using the uncalled-for DI engine. The docket-specific dependencies
(``CurrentDocket``, ``CurrentWorker``) live in the ``fastmcp-tasks`` package
(``fastmcp_tasks.dependencies``).
"""
from typing import Any
from uncalled_for import Dependency, Depends, Shared
from fastmcp.server.dependencies import (
CurrentAccessToken,
CurrentContext,
CurrentDocket,
CurrentFastMCP,
CurrentHeaders,
CurrentRequest,
CurrentWorker,
Progress,
ProgressLike,
TokenClaim,
@ -26,11 +27,9 @@ from fastmcp.server.dependencies import (
__all__ = [
"CurrentAccessToken",
"CurrentContext",
"CurrentDocket",
"CurrentFastMCP",
"CurrentHeaders",
"CurrentRequest",
"CurrentWorker",
"Dependency",
"Depends",
"Progress",
@ -38,3 +37,17 @@ __all__ = [
"Shared",
"TokenClaim",
]
# Docket-specific dependencies moved to the fastmcp-tasks package. Point users
# there instead of raising a bare AttributeError.
_MOVED_TO_TASKS = {"CurrentDocket", "CurrentWorker"}
def __getattr__(name: str) -> Any:
if name in _MOVED_TO_TASKS:
raise ImportError(
f"{name!r} moved to the fastmcp-tasks package. Install it with "
f"`pip install 'fastmcp[tasks]'` and import from "
f"`fastmcp_tasks.dependencies`."
)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View file

@ -3,17 +3,13 @@
from __future__ import annotations as _annotations
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload
from typing import TYPE_CHECKING, Any, ClassVar, Literal
import pydantic
import pydantic_core
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
from fastmcp.prompts.function_prompt import FunctionPrompt
import mcp_types
from mcp import GetPromptResult
from mcp_types import (
AudioContent,
@ -31,7 +27,6 @@ from pydantic.json_schema import SkipJsonSchema
from fastmcp.utilities.authorization import AuthCheck
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.tasks import TaskConfig, TaskMeta
from fastmcp.utilities.types import (
FastMCPBaseModel,
)
@ -242,7 +237,6 @@ class Prompt(FastMCPComponent):
icons: list[Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionPrompt:
"""Create a Prompt from a function.
@ -263,7 +257,6 @@ class Prompt(FastMCPComponent):
icons=icons,
tags=tags,
meta=meta,
task=task,
auth=auth,
)
@ -316,89 +309,19 @@ class Prompt(FastMCPComponent):
f"got {type(raw_value).__name__}"
)
@overload
async def _render(
self,
arguments: dict[str, Any] | None = None,
task_meta: None = None,
) -> PromptResult: ...
) -> PromptResult:
"""Server entry point for prompt renders.
@overload
async def _render(
self,
arguments: dict[str, Any] | None,
task_meta: TaskMeta,
) -> mcp_types.CreateTaskResult: ...
async def _render(
self,
arguments: dict[str, Any] | None = None,
task_meta: TaskMeta | None = None,
) -> PromptResult | mcp_types.CreateTaskResult:
"""Server entry point that handles task routing.
This allows ANY Prompt subclass to support background execution by setting
task_config.mode to "supported" or "required". The server calls this
method instead of render() directly.
Args:
arguments: Prompt arguments
task_meta: If provided, execute as background task and return
CreateTaskResult. If None (default), execute synchronously and
return PromptResult.
Returns:
PromptResult when task_meta is None.
CreateTaskResult when task_meta is provided.
Subclasses can override this to customize task routing behavior.
For example, FastMCPProviderPrompt overrides to delegate to child
middleware without submitting to Docket.
The server calls this method instead of render() directly so that
subclasses can customize dispatch. For example, FastMCPProviderPrompt
overrides this to delegate to child-server middleware.
"""
from fastmcp.server.tasks.routing import check_background_task
task_result = await check_background_task(
component=self,
task_type="prompt",
arguments=arguments,
task_meta=task_meta,
)
if task_result:
return task_result
# Synchronous execution
result = await self.render(arguments)
return self.convert_result(result)
def register_with_docket(self, docket: Docket) -> None:
"""Register this prompt with docket for background execution."""
if not self.task_config.supports_tasks():
return
docket.register(self.render, names=[self.key])
async def add_to_docket( # type: ignore[override]
self,
docket: Docket,
arguments: dict[str, Any] | None,
*,
fn_key: str | None = None,
task_key: str | None = None,
**kwargs: Any,
) -> Execution:
"""Schedule this prompt for background execution via docket.
Args:
docket: The Docket instance
arguments: Prompt arguments
fn_key: Function lookup key in Docket registry (defaults to self.key)
task_key: Redis storage key for the result
**kwargs: Additional kwargs passed to docket.add()
"""
lookup_key = fn_key or self.key
if task_key:
kwargs["key"] = task_key
return await docket.add(lookup_key, **kwargs)(arguments)
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.component.type": "prompt",

View file

@ -9,7 +9,6 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from types import MethodType
from typing import (
TYPE_CHECKING,
Any,
Literal,
Protocol,
@ -33,13 +32,8 @@ from fastmcp.utilities.authorization import AuthCheck
from fastmcp.utilities.docstring_parsing import ParsedDocstring, parse_docstring
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.tasks import TaskConfig
from fastmcp.utilities.types import get_cached_typeadapter
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
F = TypeVar("F", bound=Callable[..., Any])
logger = get_logger(__name__)
@ -66,7 +60,6 @@ class PromptMeta:
icons: list[Icon] | None = None
tags: set[str] | None = None
meta: dict[str, Any] | None = None
task: bool | TaskConfig | None = None
auth: AuthCheck | list[AuthCheck] | None = None
enabled: bool = True
@ -90,7 +83,6 @@ class FunctionPrompt(Prompt):
icons: list[Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionPrompt:
"""Create a Prompt from a function.
@ -110,7 +102,7 @@ class FunctionPrompt(Prompt):
# Check mutual exclusion
individual_params_provided = any(
x is not None
for x in [name, version, title, description, icons, tags, meta, task, auth]
for x in [name, version, title, description, icons, tags, meta, auth]
)
if metadata is not None and individual_params_provided:
@ -129,7 +121,6 @@ class FunctionPrompt(Prompt):
icons=icons,
tags=tags,
meta=meta,
task=task,
auth=auth,
)
@ -152,16 +143,6 @@ class FunctionPrompt(Prompt):
# docstring as the prompt description for callable class instances.
outer_docstring = parse_docstring(fn)
# Normalize task to TaskConfig and validate
task_value = metadata.task
if task_value is None:
task_config = TaskConfig(mode="forbidden")
elif isinstance(task_value, bool):
task_config = TaskConfig.from_bool(task_value)
else:
task_config = task_value
task_config.validate_function(fn, func_name)
# if the fn is a callable class, we need to get the __call__ method from here out
if not inspect.isroutine(fn) and not isinstance(fn, functools.partial):
fn = fn.__call__
@ -267,7 +248,6 @@ class FunctionPrompt(Prompt):
tags=metadata.tags or set(),
fn=wrapped_fn,
meta=metadata.meta,
task_config=task_config,
auth=metadata.auth,
)
@ -367,37 +347,6 @@ class FunctionPrompt(Prompt):
logger.exception(f"Error rendering prompt {self.name}")
raise PromptError(f"Error rendering prompt {self.name!r}: {e}") from e
def register_with_docket(self, docket: Docket) -> None:
"""Register this prompt with docket for background execution."""
if not self.task_config.supports_tasks():
return
docket.register(self.fn, names=[self.key])
async def add_to_docket(
self,
docket: Docket,
arguments: dict[str, Any] | None,
*,
fn_key: str | None = None,
task_key: str | None = None,
**kwargs: Any,
) -> Execution:
"""Schedule this prompt for background execution via docket.
FunctionPrompt splats the arguments dict since .fn expects **kwargs.
Args:
docket: The Docket instance
arguments: Prompt arguments
fn_key: Function lookup key in Docket registry (defaults to self.key)
task_key: Redis storage key for the result
**kwargs: Additional kwargs passed to docket.add()
"""
lookup_key = fn_key or self.key
if task_key:
kwargs["key"] = task_key
return await docket.add(lookup_key, **kwargs)(**(arguments or {}))
@overload
def prompt(fn: F) -> F: ...
@ -411,7 +360,6 @@ def prompt(
icons: list[Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[F], F]: ...
@overload
@ -425,7 +373,6 @@ def prompt(
icons: list[Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[F], F]: ...
@ -440,7 +387,6 @@ def prompt(
icons: list[Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Any:
"""Standalone decorator to mark a function as an MCP prompt.
@ -463,7 +409,6 @@ def prompt(
icons=icons,
tags=tags,
meta=meta,
task=task,
auth=auth,
)
target = fn.__func__ if isinstance(fn, staticmethod | MethodType) else fn

View file

@ -5,14 +5,11 @@ from __future__ import annotations
import base64
import json
from collections.abc import Callable
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, overload
from typing import TYPE_CHECKING, Annotated, Any, ClassVar
import mcp_types
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
from fastmcp.resources.function_resource import FunctionResource
import pydantic
@ -32,7 +29,6 @@ from typing_extensions import Self
from fastmcp.utilities.authorization import AuthCheck
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.tasks import TaskConfig, TaskMeta
class ResourceContent(pydantic.BaseModel):
@ -339,7 +335,6 @@ class Resource(FastMCPComponent):
tags: set[str] | None = None,
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionResource:
from fastmcp.resources.function_resource import (
@ -358,7 +353,6 @@ class Resource(FastMCPComponent):
tags=tags,
annotations=annotations,
meta=meta,
task=task,
auth=auth,
)
@ -414,43 +408,14 @@ class Resource(FastMCPComponent):
raw_value, mime_type=self.mime_type, meta=self.meta
)
@overload
async def _read(self, task_meta: None = None) -> ResourceResult: ...
async def _read(self) -> ResourceResult:
"""Server entry point for resource reads.
@overload
async def _read(self, task_meta: TaskMeta) -> mcp_types.CreateTaskResult: ...
async def _read(
self, task_meta: TaskMeta | None = None
) -> ResourceResult | mcp_types.CreateTaskResult:
"""Server entry point that handles task routing.
This allows ANY Resource subclass to support background execution by setting
task_config.mode to "supported" or "required". The server calls this
method instead of read() directly.
Args:
task_meta: If provided, execute as a background task and return
CreateTaskResult. If None (default), execute synchronously and
return ResourceResult.
Returns:
ResourceResult when task_meta is None.
CreateTaskResult when task_meta is provided.
Subclasses can override this to customize task routing behavior.
For example, FastMCPProviderResource overrides to delegate to child
middleware without submitting to Docket.
The server calls this method instead of ``read()`` directly so that
subclasses can customize dispatch. For example,
``FastMCPProviderResource`` overrides this to delegate to child-server
middleware.
"""
from fastmcp.server.tasks.routing import check_background_task
task_result = await check_background_task(
component=self, task_type="resource", arguments=None, task_meta=task_meta
)
if task_result:
return task_result
# Synchronous execution - convert result to ResourceResult
result = await self.read()
return self.convert_result(result)
@ -482,33 +447,6 @@ class Resource(FastMCPComponent):
base_key = self.make_key(str(self.uri))
return f"{base_key}@{self.version or ''}"
def register_with_docket(self, docket: Docket) -> None:
"""Register this resource with docket for background execution."""
if not self.task_config.supports_tasks():
return
docket.register(self.read, names=[self.key])
async def add_to_docket( # type: ignore[override]
self,
docket: Docket,
*,
fn_key: str | None = None,
task_key: str | None = None,
**kwargs: Any,
) -> Execution:
"""Schedule this resource for background execution via docket.
Args:
docket: The Docket instance
fn_key: Function lookup key in Docket registry (defaults to self.key)
task_key: Redis storage key for the result
**kwargs: Additional kwargs passed to docket.add()
"""
lookup_key = fn_key or self.key
if task_key:
kwargs["key"] = task_key
return await docket.add(lookup_key, **kwargs)()
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.component.type": "resource",

View file

@ -8,7 +8,6 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from types import MethodType
from typing import (
TYPE_CHECKING,
Any,
Literal,
Protocol,
@ -33,11 +32,6 @@ from fastmcp.utilities.async_utils import (
)
from fastmcp.utilities.authorization import AuthCheck
from fastmcp.utilities.mime import resolve_ui_mime_type
from fastmcp.utilities.tasks import TaskConfig
if TYPE_CHECKING:
from docket import Docket
F = TypeVar("F", bound=Callable[..., Any])
@ -66,7 +60,6 @@ class ResourceMeta:
mime_type: str | None = None
annotations: Annotations | None = None
meta: dict[str, Any] | None = None
task: bool | TaskConfig | None = None
auth: AuthCheck | list[AuthCheck] | None = None
enabled: bool = True
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY
@ -104,7 +97,6 @@ class FunctionResource(Resource):
tags: set[str] | None = None,
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionResource:
"""Create a FunctionResource from a function.
@ -131,7 +123,6 @@ class FunctionResource(Resource):
tags,
annotations,
meta,
task,
auth,
]
)
@ -159,7 +150,6 @@ class FunctionResource(Resource):
mime_type=mime_type,
annotations=annotations,
meta=meta,
task=task,
auth=auth,
)
@ -170,16 +160,6 @@ class FunctionResource(Resource):
metadata.name or getattr(fn, "__name__", None) or fn.__class__.__name__
)
# Normalize task to TaskConfig and validate
task_value = metadata.task
if task_value is None:
task_config = TaskConfig(mode="forbidden")
elif isinstance(task_value, bool):
task_config = TaskConfig.from_bool(task_value)
else:
task_config = task_value
task_config.validate_function(fn, func_name)
# if the fn is a callable class, we need to get the __call__ method from here out
if not inspect.isroutine(fn) and not isinstance(fn, functools.partial):
fn = fn.__call__
@ -215,7 +195,6 @@ class FunctionResource(Resource):
tags=metadata.tags or set(),
annotations=metadata.annotations,
meta=metadata.meta,
task_config=task_config,
auth=metadata.auth,
)
@ -240,12 +219,6 @@ class FunctionResource(Resource):
return result
def register_with_docket(self, docket: Docket) -> None:
"""Register this resource with docket for background execution."""
if not self.task_config.supports_tasks():
return
docket.register(self.fn, names=[self.key])
def resource(
uri: str,
@ -259,7 +232,6 @@ def resource(
tags: set[str] | None = None,
annotations: Annotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
) -> Callable[[F], F]:
@ -289,7 +261,6 @@ def resource(
mime_type=mime_type,
annotations=annotations,
meta=meta,
task=task,
auth=auth,
security=security,
)

View file

@ -6,22 +6,17 @@ import functools
import inspect
import re
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, ClassVar, overload
from typing import Any, ClassVar
from urllib.parse import parse_qs, quote, unquote
import mcp_types
from mcp_types import Annotations, Icon
from pydantic.json_schema import SkipJsonSchema
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
from mcp_types import ResourceTemplate as SDKResourceTemplate
from pydantic import (
Field,
field_validator,
validate_call,
)
from pydantic.json_schema import SkipJsonSchema
from fastmcp.resources.base import (
Resource,
@ -37,7 +32,6 @@ from fastmcp.utilities.authorization import AuthCheck
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.mime import resolve_ui_mime_type
from fastmcp.utilities.tasks import TaskConfig, TaskMeta
from fastmcp.utilities.types import get_cached_typeadapter
@ -235,7 +229,6 @@ class ResourceTemplate(FastMCPComponent):
tags: set[str] | None = None,
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
) -> FunctionResourceTemplate:
@ -251,7 +244,6 @@ class ResourceTemplate(FastMCPComponent):
tags=tags,
annotations=annotations,
meta=meta,
task=task,
auth=auth,
security=security,
)
@ -290,50 +282,13 @@ class ResourceTemplate(FastMCPComponent):
raw_value, mime_type=self.mime_type, meta=self.meta
)
@overload
async def _read(
self, uri: str, params: dict[str, Any], task_meta: None = None
) -> ResourceResult: ...
async def _read(self, uri: str, params: dict[str, Any]) -> ResourceResult:
"""Server entry point for template reads.
@overload
async def _read(
self, uri: str, params: dict[str, Any], task_meta: TaskMeta
) -> mcp_types.CreateTaskResult: ...
async def _read(
self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None
) -> ResourceResult | mcp_types.CreateTaskResult:
"""Server entry point that handles task routing.
This allows ANY ResourceTemplate subclass to support background execution
by setting task_config.mode to "supported" or "required". The server calls
this method instead of create_resource()/read() directly.
Args:
uri: The concrete URI being read
params: Template parameters extracted from the URI
task_meta: If provided, execute as a background task and return
CreateTaskResult. If None (default), execute synchronously and
return ResourceResult.
Returns:
ResourceResult when task_meta is None.
CreateTaskResult when task_meta is provided.
Subclasses can override this to customize task routing behavior.
For example, FastMCPProviderResourceTemplate overrides to delegate to child
middleware without submitting to Docket.
The server calls this instead of create_resource()/read() directly so
subclasses can customize dispatch (e.g. FastMCPProviderResourceTemplate
delegates to child-server middleware).
"""
from fastmcp.server.tasks.routing import check_background_task
task_result = await check_background_task(
component=self, task_type="template", arguments=params, task_meta=task_meta
)
if task_result:
return task_result
# Synchronous execution - create resource and read directly
# Call resource.read() not resource._read() to avoid task routing on ephemeral resource
resource = await self.create_resource(uri, params)
result = await resource.read()
return self.convert_result(result)
@ -387,35 +342,6 @@ class ResourceTemplate(FastMCPComponent):
base_key = self.make_key(self.uri_template)
return f"{base_key}@{self.version or ''}"
def register_with_docket(self, docket: Docket) -> None:
"""Register this template with docket for background execution."""
if not self.task_config.supports_tasks():
return
docket.register(self.read, names=[self.key])
async def add_to_docket( # type: ignore[override]
self,
docket: Docket,
params: dict[str, Any],
*,
fn_key: str | None = None,
task_key: str | None = None,
**kwargs: Any,
) -> Execution:
"""Schedule this template for background execution via docket.
Args:
docket: The Docket instance
params: Template parameters
fn_key: Function lookup key in Docket registry (defaults to self.key)
task_key: Redis storage key for the result
**kwargs: Additional kwargs passed to docket.add()
"""
lookup_key = fn_key or self.key
if task_key:
kwargs["key"] = task_key
return await docket.add(lookup_key, **kwargs)(params)
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.component.type": "resource_template",
@ -428,44 +354,13 @@ class FunctionResourceTemplate(ResourceTemplate):
fn: SkipJsonSchema[Callable[..., Any]]
@overload
async def _read(
self, uri: str, params: dict[str, Any], task_meta: None = None
) -> ResourceResult: ...
@overload
async def _read(
self, uri: str, params: dict[str, Any], task_meta: TaskMeta
) -> mcp_types.CreateTaskResult: ...
async def _read(
self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None
) -> ResourceResult | mcp_types.CreateTaskResult:
async def _read(self, uri: str, params: dict[str, Any]) -> ResourceResult:
"""Optimized server entry point that skips ephemeral resource creation.
For FunctionResourceTemplate, we can call read() directly instead of
creating a temporary resource, which is more efficient.
Args:
uri: The concrete URI being read
params: Template parameters extracted from the URI
task_meta: If provided, execute as a background task and return
CreateTaskResult. If None (default), execute synchronously and
return ResourceResult.
Returns:
ResourceResult when task_meta is None.
CreateTaskResult when task_meta is provided.
"""
from fastmcp.server.tasks.routing import check_background_task
task_result = await check_background_task(
component=self, task_type="template", arguments=params, task_meta=task_meta
)
if task_result:
return task_result
# Synchronous execution - call read() directly, skip resource creation
# Call read() directly, skip resource creation
result = await self.read(arguments=params)
return self.convert_result(result)
@ -488,7 +383,6 @@ class FunctionResourceTemplate(ResourceTemplate):
meta=self.meta,
title=self.title,
icons=self.icons,
task=self.task_config,
auth=self.auth,
)
@ -531,37 +425,6 @@ class FunctionResourceTemplate(ResourceTemplate):
return result
def register_with_docket(self, docket: Docket) -> None:
"""Register this template with docket for background execution."""
if not self.task_config.supports_tasks():
return
docket.register(self.fn, names=[self.key])
async def add_to_docket(
self,
docket: Docket,
params: dict[str, Any],
*,
fn_key: str | None = None,
task_key: str | None = None,
**kwargs: Any,
) -> Execution:
"""Schedule this template for background execution via docket.
FunctionResourceTemplate splats the params dict since .fn expects **kwargs.
Args:
docket: The Docket instance
params: Template parameters
fn_key: Function lookup key in Docket registry (defaults to self.key)
task_key: Redis storage key for the result
**kwargs: Additional kwargs passed to docket.add()
"""
lookup_key = fn_key or self.key
if task_key:
kwargs["key"] = task_key
return await docket.add(lookup_key, **kwargs)(**params)
@classmethod
def from_function(
cls,
@ -576,7 +439,6 @@ class FunctionResourceTemplate(ResourceTemplate):
tags: set[str] | None = None,
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
) -> FunctionResourceTemplate:
@ -673,15 +535,6 @@ class FunctionResourceTemplate(ResourceTemplate):
description = description if description is not None else inspect.getdoc(fn)
# Normalize task to TaskConfig and validate
if task is None:
task_config = TaskConfig(mode="forbidden")
elif isinstance(task, bool):
task_config = TaskConfig.from_bool(task)
else:
task_config = task
task_config.validate_function(fn, func_name)
# if the fn is a callable class, we need to get the __call__ method from here out
if not inspect.isroutine(fn) and not isinstance(fn, functools.partial):
fn = fn.__call__
@ -716,7 +569,6 @@ class FunctionResourceTemplate(ResourceTemplate):
tags=tags or set(),
annotations=annotations,
meta=meta,
task_config=task_config,
auth=auth,
security=security,
)

View file

@ -128,6 +128,20 @@ def _warn_sampling_deprecated() -> None:
_current_context: ContextVar[Context | None] = ContextVar("context", default=None)
#: Error raised when a tool calls ``ctx.elicit()`` inside a background task.
#: Background tasks gather input with the guard/return pattern (return an
#: ``InputRequiredResult``), which the end-and-reenter machinery drives across
#: worker legs. Imperative elicitation would require blocking a worker on a
#: client round-trip, which end-and-reenter deliberately does not do.
_TASK_ELICIT_ERROR = (
"Imperative ctx.elicit() is not supported inside a background task. Gather "
"input with the guard pattern instead: return an InputRequiredResult from "
"the tool (with input_requests), and read ctx.input_responses / "
"ctx.request_state when the task re-runs after the client answers."
)
TransportType = Literal["stdio", "sse", "streamable-http"]
_current_transport: ContextVar[TransportType | None] = ContextVar(
"transport", default=None
@ -251,6 +265,14 @@ class Context:
self._origin_request_id: str | None = origin_request_id
# Request-scoped state for non-serializable values (serializable=False)
self._request_state: dict[str, Any] = {}
# Multi-round-trip input carried in-task (SEP-2322 guard channel). A
# foreground round recovers `input_responses`/`request_state` from the
# wire request; a worker has no wire request, so the tasks extension's
# in-task loop sets these between rounds and the properties below fall
# back to them. The guard tool reads `ctx.input_responses` identically
# in both modes — only the transport differs (task store vs wire params).
self._task_input_responses: mcp_types.InputResponses | None = None
self._task_request_state: str | None = None
@property
def is_background_task(self) -> bool:
@ -312,26 +334,10 @@ class Context:
self._tokens.append(token)
# Set current server for dependency injection (use weakref to avoid reference cycles)
from fastmcp.server.dependencies import (
_current_docket,
_current_server,
_current_worker,
is_docket_available,
)
from fastmcp.server.dependencies import _current_server, is_docket_available
self._server_token = _current_server.set(weakref.ref(self.fastmcp))
# Re-set docket/worker from the server instance so mounted children
# inherit the parent's Docket via the ContextVar. Only servers that
# own the Docket (the parent) have _docket set; children skip this,
# leaving the parent's value in place.
if is_docket_available():
server = self.fastmcp
if server._docket is not None:
self._docket_token = _current_docket.set(server._docket)
if server._worker is not None:
self._worker_token = _current_worker.set(server._worker)
if not is_docket_available():
# Without docket, the lifespan won't provide a SharedContext,
# so create one scoped to this Context for Shared() dependencies.
@ -342,18 +348,8 @@ class Context:
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
"""Exit the context manager and reset the most recent token."""
from fastmcp.server.dependencies import (
_current_docket,
_current_server,
_current_worker,
)
from fastmcp.server.dependencies import _current_server
if hasattr(self, "_worker_token"):
_current_worker.reset(self._worker_token)
del self._worker_token
if hasattr(self, "_docket_token"):
_current_docket.reset(self._docket_token)
del self._docket_token
if hasattr(self, "_shared_context"):
await self._shared_context.__aexit__(exc_type, exc_val, exc_tb)
del self._shared_context
@ -393,6 +389,25 @@ class Context:
"""
return fastmcp_request_ctx.get()
def client_extension_settings(self, identifier: str) -> dict[str, Any] | None:
"""This request's per-request opt-in settings for an MCP extension.
SEP-2133 extensions negotiate per request: the client repeats its
extension capabilities in each request's ``_meta`` under
``io.modelcontextprotocol/clientCapabilities`` ``extensions``
``identifier``. Returns the declared settings dict (possibly empty) when
the extension was opted in for this request, or ``None`` when it was
not (or there is no active request). This bridges an extension's
``tools/call`` interceptor which receives a FastMCP ``Context`` to
the request's declared client capabilities.
"""
rc = self.request_context
if rc is None:
return None
from fastmcp.server.extensions import _extract_client_extension_settings
return _extract_client_extension_settings(rc.meta, identifier)
def _input_response_params(
self,
) -> mcp_types.InputResponseRequestParams | None:
@ -426,9 +441,14 @@ class Context:
keys match the `input_requests` map the tool minted; each value is the
client's result for that request (an `ElicitResult`, `CreateMessageResult`,
or `ListRootsResult`).
In a background task there is no wire request, so this falls back to the
responses the in-task guard loop delivered (see the tasks extension).
"""
params = self._input_response_params()
return params.input_responses if params else None
if params is not None and params.input_responses is not None:
return params.input_responses
return self._task_input_responses
@property
def request_state(self) -> str | None:
@ -440,9 +460,14 @@ class Context:
before the tool runs, so tampering is rejected before this is read).
`None` on the initial round. Use it to carry a small amount of computed
state across rounds without re-deriving it.
In a background task there is no wire request, so this falls back to the
state the in-task guard loop re-injected (see the tasks extension).
"""
params = self._input_response_params()
return params.request_state if params else None
if params is not None and params.request_state is not None:
return params.request_state
return self._task_request_state
@property
def lifespan_context(self) -> dict[str, Any]:
@ -768,6 +793,15 @@ class Context:
elif self._session is not None:
session = self._session
else:
# Background task: no live session, but the submitting request's
# stable session id was captured in the task snapshot. Use it so
# session-scoped state (session_id / get_state / set_state) keeps
# working in a worker, keyed to the same client that submitted.
from fastmcp.server.dependencies import _background_task_session_id
task_session_id = _background_task_session_id.get()
if task_session_id is not None:
return task_session_id
raise RuntimeError(
"session_id is not available because no session exists. "
"This typically means you're outside a request context."
@ -1334,9 +1368,10 @@ class Context:
``value`` field. Same scope rules as ``response_title``.
Note:
This method works transparently in both request and background task
contexts. In background task mode (SEP-1686), it will set the task
status to "input_required" and wait for the client to provide input.
Imperative elicitation is not available inside a background task
(calling it there raises a ``ToolError``). A task gathers input with
the guard pattern: return an ``InputRequiredResult`` and read
``ctx.input_responses`` / ``ctx.request_state`` when the task re-runs.
"""
if response_type is None and fastmcp.settings.deprecation_warnings:
warnings.warn(
@ -1356,24 +1391,22 @@ class Context:
)
if self.is_background_task:
# Background task mode: use task-aware elicitation
result = await self._elicit_for_task(
message=message,
schema=config.schema,
)
else:
# Foreground push path: server-initiated elicitation needs a
# back-channel, which the 2026-07-28 era removed (SEP-2577). Raise a
# clear era-aware error before hitting the wire instead of the SDK's
# opaque "Method not found". Handshake-era behavior is unchanged.
if self._is_modern_protocol():
raise ToolError(_ELICIT_MODERN_ERROR)
# Standard request mode: use session.elicit directly
result = await self.session.elicit(
message=message,
requested_schema=config.schema,
related_request_id=self.request_id,
)
# Background tasks gather input with the guard/return pattern, not
# imperative elicitation — the worker never blocks on a client
# round-trip. Fail fast with the guidance to use InputRequiredResult.
raise ToolError(_TASK_ELICIT_ERROR)
# Foreground push path: server-initiated elicitation needs a back-channel,
# which the 2026-07-28 era removed (SEP-2577). Raise a clear era-aware
# error before hitting the wire instead of the SDK's opaque "Method not
# found". Handshake-era behavior is unchanged.
if self._is_modern_protocol():
raise ToolError(_ELICIT_MODERN_ERROR)
# Standard request mode: use session.elicit directly
result = await self.session.elicit(
message=message,
requested_schema=config.schema,
related_request_id=self.request_id,
)
if result.action == "accept":
return handle_elicit_accept(config, result.content)
@ -1384,46 +1417,6 @@ class Context:
else:
raise ValueError(f"Unexpected elicitation action: {result.action}")
async def _elicit_for_task(
self,
message: str,
schema: dict[str, Any],
) -> mcp_types.ElicitResult:
"""Send an elicitation request from a background task (SEP-1686).
This method handles elicitation when running in a Docket worker context,
where there's no active MCP request. It:
1. Sets the task status to "input_required"
2. Sends the elicitation request with task metadata
3. Waits for the client to provide input via tasks/sendInput
4. Returns the result and resumes task execution
Args:
message: The message to display to the user
schema: The JSON schema for the expected response
Returns:
ElicitResult with the user's response
Raises:
RuntimeError: If not running in a background task context
"""
if not self.is_background_task:
raise RuntimeError(
"_elicit_for_task called but not in a background task context"
)
# Import here to avoid circular imports and optional dependency issues
from fastmcp.server.tasks.elicitation import elicit_for_task
return await elicit_for_task(
task_id=self._task_id, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
session=self._session,
message=message,
schema=schema,
fastmcp=self.fastmcp,
)
def _make_state_key(self, key: str) -> str:
"""Create session-prefixed key for state storage."""
return f"{self.session_id}:{key}"

View file

@ -1,8 +1,9 @@
"""Dependency injection for FastMCP.
DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket
using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket,
CurrentWorker) and background task execution require fastmcp[tasks].
using the uncalled-for DI engine. The docket-specific dependencies
(``CurrentDocket``, ``CurrentWorker``) and background task execution live in the
``fastmcp-tasks`` package.
"""
from __future__ import annotations
@ -10,11 +11,10 @@ from __future__ import annotations
import importlib.metadata
import inspect
import weakref
from collections.abc import AsyncGenerator, Callable, Generator, Mapping
from collections.abc import AsyncGenerator, Awaitable, Callable, Generator, Mapping
from contextlib import AsyncExitStack, asynccontextmanager, contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from datetime import datetime, timezone
from functools import lru_cache
from types import TracebackType
from typing import TYPE_CHECKING, Any, Protocol, cast, get_type_hints, runtime_checkable
@ -44,9 +44,6 @@ from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import find_kwarg_by_type, is_class_member_of_type
if TYPE_CHECKING:
from docket import Docket
from docket.worker import Worker
from fastmcp.server.context import Context
from fastmcp.server.server import FastMCP
from fastmcp.server.sessions import Session
@ -147,15 +144,11 @@ __all__ = [
"AccessToken",
"CurrentAccessToken",
"CurrentContext",
"CurrentDocket",
"CurrentFastMCP",
"CurrentHeaders",
"CurrentRequest",
"CurrentWorker",
"FastMCPRequestContext",
"Progress",
"TaskContextInfo",
"TaskContextSnapshot",
"TokenClaim",
"bind_request_context",
"extract_version_spec",
@ -166,37 +159,78 @@ __all__ = [
"get_http_request",
"get_server",
"get_session",
"get_task_context",
"get_task_session",
"is_docket_available",
"register_task_server",
"register_task_session",
"require_docket",
"resolve_dependencies",
"transform_context_annotations",
"without_injected_parameters",
]
# Task context lives in fastmcp.server.tasks.context; public symbols are
# re-exported here so existing imports from dependencies continue to work.
from fastmcp.server.tasks.context import ( # noqa: E402
TaskContextInfo,
TaskContextSnapshot,
_recall_snapshot,
get_task_context,
get_task_server,
get_task_session,
register_task_server,
register_task_session,
)
_current_server: ContextVar[weakref.ref[FastMCP] | None] = ContextVar(
"server", default=None
)
_current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None)
_current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None)
#: Hook installed by the tasks extension (``fastmcp-tasks``) so a ``ctx: Context``
#: parameter resolves inside a background-task worker, where there is no
#: foreground request context. Core ships no task engine; the extension
#: registers a factory here that builds and enters a worker ``Context`` (reading
#: the task snapshot restored by the worker). ``_CurrentContext`` falls back to
#: it when no foreground context is active. ``None`` means no tasks extension,
#: so worker context injection is unavailable and the usual "no active context"
#: error applies.
_background_context_factory: Callable[[], Awaitable[Context | None]] | None = None
def set_background_context_factory(
factory: Callable[[], Awaitable[Context | None]] | None,
) -> None:
"""Install (or clear) the background-task ``Context`` factory.
The factory returns an already-entered ``Context`` (so ``_current_context``
is set for cleanup) when called inside a worker, or ``None`` when there is
no task context. Passing ``None`` restores core's no-worker-fallback
behavior.
"""
global _background_context_factory
_background_context_factory = factory
#: Hook installed by the tasks extension so ``get_server()`` (and thus
#: ``CurrentFastMCP()``) resolves to the server a mounted task's tool lives on
#: rather than the root that started the worker (#3571). Returns that server
#: inside a worker, or ``None`` outside one. Core has no task engine, so this is
#: ``None`` unless the extension is active.
_worker_server_resolver: Callable[[], FastMCP | None] | None = None
def set_worker_server_resolver(
resolver: Callable[[], FastMCP | None] | None,
) -> None:
"""Install (or clear) the worker-server resolver used by ``get_server()``."""
global _worker_server_resolver
_worker_server_resolver = resolver
#: Headers a background task carries from its originating request. A worker has
#: no live HTTP request — especially a Redis-backed worker in a separate process
#: — so ``get_http_request()`` correctly raises there. The tasks extension sets
#: this from the task snapshot so ``get_http_headers()`` still returns the
#: submitting request's headers without fabricating a fake ``Request`` (which
#: would make ``get_http_request()``/``CurrentRequest()`` wrongly succeed).
_background_task_headers: ContextVar[dict[str, str] | None] = ContextVar(
"fastmcp_background_task_headers", default=None
)
#: The originating request's stable session id, carried into a background task.
#: A worker has no live session, so ``Context.session_id`` (and the session-scoped
#: ``get_state``/``set_state`` built on it) would otherwise raise. The tasks
#: extension sets this from the task snapshot so session-scoped state keyed by the
#: submitting client survives into the worker.
_background_task_session_id: ContextVar[str | None] = ContextVar(
"fastmcp_background_task_session_id", default=None
)
# --- Docket availability check ---
@ -236,43 +270,6 @@ def is_docket_available() -> bool:
return _DOCKET_AVAILABLE
def require_docket(feature: str) -> None:
"""Raise ImportError with install instructions if docket not available.
Args:
feature: Description of what requires docket (e.g., "`task=True`",
"CurrentDocket()"). Will be included in the error message.
"""
if is_docket_available():
return
try:
installed = importlib.metadata.version("pydocket")
except importlib.metadata.PackageNotFoundError:
installed = None
if installed is None:
detail = (
"FastMCP background tasks require the `tasks` extra. "
"Install with: pip install 'fastmcp[tasks]'."
)
else:
detail = (
f"FastMCP background tasks require pydocket>={_MIN_DOCKET_VERSION}, "
f"but pydocket {installed} is installed (likely pulled in by another "
f"package). Upgrade with: pip install -U 'pydocket>={_MIN_DOCKET_VERSION}'."
)
raise ImportError(f"{detail} (Triggered by {feature})")
# Import Progress separately — it's docket-specific, not part of uncalled-for
try:
from docket.dependencies import Progress as DocketProgress
except ImportError:
DocketProgress = None # type: ignore[assignment] # ty:ignore[invalid-assignment]
# --- Context utilities ---
@ -455,9 +452,9 @@ def get_context() -> Context:
def get_server() -> FastMCP:
"""Get the current FastMCP server instance directly.
In a background-task worker, checks the task-server map first so that
mounted-child tasks resolve to the child server (not the parent that
started the worker).
In a background-task worker the tasks extension's resolver is consulted
first, so a mounted-child task resolves to the child server rather than the
root that started the worker (#3571).
Returns:
The active FastMCP server
@ -465,13 +462,11 @@ def get_server() -> FastMCP:
Raises:
RuntimeError: If no server in context
"""
# In a task context, prefer the task-specific server mapping.
# This handles mounted-child tasks where _current_server is the parent.
task_info = get_task_context()
if task_info is not None:
task_server = get_task_server(task_info.task_id)
if task_server is not None:
return task_server
resolver = _worker_server_resolver
if resolver is not None:
worker_server = resolver()
if worker_server is not None:
return worker_server
server_ref = _current_server.get()
if server_ref is None:
@ -521,8 +516,6 @@ def get_http_request() -> Request:
"""Get the current HTTP request.
Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context.
In background tasks, returns a synthetic request populated with the
snapshotted headers from the originating HTTP request.
"""
# Try FastMCP's request context first (set during normal MCP request handling)
request = None
@ -535,33 +528,6 @@ def get_http_request() -> Request:
if request is None:
request = _current_http_request.get()
# In Docket workers, restore a minimal request from the snapshotted
# headers. The snapshot is preloaded by restore_task_snapshot before
# user code runs, so this is a pure ContextVar read.
if request is None:
task_info = get_task_context()
snapshot = _recall_snapshot(task_info.task_id) if task_info else None
task_headers = snapshot.http_headers if snapshot else None
if task_headers:
request = Request(
{
"type": "http",
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/",
"raw_path": b"/",
"query_string": b"",
"headers": [
(name.encode("latin-1"), value.encode("latin-1"))
for name, value in task_headers.items()
],
"client": None,
"server": None,
"root_path": "",
}
)
if request is None:
raise RuntimeError("No active HTTP request found.")
return request
@ -614,14 +580,21 @@ def get_http_headers(
headers: dict[str, str] = {}
try:
request = get_http_request()
for name, value in request.headers.items():
lower_name = name.lower()
if lower_name not in exclude_headers:
headers[lower_name] = str(value)
return headers
source: Any = get_http_request().headers.items()
except RuntimeError:
return {}
# No live request: inside a background-task worker, fall back to the
# headers the task carried from its originating request (set by the
# tasks extension from the snapshot). Empty elsewhere.
task_headers = _background_task_headers.get()
if task_headers is None:
return {}
source = task_headers.items()
for name, value in source:
lower_name = name.lower()
if lower_name not in exclude_headers:
headers[lower_name] = str(value)
return headers
def get_access_token() -> AccessToken | None:
@ -630,8 +603,7 @@ def get_access_token() -> AccessToken | None:
This function first tries to get the token from the current HTTP request's scope,
which is more reliable for long-lived connections where the SDK's auth_context_var
may become stale after token refresh. Falls back to the SDK's context var if no
request is available. In background tasks (Docket workers), falls back to the
token snapshot stored in Redis at task submission time.
request is available.
Returns:
The access token if an authenticated user is available, None otherwise.
@ -654,19 +626,6 @@ def get_access_token() -> AccessToken | None:
if access_token is None:
access_token = _sdk_get_access_token()
# Fall back to background task snapshot (#3095). In Docket workers,
# neither the HTTP request nor the SDK context var is available; the
# snapshot is preloaded by restore_task_snapshot before user code runs.
if access_token is None:
task_info = get_task_context()
snapshot = _recall_snapshot(task_info.task_id) if task_info else None
if snapshot is not None and snapshot.access_token_json is not None:
task_token = AccessToken.model_validate_json(snapshot.access_token_json)
if task_token.expires_at is not None:
if task_token.expires_at < int(datetime.now(timezone.utc).timestamp()):
return None
return task_token
if access_token is None or isinstance(access_token, AccessToken):
return access_token
@ -908,53 +867,35 @@ async def resolve_dependencies(
class _CurrentContext(Dependency["Context"]):
"""Async context manager for Context dependency.
In foreground (request) mode: returns the active context from _current_context.
In background (Docket worker) mode: creates a task-aware Context with task_id
and loads the unified task snapshot from Redis.
Returns the active context from _current_context (normal MCP request).
The shared default instance is a stateless factory. All per-invocation
state lives on the returned Context or in task-local ContextVars, so
concurrent tasks never share mutable state.
state lives on the returned Context, so concurrent calls never share
mutable state.
"""
async def __aenter__(self) -> Context:
from fastmcp.server.context import Context, _current_context
from fastmcp.server.context import _current_context
# Try foreground context first (normal MCP request)
context = _current_context.get()
if context is not None:
return context
# Check if we're in a Docket worker context
task_info = get_task_context()
if task_info is not None:
server = get_server()
# The snapshot is preloaded by restore_task_snapshot (worker-level
# Docket dependency) before any task code runs, so this is a pure
# ContextVar read — no Redis I/O here.
snapshot = _recall_snapshot(task_info.task_id)
origin_request_id = snapshot.origin_request_id if snapshot else None
# Session ID is stored in the snapshot for notification delivery
snapshot_session_id = snapshot.session_id if snapshot else None
session = (
get_task_session(snapshot_session_id) if snapshot_session_id else None
)
ctx = Context(
fastmcp=server,
session=session,
task_id=task_info.task_id,
origin_request_id=origin_request_id,
)
await ctx.__aenter__()
return ctx
# In a background-task worker there is no foreground context; the tasks
# extension installs a factory that builds and enters a worker Context
# from the restored task snapshot. Core has no task engine of its own,
# so this is None unless the extension is active.
factory = _background_context_factory
if factory is not None:
background = await factory()
if background is not None:
return background
raise RuntimeError(
"No active context found. This can happen if:\n"
" - Called outside an MCP request handler\n"
" - Called in a background task before session was registered\n"
" - Called in a background task before the context was established\n"
"Check `context.request_context` for None before accessing."
)
@ -1031,118 +972,6 @@ def OptionalCurrentContext() -> Context | None:
return cast("Context | None", _OptionalCurrentContext())
class _CurrentDocket(Dependency["Docket"]):
"""Async context manager for Docket dependency."""
async def __aenter__(self) -> Docket:
require_docket("CurrentDocket()")
# Check server instance first, fall back to ContextVar for mounted children
# whose parent owns the Docket
try:
docket = get_server()._docket
except RuntimeError:
docket = None
if docket is None:
docket = _current_docket.get()
if docket is None:
raise RuntimeError(
"No Docket instance found. Docket is only initialized when there are "
"task-enabled components (task=True). Add task=True to a component "
"to enable Docket infrastructure."
)
return docket
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
pass
def CurrentDocket() -> Docket:
"""Get the current Docket instance managed by FastMCP.
This dependency provides access to the Docket instance that FastMCP
automatically creates for background task scheduling.
Returns:
A dependency that resolves to the active Docket instance
Raises:
RuntimeError: If not within a FastMCP server context
ImportError: If fastmcp[tasks] not installed
Example:
```python
from fastmcp.dependencies import CurrentDocket
@mcp.tool()
async def schedule_task(docket: Docket = CurrentDocket()) -> str:
await docket.add(some_function)(arg1, arg2)
return "Scheduled"
```
"""
require_docket("CurrentDocket()")
return cast("Docket", _CurrentDocket())
class _CurrentWorker(Dependency["Worker"]):
"""Async context manager for Worker dependency."""
async def __aenter__(self) -> Worker:
require_docket("CurrentWorker()")
# Check server instance first, fall back to ContextVar for mounted children
try:
worker = get_server()._worker
except RuntimeError:
worker = None
if worker is None:
worker = _current_worker.get()
if worker is None:
raise RuntimeError(
"No Worker instance found. Worker is only initialized when there are "
"task-enabled components (task=True). Add task=True to a component "
"to enable Docket infrastructure."
)
return worker
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
pass
def CurrentWorker() -> Worker:
"""Get the current Docket Worker instance managed by FastMCP.
This dependency provides access to the Worker instance that FastMCP
automatically creates for background task processing.
Returns:
A dependency that resolves to the active Worker instance
Raises:
RuntimeError: If not within a FastMCP server context
ImportError: If fastmcp[tasks] not installed
Example:
```python
from fastmcp.dependencies import CurrentWorker
@mcp.tool()
async def check_worker_status(worker: Worker = CurrentWorker()) -> str:
return f"Worker: {worker.name}"
```
"""
require_docket("CurrentWorker()")
return cast("Worker", _CurrentWorker())
class _CurrentFastMCP(Dependency["FastMCP"]):
"""Async context manager for FastMCP server dependency."""

View file

@ -45,8 +45,6 @@ from pydantic import BaseModel
from fastmcp.server.dependencies import _lift_meta, bind_request_context
if TYPE_CHECKING:
import mcp_types
from fastmcp.server.context import Context
from fastmcp.server.server import FastMCP
from fastmcp.tools.base import ToolResult
@ -58,8 +56,10 @@ __all__ = [
]
# What an extension's tools/call interceptor observes and may produce: the tool
# result, or the claimed CreateTaskResult shape when the call is run as a task.
ToolCallOutcome: TypeAlias = "ToolResult | mcp_types.CreateTaskResult"
# result, or an extension-defined wire result model (a `BaseModel` the runner
# serializes) when the call is short-circuited — e.g. the tasks extension's
# CreateTaskResult. Core does not interpret the extension's result shape.
ToolCallOutcome: TypeAlias = "ToolResult | BaseModel"
# A method handler receives the SDK request context plus validated params and
# returns a bare result model (the runner serializes it).

View file

@ -475,11 +475,10 @@ class LowLevelServer(_Server[LifespanResultT]):
*,
protocol_version: str | None = None,
) -> mcp_types.ServerCapabilities:
"""Override to set capabilities.tasks as a first-class field per SEP-1686
and advertise the MCP Apps UI extension.
"""Override to advertise registered extensions and the MCP Apps UI extension.
``ServerCapabilities.tasks`` and ``ServerCapabilities.extensions`` are
real declared fields in v2, so we update them directly. The
``ServerCapabilities.extensions`` is a real declared field in v2, so we
update it directly. The
`FastMCP(experimental_capabilities=...)` merge also lives here rather
than in `create_initialization_options`: the modern `server/discover`
handler calls this directly, without going through
@ -487,8 +486,6 @@ class LowLevelServer(_Server[LifespanResultT]):
the handshake-era `initialize` response and silently dropped
constructor-configured experimental capabilities from `discover`.
"""
from fastmcp.server.tasks.capabilities import get_task_capabilities
merged_experimental = {
**self.fastmcp.experimental_capabilities,
**(experimental_capabilities or {}),
@ -513,7 +510,6 @@ class LowLevelServer(_Server[LifespanResultT]):
}
return capabilities.model_copy(
update={
"tasks": get_task_capabilities(),
"extensions": {
**existing_extensions,
UI_EXTENSION_ID: {},

View file

@ -445,6 +445,15 @@ class ResponseCachingMiddleware(Middleware):
if isinstance(tool_result, InputRequiredToolResult):
return tool_result
# A task-augmented call returns a CreateTaskResult (the tasks extension)
# up through this middleware — an acknowledgement that the work was
# enqueued, not a cacheable answer, and without a ToolResult's
# content/structured_content. Pass any non-ToolResult straight through
# rather than crash wrapping it (the crash would fire after the task is
# already enqueued, so a client retry could duplicate side effects).
if not isinstance(tool_result, ToolResult):
return tool_result
cacheable_tool_result: CacheableToolResult = CacheableToolResult.wrap(
value=tool_result
)

View file

@ -118,6 +118,12 @@ class ResponseLimitingMiddleware(Middleware):
if isinstance(result, InputRequiredToolResult):
return result
# A task-augmented call returns a CreateTaskResult (the tasks extension)
# up through this middleware — a small acknowledgement with no tool
# content to measure or truncate. Pass any non-ToolResult through.
if not isinstance(result, ToolResult):
return result
# Check if we should limit this tool
if self.tools is not None and context.message.name not in self.tools:
return result

View file

@ -1,18 +1,16 @@
"""Lifespan and Docket task infrastructure for FastMCP Server."""
"""Lifespan infrastructure for FastMCP Server."""
from __future__ import annotations
import asyncio
import weakref
from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager, suppress
from contextlib import AsyncExitStack, asynccontextmanager
from contextvars import ContextVar
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
import anyio
from uncalled_for import SharedContext
import fastmcp
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
@ -25,169 +23,64 @@ logger = get_logger(__name__)
# Set True by `FastMCPProvider.lifespan` immediately before it enters the
# wrapped (mounted) server's `_lifespan_manager`, and reset on exit. The
# mounted server's `_docket_lifespan` reads this and becomes a no-op so that
# Docket / Worker / SharedContext are not re-initialized — there's one set
# per runtime tree, owned by the root.
# mounted server's `_shared_context_lifespan` reads this and becomes a no-op so
# that SharedContext and the server ContextVar are not re-initialized — there's
# one set per runtime tree, owned by the root. Extension lifespans (e.g. the
# tasks extension's Docket/Worker) defer to the root the same way.
#
# Independent servers entered as siblings (e.g. via `AsyncExitStack` in the
# same async context) are NOT in a parent/child relationship; the flag is not
# set in that case, so each independently establishes its own Docket and
# server context.
# set in that case, so each independently establishes its own server context.
_lifespan_root_active: ContextVar[bool] = ContextVar(
"fastmcp_lifespan_root_active", default=False
)
class LifespanMixin:
"""Mixin providing lifespan and Docket task infrastructure for FastMCP."""
"""Mixin providing lifespan infrastructure for FastMCP."""
@property
def docket(self: FastMCP) -> Docket | None:
"""The Docket instance owned by this server.
"""The Docket instance owned by this server, if the tasks extension is active.
Returns the Docket that this server initialized as the root of a
runtime tree. Mounted children do not own their own Docket they
share the root's via ``_current_docket`` ContextVar inheritance —
so accessing ``.docket`` on a mounted child returns None even while
its tasks run on the root's Docket. For "the Docket in scope right
now," prefer reading ``_current_docket`` directly or use the
``CurrentDocket`` dependency injection.
Returns the Docket that the tasks extension initialized as the root of a
runtime tree, or None when no task backend is running. Mounted children do
not own their own Docket they share the root's via ``_current_docket``
ContextVar inheritance so accessing ``.docket`` on a mounted child
returns None even while its tasks run on the root's Docket.
"""
return self._docket
@asynccontextmanager
async def _docket_lifespan(self: FastMCP) -> AsyncIterator[None]:
"""Manage Docket instance and Worker for background task execution.
async def _shared_context_lifespan(self: FastMCP) -> AsyncIterator[None]:
"""Set up the process-level ``SharedContext`` and server ContextVar.
Docket is process-level, not server-level: only the first server in a
runtime tree starts Docket and the Worker. Mounted children entered
via ``FastMCPProvider.lifespan`` see ``_lifespan_root_active=True``
(set by the provider before delegating to ``_lifespan_manager``) and
become no-ops, sharing the root's Docket via ``_current_docket``.
``SharedContext`` backs app-scoped ``Shared()`` dependencies and is
process-level, not server-level: only the first server in a runtime tree
establishes it. Mounted children entered via ``FastMCPProvider.lifespan``
see ``_lifespan_root_active=True`` (set by the provider before delegating
to ``_lifespan_manager``) and become no-ops, sharing the root's context
via ContextVars.
Independent servers entered as siblings for example two unrelated
``FastMCP`` instances each entered through ``AsyncExitStack`` in the
same async context are not in a parent/child relationship; no
provider has set the flag for them, so each runs the full root setup.
Docket infrastructure is only initialized at the root if:
1. pydocket is installed (fastmcp[tasks] extra)
2. There are task-enabled components (task_config.mode != 'forbidden')
Users with pydocket installed but no task-enabled components won't spin
up Docket / Worker infrastructure even at the root.
``FastMCP`` instances each entered through ``AsyncExitStack`` in the same
async context are not in a parent/child relationship; no provider has
set the flag for them, so each runs the full root setup.
"""
# Nested entry: a parent in this runtime tree already owns Docket and
# SharedContext (the FastMCPProvider that mounted us set the flag).
# Stay out of their way and inherit via ContextVars.
if _lifespan_root_active.get():
yield
return
async with self._docket_lifespan_root():
yield
@asynccontextmanager
async def _docket_lifespan_root(self: FastMCP) -> AsyncIterator[None]:
"""Root-only Docket lifecycle. See _docket_lifespan for the dispatch."""
from fastmcp.server.dependencies import _current_server, is_docket_available
from fastmcp.server.dependencies import _current_server
# Set FastMCP server in ContextVar so CurrentFastMCP can access it
# (use weakref to avoid reference cycles)
server_token = _current_server.set(weakref.ref(self))
try:
# If docket is not available, skip task infrastructure but still
# set up SharedContext so Shared() dependencies work.
if not is_docket_available():
async with SharedContext():
self._capture_shared_context()
yield
return
# Collect task-enabled components at startup with all transforms applied.
# Components must be available now to be registered with Docket workers;
# dynamically added components after startup won't be registered.
try:
task_components = list(await self.get_tasks())
except Exception as e:
logger.warning(f"Failed to get tasks: {e}")
if fastmcp.settings.mounted_components_raise_on_load_error:
raise
task_components = []
# If no task-enabled components, skip Docket infrastructure but still
# set up SharedContext so Shared() dependencies work.
if not task_components:
async with SharedContext():
self._capture_shared_context()
yield
return
# Docket is available AND there are task-enabled components
from docket import Depends, Docket, Worker
from fastmcp import settings
from fastmcp.server.dependencies import (
_current_docket,
_current_worker,
)
from fastmcp.server.tasks.context import restore_task_snapshot
# Create Docket instance using configured name and URL
async with (
SharedContext(),
Docket(
name=settings.docket.name,
url=settings.docket.url,
) as docket,
):
async with SharedContext():
self._capture_shared_context()
self._docket = docket
# Register task-enabled components with Docket
for component in task_components:
component.register_with_docket(docket)
docket_token = _current_docket.set(docket)
try:
# Build worker kwargs from settings
worker_kwargs: dict[str, Any] = {
"concurrency": settings.docket.concurrency,
"redelivery_timeout": settings.docket.redelivery_timeout,
"reconnection_delay": settings.docket.reconnection_delay,
"minimum_check_interval": settings.docket.minimum_check_interval,
}
if settings.docket.worker_name:
worker_kwargs["name"] = settings.docket.worker_name
# Create and start Worker. The restore_task_snapshot
# worker-level dependency runs before every task so the
# per-task snapshot ContextVar is populated before user
# code or task-scoped dependencies observe it.
async with Worker(
docket,
dependencies=[Depends(restore_task_snapshot)],
**worker_kwargs,
) as worker:
self._worker = worker
worker_token = _current_worker.set(worker)
try:
worker_task = asyncio.create_task(worker.run_forever())
try:
yield
finally:
worker_task.cancel()
with suppress(asyncio.CancelledError):
await worker_task
finally:
_current_worker.reset(worker_token)
self._worker = None
finally:
_current_docket.reset(docket_token)
self._docket = None
yield
finally:
# Reset server ContextVar
_current_server.reset(server_token)
@asynccontextmanager
@ -196,11 +89,10 @@ class LifespanMixin:
Extension lifespans are entered once per runtime tree, at the root. A
mounted child sees ``_lifespan_root_active`` set by its
``FastMCPProvider`` and defers to the root, exactly as
``_docket_lifespan`` does for the shared Docket: an extension whose
lifespan starts shared infrastructure (a task-queue backend and worker,
say) is therefore owned by the tree root, and mounted children reach it
through the same context rather than starting a second copy.
``FastMCPProvider`` and defers to the root: an extension whose lifespan
starts shared infrastructure (a task-queue backend and worker, say) is
therefore owned by the tree root, and mounted children reach it through
the same context rather than starting a second copy.
Extensions are entered in registration order; the ``AsyncExitStack``
exits them in reverse on teardown.
@ -214,6 +106,46 @@ class LifespanMixin:
await stack.enter_async_context(extension.lifespan())
yield
async def _validate_task_extension_registered(self: FastMCP) -> None:
"""Fail loudly if a task-enabled tool has no tasks extension registered.
`task=True` on a tool is only an intent declaration; the engine that runs
it lives in the `fastmcp-tasks` package and is installed by registering a
`ServerExtension` whose identifier is `TASKS_EXTENSION_ID`
(`mcp.add_extension(...)`). A task-configured tool serving without that
extension would silently never run as a task a correctness bug so we
raise at serve time instead.
"""
from fastmcp.utilities.tasks import TASKS_EXTENSION_ID
# A mounted child defers to the root, which owns the extension and whose
# aggregated get_tasks() already covers this child's task tools — the
# same root-deferral the extension lifespan uses. Validating here would
# fail a child that legitimately relies on the root's registration.
if _lifespan_root_active.get():
return
if TASKS_EXTENSION_ID in self._extensions:
return
candidates = list(await self.get_tasks())
# ``get_tasks()`` applies server-level transforms, which can inject
# non-task tools (e.g. ResourcesAsTools' synthetic list/read tools) into
# the result, so re-filter by the actual task config here — mirroring the
# guard the old per-component docket registration applied.
task_components = [c for c in candidates if c.task_config.supports_tasks()]
if not task_components:
return
names = ", ".join(sorted(c.name for c in task_components))
raise RuntimeError(
f"Task-enabled tools ({names}) require the tasks extension, but no "
f"extension with identifier {TASKS_EXTENSION_ID!r} is registered. "
"Install it with `pip install 'fastmcp[tasks]'` and register it via "
"`mcp.add_extension(TasksExtension(...))`."
)
def _capture_shared_context(self: FastMCP) -> None:
"""Snapshot the live ``SharedContext`` ContextVar values.
@ -261,7 +193,7 @@ class LifespanMixin:
stack = AsyncExitStack()
try:
user_lifespan_result = await stack.enter_async_context(self._lifespan(self))
await stack.enter_async_context(self._docket_lifespan())
await stack.enter_async_context(self._shared_context_lifespan())
await stack.enter_async_context(self._extensions_lifespan())
self._lifespan_result = user_lifespan_result
@ -271,6 +203,8 @@ class LifespanMixin:
for provider in self.providers:
await stack.enter_async_context(provider.lifespan())
await self._validate_task_extension_registered()
self._started.set()
try:
yield
@ -286,74 +220,3 @@ class LifespanMixin:
if self._lifespan_ref_count == 0:
self._lifespan_result_set = False
self._lifespan_result = None
def _setup_task_protocol_handlers(self: FastMCP) -> None:
"""Register SEP-1686 task protocol handlers with SDK.
Only registers handlers if docket is installed. Without docket,
task protocol requests will return "method not found" errors.
"""
from fastmcp.server.dependencies import is_docket_available
if not is_docket_available():
return
from mcp.server.context import ServerRequestContext
from mcp_types import (
CancelTaskRequestParams,
GetTaskPayloadRequestParams,
GetTaskRequestParams,
PaginatedRequestParams,
)
from fastmcp.server.dependencies import bind_request_context
from fastmcp.server.tasks.requests import (
tasks_cancel_handler,
tasks_get_handler,
tasks_list_handler,
tasks_result_handler,
)
# v2 handlers take (ctx, params) and return the bare result model.
async def handle_get_task(
ctx: ServerRequestContext, params: GetTaskRequestParams
) -> Any:
with bind_request_context(ctx):
p = params.model_dump(by_alias=True, exclude_none=True)
return await tasks_get_handler(self, p)
async def handle_get_task_result(
ctx: ServerRequestContext, params: GetTaskPayloadRequestParams
) -> Any:
with bind_request_context(ctx):
p = params.model_dump(by_alias=True, exclude_none=True)
return await tasks_result_handler(self, p)
async def handle_list_tasks(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> Any:
with bind_request_context(ctx):
p = (
params.model_dump(by_alias=True, exclude_none=True)
if params
else {}
)
return await tasks_list_handler(self, p)
async def handle_cancel_task(
ctx: ServerRequestContext, params: CancelTaskRequestParams
) -> Any:
with bind_request_context(ctx):
p = params.model_dump(by_alias=True, exclude_none=True)
return await tasks_cancel_handler(self, p)
s = self._mcp_server
s.add_request_handler("tasks/get", GetTaskRequestParams, handle_get_task)
s.add_request_handler(
"tasks/result", GetTaskPayloadRequestParams, handle_get_task_result
)
s.add_request_handler("tasks/list", PaginatedRequestParams, handle_list_tasks)
s.add_request_handler(
"tasks/cancel", CancelTaskRequestParams, handle_cancel_task
)

View file

@ -20,6 +20,7 @@ from mcp_types import (
SetLevelRequestParams,
)
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from pydantic import BaseModel
from fastmcp.exceptions import (
DisabledError,
@ -29,8 +30,7 @@ from fastmcp.exceptions import (
)
from fastmcp.server.completions import CompletionValues, normalize_completion
from fastmcp.server.dependencies import bind_request_context, extract_version_spec
from fastmcp.server.tasks.config import TaskMeta
from fastmcp.tools.base import InputRequiredToolResult
from fastmcp.tools.base import InputRequiredToolResult, ToolResult
from fastmcp.utilities.async_utils import (
call_sync_fn_in_threadpool,
is_coroutine_function,
@ -127,9 +127,6 @@ class MCPOperationsMixin:
"logging/setLevel", SetLevelRequestParams, self._on_set_logging_level
)
# Register SEP-1686 task protocol handlers
self._setup_task_protocol_handlers()
async def _on_list_tools(
self: FastMCP,
ctx: ServerRequestContext,
@ -220,17 +217,9 @@ class MCPOperationsMixin:
self: FastMCP,
ctx: ServerRequestContext,
params: CallToolRequestParams,
) -> (
mcp_types.CallToolResult
| mcp_types.InputRequiredResult
| mcp_types.CreateTaskResult
):
) -> mcp_types.CallToolResult | mcp_types.InputRequiredResult | BaseModel:
"""Handle MCP 'tools/call' requests.
Task metadata is a first-class params field (``params.task``); its
presence triggers backgrounding. The tool's ``_run()`` handles the
backgrounding decision so middleware runs before Docket.
A guard tool (SEP-2322 multi-round-trip) requests client input by
returning an ``InputRequiredResult`` from its body; the run machinery
wraps that in an ``InputRequiredToolResult`` (a ``ToolResult``
@ -250,14 +239,9 @@ class MCPOperationsMixin:
)
version = _version_from_ctx(ctx)
task_meta = (
TaskMeta(ttl=params.task.ttl) if params.task is not None else None
)
try:
result = await self.call_tool(
key, arguments, version=version, task_meta=task_meta
)
result = await self.call_tool(key, arguments, version=version)
except (DisabledError, NotFoundError):
# Unknown/disabled tool: return an error result (matching the
# v1 SDK's call_tool behavior) so the client surfaces a
@ -280,8 +264,14 @@ class MCPOperationsMixin:
is_error=True,
)
if isinstance(result, mcp_types.CreateTaskResult):
if not isinstance(result, ToolResult):
# An extension's tools/call interceptor produced a non-ToolResult
# wire result — the tasks extension's CreateTaskResult when it ran
# the call as a task. Core does not interpret extension result
# shapes; hand it straight to the runner, which serializes it for
# the negotiated protocol version.
return result
if isinstance(result, InputRequiredToolResult):
# A guard tool requested client input (SEP-2322). The
# multi-round-trip result type only exists at 2026-07-28; on an
@ -305,14 +295,8 @@ class MCPOperationsMixin:
self: FastMCP,
ctx: ServerRequestContext,
params: ReadResourceRequestParams,
) -> mcp_types.ReadResourceResult | mcp_types.CreateTaskResult:
"""Handle MCP 'resources/read' requests.
Note: ``ReadResourceRequestParams`` has no ``task`` field in this SDK
version, so resource task submission over the wire is not expressible;
``task_meta`` is always None here. The CreateTaskResult return branch is
retained harmlessly pending an upstream ``task`` field on these params.
"""
) -> mcp_types.ReadResourceResult:
"""Handle MCP 'resources/read' requests."""
with bind_request_context(ctx):
uri = params.uri
logger.debug(f"[{self.name}] Handler called: read_resource %s", uri)
@ -336,21 +320,14 @@ class MCPOperationsMixin:
# already happened inside read_resource.
raise to_mcp_error(e) from e
if isinstance(result, mcp_types.CreateTaskResult):
return result
return result.to_mcp_result(uri)
async def _on_get_prompt(
self: FastMCP,
ctx: ServerRequestContext,
params: GetPromptRequestParams,
) -> mcp_types.GetPromptResult | mcp_types.CreateTaskResult:
"""Handle MCP 'prompts/get' requests.
Note: ``GetPromptRequestParams`` has no ``task`` field in this SDK
version, so prompt task submission over the wire is not expressible;
``task_meta`` is always None here.
"""
) -> mcp_types.GetPromptResult:
"""Handle MCP 'prompts/get' requests."""
with bind_request_context(ctx):
name = params.name
arguments = params.arguments
@ -374,8 +351,6 @@ class MCPOperationsMixin:
# Masking already happened inside render_prompt.
raise to_mcp_error(e) from e
if isinstance(result, mcp_types.CreateTaskResult):
return result
return result.to_mcp_prompt_result()
async def _on_set_logging_level(

View file

@ -12,25 +12,20 @@ from __future__ import annotations
from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any, overload
from typing import TYPE_CHECKING, Any
import mcp_types
from pydantic import AnyUrl
from fastmcp.prompts.base import Prompt, PromptResult
from fastmcp.resources.base import Resource, ResourceResult
from fastmcp.resources.template import ResourceTemplate, expand_uri_template
from fastmcp.server.providers.base import Provider
from fastmcp.server.tasks.config import TaskMeta
from fastmcp.server.telemetry import delegate_span
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.versions import VersionSpec
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
from fastmcp.server.server import FastMCP
@ -80,32 +75,13 @@ class FastMCPProviderTool(Tool):
icons=tool.icons,
)
@overload
async def _run(
self,
arguments: dict[str, Any],
task_meta: None = None,
) -> ToolResult: ...
async def _run(self, arguments: dict[str, Any]) -> ToolResult:
"""Delegate to the child server's call_tool().
@overload
async def _run(
self,
arguments: dict[str, Any],
task_meta: TaskMeta,
) -> mcp_types.CreateTaskResult: ...
async def _run(
self,
arguments: dict[str, Any],
task_meta: TaskMeta | None = None,
) -> ToolResult | mcp_types.CreateTaskResult:
"""Delegate to child server's call_tool() with task_meta.
Passes task_meta through to the child server so it can handle
backgrounding appropriately. fn_key is already set by the parent
server before calling this method. A child tool that requests client
input (SEP-2322) returns an `InputRequiredToolResult`, which forwards
through this delegation to the parent's wire handler unchanged.
fn_key is already set by the parent server before calling this method. A
child tool that requests client input (SEP-2322) returns an
`InputRequiredToolResult`, which forwards through this delegation to the
parent's wire handler unchanged.
"""
# Pass exact version so child executes the correct version
version = VersionSpec(eq=self.version) if self.version else None
@ -120,27 +96,20 @@ class FastMCPProviderTool(Tool):
self._original_name,
arguments,
version=version,
task_meta=task_meta,
)
async def run(self, arguments: dict[str, Any]) -> ToolResult:
"""Delegate to child server's call_tool() without task_meta.
"""Delegate to the child server's call_tool().
This is called when the tool is used within a TransformedTool
forwarding function or other contexts where task_meta is not available.
forwarding function or other contexts.
"""
# Pass exact version so child executes the correct version
version = VersionSpec(eq=self.version) if self.version else None
result = await self._server.call_tool(
return await self._server.call_tool(
self._original_name, arguments, version=version
)
# Result from call_tool should always be ToolResult when no task_meta.
if isinstance(result, mcp_types.CreateTaskResult):
raise RuntimeError(
"Unexpected CreateTaskResult from call_tool without task_meta"
)
return result
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
@ -188,20 +157,10 @@ class FastMCPProviderResource(Resource):
icons=resource.icons,
)
@overload
async def _read(self, task_meta: None = None) -> ResourceResult: ...
async def _read(self) -> ResourceResult:
"""Delegate to the child server's read_resource().
@overload
async def _read(self, task_meta: TaskMeta) -> mcp_types.CreateTaskResult: ...
async def _read(
self, task_meta: TaskMeta | None = None
) -> ResourceResult | mcp_types.CreateTaskResult:
"""Delegate to child server's read_resource() with task_meta.
Passes task_meta through to the child server so it can handle
backgrounding appropriately. fn_key is already set by the parent
server before calling this method.
fn_key is already set by the parent server before calling this method.
"""
# Pass exact version so child reads the correct version
version = VersionSpec(eq=self.version) if self.version else None
@ -212,9 +171,7 @@ class FastMCPProviderResource(Resource):
self._original_uri or "",
method="resources/read",
):
return await self._server.read_resource(
self._original_uri, version=version, task_meta=task_meta
)
return await self._server.read_resource(self._original_uri, version=version)
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
@ -260,30 +217,10 @@ class FastMCPProviderPrompt(Prompt):
icons=prompt.icons,
)
@overload
async def _render(
self,
arguments: dict[str, Any] | None = None,
task_meta: None = None,
) -> PromptResult: ...
async def _render(self, arguments: dict[str, Any] | None = None) -> PromptResult:
"""Delegate to the child server's render_prompt().
@overload
async def _render(
self,
arguments: dict[str, Any] | None,
task_meta: TaskMeta,
) -> mcp_types.CreateTaskResult: ...
async def _render(
self,
arguments: dict[str, Any] | None = None,
task_meta: TaskMeta | None = None,
) -> PromptResult | mcp_types.CreateTaskResult:
"""Delegate to child server's render_prompt() with task_meta.
Passes task_meta through to the child server so it can handle
backgrounding appropriately. fn_key is already set by the parent
server before calling this method.
fn_key is already set by the parent server before calling this method.
"""
# Pass exact version so child renders the correct version
version = VersionSpec(eq=self.version) if self.version else None
@ -295,27 +232,21 @@ class FastMCPProviderPrompt(Prompt):
method="prompts/get",
):
return await self._server.render_prompt(
self._original_name, arguments, version=version, task_meta=task_meta
self._original_name, arguments, version=version
)
async def render(self, arguments: dict[str, Any] | None = None) -> PromptResult:
"""Delegate to child server's render_prompt() without task_meta.
"""Delegate to the child server's render_prompt().
This is called when the prompt is used within a transformed context
or other contexts where task_meta is not available.
or other contexts.
"""
# Pass exact version so child renders the correct version
version = VersionSpec(eq=self.version) if self.version else None
result = await self._server.render_prompt(
return await self._server.render_prompt(
self._original_name, arguments, version=version
)
# Result from render_prompt should always be PromptResult when no task_meta
if isinstance(result, mcp_types.CreateTaskResult):
raise RuntimeError(
"Unexpected CreateTaskResult from render_prompt without task_meta"
)
return result
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
@ -391,24 +322,10 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
icons=self.icons,
)
@overload
async def _read(
self, uri: str, params: dict[str, Any], task_meta: None = None
) -> ResourceResult: ...
async def _read(self, uri: str, params: dict[str, Any]) -> ResourceResult:
"""Delegate to the child server's read_resource().
@overload
async def _read(
self, uri: str, params: dict[str, Any], task_meta: TaskMeta
) -> mcp_types.CreateTaskResult: ...
async def _read(
self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None
) -> ResourceResult | mcp_types.CreateTaskResult:
"""Delegate to child server's read_resource() with task_meta.
Passes task_meta through to the child server so it can handle
backgrounding appropriately. fn_key is already set by the parent
server before calling this method.
fn_key is already set by the parent server before calling this method.
"""
# Expand the original template with params to get internal URI
original_uri = expand_uri_template(self._original_uri_template or "", params)
@ -422,50 +339,7 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
self._original_uri_template or "",
method="resources/read",
):
return await self._server.read_resource(
original_uri, version=version, task_meta=task_meta
)
async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult:
"""Read the resource content for background task execution.
Reads the resource via the wrapped server and returns the ResourceResult.
This method is called by Docket during background task execution.
"""
# Expand the original template with arguments to get internal URI
original_uri = expand_uri_template(self._original_uri_template or "", arguments)
# Pass exact version so child reads the correct version
version = VersionSpec(eq=self.version) if self.version else None
# Read from the wrapped server
result = await self._server.read_resource(original_uri, version=version)
if isinstance(result, mcp_types.CreateTaskResult):
raise RuntimeError("Unexpected CreateTaskResult during Docket execution")
return result
def register_with_docket(self, docket: Docket) -> None:
"""No-op: the child's actual template is registered via get_tasks()."""
async def add_to_docket(
self,
docket: Docket,
params: dict[str, Any],
*,
fn_key: str | None = None,
task_key: str | None = None,
**kwargs: Any,
) -> Execution:
"""Schedule this template for background execution via docket.
The child's FunctionResourceTemplate.fn is registered (via get_tasks),
and it expects splatted **kwargs, so we splat params here.
"""
lookup_key = fn_key or self.key
if task_key:
kwargs["key"] = task_key
return await docket.add(lookup_key, **kwargs)(**params)
return await self._server.read_resource(original_uri, version=version)
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {

View file

@ -349,7 +349,6 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]:
)
components.append(tool)
elif isinstance(meta, ResourceMeta):
resolved_task = meta.task if meta.task is not None else False
has_uri_params = "{" in meta.uri and "}" in meta.uri
wrapper_fn = without_injected_parameters(obj)
has_func_params = bool(inspect.signature(wrapper_fn).parameters)
@ -367,7 +366,6 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]:
tags=meta.tags,
annotations=meta.annotations,
meta=meta.meta,
task=resolved_task,
auth=meta.auth,
)
else:
@ -383,12 +381,10 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]:
tags=meta.tags,
annotations=meta.annotations,
meta=meta.meta,
task=resolved_task,
auth=meta.auth,
)
components.append(resource)
elif isinstance(meta, PromptMeta):
resolved_task = meta.task if meta.task is not None else False
prompt = Prompt.from_function(
obj,
name=meta.name,
@ -398,7 +394,6 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]:
icons=meta.icons,
tags=meta.tags,
meta=meta.meta,
task=resolved_task,
auth=meta.auth,
)
components.append(prompt)

View file

@ -16,7 +16,6 @@ import mcp_types
from fastmcp.prompts.base import Prompt
from fastmcp.prompts.function_prompt import FunctionPrompt
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.utilities.types import AnyFunction
if TYPE_CHECKING:
@ -45,7 +44,6 @@ class PromptDecoratorMixin:
meta = get_fastmcp_meta(prompt)
if meta is not None and isinstance(meta, PromptMeta):
resolved_task = meta.task if meta.task is not None else False
enabled = meta.enabled
prompt = Prompt.from_function(
prompt,
@ -56,7 +54,6 @@ class PromptDecoratorMixin:
icons=meta.icons,
tags=meta.tags,
meta=meta.meta,
task=resolved_task,
auth=meta.auth,
)
else:
@ -82,7 +79,6 @@ class PromptDecoratorMixin:
tags: set[str] | None = None,
enabled: bool = True,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> F: ...
@ -99,7 +95,6 @@ class PromptDecoratorMixin:
tags: set[str] | None = None,
enabled: bool = True,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[F], F]: ...
@ -115,7 +110,6 @@ class PromptDecoratorMixin:
tags: set[str] | None = None,
enabled: bool = True,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> (
Callable[[AnyFunction], FunctionPrompt]
@ -140,7 +134,6 @@ class PromptDecoratorMixin:
tags: Optional set of tags for categorizing the prompt
enabled: Whether the prompt is enabled (default True). If False, adds to blocklist.
meta: Optional meta information about the prompt
task: Optional task configuration for background execution
auth: Optional authorization checks for the prompt
Returns:
@ -198,7 +191,6 @@ class PromptDecoratorMixin:
icons=icons,
tags=tags,
meta=meta,
task=task,
auth=auth,
enabled=enabled,
)
@ -232,6 +224,5 @@ class PromptDecoratorMixin:
tags=tags,
meta=meta,
enabled=enabled,
task=task,
auth=auth,
)

View file

@ -21,7 +21,6 @@ from fastmcp.resources.security import (
)
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.utilities.types import AnyFunction
if TYPE_CHECKING:
@ -54,7 +53,6 @@ class ResourceDecoratorMixin:
meta = get_fastmcp_meta(resource)
if meta is not None and isinstance(meta, ResourceMeta):
resolved_task = meta.task if meta.task is not None else False
enabled = meta.enabled
has_uri_params = "{" in meta.uri and "}" in meta.uri
wrapper_fn = without_injected_parameters(resource)
@ -73,7 +71,6 @@ class ResourceDecoratorMixin:
tags=meta.tags,
annotations=meta.annotations,
meta=meta.meta,
task=resolved_task,
auth=meta.auth,
security=meta.security,
)
@ -90,7 +87,6 @@ class ResourceDecoratorMixin:
tags=meta.tags,
annotations=meta.annotations,
meta=meta.meta,
task=resolved_task,
auth=meta.auth,
)
else:
@ -123,7 +119,6 @@ class ResourceDecoratorMixin:
enabled: bool = True,
annotations: Annotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
) -> Callable[[F], F]:
@ -143,7 +138,6 @@ class ResourceDecoratorMixin:
enabled: Whether the resource is enabled (default True). If False, adds to blocklist.
annotations: Optional annotations about the resource's behavior
meta: Optional meta information about the resource
task: Optional task configuration for background execution
auth: Optional authorization checks for the resource
Returns:
@ -206,7 +200,6 @@ class ResourceDecoratorMixin:
mime_type=mime_type,
annotations=annotations,
meta=meta,
task=task,
auth=auth,
enabled=enabled,
security=security,

View file

@ -26,9 +26,9 @@ import mcp_types
from mcp_types import ToolAnnotations
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.base import Tool
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.utilities.tasks import TaskConfig
from fastmcp.utilities.types import AnyFunction, NotSet, NotSetT
try:

View file

@ -17,7 +17,6 @@ from fastmcp.resources import (
ResourceTemplate,
)
from fastmcp.server.dependencies import get_http_headers
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.exceptions import (
HTTP_STATUS_ERRORS,
@ -27,6 +26,7 @@ from fastmcp.utilities.exceptions import (
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.openapi import HTTPRoute
from fastmcp.utilities.openapi.director import RequestDirector
from fastmcp.utilities.tasks import TaskConfig
if TYPE_CHECKING:
from fastmcp.server import Context

View file

@ -51,11 +51,11 @@ from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
from fastmcp.server.providers.aggregate import ProviderErrorStrategy
from fastmcp.server.providers.base import Provider
from fastmcp.server.server import FastMCP
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.telemetry import inject_trace_context
from fastmcp.tools.base import InputRequiredToolResult, Tool, ToolResult
from fastmcp.utilities.components import FastMCPComponent, get_fastmcp_metadata
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.tasks import TaskConfig
from fastmcp.utilities.versions import VersionSpec, version_sort_key
if TYPE_CHECKING:
@ -1353,6 +1353,12 @@ class ProxyClient(Client[ClientTransportT]):
_proxy_rc_ref: list[Any]
_proxy_restoring_handler_keys: set[str]
# A proxy forwards calls; it must not advertise task support to its backend.
# Proxied tools run synchronously (forbidden mode), and the proxy has no path
# to drive a backend task on the front connection's behalf, so the internal
# tasks client extension is not folded into a proxy's backend client.
_auto_internal_extensions: bool = False
def __init__(
self,
transport: ClientTransportT

View file

@ -98,16 +98,12 @@ class SkillFileTemplate(ResourceTemplate):
else:
return full_path.read_bytes()
async def _read( # type: ignore[override]
async def _read(
self,
uri: str,
params: dict[str, Any],
task_meta: Any = None,
) -> ResourceResult: # ty:ignore[invalid-method-override]
"""Server entry point - read file directly without creating ephemeral resource.
Note: task_meta is ignored - this template doesn't support background tasks.
"""
) -> ResourceResult:
"""Server entry point - read file directly without creating ephemeral resource."""
# Call read() directly and convert to ResourceResult
result = await self.read(arguments=params)
return self.convert_result(result)

View file

@ -14,7 +14,6 @@ from contextlib import (
AbstractAsyncContextManager,
asynccontextmanager,
)
from dataclasses import replace
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
@ -80,7 +79,6 @@ from fastmcp.server.middleware.middleware import (
from fastmcp.server.mixins import LifespanMixin, MCPOperationsMixin, TransportMixin
from fastmcp.server.providers import LocalProvider, Provider
from fastmcp.server.providers.aggregate import AggregateProvider
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
from fastmcp.server.telemetry import server_span
from fastmcp.server.transforms import (
ToolTransform,
@ -94,6 +92,7 @@ from fastmcp.tools.tool_transform import ToolTransformConfig
from fastmcp.utilities.components import FastMCPComponent, _coerce_version
from fastmcp.utilities.exceptions import HTTP_STATUS_ERRORS, TIMEOUT_ERRORS
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.tasks import TaskConfig
from fastmcp.utilities.types import AnyFunction, FastMCPBaseModel, NotSet, NotSetT
from fastmcp.utilities.versions import (
VersionSpec,
@ -655,7 +654,15 @@ class FastMCP(
can reach it), its method bindings are wired onto the low-level server,
and it is recorded for capability advertisement, interception, and
lifespan entry. Registering two extensions with the same identifier is
an error.
an error, as is registering after the server's lifespan has started —
the extension's lifespan could no longer run, leaving it silently
half-active.
Extensions are served by the server they are registered on. A mounted
child's extensions do not propagate to the root: the root serves the
wire, so only root-registered extensions advertise capabilities and
answer methods (matching the lifespan, which also defers to the root).
Register extensions on the server you run.
"""
from fastmcp.server.extensions import (
build_method_handler,
@ -670,6 +677,12 @@ class FastMCP(
f"An extension with identifier {extension.identifier!r} is "
"already registered."
)
if self._lifespan_result_set:
raise RuntimeError(
f"Cannot register extension {extension.identifier!r}: the "
"server's lifespan has already started, so the extension's "
"lifespan would never run. Register extensions before serving."
)
extension._bind(self)
for binding in extension.methods():
@ -1316,7 +1329,6 @@ class FastMCP(
return None
return max(authorized, key=version_sort_key)
@overload
async def call_tool(
self,
name: str,
@ -1324,29 +1336,7 @@ class FastMCP(
*,
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: None = None,
) -> ToolResult: ...
@overload
async def call_tool(
self,
name: str,
arguments: dict[str, Any] | None = None,
*,
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: TaskMeta,
) -> mcp_types.CreateTaskResult: ...
async def call_tool(
self,
name: str,
arguments: dict[str, Any] | None = None,
*,
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: TaskMeta | None = None,
) -> ToolResult | mcp_types.CreateTaskResult:
) -> ToolResult:
"""Call a tool by name.
This is the public API for executing tools. By default, middleware is applied.
@ -1357,13 +1347,9 @@ class FastMCP(
version: Specific version to call. If None, calls highest version.
run_middleware: If True (default), apply the middleware chain.
Set to False when called from middleware to avoid re-applying.
task_meta: If provided, execute as a background task and return
CreateTaskResult. If None (default), execute synchronously and
return ToolResult.
Returns:
ToolResult when task_meta is None.
CreateTaskResult when task_meta is provided.
ToolResult.
A guard tool that requests client input (SEP-2322 multi-round-trip)
returns an ``InputRequiredToolResult`` (a ``ToolResult`` subclass); it
@ -1425,7 +1411,6 @@ class FastMCP(
context.message.arguments or {},
version=version,
run_middleware=False,
task_meta=task_meta,
)
),
)
@ -1467,10 +1452,8 @@ class FastMCP(
if tool is None:
raise NotFoundError(f"Unknown tool: {name!r}")
span.set_attributes(tool.get_span_attributes())
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=tool.key)
try:
return await tool._run(arguments or {}, task_meta=task_meta)
return await tool._run(arguments or {})
except ValidationError as e:
# Argument-validation failure (a bad call). FunctionTool
# converts pydantic's call-validation error into fastmcp's
@ -1521,34 +1504,13 @@ class FastMCP(
raise ToolError(f"Error calling tool {name!r}") from e
raise ToolError(f"Error calling tool {name!r}: {e}") from e
@overload
async def read_resource(
self,
uri: str,
*,
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: None = None,
) -> ResourceResult: ...
@overload
async def read_resource(
self,
uri: str,
*,
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: TaskMeta,
) -> mcp_types.CreateTaskResult: ...
async def read_resource(
self,
uri: str,
*,
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: TaskMeta | None = None,
) -> ResourceResult | mcp_types.CreateTaskResult:
) -> ResourceResult:
"""Read a resource by URI.
This is the public API for reading resources. By default, middleware is applied.
@ -1559,25 +1521,14 @@ class FastMCP(
version: Specific version to read. If None, reads highest version.
run_middleware: If True (default), apply the middleware chain.
Set to False when called from middleware to avoid re-applying.
task_meta: If provided, execute as a background task and return
CreateTaskResult. If None (default), execute synchronously and
return ResourceResult.
Returns:
ResourceResult when task_meta is None.
CreateTaskResult when task_meta is provided.
ResourceResult.
Raises:
NotFoundError: If resource not found or disabled
ResourceError: If resource read fails
"""
# Note: fn_key enrichment happens here after finding the resource/template.
# Resources and templates use different key formats:
# - Resources use resource.key (derived from the concrete URI)
# - Templates use template.key (the template pattern)
# For mounted servers, the parent's provider sets fn_key to the
# namespaced key before delegating, ensuring correct Docket routing.
async with fastmcp.server.context.Context(fastmcp=self) as ctx:
if run_middleware:
mw_context = MiddlewareContext(
@ -1596,7 +1547,6 @@ class FastMCP(
str(context.message.uri),
version=version,
run_middleware=False,
task_meta=task_meta,
),
)
@ -1619,16 +1569,14 @@ class FastMCP(
synthesized = await synthesize_prefab_resource_by_uri(self, uri)
if synthesized is not None:
span.set_attributes(synthesized.get_span_attributes())
return await synthesized._read(task_meta=task_meta)
return await synthesized._read()
# Try concrete resources first (transforms + auth via _get_resource)
resource = await self.get_resource(uri, version=version)
if resource is not None:
span.set_attributes(resource.get_span_attributes())
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=resource.key)
try:
return await resource._read(task_meta=task_meta)
return await resource._read()
except FastMCPError as e:
logger.log(
e.log_level,
@ -1692,10 +1640,8 @@ class FastMCP(
)
raise ResourceSecurityError(f"Unknown resource: {uri!r}")
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=template.key)
try:
return await template._read(uri, params, task_meta=task_meta)
return await template._read(uri, params)
except FastMCPError as e:
logger.log(
e.log_level, f"Error reading resource {uri!r}", exc_info=True
@ -1724,7 +1670,6 @@ class FastMCP(
raise ResourceError(f"Error reading resource {uri!r}") from e
raise ResourceError(f"Error reading resource {uri!r}: {e}") from e
@overload
async def render_prompt(
self,
name: str,
@ -1732,29 +1677,7 @@ class FastMCP(
*,
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: None = None,
) -> PromptResult: ...
@overload
async def render_prompt(
self,
name: str,
arguments: dict[str, Any] | None = None,
*,
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: TaskMeta,
) -> mcp_types.CreateTaskResult: ...
async def render_prompt(
self,
name: str,
arguments: dict[str, Any] | None = None,
*,
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: TaskMeta | None = None,
) -> PromptResult | mcp_types.CreateTaskResult:
) -> PromptResult:
"""Render a prompt by name.
This is the public API for rendering prompts. By default, middleware is applied.
@ -1766,13 +1689,9 @@ class FastMCP(
version: Specific version to render. If None, renders highest version.
run_middleware: If True (default), apply the middleware chain.
Set to False when called from middleware to avoid re-applying.
task_meta: If provided, execute as a background task and return
CreateTaskResult. If None (default), execute synchronously and
return PromptResult.
Returns:
PromptResult when task_meta is None.
CreateTaskResult when task_meta is provided.
PromptResult.
Raises:
NotFoundError: If prompt not found or disabled
@ -1798,7 +1717,6 @@ class FastMCP(
context.message.arguments,
version=version,
run_middleware=False,
task_meta=task_meta,
),
)
@ -1816,10 +1734,8 @@ class FastMCP(
if prompt is None:
raise NotFoundError(f"Unknown prompt: {name!r}")
span.set_attributes(prompt.get_span_attributes())
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=prompt.key)
try:
return await prompt._render(arguments, task_meta=task_meta)
return await prompt._render(arguments)
except FastMCPError as e:
logger.log(
e.log_level, f"Error rendering prompt {name!r}", exc_info=True
@ -2025,7 +1941,6 @@ class FastMCP(
annotations: Annotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
app: AppConfig | dict[str, Any] | bool | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
) -> Callable[[F], F]:
@ -2125,7 +2040,6 @@ class FastMCP(
tags=tags,
annotations=annotations,
meta=meta,
task=task if task is not None else self._support_tasks_by_default,
auth=auth,
security=security,
)
@ -2155,7 +2069,6 @@ class FastMCP(
icons: list[mcp_types.Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> F: ...
@ -2171,7 +2084,6 @@ class FastMCP(
icons: list[mcp_types.Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[F], F]: ...
@ -2186,7 +2098,6 @@ class FastMCP(
icons: list[mcp_types.Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> (
Callable[[AnyFunction], FunctionPrompt]
@ -2271,7 +2182,6 @@ class FastMCP(
icons=icons,
tags=tags,
meta=meta,
task=task if task is not None else self._support_tasks_by_default,
auth=auth,
)

View file

@ -1,38 +0,0 @@
"""MCP SEP-1686 background tasks support.
This module implements protocol-level background task execution for MCP servers.
"""
from fastmcp.server.tasks.capabilities import get_task_capabilities
from fastmcp.server.tasks.config import TaskConfig, TaskMeta, TaskMode
from fastmcp.server.tasks.elicitation import (
elicit_for_task,
handle_task_input,
relay_elicitation,
)
from fastmcp.server.tasks.keys import (
build_task_key,
get_client_task_id_from_key,
parse_task_key,
)
from fastmcp.server.tasks.notifications import (
ensure_subscriber_running,
push_notification,
stop_subscriber,
)
__all__ = [
"TaskConfig",
"TaskMeta",
"TaskMode",
"build_task_key",
"elicit_for_task",
"ensure_subscriber_running",
"get_client_task_id_from_key",
"get_task_capabilities",
"handle_task_input",
"parse_task_key",
"push_notification",
"relay_elicitation",
"stop_subscriber",
]

View file

@ -1,48 +0,0 @@
"""SEP-1686 task capabilities declaration."""
from mcp_types import (
ServerTasksCapability,
ServerTasksRequestsCapability,
TasksCallCapability,
TasksCancelCapability,
TasksListCapability,
TasksToolsCapability,
)
def get_task_capabilities() -> ServerTasksCapability | None:
"""Return the SEP-1686 task capabilities.
Returns task capabilities as a first-class ServerCapabilities field,
declaring support for list, cancel, and request operations per SEP-1686.
Returns None if a compatible pydocket is not installed (no task support).
Uses the canonical ``is_docket_available()`` check so that capability
advertisement and handler registration stay in sync otherwise a server
with an old transitive pydocket would advertise task support and then
return "method not found" when clients invoked it.
Only tools are advertised as task-capable. In the SDK v2 b1 wire types,
``ReadResourceRequestParams`` / ``GetPromptRequestParams`` carry no ``task``
field (sdk-feedback #3), so resource/prompt task submissions are not
wire-expressible and always graceful-degrade to synchronous execution.
Advertising ``prompts``/``resources`` task support would mislead
capability-discovering clients into sending task-augmented reads/gets that
silently run synchronously. Restore them here once the SDK adds task
metadata to those request params.
"""
# Function-local import to avoid a circular import at module load time:
# fastmcp.server.tasks.__init__ pulls in this module, and dependencies
# transitively reaches back into fastmcp.server.tasks.keys.
from fastmcp.server.dependencies import is_docket_available
if not is_docket_available():
return None
return ServerTasksCapability(
list=TasksListCapability(),
cancel=TasksCancelCapability(),
requests=ServerTasksRequestsCapability(
tools=TasksToolsCapability(call=TasksCallCapability()),
),
)

View file

@ -1,19 +0,0 @@
"""Backward-compatible exports for task configuration primitives."""
from fastmcp.utilities.tasks import (
DEFAULT_POLL_INTERVAL,
DEFAULT_POLL_INTERVAL_MS,
DEFAULT_TTL_MS,
TaskConfig,
TaskMeta,
TaskMode,
)
__all__ = [
"DEFAULT_POLL_INTERVAL",
"DEFAULT_POLL_INTERVAL_MS",
"DEFAULT_TTL_MS",
"TaskConfig",
"TaskMeta",
"TaskMode",
]

View file

@ -1,347 +0,0 @@
"""Background task elicitation support (SEP-1686).
This module provides elicitation capabilities for background tasks running
in Docket workers. Unlike regular MCP requests, background tasks don't have
an active request context, so elicitation requires special handling:
1. Set task status to "input_required" via Redis
2. Send notifications/tasks/status with elicitation metadata
3. Wait for client to send input via tasks/sendInput
4. Resume task execution with the provided input
This uses the public MCP SDK APIs where possible, with minimal use of
internal APIs for background task coordination.
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
import mcp_types
from mcp import ServerSession
from fastmcp.server.tasks.context import get_task_context, get_task_session_id
from fastmcp.server.tasks.keys import task_redis_prefix
from fastmcp.server.tasks.notifications import push_notification
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from fastmcp.server.server import FastMCP
# TTL for elicitation state (1 hour)
ELICIT_TTL_SECONDS = 3600
def _elicit_keys(task_scope: str | None, task_id: str) -> tuple[str, str, str]:
"""Build (request, response, status) Redis keys for a task's elicitation."""
prefix = f"{task_redis_prefix(task_scope)}:{task_id}:elicit"
return f"{prefix}:request", f"{prefix}:response", f"{prefix}:status"
async def elicit_for_task(
task_id: str,
session: ServerSession | None,
message: str,
schema: dict[str, Any],
fastmcp: FastMCP,
) -> mcp_types.ElicitResult:
"""Send an elicitation request from a background task.
This function handles the complexity of eliciting user input when running
in a Docket worker context where there's no active MCP request.
Args:
task_id: The background task ID
session: The MCP ServerSession for this task
message: The message to display to the user
schema: The JSON schema for the expected response
fastmcp: The FastMCP server instance
Returns:
ElicitResult containing the user's response
Raises:
RuntimeError: If Docket is not available
MCPError: If the elicitation request fails
"""
docket = fastmcp._docket
if docket is None:
raise RuntimeError(
"Background task elicitation requires Docket. "
"Ensure 'fastmcp[tasks]' is installed and the server has task-enabled components."
)
# Generate a unique request ID for this elicitation
request_id = str(uuid.uuid4())
task_context = get_task_context()
if task_context is not None:
task_scope = task_context.task_scope
# Prefer the live session's cached ID (always available in-process),
# fall back to the snapshot for distributed workers.
session_id = (
getattr(session, "_fastmcp_state_prefix", None) or get_task_session_id()
)
else:
raise RuntimeError(
"Cannot determine task scope for elicitation. "
"This typically means elicit_for_task() was called outside a Docket worker context."
)
# Store elicitation request in Redis
request_key, response_key, status_key = _elicit_keys(task_scope, task_id)
elicit_request = {
"request_id": request_id,
"message": message,
"schema": schema,
}
async with docket.redis() as redis:
# Store the elicitation request
await redis.set(
docket.key(request_key),
json.dumps(elicit_request),
ex=ELICIT_TTL_SECONDS,
)
# Set status to "waiting"
await redis.set(
docket.key(status_key),
"waiting",
ex=ELICIT_TTL_SECONDS,
)
# Send task status update notification with input_required status.
# Use notifications/tasks/status so typed MCP clients can consume it.
#
# NOTE: We use the distributed notification queue instead of session.send_notification()
# This enables notifications to work when workers run in separate processes
# (Azure Web PubSub / Service Bus inspired pattern)
timestamp = datetime.now(timezone.utc).isoformat()
notification_dict = {
"method": "notifications/tasks/status",
"params": {
"taskId": task_id,
"status": "input_required",
"statusMessage": message,
"createdAt": timestamp,
"lastUpdatedAt": timestamp,
"ttl": ELICIT_TTL_SECONDS * 1000,
},
"_meta": {
"io.modelcontextprotocol/related-task": {
"taskId": task_id,
"status": "input_required",
"statusMessage": message,
"task_scope": task_scope,
"elicitation": {
"requestId": request_id,
"message": message,
"requestedSchema": schema,
},
}
},
}
if session_id is None:
logger.warning(
"No session_id available for task %s, cannot deliver elicitation notification",
task_id,
)
return mcp_types.ElicitResult(action="cancel", content=None)
try:
await push_notification(session_id, notification_dict, docket)
except Exception as e:
# Fail fast: if notification can't be queued, client won't know to respond
# Return cancel immediately rather than waiting for 1-hour timeout
logger.warning(
"Failed to queue input_required notification for task %s, cancelling elicitation: %s",
task_id,
e,
)
# Best-effort cleanup
try:
async with docket.redis() as redis:
await redis.delete(
docket.key(request_key),
docket.key(status_key),
)
except Exception:
pass # Keys will expire via TTL
return mcp_types.ElicitResult(action="cancel", content=None)
# Wait for response using BLPOP (blocking pop)
# This is much more efficient than polling - single Redis round-trip
# that blocks until a response is pushed, vs 7,200 round-trips/hour with polling
max_wait_seconds = ELICIT_TTL_SECONDS
try:
async with docket.redis() as redis:
# BLPOP blocks until an item is pushed to the list or timeout
# Returns tuple of (key, value) or None on timeout
result = await redis.blpop(
[docket.key(response_key)],
timeout=max_wait_seconds,
)
if result:
# result is (key, value) tuple
_key, response_data = result
response = json.loads(response_data)
# Clean up Redis keys
await redis.delete(
docket.key(request_key),
docket.key(status_key),
)
# Convert to ElicitResult
return mcp_types.ElicitResult(
action=response.get("action", "accept"),
content=response.get("content"),
)
except Exception as e:
logger.warning(
"BLPOP failed for task %s elicitation, falling back to cancel: %s",
task_id,
e,
)
# Timeout or error - treat as cancellation
# Best-effort cleanup - if Redis is unavailable, keys will expire via TTL
try:
async with docket.redis() as redis:
await redis.delete(
docket.key(request_key),
docket.key(response_key),
docket.key(status_key),
)
except Exception as cleanup_error:
logger.debug(
"Failed to clean up elicitation keys for task %s (will expire via TTL): %s",
task_id,
cleanup_error,
)
return mcp_types.ElicitResult(action="cancel", content=None)
async def relay_elicitation(
session: ServerSession,
task_scope: str | None,
task_id: str,
elicitation: dict[str, Any],
fastmcp: FastMCP,
) -> None:
"""Relay elicitation from a background task worker to the client.
Called by the notification subscriber when it detects an input_required
notification with elicitation metadata. Sends a standard elicitation/create
request to the client session, then uses handle_task_input() to push the
response to Redis so the blocked worker can resume.
Args:
session: MCP ServerSession
task_scope: Authorization scope for Redis key construction
task_id: Background task ID
elicitation: Elicitation metadata (message, requestedSchema)
fastmcp: FastMCP server instance
"""
try:
result = await session.elicit(
message=elicitation["message"],
requested_schema=elicitation["requestedSchema"],
)
await handle_task_input(
task_id=task_id,
task_scope=task_scope,
action=result.action,
content=result.content,
fastmcp=fastmcp,
)
logger.debug(
"Relayed elicitation response for task %s (action=%s)",
task_id,
result.action,
)
except Exception as e:
logger.warning("Failed to relay elicitation for task %s: %s", task_id, e)
# Push a cancel response so the worker's BLPOP doesn't block forever
success = await handle_task_input(
task_id=task_id,
task_scope=task_scope,
action="cancel",
content=None,
fastmcp=fastmcp,
)
if not success:
logger.warning(
"Failed to push cancel response for task %s "
"(worker may block until TTL)",
task_id,
)
async def handle_task_input(
task_id: str,
task_scope: str | None,
action: str,
content: dict[str, Any] | None,
fastmcp: FastMCP,
) -> bool:
"""Handle input sent to a background task via tasks/sendInput.
This is called when a client sends input in response to an elicitation
request from a background task.
Args:
task_id: The background task ID
task_scope: Authorization scope for Redis key construction
action: The elicitation action ("accept", "decline", "cancel")
content: The response content (for "accept" action)
fastmcp: The FastMCP server instance
Returns:
True if the input was successfully stored, False otherwise
"""
docket = fastmcp._docket
if docket is None:
return False
_, response_key, status_key = _elicit_keys(task_scope, task_id)
response = {
"action": action,
"content": content,
}
async with docket.redis() as redis:
# Check if there's a pending elicitation
status = await redis.get(docket.key(status_key))
if status is None or status.decode("utf-8") != "waiting":
return False
# Push response to list - this wakes up the BLPOP in elicit_for_task
# Using LPUSH instead of SET enables the efficient blocking wait pattern
await redis.lpush(
docket.key(response_key),
json.dumps(response),
)
# Set TTL on the response list (in case BLPOP doesn't consume it)
await redis.expire(docket.key(response_key), ELICIT_TTL_SECONDS)
# Update status to "responded"
await redis.set(
docket.key(status_key),
"responded",
ex=ELICIT_TTL_SECONDS,
)
return True

View file

@ -1,263 +0,0 @@
"""SEP-1686 task execution handlers.
Handles queuing tool/prompt/resource executions to Docket as background tasks.
"""
from __future__ import annotations
import asyncio
import uuid
from contextlib import suppress
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Literal
import mcp_types
from mcp.shared.exceptions import MCPError
from mcp_types import INTERNAL_ERROR
from fastmcp.server.dependencies import (
_current_docket,
get_context,
)
from fastmcp.server.tasks.config import TaskMeta
from fastmcp.server.tasks.context import (
TaskContextSnapshot,
get_task_scope,
register_task_server,
register_task_session,
)
from fastmcp.server.tasks.keys import build_task_key, task_redis_prefix
from fastmcp.tools.function_tool import _strict_input_validation
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
from fastmcp.prompts.base import Prompt
from fastmcp.resources.base import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.tools.base import Tool
logger = get_logger(__name__)
# Redis mapping TTL buffer: Add 15 minutes to Docket's execution_ttl
TASK_MAPPING_TTL_BUFFER_SECONDS = 15 * 60
async def submit_to_docket(
task_type: Literal["tool", "resource", "template", "prompt"],
key: str,
component: Tool | Resource | ResourceTemplate | Prompt,
arguments: dict[str, Any] | None = None,
task_meta: TaskMeta | None = None,
) -> mcp_types.CreateTaskResult:
"""Submit any component to Docket for background execution (SEP-1686).
Unified handler for all component types. Called by component's internal
methods (_run, _read, _render) when task metadata is present and mode allows.
Queues the component's method to Docket, stores raw return values,
and converts to MCP types on retrieval.
Args:
task_type: Component type for task key construction
key: The component key as seen by MCP layer (with namespace prefix)
component: The component instance (Tool, Resource, ResourceTemplate, Prompt)
arguments: Arguments/params (None for Resource which has no args)
task_meta: Task execution metadata. If task_meta.ttl is provided, it
overrides the server default (docket.execution_ttl).
Returns:
CreateTaskResult: Task stub with proper Task object
"""
# Validate and coerce arguments before creating any task state. A failure
# here must surface before the Redis metadata and initial "working"
# notification below are written, otherwise an invalid input would orphan a
# task the client has already observed (#4349).
#
# Honor the server's strict_input_validation setting so a strict tool
# rejects lax coercions (e.g. {"n": "1"} for n: int) at submission just as
# it does on the synchronous call path — otherwise task=True would bypass
# strict validation entirely.
if arguments is not None:
arguments = component.coerce_task_arguments(
arguments, strict=_strict_input_validation()
)
# Generate server-side task ID per SEP-1686 final spec (line 375-377)
# Server MUST generate task IDs, clients no longer provide them
server_task_id = str(uuid.uuid4())
# Record creation timestamp per SEP-1686 final spec (line 430). SDK v2
# types `Task.created_at` / `TaskStatusNotificationParams.created_at` as ISO
# strings, so carry a serialized copy for wire-crossing models.
created_at = datetime.now(timezone.utc)
created_at_iso = created_at.isoformat()
ctx = get_context()
# Authorization scope for task isolation (auth identity, or None for anonymous)
task_scope = get_task_scope()
# Transport session ID for notification delivery
try:
session_id = ctx.session_id
except RuntimeError:
session_id = None
# Try the server's own Docket first; fall back to the ContextVar for
# mounted children (whose parent server owns the Docket instance).
docket = ctx.fastmcp._docket or _current_docket.get()
if docket is None:
raise MCPError(
code=INTERNAL_ERROR,
message="Background tasks require a running FastMCP server context",
)
# Register the current server so background workers resolve
# CurrentFastMCP() / ctx.fastmcp to the correct (child) server
# for mounted tasks. At this point ctx.fastmcp is the child because
# we're inside the child's call_tool dispatch.
register_task_server(server_task_id, ctx.fastmcp)
# Build full task key with embedded metadata
task_key = build_task_key(task_scope, server_task_id, task_type, key)
# Determine TTL: use task_meta.ttl if provided, else docket default
if task_meta is not None and task_meta.ttl is not None:
ttl_ms = task_meta.ttl
else:
ttl_ms = int(docket.execution_ttl.total_seconds() * 1000)
ttl_seconds = int(ttl_ms / 1000) + TASK_MAPPING_TTL_BUFFER_SECONDS
# Store task metadata in Redis for protocol handlers
prefix = task_redis_prefix(task_scope)
task_meta_key = docket.key(f"{prefix}:{server_task_id}")
created_at_key = docket.key(f"{prefix}:{server_task_id}:created_at")
poll_interval_key = docket.key(f"{prefix}:{server_task_id}:poll_interval")
poll_interval_ms = int(component.task_config.poll_interval.total_seconds() * 1000)
# Snapshot all context (access token, headers, origin request ID,
# and session_id for notification delivery in background workers)
snapshot = TaskContextSnapshot.capture()
async with docket.redis() as redis:
await redis.set(task_meta_key, task_key, ex=ttl_seconds)
await redis.set(created_at_key, created_at.isoformat(), ex=ttl_seconds)
await redis.set(poll_interval_key, str(poll_interval_ms), ex=ttl_seconds)
await snapshot.save(docket, task_scope, server_task_id, ttl_seconds)
# Register session for Context access in background workers (SEP-1686)
# This enables elicitation/sampling from background tasks via weakref
# Skip when there is no session (programmatic calls without MCP session)
if session_id is not None:
register_task_session(session_id, ctx.session)
# Send an initial tasks/status notification before queueing.
# This guarantees clients can observe task creation immediately.
notification = mcp_types.TaskStatusNotification.model_validate(
{
"method": "notifications/tasks/status",
"params": {
"taskId": server_task_id,
"status": "working",
"statusMessage": "Task submitted",
"createdAt": created_at_iso,
"lastUpdatedAt": created_at_iso,
"ttl": ttl_ms,
"pollInterval": poll_interval_ms,
},
"_meta": {
"io.modelcontextprotocol/related-task": {
"taskId": server_task_id,
}
},
}
)
# SDK v2: `ServerNotification` is a union type, not a wrapper class;
# `send_notification` takes the bare notification model directly.
with suppress(Exception):
# Don't let notification failures break task creation
await ctx.session.send_notification(notification) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
# Queue function to Docket by key (result storage via execution_ttl)
# Use component.add_to_docket() which handles calling conventions
# `fn_key` is the function lookup key (e.g., "child_multiply")
# `task_key` is the task result key (e.g., "fastmcp:task:{task_scope}:{task_id}:tool:child_multiply")
# Resources don't take arguments; tools/prompts/templates always pass arguments (even if None/empty)
if task_type == "resource":
await component.add_to_docket(docket, fn_key=key, task_key=task_key) # type: ignore[call-arg] # ty:ignore[missing-argument]
else:
await component.add_to_docket(docket, arguments, fn_key=key, task_key=task_key) # type: ignore[call-arg] # ty:ignore[invalid-argument-type, too-many-positional-arguments]
# Spawn subscription task to send status notifications (SEP-1686 optional feature).
# SDK v2 constructs a ServerSession per request and exposes no per-connection
# task group, so the subscription runs as a standalone asyncio task that
# outlives the submitting request; it is cancelled when the connection closes.
# Deferred: subscriptions and notifications depend on docket at import time
from fastmcp.server.tasks.subscriptions import subscribe_to_task_updates
subscription_task = asyncio.create_task(
subscribe_to_task_updates(
server_task_id,
task_key,
ctx.session,
docket,
poll_interval_ms,
),
name=f"task-subscription-{server_task_id[:8]}",
)
connection = getattr(ctx.session, "_connection", None)
if connection is not None:
async def _cancel_subscription() -> None:
if not subscription_task.done():
subscription_task.cancel()
with suppress(asyncio.CancelledError):
await subscription_task
connection.exit_stack.push_async_callback(_cancel_subscription)
# Deferred: notifications depends on docket at import time
from fastmcp.server.tasks.notifications import (
ensure_subscriber_running,
stop_subscriber,
)
if session_id is not None:
try:
await ensure_subscriber_running(
session_id, ctx.session, docket, ctx.fastmcp
)
# Register cleanup callback on connection exit (once per session).
# SDK v2 constructs ServerSession per request, so the stable
# per-connection lifecycle hook lives on the underlying Connection
# (`connection.exit_stack`), not the session. The registration flag
# is likewise stashed on the connection's `state` so it survives
# across requests.
connection = getattr(ctx.session, "_connection", None)
if connection is not None and not connection.state.get(
"_notification_cleanup_registered"
):
async def _cleanup_subscriber() -> None:
await stop_subscriber(session_id) # type: ignore[arg-type]
connection.exit_stack.push_async_callback(_cleanup_subscriber)
connection.state["_notification_cleanup_registered"] = True
except Exception as e:
# Non-fatal: elicitation will still work via polling fallback
logger.debug("Failed to start notification subscriber: %s", e)
# Return CreateTaskResult with proper Task object
# Tasks MUST begin in "working" status per SEP-1686 final spec (line 381)
return mcp_types.CreateTaskResult(
task=mcp_types.Task(
task_id=server_task_id,
status="working",
created_at=created_at_iso,
last_updated_at=created_at_iso,
ttl=ttl_ms,
poll_interval=poll_interval_ms,
)
)

View file

@ -1,312 +0,0 @@
"""Distributed notification queue for background task events (SEP-1686).
Enables distributed Docket workers to send MCP notifications to clients
without holding session references. Workers push to a Redis queue,
the MCP server process subscribes and forwards to the client's session.
Pattern: Fire-and-forward with retry
- One queue per session_id
- LPUSH/BRPOP for reliable ordered delivery
- Retry up to 3 times on delivery failure, then discard
- TTL-based expiration for stale messages
Note: Docket's execution.subscribe() handles task state/progress events via
Redis Pub/Sub. This module handles elicitation-specific notifications that
require reliable delivery (input_required prompts, cancel signals).
"""
from __future__ import annotations
import asyncio
import json
import logging
import weakref
from contextlib import suppress
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
import mcp_types
if TYPE_CHECKING:
from docket import Docket
from mcp.server.session import ServerSession
from fastmcp.server.server import FastMCP
logger = logging.getLogger(__name__)
# Redis key patterns
NOTIFICATION_QUEUE_KEY = "fastmcp:notifications:{session_id}"
NOTIFICATION_ACTIVE_KEY = "fastmcp:notifications:{session_id}:active"
# Configuration
NOTIFICATION_TTL_SECONDS = 300 # 5 minute message TTL (elicitation response window)
MAX_DELIVERY_ATTEMPTS = 3 # Retry failed deliveries before discarding
SUBSCRIBER_TIMEOUT_SECONDS = 30 # BRPOP timeout (also heartbeat interval)
async def push_notification(
session_id: str,
notification: dict[str, Any],
docket: Docket,
) -> None:
"""Push notification to session's queue (called from Docket worker).
Used for elicitation-specific notifications (input_required, cancel)
that need reliable delivery across distributed processes.
Args:
session_id: Target session's identifier
notification: MCP notification dict (method, params, _meta)
docket: Docket instance for Redis access
"""
key = docket.key(NOTIFICATION_QUEUE_KEY.format(session_id=session_id))
message = json.dumps(
{
"notification": notification,
"attempt": 0,
"enqueued_at": datetime.now(timezone.utc).isoformat(),
}
)
async with docket.redis() as redis:
await redis.lpush(key, message)
await redis.expire(key, NOTIFICATION_TTL_SECONDS)
async def notification_subscriber_loop(
session_id: str,
session: ServerSession,
docket: Docket,
fastmcp: FastMCP,
) -> None:
"""Subscribe to notification queue and forward to session.
Runs in the MCP server process. Bridges distributed workers to clients.
This loop:
1. Maintains a heartbeat (active subscriber marker for debugging)
2. Blocks on BRPOP waiting for notifications
3. Forwards notifications to the client's session
4. Retries failed deliveries, then discards (no dead-letter queue)
Args:
session_id: Session identifier to subscribe to
session: MCP ServerSession for sending notifications
docket: Docket instance for Redis access
fastmcp: FastMCP server instance (for elicitation relay)
"""
queue_key = docket.key(NOTIFICATION_QUEUE_KEY.format(session_id=session_id))
active_key = docket.key(NOTIFICATION_ACTIVE_KEY.format(session_id=session_id))
logger.debug("Starting notification subscriber for session %s", session_id)
while True:
try:
async with docket.redis() as redis:
# Heartbeat: mark subscriber as active (for distributed debugging)
await redis.set(active_key, "1", ex=SUBSCRIBER_TIMEOUT_SECONDS * 2)
# Blocking wait for notification (timeout refreshes heartbeat)
# Using BRPOP (right pop) for FIFO order with LPUSH (left push)
result = await redis.brpop(
[queue_key], timeout=SUBSCRIBER_TIMEOUT_SECONDS
)
if not result:
continue # Timeout - refresh heartbeat and retry
_, message_bytes = result
message = json.loads(message_bytes)
notification_dict = message["notification"]
attempt = message.get("attempt", 0)
try:
# Reconstruct and send MCP notification
await _send_mcp_notification(
session, notification_dict, session_id, docket, fastmcp
)
logger.debug(
"Delivered notification to session %s (attempt %d)",
session_id,
attempt + 1,
)
except Exception as send_error:
# Delivery failed - retry or discard
if attempt < MAX_DELIVERY_ATTEMPTS - 1:
# Re-queue with incremented attempt (back of queue)
message["attempt"] = attempt + 1
message["last_error"] = str(send_error)
await redis.lpush(queue_key, json.dumps(message))
logger.debug(
"Requeued notification for session %s (attempt %d): %s",
session_id,
attempt + 2,
send_error,
)
else:
# Discard after max attempts (session likely disconnected)
logger.warning(
"Discarding notification for session %s after %d attempts: %s",
session_id,
MAX_DELIVERY_ATTEMPTS,
send_error,
)
except asyncio.CancelledError:
# Graceful shutdown - leave pending messages in queue for reconnect
logger.debug("Notification subscriber cancelled for session %s", session_id)
break
except Exception as e:
logger.debug(
"Notification subscriber error for session %s: %s", session_id, e
)
await asyncio.sleep(1) # Backoff on error
async def _send_mcp_notification(
session: ServerSession,
notification_dict: dict[str, Any],
session_id: str,
docket: Docket,
fastmcp: FastMCP,
) -> None:
"""Reconstruct MCP notification from dict and send to session.
For input_required notifications with elicitation metadata, also sends
a standard elicitation/create request to the client and relays the
response back to the worker via Redis.
Args:
session: MCP ServerSession
notification_dict: Notification as dict (method, params, _meta)
session_id: Session identifier (for elicitation relay)
docket: Docket instance (for notification delivery)
fastmcp: FastMCP server instance (for elicitation relay)
"""
method = notification_dict.get("method", "notifications/tasks/status")
if method != "notifications/tasks/status":
raise ValueError(f"Unsupported notification method for subscriber: {method}")
# SDK v2: a notification's `_meta` lives on its params (`params._meta`), not
# at the notification envelope level, so nest it under params before parsing.
params_dict = dict(notification_dict.get("params", {}))
meta_dict = notification_dict.get("_meta")
if meta_dict is not None:
params_dict["_meta"] = meta_dict
notification = mcp_types.TaskStatusNotification.model_validate(
{
"method": "notifications/tasks/status",
"params": params_dict,
}
)
# SDK v2: `ServerNotification` is a union type; send the bare model.
await session.send_notification(notification) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
# If this is an input_required notification with elicitation metadata,
# relay the elicitation to the client via standard elicitation/create
params = notification_dict.get("params", {})
if params.get("status") == "input_required":
meta = notification_dict.get("_meta", {})
related_task = meta.get("io.modelcontextprotocol/related-task", {})
elicitation = related_task.get("elicitation")
if elicitation:
task_id = params.get("taskId")
if not task_id:
logger.warning(
"input_required notification missing taskId, skipping relay"
)
return
if "task_scope" not in related_task:
logger.warning(
"input_required notification for task %s missing task_scope "
"metadata, skipping elicitation relay",
task_id,
)
return
task_scope = related_task["task_scope"]
from fastmcp.server.tasks.elicitation import relay_elicitation
task = asyncio.create_task(
relay_elicitation(session, task_scope, task_id, elicitation, fastmcp),
name=f"elicitation-relay-{task_id[:8]}",
)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
# =============================================================================
# Subscriber Management
# =============================================================================
# Strong references to fire-and-forget relay tasks (prevent GC mid-flight)
_background_tasks: set[asyncio.Task[None]] = set()
# Registry of active subscribers per session (prevents duplicates)
# Uses weakref to session to detect disconnects
_active_subscribers: dict[
str, tuple[asyncio.Task[None], weakref.ref[ServerSession]]
] = {}
async def ensure_subscriber_running(
session_id: str,
session: ServerSession,
docket: Docket,
fastmcp: FastMCP,
) -> None:
"""Start notification subscriber if not already running (idempotent).
Subscriber is created on first task submission and cleaned up on disconnect.
Safe to call multiple times for the same session.
Args:
session_id: Session identifier
session: MCP ServerSession
docket: Docket instance
fastmcp: FastMCP server instance (for elicitation relay)
"""
# Check if subscriber already running for this session
if session_id in _active_subscribers:
task, session_ref = _active_subscribers[session_id]
# Check if task is still running AND session is still alive
if not task.done() and session_ref() is not None:
return # Already running
# Task finished or session dead - clean up
if not task.done():
task.cancel()
with suppress(asyncio.CancelledError):
await task
del _active_subscribers[session_id]
# Start new subscriber task
task = asyncio.create_task(
notification_subscriber_loop(session_id, session, docket, fastmcp),
name=f"notification-subscriber-{session_id[:8]}",
)
_active_subscribers[session_id] = (task, weakref.ref(session))
logger.debug("Started notification subscriber for session %s", session_id)
async def stop_subscriber(session_id: str) -> None:
"""Stop notification subscriber for a session.
Called when session disconnects. Pending messages remain in queue
for delivery if client reconnects (with TTL expiration).
Args:
session_id: Session identifier
"""
if session_id not in _active_subscribers:
return
task, _ = _active_subscribers.pop(session_id)
if not task.done():
task.cancel()
with suppress(asyncio.CancelledError):
await task
logger.debug("Stopped notification subscriber for session %s", session_id)
def get_subscriber_count() -> int:
"""Get number of active subscribers (for monitoring)."""
return len(_active_subscribers)

View file

@ -1,469 +0,0 @@
"""SEP-1686 task request handlers.
Handles MCP task protocol requests: tasks/get, tasks/result, tasks/list, tasks/cancel.
These handlers query and manage existing tasks (contrast with handlers.py which creates tasks).
This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Literal
import mcp_types
from docket.execution import ExecutionState
from mcp.shared.exceptions import MCPError
from mcp_types import (
INTERNAL_ERROR,
INVALID_PARAMS,
CancelTaskResult,
GetTaskResult,
ListTasksResult,
)
import fastmcp.server.context
from fastmcp.exceptions import NotFoundError
from fastmcp.prompts.base import Prompt
from fastmcp.resources.base import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.tasks.config import DEFAULT_POLL_INTERVAL_MS, DEFAULT_TTL_MS
from fastmcp.server.tasks.context import get_task_scope
from fastmcp.server.tasks.keys import parse_task_key, task_redis_prefix
from fastmcp.tools.base import InputRequiredToolResult, Tool
from fastmcp.utilities.versions import VersionSpec
if TYPE_CHECKING:
from fastmcp.server.server import FastMCP
# Map Docket execution states to MCP task status strings
# Per SEP-1686 final spec (line 381): tasks MUST begin in "working" status
DOCKET_TO_MCP_STATE: dict[ExecutionState, str] = {
ExecutionState.SCHEDULED: "working", # Initial state per spec
ExecutionState.QUEUED: "working", # Initial state per spec
ExecutionState.RUNNING: "working",
ExecutionState.COMPLETED: "completed",
ExecutionState.FAILED: "failed",
ExecutionState.CANCELLED: "cancelled",
}
def _normalize_iso_timestamp(stored: str | None) -> str:
"""Return an ISO 8601 timestamp string for a Task's createdAt/lastUpdatedAt.
The v2 Task model types these fields as ISO 8601 strings. `stored` is the
value read from Redis (already an ISO string) or None; either way this
returns a valid ISO string, falling back to the current UTC time.
"""
if stored:
try:
return datetime.fromisoformat(stored.replace("Z", "+00:00")).isoformat()
except (ValueError, AttributeError):
pass
return datetime.now(timezone.utc).isoformat()
def _parse_key_version(key_suffix: str) -> tuple[str, str | None]:
"""Parse a key suffix into (name_or_uri, version).
Keys always contain @ as a version delimiter (sentinel pattern):
- "add@1.0" ("add", "1.0") # versioned
- "add@" ("add", None) # unversioned
- "user@example.com@1.0" ("user@example.com", "1.0") # @ in URI
Uses rsplit to split on the LAST @ which is always the version delimiter.
Falls back to treating the whole string as the name if @ is not present
(for backwards compatibility with legacy task keys).
"""
if "@" not in key_suffix:
# Legacy key without version sentinel - treat as unversioned
return key_suffix, None
name_or_uri, version = key_suffix.rsplit("@", 1)
return name_or_uri, version if version else None
async def _lookup_task_execution(
docket: Any,
task_scope: str | None,
client_task_id: str,
) -> tuple[Any, str | None, int]:
"""Look up task execution and metadata from Redis.
Consolidates the common pattern of fetching task metadata from Redis,
validating it exists, and retrieving the Docket execution.
Args:
docket: Docket instance
task_scope: Authorization scope
client_task_id: Client-provided task ID
Returns:
Tuple of (execution, created_at, poll_interval_ms)
Raises:
MCPError: If task not found or execution not found
"""
prefix = task_redis_prefix(task_scope)
task_meta_key = docket.key(f"{prefix}:{client_task_id}")
created_at_key = docket.key(f"{prefix}:{client_task_id}:created_at")
poll_interval_key = docket.key(f"{prefix}:{client_task_id}:poll_interval")
# Fetch metadata (single round-trip with mget)
async with docket.redis() as redis:
task_key_bytes, created_at_bytes, poll_interval_bytes = await redis.mget(
task_meta_key, created_at_key, poll_interval_key
)
# Decode and validate task_key
task_key = task_key_bytes.decode("utf-8") if task_key_bytes else None
if not task_key:
raise MCPError(code=INVALID_PARAMS, message=f"Task {client_task_id} not found")
# Get execution
execution = await docket.get_execution(task_key)
if not execution:
raise MCPError(
code=INVALID_PARAMS,
message=f"Task {client_task_id} execution not found",
)
# Parse metadata with defaults
created_at = created_at_bytes.decode("utf-8") if created_at_bytes else None
try:
poll_interval_ms = (
int(poll_interval_bytes.decode("utf-8"))
if poll_interval_bytes
else DEFAULT_POLL_INTERVAL_MS
)
except (ValueError, UnicodeDecodeError):
poll_interval_ms = DEFAULT_POLL_INTERVAL_MS
return execution, created_at, poll_interval_ms
async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskResult:
"""Handle MCP 'tasks/get' request (SEP-1686).
Args:
server: FastMCP server instance
params: Request params containing taskId
Returns:
GetTaskResult: Task status response with spec-compliant fields
"""
async with fastmcp.server.context.Context(fastmcp=server):
client_task_id = params.get("taskId")
if not client_task_id:
raise MCPError(
code=INVALID_PARAMS, message="Missing required parameter: taskId"
)
# Get authorization scope for task lookup
task_scope = get_task_scope()
# Get Docket instance
docket = server._docket
if docket is None:
raise MCPError(
code=INTERNAL_ERROR,
message="Background tasks require Docket",
)
# Look up task execution and metadata
execution, created_at, poll_interval_ms = await _lookup_task_execution(
docket, task_scope, client_task_id
)
# Sync state from Redis
await execution.sync()
# Map Docket state to MCP state
state_map = DOCKET_TO_MCP_STATE
mcp_state: Literal[
"working", "input_required", "completed", "failed", "cancelled"
] = state_map.get(execution.state, "failed") # type: ignore[assignment] # ty:ignore[invalid-assignment]
# Build response (use default ttl since we don't track per-task values)
# createdAt is REQUIRED per SEP-1686 final spec (line 430)
# Per spec lines 447-448: SHOULD NOT include related-task metadata in tasks/get
error_message = None
status_message = None
if execution.state == ExecutionState.FAILED:
try:
await execution.get_result(timeout=timedelta(seconds=0))
except Exception as error:
error_message = str(error)
status_message = f"Task failed: {error_message}"
elif execution.progress and execution.progress.message:
# Extract progress message from Docket if available (spec line 403)
status_message = execution.progress.message
# createdAt is required per spec, but can be None from Redis. The v2
# Task model types createdAt/lastUpdatedAt as ISO 8601 strings, so
# normalize the stored value (or fall back to now) to an ISO string.
created_at_iso = _normalize_iso_timestamp(created_at)
return GetTaskResult(
task_id=client_task_id,
status=mcp_state,
created_at=created_at_iso,
last_updated_at=datetime.now(timezone.utc).isoformat(),
ttl=DEFAULT_TTL_MS,
poll_interval=poll_interval_ms,
status_message=status_message,
)
async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
"""Handle MCP 'tasks/result' request (SEP-1686).
Converts raw task return values to MCP types based on task type.
Args:
server: FastMCP server instance
params: Request params containing taskId
Returns:
MCP result (CallToolResult, GetPromptResult, or ReadResourceResult)
"""
async with fastmcp.server.context.Context(fastmcp=server):
client_task_id = params.get("taskId")
if not client_task_id:
raise MCPError(
code=INVALID_PARAMS, message="Missing required parameter: taskId"
)
# Get authorization scope for task lookup
task_scope = get_task_scope()
# Get execution from Docket (use instance attribute for cross-task access)
docket = server._docket
if docket is None:
raise MCPError(
code=INTERNAL_ERROR,
message="Background tasks require Docket",
)
# Look up full task key from Redis
task_meta_key = docket.key(f"{task_redis_prefix(task_scope)}:{client_task_id}")
async with docket.redis() as redis:
task_key_bytes = await redis.get(task_meta_key)
task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8")
if task_key is None:
raise MCPError(
code=INVALID_PARAMS,
message=f"Invalid taskId: {client_task_id} not found",
)
execution = await docket.get_execution(task_key)
if execution is None:
raise MCPError(
code=INVALID_PARAMS,
message=f"Invalid taskId: {client_task_id} not found",
)
# Sync state from Redis
await execution.sync()
# Check if completed
state_map = DOCKET_TO_MCP_STATE
if execution.state not in (ExecutionState.COMPLETED, ExecutionState.FAILED):
mcp_state = state_map.get(execution.state, "failed")
raise MCPError(
code=INVALID_PARAMS,
message=f"Task not completed yet (current state: {mcp_state})",
)
# Get result from Docket
try:
raw_value = await execution.get_result(timeout=timedelta(seconds=0))
except Exception as error:
# Task failed - return error result
return mcp_types.CallToolResult(
content=[mcp_types.TextContent(type="text", text=str(error))],
is_error=True,
_meta={ # type: ignore[call-arg] # _meta is Pydantic alias for meta field
"io.modelcontextprotocol/related-task": {
"taskId": client_task_id,
}
},
)
# Parse task key to get component key
key_parts = parse_task_key(task_key)
component_key = key_parts["component_identifier"]
# Look up component by its prefixed key (inlined from deleted get_component)
component: Tool | Resource | ResourceTemplate | Prompt | None = None
try:
if component_key.startswith("tool:"):
name, version_str = _parse_key_version(component_key[5:])
version = VersionSpec(eq=version_str) if version_str else None
component = await server.get_tool(name, version)
elif component_key.startswith("resource:"):
uri, version_str = _parse_key_version(component_key[9:])
version = VersionSpec(eq=version_str) if version_str else None
component = await server.get_resource(uri, version)
elif component_key.startswith("template:"):
uri, version_str = _parse_key_version(component_key[9:])
version = VersionSpec(eq=version_str) if version_str else None
component = await server.get_resource_template(uri, version)
elif component_key.startswith("prompt:"):
name, version_str = _parse_key_version(component_key[7:])
version = VersionSpec(eq=version_str) if version_str else None
component = await server.get_prompt(name, version)
except NotFoundError:
component = None
if component is None:
raise MCPError(
code=INTERNAL_ERROR,
message=f"Component not found for task: {component_key}",
)
# Build related-task metadata
related_task_meta = {
"io.modelcontextprotocol/related-task": {
"taskId": client_task_id,
}
}
# Convert based on component type.
# Each branch merges related_task_meta with any existing _meta
# (e.g. fastmcp.wrap_result) rather than overwriting it.
if isinstance(component, Tool):
if isinstance(
raw_value, mcp_types.InputRequiredResult | InputRequiredToolResult
):
raise MCPError(
code=INTERNAL_ERROR,
message=(
f"Tool {component_key!r} requested input while running as a "
"background task. Input-required (multi-round-trip) tools "
"need a live request to answer the prompt and cannot run as "
"tasks; remove task execution from this tool or the code path "
"that returns an InputRequiredResult."
),
)
fastmcp_result = component.convert_result(raw_value)
mcp_result = fastmcp_result.to_mcp_result()
if isinstance(mcp_result, mcp_types.CallToolResult):
merged = {**(mcp_result.meta or {}), **related_task_meta}
mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
elif isinstance(mcp_result, tuple):
content, structured_content = mcp_result
mcp_result = mcp_types.CallToolResult(
content=content,
structured_content=structured_content,
_meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field
)
else:
mcp_result = mcp_types.CallToolResult(
content=mcp_result,
_meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field
)
return mcp_result
elif isinstance(component, Prompt):
fastmcp_result = component.convert_result(raw_value)
mcp_result = fastmcp_result.to_mcp_prompt_result()
merged = {**(mcp_result.meta or {}), **related_task_meta}
mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
return mcp_result
elif isinstance(component, ResourceTemplate):
fastmcp_result = component.convert_result(raw_value)
mcp_result = fastmcp_result.to_mcp_result(component.uri_template)
merged = {**(mcp_result.meta or {}), **related_task_meta}
mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
return mcp_result
elif isinstance(component, Resource):
fastmcp_result = component.convert_result(raw_value)
mcp_result = fastmcp_result.to_mcp_result(str(component.uri))
merged = {**(mcp_result.meta or {}), **related_task_meta}
mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
return mcp_result
else:
raise MCPError(
code=INTERNAL_ERROR,
message=f"Internal error: Unknown component type: {type(component).__name__}",
)
async def tasks_list_handler(
server: FastMCP, params: dict[str, Any]
) -> ListTasksResult:
"""Handle MCP 'tasks/list' request (SEP-1686).
Note: With client-side tracking, this returns minimal info.
Args:
server: FastMCP server instance
params: Request params (cursor, limit)
Returns:
ListTasksResult: Response with tasks list and pagination
"""
# Return empty list - client tracks tasks locally
return ListTasksResult(tasks=[], next_cursor=None)
async def tasks_cancel_handler(
server: FastMCP, params: dict[str, Any]
) -> CancelTaskResult:
"""Handle MCP 'tasks/cancel' request (SEP-1686).
Cancels a running task, transitioning it to cancelled state.
Args:
server: FastMCP server instance
params: Request params containing taskId
Returns:
CancelTaskResult: Task status response showing cancelled state
"""
async with fastmcp.server.context.Context(fastmcp=server):
client_task_id = params.get("taskId")
if not client_task_id:
raise MCPError(
code=INVALID_PARAMS, message="Missing required parameter: taskId"
)
# Get authorization scope for task lookup
task_scope = get_task_scope()
# Get Docket instance
docket = server._docket
if docket is None:
raise MCPError(
code=INTERNAL_ERROR,
message="Background tasks require Docket",
)
# Look up task execution and metadata
execution, created_at, poll_interval_ms = await _lookup_task_execution(
docket, task_scope, client_task_id
)
# Cancel via Docket (now sets CANCELLED state natively)
# Note: We need to get task_key from execution.key for cancellation
await docket.cancel(execution.key)
# Return task status with cancelled state
# createdAt is REQUIRED per SEP-1686 final spec (line 430)
# Per spec lines 447-448: SHOULD NOT include related-task metadata in tasks/cancel
return CancelTaskResult(
task_id=client_task_id,
status="cancelled",
created_at=_normalize_iso_timestamp(created_at),
last_updated_at=datetime.now(timezone.utc).isoformat(),
ttl=DEFAULT_TTL_MS,
poll_interval=poll_interval_ms,
status_message="Task cancelled",
)

View file

@ -1,72 +0,0 @@
"""Task routing helper for MCP components.
Provides unified task mode enforcement and docket routing logic.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal
import mcp_types
from mcp.shared.exceptions import MCPError
from mcp_types import METHOD_NOT_FOUND
from fastmcp.server.tasks.config import TaskMeta
from fastmcp.server.tasks.handlers import submit_to_docket
if TYPE_CHECKING:
from fastmcp.prompts.base import Prompt
from fastmcp.resources.base import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.tools.base import Tool
TaskType = Literal["tool", "resource", "template", "prompt"]
async def check_background_task(
component: Tool | Resource | ResourceTemplate | Prompt,
task_type: TaskType,
arguments: dict[str, Any] | None = None,
task_meta: TaskMeta | None = None,
) -> mcp_types.CreateTaskResult | None:
"""Check task mode and submit to background if requested.
Args:
component: The MCP component
task_type: Type of task ("tool", "resource", "template", "prompt")
arguments: Arguments for tool/prompt/template execution
task_meta: Task execution metadata. If provided, execute as background task.
Returns:
CreateTaskResult if submitted to docket, None for sync execution
Raises:
MCPError: If mode="required" but no task metadata, or mode="forbidden"
but task metadata is present
"""
task_config = component.task_config
# Infer label from component
entity_label = f"{type(component).__name__} '{component.title or component.key}'"
# Enforce mode="required" - must have task metadata
if task_config.mode == "required" and not task_meta:
raise MCPError(
code=METHOD_NOT_FOUND,
message=f"{entity_label} requires task-augmented execution",
)
# Enforce mode="forbidden" - cannot be called with task metadata
if not task_config.supports_tasks() and task_meta:
raise MCPError(
code=METHOD_NOT_FOUND,
message=f"{entity_label} does not support task-augmented execution",
)
# No task metadata - synchronous execution
if not task_meta:
return None
# fn_key is expected to be set; fall back to component.key for direct calls
fn_key = task_meta.fn_key or component.key
return await submit_to_docket(task_type, fn_key, component, arguments, task_meta)

View file

@ -1,282 +0,0 @@
"""Task subscription helpers for sending MCP notifications (SEP-1686).
Subscribes to Docket execution state changes and sends notifications/tasks/status
to clients when their tasks change state.
This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available.
"""
from __future__ import annotations
import asyncio
from contextlib import suppress
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from docket.execution import ExecutionState
from mcp_types import TaskStatusNotification, TaskStatusNotificationParams
from fastmcp.server.tasks.config import DEFAULT_TTL_MS
from fastmcp.server.tasks.keys import parse_task_key, task_redis_prefix
from fastmcp.server.tasks.requests import DOCKET_TO_MCP_STATE
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
from mcp.server.session import ServerSession
logger = get_logger(__name__)
# Initial interval for reconciling execution state against Redis (seconds). The
# interval doubles on each idle reconcile up to the task's poll_interval, so fast
# tasks are caught within the first ~20ms checks while long-running tasks converge
# to roughly one sync per advertised poll interval.
_MIN_RECONCILE_INTERVAL_SECONDS = 0.02
async def subscribe_to_task_updates(
task_id: str,
task_key: str,
session: ServerSession,
docket: Docket,
poll_interval_ms: int = 5000,
) -> None:
"""Subscribe to Docket execution events and send MCP notifications.
Per SEP-1686 lines 436-444, servers MAY send notifications/tasks/status
when task state changes. This is an optional optimization that reduces
client polling frequency.
Args:
task_id: Client-visible task ID (server-generated UUID)
task_key: Internal Docket execution key (includes session, type, component)
session: MCP ServerSession for sending notifications
docket: Docket instance for subscribing to execution events
poll_interval_ms: Poll interval in milliseconds to include in notifications
Note: Docket's ``execution.subscribe()`` replays the current state and a progress
event before it subscribes to Redis pub/sub. A task that completes during that
window has its terminal state publish lost, so no live event ever arrives a
common case for fast tasks. Because there is no reliable signal for when the
subscription goes live (the replayed state event arrives two iterations early),
we simply reconcile the execution against Redis on every idle interval until a
terminal state is observed. The interval backs off exponentially toward the
task's advertised poll interval, so a long-running task costs about one sync per
poll interval while live pub/sub events still short-circuit the wait instantly.
"""
terminal_states = {
ExecutionState.COMPLETED,
ExecutionState.FAILED,
ExecutionState.CANCELLED,
}
try:
execution = await docket.get_execution(task_key)
if execution is None:
logger.warning(f"No execution found for task {task_id}")
return
subscription = execution.subscribe()
# Keep a single outstanding __anext__ across reconcile timeouts. asyncio.wait
# returns on timeout without cancelling it, so the generator (and its pub/sub
# subscription) stays intact — unlike wait_for, which would cancel mid-iteration.
next_event = asyncio.ensure_future(subscription.__anext__())
# Reconcile cadence backs off exponentially so a task that runs for a long
# time (or that no worker ever claims) doesn't pin this loop at 50 syncs/sec
# forever; the task's advertised poll interval is the natural ceiling.
reconcile_backoff = _MIN_RECONCILE_INTERVAL_SECONDS
reconcile_ceiling = max(
poll_interval_ms / 1000, _MIN_RECONCILE_INTERVAL_SECONDS
)
try:
while True:
done, _ = await asyncio.wait({next_event}, timeout=reconcile_backoff)
if not done:
# No live event yet: reconcile against Redis in case a
# terminal transition was published before pub/sub went live.
await execution.sync()
if execution.state in terminal_states:
await _send_status_notification(
session=session,
task_id=task_id,
task_key=task_key,
docket=docket,
state=execution.state,
poll_interval_ms=poll_interval_ms,
)
break
reconcile_backoff = min(reconcile_backoff * 2, reconcile_ceiling)
continue
try:
event = next_event.result()
except StopAsyncIteration:
break
if event["type"] == "state":
state = ExecutionState(event["state"])
# Send notifications/tasks/status when state changes
await _send_status_notification(
session=session,
task_id=task_id,
task_key=task_key,
docket=docket,
state=state,
poll_interval_ms=poll_interval_ms,
)
# Stop subscribing once the task reaches a terminal state
if state in terminal_states:
break
elif event["type"] == "progress":
# Send notification when progress message changes
await _send_progress_notification(
session=session,
task_id=task_id,
task_key=task_key,
docket=docket,
execution=execution,
poll_interval_ms=poll_interval_ms,
)
next_event = asyncio.ensure_future(subscription.__anext__())
finally:
if not next_event.done():
next_event.cancel()
with suppress(asyncio.CancelledError, StopAsyncIteration):
await next_event
await subscription.aclose()
except Exception as e:
logger.warning(f"Subscription task failed for {task_id}: {e}", exc_info=True)
async def _send_status_notification(
session: ServerSession,
task_id: str,
task_key: str,
docket: Docket,
state: ExecutionState,
poll_interval_ms: int = 5000,
) -> None:
"""Send notifications/tasks/status to client.
Per SEP-1686 line 454: notification SHOULD NOT include related-task metadata
(taskId is already in params).
Args:
session: MCP ServerSession
task_id: Client-visible task ID
task_key: Internal task key (for metadata lookup)
docket: Docket instance
state: Docket execution state (enum)
poll_interval_ms: Poll interval in milliseconds
"""
# Map Docket state to MCP status
state_map = DOCKET_TO_MCP_STATE
mcp_status = state_map.get(state, "failed")
# Extract task_scope from task_key for Redis lookup
key_parts = parse_task_key(task_key)
task_scope = key_parts["task_scope"]
created_at_key = docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:created_at")
async with docket.redis() as redis:
created_at_bytes = await redis.get(created_at_key)
created_at = (
created_at_bytes.decode("utf-8")
if created_at_bytes
else datetime.now(timezone.utc).isoformat()
)
# Build status message
status_message = None
if state == ExecutionState.COMPLETED:
status_message = "Task completed successfully"
elif state == ExecutionState.FAILED:
status_message = "Task failed"
elif state == ExecutionState.CANCELLED:
status_message = "Task cancelled"
params_dict = {
"taskId": task_id,
"status": mcp_status,
"createdAt": created_at,
"lastUpdatedAt": datetime.now(timezone.utc).isoformat(),
"ttl": DEFAULT_TTL_MS,
"pollInterval": poll_interval_ms,
}
if status_message:
params_dict["statusMessage"] = status_message
# Create notification (no related-task metadata per spec line 454)
notification = TaskStatusNotification(
params=TaskStatusNotificationParams.model_validate(params_dict),
)
# Send notification (don't let failures break the subscription)
with suppress(Exception):
await session.send_notification(notification) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
async def _send_progress_notification(
session: ServerSession,
task_id: str,
task_key: str,
docket: Docket,
execution: Execution,
poll_interval_ms: int = 5000,
) -> None:
"""Send notifications/tasks/status when progress updates.
Args:
session: MCP ServerSession
task_id: Client-visible task ID
task_key: Internal task key
docket: Docket instance
execution: Execution object with current progress
poll_interval_ms: Poll interval in milliseconds
"""
# Sync execution to get latest progress
await execution.sync()
# Only send if there's a progress message
if not execution.progress or not execution.progress.message:
return
# Map Docket state to MCP status
state_map = DOCKET_TO_MCP_STATE
mcp_status = state_map.get(execution.state, "failed")
# Extract task_scope from task_key for Redis lookup
key_parts = parse_task_key(task_key)
task_scope = key_parts["task_scope"]
created_at_key = docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:created_at")
async with docket.redis() as redis:
created_at_bytes = await redis.get(created_at_key)
created_at = (
created_at_bytes.decode("utf-8")
if created_at_bytes
else datetime.now(timezone.utc).isoformat()
)
params_dict = {
"taskId": task_id,
"status": mcp_status,
"createdAt": created_at,
"lastUpdatedAt": datetime.now(timezone.utc).isoformat(),
"ttl": DEFAULT_TTL_MS,
"pollInterval": poll_interval_ms,
"statusMessage": execution.progress.message,
}
# Create and send notification
notification = TaskStatusNotification(
params=TaskStatusNotificationParams.model_validate(params_dict),
)
with suppress(Exception):
await session.send_notification(notification) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]

View file

@ -2,7 +2,6 @@ from __future__ import annotations as _annotations
import inspect
import os
from datetime import timedelta
from pathlib import Path
from typing import Annotated, Any, Literal
@ -30,109 +29,6 @@ DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
TEN_MB_IN_BYTES = 1024 * 1024 * 10
class DocketSettings(BaseSettings):
"""Docket worker configuration."""
model_config = SettingsConfigDict(
env_prefix="FASTMCP_DOCKET_",
extra="ignore",
)
name: Annotated[
str,
Field(
description=inspect.cleandoc(
"""
Name for the Docket queue. All servers/workers sharing the same name
and backend URL will share a task queue.
"""
),
),
] = "fastmcp"
url: Annotated[
str,
Field(
description=inspect.cleandoc(
"""
URL for the Docket backend. Supports:
- memory:// - In-memory backend (single process only)
- redis://host:port/db - Redis/Valkey backend (distributed, multi-process)
Example: redis://localhost:6379/0
Default is memory:// for single-process scenarios. Use Redis or Valkey
when coordinating tasks across multiple processes (e.g., additional
workers via the fastmcp tasks CLI).
"""
),
),
] = "memory://"
worker_name: Annotated[
str | None,
Field(
description=inspect.cleandoc(
"""
Name for the Docket worker. If None, Docket will auto-generate
a unique worker name.
"""
),
),
] = None
concurrency: Annotated[
int,
Field(
description=inspect.cleandoc(
"""
Maximum number of tasks the worker can process concurrently.
"""
),
),
] = 10
redelivery_timeout: Annotated[
timedelta,
Field(
description=inspect.cleandoc(
"""
Task redelivery timeout. If a worker doesn't complete
a task within this time, the task will be redelivered to another
worker.
"""
),
),
] = timedelta(seconds=300)
reconnection_delay: Annotated[
timedelta,
Field(
description=inspect.cleandoc(
"""
Delay between reconnection attempts when the worker
loses connection to the Docket backend.
"""
),
),
] = timedelta(seconds=5)
minimum_check_interval: Annotated[
timedelta,
Field(
description=inspect.cleandoc(
"""
How frequently the worker polls for new tasks. Lower
values reduce latency for task pickup at the cost of
more CPU usage. The default of 50ms is a good balance;
increase for high-volume production deployments where
tasks are long-running.
"""
),
),
] = timedelta(milliseconds=50)
class Settings(BaseSettings):
"""FastMCP settings."""
@ -185,8 +81,6 @@ class Settings(BaseSettings):
return v.upper()
return v
docket: DocketSettings = DocketSettings()
enable_rich_logging: Annotated[
bool,
Field(
@ -287,24 +181,6 @@ class Settings(BaseSettings):
),
] = 5
client_task_poll_interval: Annotated[
float,
Field(
description=inspect.cleandoc(
"""
Ceiling, in seconds, for the fallback poll backoff while waiting on a
background task (SEP-1686). Applies only when the server does not
advertise its own pollInterval: in that case Task.wait() starts polling
fast (~20ms) and doubles up to this ceiling, so quick tasks resolve
promptly while long-running tasks don't hammer the server. When the
server does advertise a pollInterval, that interval is honored exactly
and this setting is ignored. Must be positive.
"""
),
gt=0,
),
] = 0.5
# Transport settings
transport: Literal["stdio", "http", "sse", "streamable-http"] = "stdio"

View file

@ -6,7 +6,6 @@ from typing import (
Annotated,
Any,
ClassVar,
overload,
)
import mcp_types
@ -27,7 +26,7 @@ from pydantic.json_schema import SkipJsonSchema
from fastmcp.utilities.authorization import AuthCheck
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.tasks import TaskConfig, TaskMeta
from fastmcp.utilities.tasks import TaskConfig
from fastmcp.utilities.types import (
Audio,
File,
@ -45,9 +44,6 @@ except ImportError:
_HAS_PREFAB = False
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
@ -416,87 +412,15 @@ class Tool(FastMCPComponent):
meta={"fastmcp": {"wrap_result": True}} if wrap_result else None,
)
@overload
async def _run(
self,
arguments: dict[str, Any],
task_meta: None = None,
) -> ToolResult: ...
async def _run(self, arguments: dict[str, Any]) -> ToolResult:
"""Server entry point for tool execution.
@overload
async def _run(
self,
arguments: dict[str, Any],
task_meta: TaskMeta,
) -> mcp_types.CreateTaskResult: ...
async def _run(
self,
arguments: dict[str, Any],
task_meta: TaskMeta | None = None,
) -> ToolResult | mcp_types.CreateTaskResult:
"""Server entry point that handles task routing.
This allows ANY Tool subclass to support background execution by setting
task_config.mode to "supported" or "required". The server calls this
method instead of run() directly.
Args:
arguments: Tool arguments
task_meta: If provided, execute as background task and return
CreateTaskResult. If None (default), execute synchronously and
return ToolResult.
Returns:
ToolResult when task_meta is None.
CreateTaskResult when task_meta is provided.
Subclasses can override this to customize task routing behavior.
For example, FastMCPProviderTool overrides to delegate to child
middleware without submitting to Docket.
The server calls this method instead of ``run()`` directly so that
subclasses can customize dispatch. For example, ``FastMCPProviderTool``
overrides this to delegate to child-server middleware.
"""
from fastmcp.server.tasks.routing import check_background_task
task_result = await check_background_task(
component=self,
task_type="tool",
arguments=arguments,
task_meta=task_meta,
)
if task_result:
return task_result
return await self.run(arguments)
def register_with_docket(self, docket: Docket) -> None:
"""Register this tool with docket for background execution."""
if not self.task_config.supports_tasks():
return
docket.register(self.run, names=[self.key])
async def add_to_docket( # type: ignore[override]
self,
docket: Docket,
arguments: dict[str, Any],
*,
fn_key: str | None = None,
task_key: str | None = None,
**kwargs: Any,
) -> Execution:
"""Schedule this tool for background execution via docket.
Args:
docket: The Docket instance
arguments: Tool arguments
fn_key: Function lookup key in Docket registry (defaults to self.key)
task_key: Redis storage key for the result
**kwargs: Additional kwargs passed to docket.add()
"""
lookup_key = fn_key or self.key
if task_key:
kwargs["key"] = task_key
return await docket.add(lookup_key, **kwargs)(arguments)
@classmethod
def from_tool(
cls,

View file

@ -10,7 +10,6 @@ from dataclasses import dataclass, field
from functools import lru_cache
from types import MethodType
from typing import (
TYPE_CHECKING,
Annotated,
Any,
Literal,
@ -53,10 +52,6 @@ from fastmcp.utilities.types import (
logger = get_logger(__name__)
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
class _ToolBodyError(Exception):
"""Marks a ``pydantic.ValidationError`` raised while executing a tool's body.
@ -511,88 +506,6 @@ class FunctionTool(Tool):
return list(result)
return result
def register_with_docket(self, docket: Docket) -> None:
"""Register this tool with docket for background execution.
Registers the raw function so Docket sees and resolves ALL
dependencies both FastMCP's (CurrentContext, Progress) and
Docket-native ones (Retry, Timeout, ConcurrencyLimit).
"""
if not self.task_config.supports_tasks():
return
docket.register(self.fn, names=[self.key])
async def add_to_docket(
self,
docket: Docket,
arguments: dict[str, Any],
*,
fn_key: str | None = None,
task_key: str | None = None,
**kwargs: Any,
) -> Execution:
"""Schedule this tool for background execution via docket.
FunctionTool splats the arguments dict since .fn expects **kwargs.
Args:
docket: The Docket instance
arguments: Tool arguments
fn_key: Function lookup key in Docket registry (defaults to self.key)
task_key: Redis storage key for the result
**kwargs: Additional kwargs passed to docket.add()
"""
lookup_key = fn_key or self.key
if task_key:
kwargs["key"] = task_key
return await docket.add(lookup_key, **kwargs)(**arguments)
def coerce_task_arguments(
self, arguments: dict[str, Any], *, strict: bool = False
) -> dict[str, Any]:
"""Validate client arguments against their declared parameter types.
The synchronous ``run()`` path validates arguments through the
function's Pydantic TypeAdapter, so a parameter typed as a model
arrives as a model instance. The task path hands the raw arguments to
Docket, which binds them to the function signature without coercion
so without this a model-typed parameter would reach the function as a
raw dict (#4349). ``submit_to_docket`` calls this up front so coerced
values are what get queued, and validation errors surface before any
task state is created. Coerced values survive the trip to the worker
because Docket serializes task arguments with cloudpickle.
``strict`` mirrors the synchronous path's ``strict_input_validation``
handling: when set, arguments are validated in strict mode so lax
coercions (e.g. the string ``"1"`` into an ``int``) are rejected at
submission rather than silently coerced and queued.
Injected dependency parameters (Context, Depends()) are excluded via
the same wrapper used by the synchronous path, so only client-supplied
arguments are coerced and Docket's dependency resolution is untouched.
"""
from fastmcp.server.dependencies import without_injected_parameters
wrapper_fn = without_injected_parameters(
self.fn, run_in_thread=self.run_in_thread
)
hints = _resolve_param_hints(wrapper_fn)
coerced = dict(arguments)
for name, value in arguments.items():
annotation = hints.get(name)
if annotation is None:
continue
adapter = get_cached_typeadapter(annotation)
try:
coerced[name] = adapter.validate_python(value, strict=strict)
except PydanticValidationError as e:
# Argument coercion failure on the task path is a bad call, just
# like the synchronous path — surface it as fastmcp's
# ValidationError so it is classified consistently (see #4128).
raise ValidationError(str(e), log_level=logging.WARNING) from e
return coerced
@overload
def tool(fn: F) -> F: ...

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, TypedDict, cast
from typing import Annotated, Any, ClassVar, TypedDict, cast
from mcp_types import Icon
from pydantic import BeforeValidator, Field
@ -10,10 +10,6 @@ from typing_extensions import Self, TypeVar
from fastmcp.utilities.tasks import TaskConfig
from fastmcp.utilities.types import FastMCPBaseModel
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
T = TypeVar("T", default=Any)
@ -118,7 +114,11 @@ class FastMCPComponent(FastMCPBaseModel):
)
task_config: Annotated[
TaskConfig,
Field(description="Background task execution configuration (SEP-1686)."),
Field(
description="Background task execution configuration (SEP-2663). "
"Only tools support task execution; other component types always "
"carry the default 'forbidden' config."
),
] = Field(default_factory=lambda: TaskConfig(mode="forbidden"))
@classmethod
@ -224,56 +224,6 @@ class FastMCPComponent(FastMCPBaseModel):
"""Create a copy of the component."""
return self.model_copy()
def register_with_docket(self, docket: Docket) -> None:
"""Register this component with docket for background execution.
No-ops if task_config.mode is "forbidden". Subclasses override to
register their callable (self.run, self.read, self.render, or self.fn).
"""
# Base implementation: no-op (subclasses override)
def coerce_task_arguments(
self, arguments: dict[str, Any], *, strict: bool = False
) -> dict[str, Any]:
"""Validate and coerce task arguments before any task state is created.
Called by ``submit_to_docket`` up front, so invalid inputs raise before
the task's Redis metadata and initial status notification exist —
otherwise a coercion failure during queueing would orphan a task the
client has already observed. The base implementation is a no-op;
components that splat arguments into a typed Python callable (e.g.
``FunctionTool``) override this to mirror the synchronous validation
path.
When ``strict`` is set (server-level ``strict_input_validation``),
overrides validate in strict mode so the task path rejects lax
coercions (e.g. the string ``"1"`` into an ``int``) exactly as the
synchronous call path does.
"""
return arguments
async def add_to_docket(
self, docket: Docket, *args: Any, **kwargs: Any
) -> Execution:
"""Schedule this component for background execution via docket.
Subclasses override this to handle their specific calling conventions:
- Tool: add_to_docket(docket, arguments: dict, **kwargs)
- Resource: add_to_docket(docket, **kwargs)
- ResourceTemplate: add_to_docket(docket, params: dict, **kwargs)
- Prompt: add_to_docket(docket, arguments: dict | None, **kwargs)
The **kwargs are passed through to docket.add() (e.g., key=task_key).
"""
if not self.task_config.supports_tasks():
raise RuntimeError(
f"Cannot add {self.__class__.__name__} '{self.name}' to docket: "
f"task execution not supported"
)
raise NotImplementedError(
f"{self.__class__.__name__} does not implement add_to_docket()"
)
def get_span_attributes(self) -> dict[str, Any]:
"""Return span attributes for telemetry.

View file

@ -13,6 +13,13 @@ from fastmcp.utilities.async_utils import is_coroutine_function
TaskMode = Literal["forbidden", "optional", "required"]
#: Reverse-DNS identifier of the SEP-2663 tasks extension. A tool declared with
#: ``task=True`` requires an extension with this identifier to be registered on
#: the server (``mcp.add_extension(...)``); the ``fastmcp-tasks`` package
#: provides it. Kept here as pure declaration so core can check for the
#: extension without importing the tasks package.
TASKS_EXTENSION_ID = "io.modelcontextprotocol/tasks"
DEFAULT_POLL_INTERVAL = timedelta(seconds=5)
DEFAULT_POLL_INTERVAL_MS = int(DEFAULT_POLL_INTERVAL.total_seconds() * 1000)
DEFAULT_TTL_MS = 60_000
@ -59,10 +66,6 @@ class TaskConfig:
if not self.supports_tasks():
return
from fastmcp.server.dependencies import require_docket
require_docket(f"`task=True` on function '{name}'")
fn_to_check = fn
if (
not inspect.isroutine(fn)

View file

@ -108,4 +108,3 @@ server = [
"watchfiles>=1.0.0",
"websockets>=15.0.1",
]
tasks = ["pydocket>=0.20.0"]

83
fastmcp_tasks/README.md Normal file
View file

@ -0,0 +1,83 @@
# fastmcp-tasks
A complete implementation of background tasks for the Model Context Protocol — the `io.modelcontextprotocol/tasks` extension defined in [SEP-2663](https://github.com/modelcontextprotocol/ext-tasks).
The MCP tasks extension is a Final SEP, but as of this writing it ships in the ecosystem as a schema and a prose specification — no language SDK provides a working runtime for it. `fastmcp-tasks` is, to our knowledge, the first: a full server-side implementation of the protocol, built on the durable execution engine ([docket](https://github.com/chrisguidry/docket)) that FastMCP has run in production since v3. If you want to actually *run* MCP background tasks today, this is the implementation.
## What background tasks are
Most tool calls are synchronous: the client sends `tools/call` and holds the request open until the tool returns. That breaks down for work that takes minutes or hours — a long analysis, a batch job, a slow external API. The tasks extension lets a server answer such a call *immediately* with a durable task handle, then run the work in the background while the client polls for completion on its own schedule.
The model is poll-based and stateless by construction, which is what makes it survive disconnects, server restarts, and load balancers:
1. A client that supports tasks issues a normal `tools/call` with a per-request opt-in.
2. The server decides whether to run it as a task. If it does, it returns a `CreateTaskResult` carrying a server-generated task id — right away, before the work starts.
3. The client polls `tasks/get` until the task reaches a terminal state, then reads the result inlined in the response.
4. `tasks/cancel` requests cancellation; `tasks/update` answers any input the task asks for mid-run.
The server owns the task's durable state, so the client can poll across independent requests — from any process, after a crash, through any replica — with no session affinity required.
## Usage
Install it as the `tasks` extra on FastMCP:
```bash
uv pip install "fastmcp[tasks]"
```
Register the extension on your server and mark the tools that may run as tasks. The extension is where the backend is configured — point it at Redis for a distributed deployment, or leave it on the in-memory default for a single process:
```python
from fastmcp import FastMCP
from fastmcp_tasks import TasksExtension
mcp = FastMCP("Analytics")
mcp.add_extension(TasksExtension(url="redis://localhost:6379/0"))
@mcp.tool(task=True)
async def analyze(dataset: str) -> str:
# Long-running work. The client gets a task handle immediately and
# polls for the result; this runs in a background worker.
...
```
`task=True` is a declaration of intent — this tool *may* run as a task — while the server, per the spec, decides per call whether to actually task it. Use `TaskConfig` for finer control:
```python
from fastmcp.utilities.tasks import TaskConfig
@mcp.tool(task=TaskConfig(mode="required"))
async def must_run_async(n: int) -> int:
# Always runs as a task; a client that has not opted in is told so.
...
```
Registering `TasksExtension` is required to serve `task=True` tools — the tool declares intent, the extension provides the engine. A `task=True` tool on a server with no tasks extension registered fails loudly at startup rather than silently running inline.
### Running out-of-process workers
For distributed deployments backed by Redis, run dedicated worker processes alongside your server:
```bash
python -m fastmcp_tasks.worker_cli worker server.py
```
Workers and servers that share a backend URL and queue name share a task queue, so you can scale execution independently of your request-serving frontends.
## Configuration
The backend is configured on the extension. Every option also has a `FASTMCP_DOCKET_*` environment variable, so an env-configured deployment can construct `TasksExtension()` with no arguments:
| Option | Env var | Default | Description |
| --- | --- | --- | --- |
| `url` | `FASTMCP_DOCKET_URL` | `memory://` | Backend URL. `memory://` for single-process; `redis://host:port/db` for distributed workers. |
| `name` | `FASTMCP_DOCKET_NAME` | `fastmcp` | Queue name. Servers and workers sharing a name and URL share a queue. |
| `concurrency` | `FASTMCP_DOCKET_CONCURRENCY` | `10` | Maximum concurrent tasks per worker. |
See the [FastMCP task documentation](https://gofastmcp.com/servers/tasks) for the full reference.
## Status
The tasks extension is an experimental MCP extension, and `fastmcp-tasks` tracks its draft schema. The protocol's shape is settled — SEP-2663 is Final — but field-level details may still move; this package versions independently so it can follow the schema without waiting on a FastMCP release.

View file

@ -0,0 +1,20 @@
"""Background task execution for FastMCP via the SEP-2663 tasks extension."""
from importlib.metadata import PackageNotFoundError, version
from fastmcp.client.extension_hooks import register_internal_client_extension_factory
from fastmcp_tasks.client import ToolTask, _build_tasks_client_extension, call_tool_task
from fastmcp_tasks.extension import TasksExtension
try:
__version__ = version("fastmcp-tasks")
except PackageNotFoundError:
__version__ = "0.0.0"
# Register the client half so every FastMCP `Client` transparently drives a
# task-serving backend's background tasks (see `fastmcp_tasks.client`). Importing
# this package — which any task deployment does, server or client side — is what
# turns on client task support.
register_internal_client_extension_factory(_build_tasks_client_extension)
__all__ = ["TasksExtension", "ToolTask", "call_tool_task", "__version__"]

View file

@ -0,0 +1,567 @@
"""SEP-2663 client task support: the tasks extension, resolver, and handle.
FastMCP drives a server's background tasks transparently. When a `task=True`
tool runs a call as a task, the server answers `tools/call` with a claimed
`CreateTaskResult` (SEP-2133) instead of the tool's result. This module supplies
the client half:
- `TasksClientExtension` advertises the tasks capability (so the server *may*
task the call) and declares a `ResultClaim` for `resultType: "task"`. It is
registered on every FastMCP `Client` automatically, so the caller opts in to
nothing.
- The claim's resolver polls `tasks/get` to completion under the hood and returns
the tool's real result as a `CallToolResult` — the caller of `call_tool` never
learns the call was tasked. A task that pauses for input is answered through the
client's `elicitation_handler` via `tasks/update`, then polling resumes.
- `ToolTask` is the explicit handle for callers who want to return immediately and
drive the task themselves (`status`/`wait`/`result`/`cancel`), built via
`call_tool_task`.
Tasks are modern-protocol only: on a legacy connection the SDK strips the
capability ad, the server never tasks, and this extension is inert.
"""
from __future__ import annotations
import asyncio
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, cast
import mcp_types
from mcp.client.extension import ClaimContext, ClientExtension, ResultClaim
from mcp.client.session import ClientRequestContext, ClientSession, ElicitationFnT
from mcp_types import CallToolResult
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from fastmcp.client.telemetry import client_span
from fastmcp.exceptions import ToolError
from fastmcp.telemetry import inject_trace_context
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.tasks import TASKS_EXTENSION_ID
from fastmcp.utilities.timeout import normalize_timeout_to_seconds
from fastmcp_tasks.client_models import (
CancelTaskRequest,
CancelTaskRequestParams,
ClientCreateTaskResult,
ClientGetTaskResult,
GetTaskRequest,
GetTaskRequestParams,
UpdateTaskRequest,
UpdateTaskRequestParams,
)
from fastmcp_tasks.settings import client_settings
if TYPE_CHECKING:
from fastmcp.client.client import CallToolResult as FastMCPCallToolResult
from fastmcp.client.client import Client
logger = get_logger(__name__)
#: Floor for the fallback poll interval (seconds). When the server does not
#: advertise a `pollIntervalMs`, each drive starts its backoff ramp here so quick
#: tasks resolve fast; when it does advertise one, this floors it so a server
#: sending `0` cannot spin the client in a tight loop.
MIN_POLL_INTERVAL = 0.02
_TERMINAL_STATES = frozenset({"completed", "failed", "cancelled"})
# ---------------------------------------------------------------------------
# Wire senders (tasks/get, tasks/update, tasks/cancel) over a ClientSession
# ---------------------------------------------------------------------------
def _trace_meta() -> mcp_types.RequestParamsMeta | None:
"""Trace context for a task management request, for the current client span.
Task management calls (`tasks/get`/`update`/`cancel`) use ordinary
client-to-server trace propagation, so their server-side spans nest under
the client span rather than becoming disconnected trace roots.
"""
return cast("mcp_types.RequestParamsMeta | None", inject_trace_context(None))
async def _send_get(
session: ClientSession,
task_id: str,
read_timeout_seconds: float | None = None,
) -> ClientGetTaskResult:
"""Send `tasks/get` and parse the detailed task response."""
with client_span("tasks/get", "tasks/get", task_id):
request = GetTaskRequest(
params=GetTaskRequestParams(task_id=task_id, meta=_trace_meta())
)
return await session.send_request(
request,
ClientGetTaskResult,
request_read_timeout_seconds=read_timeout_seconds,
)
async def _send_update(
session: ClientSession,
task_id: str,
input_responses: dict[str, Any],
read_timeout_seconds: float | None = None,
) -> None:
"""Send `tasks/update` delivering the caller's answers to a parked task."""
with client_span("tasks/update", "tasks/update", task_id):
request = UpdateTaskRequest(
params=UpdateTaskRequestParams(
task_id=task_id, input_responses=input_responses, meta=_trace_meta()
)
)
await session.send_request(
request, mcp_types.Result, request_read_timeout_seconds=read_timeout_seconds
)
async def _send_cancel(
session: ClientSession,
task_id: str,
read_timeout_seconds: float | None = None,
) -> None:
"""Send `tasks/cancel` to cooperatively cancel a task."""
with client_span("tasks/cancel", "tasks/cancel", task_id):
request = CancelTaskRequest(
params=CancelTaskRequestParams(task_id=task_id, meta=_trace_meta())
)
await session.send_request(
request, mcp_types.Result, request_read_timeout_seconds=read_timeout_seconds
)
# ---------------------------------------------------------------------------
# Poll cadence
# ---------------------------------------------------------------------------
def _poll_ceiling(poll_interval_ms: float | None) -> float:
"""The upper bound for the poll backoff, in seconds.
A server-advertised `pollIntervalMs` is a deliberate statement about how much
load the server wants to take, so it caps the backoff. A zero, negative, or
absent value falls back to the `poll_interval` client setting; the ceiling is
never below `MIN_POLL_INTERVAL` so a hostile `0` cannot spin the client.
"""
if poll_interval_ms is not None and poll_interval_ms > 0:
return max(poll_interval_ms / 1000, MIN_POLL_INTERVAL)
return client_settings.poll_interval
def _next_poll_delay(
poll_interval_ms: float | None, backoff: float
) -> tuple[float, float]:
"""Delay before the next poll, plus the backoff for the round after.
With no status notifications on the modern protocol, polling is the only
signal, so a fixed cadence at the server's advertised interval would make a
quick task take that full interval to observe as done. Instead the backoff
ramps from `MIN_POLL_INTERVAL`, doubling each round up to the ceiling
(`_poll_ceiling`): a quick task resolves in ~20ms while a long one settles to
the server's advertised cadence, hammering neither.
"""
ceiling = _poll_ceiling(poll_interval_ms)
return min(backoff, ceiling), min(backoff * 2, ceiling)
# ---------------------------------------------------------------------------
# In-task input: answer a parked task's requests via the elicitation handler
# ---------------------------------------------------------------------------
async def _answer_input_requests(
session: ClientSession,
task_id: str,
input_requests: dict[str, Any],
elicitation_callback: ElicitationFnT | None,
read_timeout_seconds: float | None = None,
) -> None:
"""Answer a task's outstanding input requests, then deliver via `tasks/update`.
Each request is surfaced by a server-minted key and carries a serialized
`ElicitRequest`. The client's elicitation handler produces each answer; the
keyed answers are sent back with `tasks/update`, which re-enters the task.
Sampling and roots requests are not supported on the modern protocol.
"""
if elicitation_callback is None:
raise ToolError(
f"Task {task_id} requires input but the client has no elicitation "
"handler; pass elicitation_handler= to Client() to drive tasks that "
"ask for input."
)
# Bound the whole answer phase — elicitation callbacks included — by the
# call's remaining budget: a stalled handler must not outlast `timeout=N`
# any more than a stalled poll does, matching the synchronous path.
loop = asyncio.get_event_loop()
deadline = (
None if read_timeout_seconds is None else loop.time() + read_timeout_seconds
)
def _remaining() -> float | None:
if deadline is None:
return None
left = deadline - loop.time()
if left <= 0:
raise TimeoutError(f"Task {task_id} timed out awaiting input")
return left
responses: dict[str, Any] = {}
for surfaced_key, payload in input_requests.items():
method = payload.get("method") if isinstance(payload, dict) else None
if method != "elicitation/create":
raise ToolError(
f"Task {task_id} requested in-task input via {method!r}, which the "
"client cannot answer; only elicitation is supported on the modern "
"protocol (sampling and roots are deprecated)."
)
request = mcp_types.ElicitRequest.model_validate(payload)
context = ClientRequestContext(
session=session, request_id=f"task-{task_id}-{surfaced_key}"
)
budget = _remaining()
call = elicitation_callback(context, request.params)
try:
answer = await (
asyncio.wait_for(call, budget) if budget is not None else call
)
except asyncio.TimeoutError as exc:
# Normalize to the builtin: on Python 3.10 `asyncio.wait_for` raises
# `asyncio.TimeoutError`, a distinct type from the builtin the rest of
# the drive raises (they were unified in 3.11).
raise TimeoutError(f"Task {task_id} timed out awaiting input") from exc
if isinstance(answer, mcp_types.ErrorData):
raise ToolError(f"Elicitation for task {task_id} failed: {answer.message}")
responses[surfaced_key] = answer.model_dump(
by_alias=True, mode="json", exclude_none=True
)
await _send_update(session, task_id, responses, _remaining())
# ---------------------------------------------------------------------------
# The shared poll loop
# ---------------------------------------------------------------------------
async def _drive_to_terminal(
session: ClientSession,
task_id: str,
elicitation_callback: ElicitationFnT | None,
timeout_seconds: float | None = None,
) -> ClientGetTaskResult:
"""Poll `tasks/get` until the task reaches a terminal state.
`working` sleeps and polls again; `input_required` answers the outstanding
requests through the elicitation handler and re-enters; a terminal state
(completed / failed / cancelled) is returned. Shared by the transparent
resolver and `ToolTask.result()`.
`timeout_seconds`, when set, is one deadline for the *entire* drive not a
per-request timeout. The synchronous path aborts a `tools/call` once total
execution exceeds the call's timeout, so the tasked path must too: each poll
and sleep is bounded by the time remaining, and a `TimeoutError` is raised
once the deadline passes. `None` drives to completion unbounded (the default
for `ToolTask.result()`, whose caller bounds waiting via `wait(timeout=...)`).
"""
loop = asyncio.get_event_loop()
deadline = None if timeout_seconds is None else loop.time() + timeout_seconds
backoff = MIN_POLL_INTERVAL
def remaining() -> float | None:
return None if deadline is None else deadline - loop.time()
while True:
budget = remaining()
if budget is not None and budget <= 0:
raise TimeoutError(
f"Task {task_id} did not finish within {timeout_seconds}s"
)
current = await _send_get(session, task_id, budget)
if current.status in _TERMINAL_STATES:
return current
if current.status == "input_required":
await _answer_input_requests(
session,
task_id,
current.input_requests or {},
elicitation_callback,
remaining(),
)
backoff = MIN_POLL_INTERVAL
continue
# working
delay, backoff = _next_poll_delay(current.poll_interval_ms, backoff)
budget = remaining()
if budget is not None:
delay = min(delay, budget)
await asyncio.sleep(delay)
def _inlined_call_tool_result(result: dict[str, Any] | None) -> CallToolResult:
"""Parse a completed task's inlined result dict into a `CallToolResult`."""
return CallToolResult.model_validate(result or {})
def _terminal_error_message(final: ClientGetTaskResult) -> str:
"""The best available error message for a failed task."""
if isinstance(final.error, dict):
message = final.error.get("message")
if isinstance(message, str) and message:
return message
if final.status_message:
return final.status_message
return f"Task {final.task_id} failed"
# ---------------------------------------------------------------------------
# The tasks client extension and its claim resolver
# ---------------------------------------------------------------------------
class TasksClientExtension(ClientExtension):
"""The client half of the `io.modelcontextprotocol/tasks` extension (SEP-2663).
Advertising this extension tells the server the client can drive tasks, so a
`task=True` tool may run as a task; the declared `ResultClaim` then resolves
the `CreateTaskResult` the server returns by polling `tasks/get` to the real
result. Registered automatically on every FastMCP `Client`.
"""
identifier = TASKS_EXTENSION_ID
def __init__(self, elicitation_callback: ElicitationFnT | None = None) -> None:
self._elicitation_callback = elicitation_callback
def settings(self) -> dict[str, Any]:
"""The tasks extension advertises no per-extension settings."""
return {}
def claims(self) -> Sequence[ResultClaim[Any]]:
return (
ResultClaim(
result_type="task",
model=ClientCreateTaskResult,
resolve=self._resolve_task,
protocol_versions=frozenset(MODERN_PROTOCOL_VERSIONS),
),
)
async def _resolve_task(
self, create_result: ClientCreateTaskResult, ctx: ClaimContext
) -> CallToolResult:
"""Finish a tasked `tools/call` by polling `tasks/get` to completion.
Returns the tool's real result on completion; a failed or cancelled task
becomes an error `CallToolResult` so the ordinary `call_tool` error path
(raise `ToolError`) applies uniformly, and the completed inlined result is
schema-valid so the SDK's output-schema revalidation passes.
"""
final = await _drive_to_terminal(
ctx.session,
create_result.task_id,
self._elicitation_callback,
ctx.read_timeout_seconds,
)
if final.status == "completed":
return _inlined_call_tool_result(final.result)
if final.status == "failed":
message = _terminal_error_message(final)
else:
message = f"Task {final.task_id} was cancelled"
return CallToolResult(
content=[mcp_types.TextContent(type="text", text=message)],
is_error=True,
)
def _build_tasks_client_extension(
elicitation_callback: ElicitationFnT | None,
) -> ClientExtension:
"""Factory registered with core so every `Client` folds in task support."""
return TasksClientExtension(elicitation_callback)
# ---------------------------------------------------------------------------
# The explicit task handle (return-quickly surface)
# ---------------------------------------------------------------------------
class ToolTask:
"""A handle to a tool call the server is running as a background task.
Returned by `call_tool_task`. Lets a caller return immediately and then drive
the task: check `status`, `wait` for a state, get the finished `result`
(answering any input prompts through the client's elicitation handler), or
`cancel`. Awaiting the handle is shorthand for `result()`.
"""
def __init__(
self,
client: Client,
tool_name: str,
create_result: ClientCreateTaskResult,
*,
raise_on_error: bool = True,
) -> None:
self._client = client
self._tool_name = tool_name
self._create_result = create_result
self._raise_on_error = raise_on_error
self._cached_result: FastMCPCallToolResult | None = None
@property
def task_id(self) -> str:
"""The server-generated task id."""
return self._create_result.task_id
@property
def create_result(self) -> ClientCreateTaskResult:
"""The raw `CreateTaskResult` the server returned for the tasked call."""
return self._create_result
@property
def _session(self) -> ClientSession:
return self._client.session
@property
def _elicitation_callback(self) -> ElicitationFnT | None:
return self._client._elicitation_callback
async def status(self) -> ClientGetTaskResult:
"""Fetch the task's current status via `tasks/get`."""
return await _send_get(self._session, self.task_id)
async def wait(
self, *, state: str | None = None, timeout: float = 300.0
) -> ClientGetTaskResult:
"""Poll until the task reaches `state` (or any terminal state if `None`).
Does not answer input prompts: a caller that wants automatic answering
should use `result()`. `wait(state="input_required")` lets a caller
observe the parked state and answer it manually.
"""
loop = asyncio.get_event_loop()
deadline = loop.time() + timeout
backoff = MIN_POLL_INTERVAL
while True:
remaining = deadline - loop.time()
if remaining <= 0:
raise TimeoutError(
f"Task {self.task_id} did not reach "
f"{state or 'a terminal state'} within {timeout}s"
)
# Bound the request itself by the remaining deadline: a stalled
# `tasks/get` must not block past the caller's timeout waiting for
# the session-wide default before the deadline is next checked.
current = await _send_get(
self._session, self.task_id, read_timeout_seconds=remaining
)
if state is not None:
if current.status == state:
return current
elif current.status in _TERMINAL_STATES:
return current
remaining = deadline - loop.time()
if remaining <= 0:
raise TimeoutError(
f"Task {self.task_id} did not reach "
f"{state or 'a terminal state'} within {timeout}s"
)
delay, backoff = _next_poll_delay(current.poll_interval_ms, backoff)
# Never sleep past the deadline, so `wait` returns on time rather
# than up to one poll interval late.
await asyncio.sleep(min(delay, remaining))
async def result(self) -> FastMCPCallToolResult:
"""Drive the task to completion and return its parsed result.
Answers any input prompts through the client's elicitation handler.
Raises `ToolError` on a failed or cancelled task when `raise_on_error`
(the default); otherwise returns an error result. The result is cached, so
repeated calls return the same object.
"""
if self._cached_result is not None:
return self._cached_result
final = await _drive_to_terminal(
self._session, self.task_id, self._elicitation_callback
)
if final.status == "completed":
mcp_result = _inlined_call_tool_result(final.result)
else:
if final.status == "failed":
message = _terminal_error_message(final)
else:
message = f"Task {self.task_id} was cancelled"
if self._raise_on_error:
raise ToolError(message)
mcp_result = CallToolResult(
content=[mcp_types.TextContent(type="text", text=message)],
is_error=True,
)
parsed = await self._client._parse_call_tool_result(
self._tool_name, mcp_result, raise_on_error=self._raise_on_error
)
self._cached_result = parsed
return parsed
async def cancel(self) -> None:
"""Request cooperative cancellation of the task via `tasks/cancel`."""
await _send_cancel(self._session, self.task_id)
def __await__(self):
return self.result().__await__()
async def call_tool_task(
client: Client,
name: str,
arguments: dict[str, Any] | None = None,
*,
timeout: float | int | None = None,
raise_on_error: bool = True,
version: str | None = None,
meta: dict[str, Any] | None = None,
) -> ToolTask:
"""Call a tool as a background task and return a `ToolTask` handle immediately.
Unlike `client.call_tool` (which polls to completion transparently), this
returns as soon as the server accepts the task, so the caller can do other
work and drive the task through the handle. Requires the server to run the
call as a task (a `task=True` tool on a task-serving backend); a call the
server runs synchronously raises `ToolError`.
`version` targets a specific component version, the same as
`client.call_tool(..., version=...)`: the server tasks that version rather
than the highest. It is carried in the request metadata FastMCP reads.
"""
read_timeout_seconds = normalize_timeout_to_seconds(timeout)
combined_meta: dict[str, Any] = dict(meta) if meta else {}
if version is not None:
fastmcp_meta = dict(combined_meta.get("fastmcp") or {})
fastmcp_meta["version"] = version
combined_meta["fastmcp"] = fastmcp_meta
with client_span("tools/call", "tools/call", name, tool_name=name):
# Propagate the trace into the tasked submission, like a foreground call.
propagated = inject_trace_context(combined_meta)
request_meta = cast("mcp_types.RequestParamsMeta | None", propagated or None)
raw = await client._await_with_session_monitoring(
client.session.call_tool(
name=name,
arguments=arguments or {},
read_timeout_seconds=read_timeout_seconds,
meta=request_meta,
allow_claimed=True,
)
)
if isinstance(raw, ClientCreateTaskResult):
return ToolTask(client, name, raw, raise_on_error=raise_on_error)
raise ToolError(
f"Tool {name!r} did not run as a task: the server returned a "
f"{type(raw).__name__} instead of a task. Ensure the tool is declared "
"task=True and the connection is modern (mode='auto')."
)

View file

@ -0,0 +1,130 @@
"""Client-side wire models for the SEP-2663 tasks extension.
These mirror the server models in ``models.py`` but flip the alias direction:
the server *produces* the wire (``serialization_alias`` -> camelCase dump), while
the client *consumes* it. The SDK validates both a claimed ``tools/call`` result
and a ``tasks/get`` response with ``model_validate(raw, by_name=False)``, so these
models declare **validation** aliases (``Field(alias="taskId")``) to read the
camelCase wire keys.
``ClientCreateTaskResult`` is the claim shape the tasks ``ResultClaim`` resolves.
It must subclass ``mcp_types.Result`` (not ``CallToolResult`` /
``InputRequiredResult``) and pin ``result_type`` to ``Literal["task"]`` the
SDK's ``ResultClaim.__post_init__`` enforces exactly this. ``ClientGetTaskResult``
is the typed ``tasks/get`` response: the flat task fields plus exactly one of
``result`` (completed), ``error`` (failed), or ``inputRequests`` (input_required).
"""
from __future__ import annotations
from typing import Any, Literal
import mcp_types
from mcp_types import RequestParams, Result
from pydantic import ConfigDict, Field
__all__ = [
"TaskStatus",
"ClientCreateTaskResult",
"ClientGetTaskResult",
"GetTaskRequest",
"GetTaskRequestParams",
"UpdateTaskRequest",
"UpdateTaskRequestParams",
"CancelTaskRequest",
"CancelTaskRequestParams",
]
TaskStatus = Literal["working", "input_required", "completed", "failed", "cancelled"]
class _ClientTaskFields(Result):
"""The flat task fields shared by every SEP-2663 task result, read from the wire.
Validation aliases (camelCase) because the SDK validates the server's
``model_dump(by_alias=True)`` output with ``by_name=False``.
"""
model_config = ConfigDict(populate_by_name=True)
task_id: str = Field(alias="taskId")
status: TaskStatus
created_at: str = Field(alias="createdAt")
last_updated_at: str = Field(alias="lastUpdatedAt")
ttl_ms: float | None = Field(default=None, alias="ttlMs")
status_message: str | None = Field(default=None, alias="statusMessage")
poll_interval_ms: float | None = Field(default=None, alias="pollIntervalMs")
class ClientCreateTaskResult(_ClientTaskFields):
"""The claimed ``tools/call`` result the server returns when it runs a call as a task.
Pinned to ``resultType: "task"`` so the tasks ``ResultClaim`` can key on it.
The resolver polls ``tasks/get`` from here to the finished result.
"""
result_type: Literal["task"] = Field(alias="resultType")
class ClientGetTaskResult(_ClientTaskFields):
"""The typed ``tasks/get`` response: task fields plus the inlined outcome.
Exactly one of ``result`` / ``error`` / ``input_requests`` is set, matching
the task's status. ``result_type`` is ``"complete"`` because ``tasks/get``
itself always completes normally, whatever the task's own status.
"""
result_type: Literal["complete"] = Field(alias="resultType")
result: dict[str, Any] | None = None
error: dict[str, Any] | None = None
input_requests: dict[str, Any] | None = Field(default=None, alias="inputRequests")
class GetTaskRequestParams(RequestParams):
"""Params for ``tasks/get`` / ``tasks/cancel``: the target task id.
These are outbound (client -> server), so they carry *serialization* aliases:
the client constructs them by field name and `send_request` dumps them to the
camelCase wire shape with `by_alias=True`.
"""
model_config = ConfigDict(populate_by_name=True)
task_id: str = Field(serialization_alias="taskId")
CancelTaskRequestParams = GetTaskRequestParams
class UpdateTaskRequestParams(RequestParams):
"""Params for ``tasks/update``: task id plus the caller's input responses."""
model_config = ConfigDict(populate_by_name=True)
task_id: str = Field(serialization_alias="taskId")
input_responses: dict[str, Any] = Field(serialization_alias="inputResponses")
class GetTaskRequest(mcp_types.Request[GetTaskRequestParams, Literal["tasks/get"]]):
"""``tasks/get`` request envelope for ``ClientSession.send_request``."""
method: Literal["tasks/get"] = "tasks/get"
params: GetTaskRequestParams
class UpdateTaskRequest(
mcp_types.Request[UpdateTaskRequestParams, Literal["tasks/update"]]
):
"""``tasks/update`` request envelope for ``ClientSession.send_request``."""
method: Literal["tasks/update"] = "tasks/update"
params: UpdateTaskRequestParams
class CancelTaskRequest(
mcp_types.Request[CancelTaskRequestParams, Literal["tasks/cancel"]]
):
"""``tasks/cancel`` request envelope for ``ClientSession.send_request``."""
method: Literal["tasks/cancel"] = "tasks/cancel"
params: CancelTaskRequestParams

View file

@ -0,0 +1,182 @@
"""Docket-touching component logic relocated from core component classes.
During the SEP-1686 -> SEP-2663 migration the ``register_with_docket`` /
``add_to_docket`` / ``coerce_task_arguments`` methods were removed from the core
``FastMCPComponent`` classes (Tool, Resource, ResourceTemplate, Prompt). Their
bodies live here as type-dispatched functions that ``TasksExtension`` wires into
the Docket engine, preserving each type's calling convention.
The functions dispatch on the concrete component type because each type splats
its arguments differently into the Docket-registered callable:
- ``FunctionTool``/``FunctionResource``/``FunctionResourceTemplate``/``FunctionPrompt``
register the raw ``fn`` so Docket resolves ALL dependencies (FastMCP's and
Docket-native), and splat their arguments (``**kwargs``) into it.
- Base ``Tool``/``Resource``/``ResourceTemplate``/``Prompt`` register their
``run``/``read``/``render`` entry point and pass arguments positionally.
Only tools carry a task-capable ``task_config`` (SEP-2663 is tools-only); the
resource/prompt/template branches are retained for engine completeness, not
because core still declares them.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from pydantic import ValidationError as PydanticValidationError
from fastmcp.exceptions import ValidationError
from fastmcp.prompts.base import Prompt
from fastmcp.prompts.function_prompt import FunctionPrompt
from fastmcp.resources.base import Resource
from fastmcp.resources.function_resource import FunctionResource
from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate
from fastmcp.tools.base import Tool
from fastmcp.tools.function_tool import FunctionTool, _resolve_param_hints
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.types import get_cached_typeadapter
from fastmcp_tasks.input_loop import reentrant_task_fn
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
def register_component_with_docket(component: FastMCPComponent, docket: Docket) -> None:
"""Register a component's callable with Docket for background execution.
No-ops if ``task_config.mode`` is ``forbidden``. Function-backed components
register their raw ``fn`` (so Docket resolves all dependencies); base
components register their ``run``/``read``/``render`` entry point.
"""
if not component.task_config.supports_tasks():
return
if isinstance(component, FunctionTool):
# Run the tool through the guard loop so a body that returns an
# InputRequiredResult drives the reentrant in-task input cycle. The
# wrapper is signature-preserving, so Docket's dependency injection is
# unchanged for a body that never asks for input.
docket.register(
reentrant_task_fn(component.fn, component.name), names=[component.key]
)
elif isinstance(component, Tool):
# Custom Tool subclasses route through the same wrapper so a raised
# error becomes a masked, completed `is_error` result — matching the
# synchronous `tools/call` path — rather than a Docket `FAILED` task
# that leaks the raw exception text past the server's masking policy.
docket.register(
reentrant_task_fn(component.run, component.name), names=[component.key]
)
elif isinstance(component, FunctionResource):
docket.register(component.fn, names=[component.key])
elif isinstance(component, FunctionResourceTemplate):
docket.register(component.fn, names=[component.key])
elif isinstance(component, ResourceTemplate):
docket.register(component.read, names=[component.key])
elif isinstance(component, Resource):
docket.register(component.read, names=[component.key])
elif isinstance(component, FunctionPrompt):
docket.register(component.fn, names=[component.key])
elif isinstance(component, Prompt):
docket.register(component.render, names=[component.key])
else:
raise NotImplementedError(
f"{type(component).__name__} does not support Docket registration"
)
async def add_component_to_docket(
component: FastMCPComponent,
docket: Docket,
arguments: dict[str, Any] | None,
*,
fn_key: str | None = None,
task_key: str | None = None,
**kwargs: Any,
) -> Execution:
"""Schedule a component for background execution via Docket.
Handles each component type's calling convention:
- ``FunctionTool``: splats the arguments dict (``.fn`` expects ``**kwargs``).
- base ``Tool``: passes the arguments dict positionally.
- ``Resource`` (any): no arguments.
- ``FunctionResourceTemplate``: splats the params dict.
- base ``ResourceTemplate``: passes params positionally.
- ``FunctionPrompt``: splats the arguments dict (or empty).
- base ``Prompt``: passes arguments positionally.
"""
if not component.task_config.supports_tasks():
raise RuntimeError(
f"Cannot add {type(component).__name__} '{component.name}' to docket: "
f"task execution not supported"
)
lookup_key = fn_key or component.key
if task_key:
kwargs["key"] = task_key
adder = docket.add(lookup_key, **kwargs)
if isinstance(component, FunctionTool):
return await adder(**(arguments or {}))
elif isinstance(component, Tool):
return await adder(arguments)
elif isinstance(component, Resource):
return await adder()
elif isinstance(component, FunctionResourceTemplate):
return await adder(**(arguments or {}))
elif isinstance(component, ResourceTemplate):
return await adder(arguments)
elif isinstance(component, FunctionPrompt):
return await adder(**(arguments or {}))
elif isinstance(component, Prompt):
return await adder(arguments)
else:
raise NotImplementedError(
f"{type(component).__name__} does not implement add_to_docket()"
)
def coerce_task_arguments(
component: FastMCPComponent,
arguments: dict[str, Any],
*,
strict: bool = False,
) -> dict[str, Any]:
"""Validate and coerce task arguments before any task state is created.
Called by ``submit_to_docket`` up front, so invalid inputs raise before the
task's Redis metadata and initial status notification exist — otherwise a
coercion failure during queueing would orphan a task the client has already
observed. Only ``FunctionTool`` splats arguments into a typed Python callable
and therefore mirrors the synchronous validation path; every other component
type is a no-op passthrough.
When ``strict`` is set (server-level ``strict_input_validation``), arguments
are validated in strict mode so the task path rejects lax coercions (e.g. the
string ``"1"`` into an ``int``) exactly as the synchronous call path does.
"""
if not isinstance(component, FunctionTool):
return arguments
from fastmcp.server.dependencies import without_injected_parameters
wrapper_fn = without_injected_parameters(
component.fn, run_in_thread=component.run_in_thread
)
hints = _resolve_param_hints(wrapper_fn)
coerced = dict(arguments)
for name, value in arguments.items():
annotation = hints.get(name)
if annotation is None:
continue
adapter = get_cached_typeadapter(annotation)
try:
coerced[name] = adapter.validate_python(value, strict=strict)
except PydanticValidationError as e:
raise ValidationError(str(e), log_level=logging.WARNING) from e
return coerced

View file

@ -16,7 +16,11 @@ from contextvars import ContextVar
from dataclasses import dataclass
from typing import TYPE_CHECKING
from fastmcp.server.tasks.keys import parse_task_key, task_redis_prefix
from fastmcp_tasks.keys import (
leg_number_from_key,
parse_task_key,
task_redis_prefix,
)
try:
from docket import TaskKey
@ -34,6 +38,7 @@ if TYPE_CHECKING:
from docket import Docket
from mcp.server.session import ServerSession
from fastmcp.server.context import Context
from fastmcp.server.server import FastMCP
_logger = logging.getLogger(__name__)
@ -88,7 +93,7 @@ def get_task_context() -> TaskContextInfo | None:
Returns:
TaskContextInfo with task_id and task_scope, or None if not in a task.
"""
from fastmcp.server.dependencies import is_docket_available
from fastmcp_tasks.dependencies import is_docket_available
if not is_docket_available():
return None
@ -108,6 +113,26 @@ def get_task_context() -> TaskContextInfo | None:
return None
def get_task_leg_number() -> int:
"""Return the current leg number of the running task (1 outside a re-entry).
Each re-entry after client input runs as a fresh Docket execution under a
per-leg key; the capture wrapper reads this to scope a leg's outstanding
input requests so successive legs never collide in Redis.
"""
from fastmcp_tasks.dependencies import is_docket_available
if not is_docket_available():
return 1
from docket.dependencies import current_execution
try:
return leg_number_from_key(current_execution.get().key)
except LookupError:
return 1
@dataclass(frozen=True, slots=True)
class TaskContextSnapshot:
"""All context data snapshotted at task-submission time.
@ -119,10 +144,24 @@ class TaskContextSnapshot:
http_headers: dict[str, str] | None = None
origin_request_id: str | None = None
session_id: str | None = None
owning_tool_name: str | None = None
owning_tool_version: str | None = None
@classmethod
def capture(cls) -> TaskContextSnapshot:
"""Capture current context for background task execution."""
def capture(
cls,
owning_tool_name: str | None = None,
owning_tool_version: str | None = None,
) -> TaskContextSnapshot:
"""Capture current context for background task execution.
``owning_tool_name``/``owning_tool_version`` identify the exact tool the
call targeted. A remote worker (separate process) cannot reach the
submitting process's server map, so it re-resolves the owning (child)
server from this name and version against the root see
``make_task_context``. The version matters when two versions of the same
mounted tool name live on different child servers.
"""
from fastmcp.server.dependencies import (
get_access_token,
get_context,
@ -145,6 +184,8 @@ class TaskContextSnapshot:
str(request_context.request_id) if request_context is not None else None
),
session_id=session_id,
owning_tool_name=owning_tool_name,
owning_tool_version=owning_tool_version,
)
@classmethod
@ -161,6 +202,8 @@ class TaskContextSnapshot:
http_headers=headers,
origin_request_id=parsed.get("origin_request_id"),
session_id=parsed.get("session_id"),
owning_tool_name=parsed.get("owning_tool_name"),
owning_tool_version=parsed.get("owning_tool_version"),
)
def to_json(self) -> str:
@ -171,6 +214,8 @@ class TaskContextSnapshot:
"http_headers": self.http_headers,
"origin_request_id": self.origin_request_id,
"session_id": self.session_id,
"owning_tool_name": self.owning_tool_name,
"owning_tool_version": self.owning_tool_version,
}
)
@ -247,7 +292,8 @@ async def restore_task_snapshot(key: str = TaskKey()) -> None:
# Non-fastmcp key (e.g. docket scheduler internals) — nothing to do.
return
from fastmcp.server.dependencies import _current_docket, get_server
from fastmcp.server.dependencies import get_server
from fastmcp_tasks.dependencies import _current_docket
try:
docket = get_server()._docket
@ -267,7 +313,12 @@ async def restore_task_snapshot(key: str = TaskKey()) -> None:
)
if raw is None:
return
_remember_snapshot(task_id, TaskContextSnapshot.from_json(raw))
snapshot = TaskContextSnapshot.from_json(raw)
_remember_snapshot(task_id, snapshot)
# Restore the ambient request context (auth token, headers) so core's
# get_access_token()/get_http_headers() see the submitting caller inside
# the worker, exactly as a normal request would.
_apply_snapshot_to_context(snapshot)
except Exception:
_logger.warning("Failed to restore task snapshot for %s", key, exc_info=True)
@ -361,3 +412,160 @@ def get_task_server(task_id: str) -> FastMCP | None:
if server is None:
_task_server_map.pop(task_id, None)
return server
def resolve_worker_server() -> FastMCP | None:
"""Return the server owning the current task's tool, or None outside a task.
Installed as core's worker-server resolver by ``TasksExtension`` so
``get_server()``/``CurrentFastMCP()`` inside a worker resolve to the (child)
server the task was submitted against, not the root that runs the worker.
The map is populated at submission (same process) and, for a remote worker,
by ``make_task_context`` re-resolving from the snapshot before the tool runs.
"""
task_info = get_task_context()
if task_info is None:
return None
return get_task_server(task_info.task_id)
async def _resolve_owning_server(
snapshot: TaskContextSnapshot | None,
) -> FastMCP | None:
"""Re-resolve a mounted task's owning child server from the root (remote worker).
A separate worker process cannot reach the submitting process's server map,
so the owning server is recovered by looking the snapshotted tool name up on
the root: a mounted tool resolves to a ``FastMCPProviderTool`` referencing
its child server. Returns ``None`` for an unmounted tool (the root owns it)
or when the name no longer resolves, so the caller falls back to the root.
"""
if snapshot is None or snapshot.owning_tool_name is None:
return None
from fastmcp.exceptions import NotFoundError
from fastmcp.server.dependencies import get_server
from fastmcp.server.providers.fastmcp_provider import FastMCPProviderTool
from fastmcp.utilities.versions import VersionSpec
root = get_server()
# Resolve the exact version the call targeted: two versions of the same
# mounted tool name can live on different child servers, so omitting the
# version could pick the wrong server's state and masking policy.
version = (
VersionSpec(eq=snapshot.owning_tool_version)
if snapshot.owning_tool_version
else None
)
try:
tool = await root.get_tool(snapshot.owning_tool_name, version)
except NotFoundError:
return None
if isinstance(tool, FastMCPProviderTool):
return tool._server
return None
def _apply_snapshot_to_context(snapshot: TaskContextSnapshot) -> None:
"""Populate the ambient request context a worker's tool body reads.
A Docket worker has no live request or SDK auth context especially a
Redis-backed worker in a separate process. This restores the context vars a
tool reads so ``get_access_token()`` / ``get_http_headers()`` work unchanged:
the SDK auth context var (from the snapshotted token) and core's background
task-headers var (from the snapshotted headers). It deliberately does *not*
fabricate a live ``Request``, so ``get_http_request()`` / ``CurrentRequest()``
still raise inside a task there is no request. Runs inside
``restore_task_snapshot`` (a Docket dependency), whose context vars propagate
to the tool the same way the snapshot var already does.
Both vars are set unconditionally to *this* snapshot's state (``None`` when
it carries no token/headers), never left as-is: a Docket worker may reuse an
asyncio context across tasks, so an anonymous task following an authenticated
one must not inherit the prior caller's identity or headers.
"""
import time
from mcp.server.auth.middleware.auth_context import auth_context_var
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from fastmcp.server.auth import AccessToken
from fastmcp.server.dependencies import (
_background_task_headers,
_background_task_session_id,
)
user: AuthenticatedUser | None = None
if snapshot.access_token_json is not None:
token = AccessToken.model_validate_json(snapshot.access_token_json)
# A task may sit queued past its submitter's token expiry. Install it
# only if still valid — mirroring the SDK's bearer check — so a delayed
# task never runs under credentials a live request would reject (401).
# An expired token leaves the worker unauthenticated, the honest state.
if token.expires_at is None or token.expires_at >= int(time.time()):
user = AuthenticatedUser(token)
auth_context_var.set(user)
_background_task_headers.set(
dict(snapshot.http_headers) if snapshot.http_headers else None
)
_background_task_session_id.set(snapshot.session_id)
async def make_task_context() -> Context | None:
"""Build and enter a worker ``Context`` for the current background task.
Installed as core's background-context factory by ``TasksExtension`` so a
``ctx: Context`` parameter resolves inside a Docket worker. Returns ``None``
when not running in a task (so core falls through to its usual error). The
snapshot restored by ``restore_task_snapshot`` supplies the origin request
id; the server prefers the one registered at submission time so mounted
tasks resolve to the child server. No live session is attached SEP-2663
input and status are polled, so the worker needs no back-channel.
For a re-entered leg (after the client answered a guard ask), the accumulated
per-leg state is loaded and injected so the tool reads ``ctx.input_responses``
/ ``ctx.request_state`` identically to the foreground guard contract. Leg 1
loads nothing (both ``None``).
"""
from fastmcp.server.context import Context
from fastmcp.server.dependencies import get_server
task_info = get_task_context()
if task_info is None:
return None
snapshot = _recall_snapshot(task_info.task_id)
server = get_task_server(task_info.task_id)
if server is None:
# In-process submission map missed — this is a remote worker (separate
# process). Re-resolve the owning (child) server from the root using the
# snapshotted tool name, and register it so `CurrentFastMCP()` mid-tool
# resolves the child too. Falls back to the root when unmounted or
# unresolvable.
server = await _resolve_owning_server(snapshot) or get_server()
register_task_server(task_info.task_id, server)
origin_request_id = snapshot.origin_request_id if snapshot else None
ctx = Context(
fastmcp=server,
session=None,
task_id=task_info.task_id,
origin_request_id=origin_request_id,
)
await ctx.__aenter__()
docket = server._docket
if docket is None:
from fastmcp_tasks.dependencies import _current_docket
docket = _current_docket.get()
if docket is not None:
from fastmcp_tasks.input_store import load_pending_input
request_state, input_responses = await load_pending_input(
docket, task_info.task_scope, task_info.task_id
)
ctx._task_request_state = request_state
ctx._task_input_responses = input_responses
return ctx

View file

@ -0,0 +1,245 @@
"""SEP-2663 task creation: enqueue an augmented tool call to Docket.
Adapted from the SEP-1686 ``submit_to_docket`` path. The wire surface changed
(a flat ``CreateTaskResult`` with ``ttlMs``/``pollIntervalMs``, no client-supplied
task id or ttl) and the SEP-1686 push machinery the initial status
notification, the per-task subscription, and the notification subscriber is
gone, because SEP-2663 in-task input and status are polled, not pushed. The
operational core is preserved: strict argument coercion up front, a
server-generated high-entropy task id, the auth-scoped compound key, the context
snapshot restored in the worker, and durable creation (metadata is written
before the result is returned, so a subsequent ``tasks/get`` always resolves).
"""
from __future__ import annotations
import asyncio
import secrets
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from mcp.shared.exceptions import MCPError
from mcp_types import INTERNAL_ERROR
from fastmcp.tools.base import Tool
from fastmcp.tools.function_tool import _strict_input_validation
from fastmcp.utilities.logging import get_logger
from fastmcp_tasks.components import add_component_to_docket, coerce_task_arguments
from fastmcp_tasks.context import (
TaskContextSnapshot,
get_task_scope,
register_task_server,
)
from fastmcp_tasks.dependencies import _current_docket
from fastmcp_tasks.input_store import save_current_leg, save_task_args
from fastmcp_tasks.keys import build_task_key, task_redis_prefix
from fastmcp_tasks.models import CreateTaskResult
if TYPE_CHECKING:
from docket import Docket
from fastmcp.server.context import Context
from fastmcp.server.server import FastMCP
logger = get_logger(__name__)
# Redis mapping TTL buffer: keep task metadata a little longer than the Docket
# execution TTL so a client polling right at the edge still resolves the task.
TASK_MAPPING_TTL_BUFFER_SECONDS = 15 * 60
# Bounded read-your-writes wait so durable creation holds on distributed
# backends where the enqueued execution may not be immediately visible.
_DURABLE_CREATE_TIMEOUT_SECONDS = 5.0
_DURABLE_CREATE_POLL_SECONDS = 0.02
async def create_task(
tool: Tool,
arguments: dict[str, object] | None,
context: Context,
) -> CreateTaskResult:
"""Run an augmented ``tools/call`` as a background task (SEP-2663).
Coerces and validates arguments (honoring strict input validation), mints a
server-generated task id, snapshots the request context, enqueues the tool's
callable on Docket under the auth-scoped compound key, and returns a
``CreateTaskResult`` in ``working`` status. Does not return until the task's
metadata is durably written and its execution is visible, so an immediately
following ``tasks/get`` resolves.
"""
# The interceptor resolves the tool via get_tool(), which for a mounted tool
# returns a provider wrapper — but Docket registered the underlying component
# from get_tasks() under the same key, with that component's calling
# convention (a FunctionTool splats **kwargs; a base Tool takes the dict
# positionally). Execute against the registered component so coercion and
# argument-splatting match what the worker will invoke.
component = await _registered_task_component(context, tool)
raw_arguments = dict(arguments or {})
coerced = coerce_task_arguments(
component, raw_arguments, strict=_strict_input_validation()
)
task_id = secrets.token_urlsafe(32)
created_at = datetime.now(timezone.utc).isoformat()
task_scope = get_task_scope()
docket = context.fastmcp._docket or _current_docket.get()
if docket is None:
raise MCPError(
code=INTERNAL_ERROR,
message="Background tasks require a running tasks extension (Docket).",
)
# Resolve mounted tasks to the owning (child) server in the worker, so
# CurrentFastMCP()/ctx.fastmcp inside the task point at the server the tool
# lives on rather than the root the interceptor ran on (#3571).
register_task_server(task_id, _owning_server(tool, context.fastmcp))
key = component.key
task_key = build_task_key(task_scope, task_id, "tool", key)
ttl_ms = int(docket.execution_ttl.total_seconds() * 1000)
ttl_seconds = int(ttl_ms / 1000) + TASK_MAPPING_TTL_BUFFER_SECONDS
poll_interval_ms = int(component.task_config.poll_interval.total_seconds() * 1000)
prefix = task_redis_prefix(task_scope)
task_meta_key = docket.key(f"{prefix}:{task_id}")
created_at_key = docket.key(f"{prefix}:{task_id}:created_at")
poll_interval_key = docket.key(f"{prefix}:{task_id}:poll_interval")
snapshot = TaskContextSnapshot.capture(
owning_tool_name=tool.name, owning_tool_version=tool.version
)
async with docket.redis() as redis:
await redis.set(task_meta_key, task_key, ex=ttl_seconds)
await redis.set(created_at_key, created_at, ex=ttl_seconds)
await redis.set(poll_interval_key, str(poll_interval_ms), ex=ttl_seconds)
# End-and-reenter state: the raw (wire) arguments feed every leg, re-coerced
# per leg, and the leg pointer starts at leg 1 (the base task key). A guard
# return re-enters by enqueuing the next leg with these same arguments (see
# handlers.enqueue_next_leg).
await save_task_args(docket, task_scope, task_id, raw_arguments, ttl_seconds)
await save_current_leg(docket, task_scope, task_id, task_key, 1, ttl_seconds)
await snapshot.save(docket, task_scope, task_id, ttl_seconds)
await add_component_to_docket(
component, docket, coerced, fn_key=key, task_key=task_key
)
await _await_durable_creation(docket, task_key)
return CreateTaskResult(
task_id=task_id,
status="working",
created_at=created_at,
last_updated_at=created_at,
ttl_ms=ttl_ms,
poll_interval_ms=poll_interval_ms,
)
def _owning_server(tool: Tool, fallback: FastMCP) -> FastMCP:
"""The server a mounted tool lives on, for worker context resolution.
A mounted tool is a ``FastMCPProviderTool`` that references the child server
it came from, so ``CurrentFastMCP()``/``ctx.fastmcp`` inside the task point at
that server rather than the root the interceptor ran on (#3571). Resolution
is single-level: a tool reached through several nested mounts resolves to the
outermost mounted child (the mount point the call arrived through), which
still reaches deeper components through its own mounts. A non-mounted tool
falls back to the server the call arrived on.
"""
from fastmcp.server.providers.fastmcp_provider import FastMCPProviderTool
if isinstance(tool, FastMCPProviderTool):
return tool._server
return fallback
async def registered_component_for_key(server: FastMCP, component_key: str) -> Tool:
"""Return the Docket-registered task component matching ``component_key``.
``get_tasks()`` yields the same components registered with Docket (the
underlying ``FunctionTool`` for a mounted tool, not a provider wrapper), so
matching by ``key`` recovers the component whose calling convention agrees
with the worker. Used when re-entering a task leg, where only the stored
compound key (not the original ``Tool`` object) is available.
"""
for component in await server.get_tasks():
if component.key == component_key and isinstance(component, Tool):
return component
raise MCPError(
code=INTERNAL_ERROR,
message=f"No task-enabled component found for {component_key!r}.",
)
async def enqueue_task_leg(
server: FastMCP,
docket: Docket,
component: Tool,
raw_arguments: dict[str, object],
leg_key: str,
) -> None:
"""Enqueue a fresh Docket execution (the next leg) for a re-entered task.
Re-coerces the stored wire arguments (each leg validates independently, as a
foreground retry would) and adds the component's registered callable — the
capture wrapper under ``leg_key``. Waits for the execution to become
durable so a ``tasks/get`` immediately after ``tasks/update`` resolves.
"""
coerced = coerce_task_arguments(
component, dict(raw_arguments), strict=_strict_input_validation()
)
await add_component_to_docket(
component, docket, coerced, fn_key=component.key, task_key=leg_key
)
await _await_durable_creation(docket, leg_key)
async def _registered_task_component(context: Context, tool: Tool) -> Tool:
"""Return the component Docket registered for ``tool``'s key.
``get_tasks()`` yields the same components that were registered with Docket
(the underlying ``FunctionTool`` for a mounted tool, not the provider
wrapper the interceptor's ``get_tool`` returns). Matching by ``key`` recovers
the registered component so the calling convention agrees with the worker.
Falls back to the interceptor's tool if no match is found (e.g. a dynamically
added tool not present at registration time).
"""
for component in await context.fastmcp.get_tasks():
if component.key == tool.key and isinstance(component, Tool):
return component
return tool
async def _await_durable_creation(docket: Docket, task_key: str) -> None:
"""Block until the enqueued execution is visible (durable-create MUST).
The metadata write above already makes ``tasks/get`` resolvable; this extra
check guards distributed backends where the execution record propagates
slightly behind the enqueue. Bounded so a backend hiccup can't hang creation.
"""
deadline = asyncio.get_event_loop().time() + _DURABLE_CREATE_TIMEOUT_SECONDS
while True:
execution = await docket.get_execution(task_key)
if execution is not None:
return
if asyncio.get_event_loop().time() >= deadline:
# SEP-2663 durable-create: a CreateTaskResult MUST NOT be returned
# unless a subsequent tasks/get would resolve. Returning a handle
# that can 404 is the exact failure the requirement forbids, so a
# backend that never surfaces the execution is a create error.
raise MCPError(
code=INTERNAL_ERROR,
message=(
"Task creation did not become durable in time; the task "
"backend did not surface the enqueued execution."
),
)
await asyncio.sleep(_DURABLE_CREATE_POLL_SECONDS)

View file

@ -0,0 +1,184 @@
"""Docket-specific dependency injection for FastMCP background tasks.
Moved out of ``fastmcp.server.dependencies`` during the SEP-1686 -> SEP-2663
migration. These helpers are all docket-touching: the ``require_docket``
install-hint, the docket/worker ContextVars, and the ``CurrentDocket`` /
``CurrentWorker`` dependencies. Everything here is wire-agnostic engine plumbing
that ``TasksExtension`` drives.
The generic ``is_docket_available`` probe stays in ``fastmcp.server.dependencies``
(core's ``Context``/``Progress`` still use it) and is re-exported here for the
tasks package's callers.
"""
from __future__ import annotations
import importlib.metadata
from contextvars import ContextVar
from types import TracebackType
from typing import TYPE_CHECKING, cast
from uncalled_for import Dependency
from fastmcp.server.dependencies import (
_MIN_DOCKET_VERSION,
get_server,
is_docket_available,
)
if TYPE_CHECKING:
from docket import Docket
from docket.worker import Worker
__all__ = [
"CurrentDocket",
"CurrentWorker",
"is_docket_available",
"require_docket",
]
_current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None)
_current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None)
def require_docket(feature: str) -> None:
"""Raise ImportError with install instructions if docket not available.
Args:
feature: Description of what requires docket (e.g., "`task=True`",
"CurrentDocket()"). Will be included in the error message.
"""
if is_docket_available():
return
try:
installed = importlib.metadata.version("pydocket")
except importlib.metadata.PackageNotFoundError:
installed = None
if installed is None:
detail = (
"FastMCP background tasks require the `tasks` extra. "
"Install with: pip install 'fastmcp[tasks]'."
)
else:
detail = (
f"FastMCP background tasks require pydocket>={_MIN_DOCKET_VERSION}, "
f"but pydocket {installed} is installed (likely pulled in by another "
f"package). Upgrade with: pip install -U 'pydocket>={_MIN_DOCKET_VERSION}'."
)
raise ImportError(f"{detail} (Triggered by {feature})")
class _CurrentDocket(Dependency["Docket"]):
"""Async context manager for Docket dependency."""
async def __aenter__(self) -> Docket:
require_docket("CurrentDocket()")
# Check server instance first, fall back to ContextVar for mounted children
# whose parent owns the Docket
try:
docket = get_server()._docket
except RuntimeError:
docket = None
if docket is None:
docket = _current_docket.get()
if docket is None:
raise RuntimeError(
"No Docket instance found. Docket is only initialized when there are "
"task-enabled components (task=True). Add task=True to a component "
"to enable Docket infrastructure."
)
return docket
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
pass
def CurrentDocket() -> Docket:
"""Get the current Docket instance managed by FastMCP.
This dependency provides access to the Docket instance that FastMCP
automatically creates for background task scheduling.
Returns:
A dependency that resolves to the active Docket instance
Raises:
RuntimeError: If not within a FastMCP server context
ImportError: If fastmcp[tasks] not installed
Example:
```python
from fastmcp_tasks.dependencies import CurrentDocket
@mcp.tool()
async def schedule_task(docket: Docket = CurrentDocket()) -> str:
await docket.add(some_function)(arg1, arg2)
return "Scheduled"
```
"""
require_docket("CurrentDocket()")
return cast("Docket", _CurrentDocket())
class _CurrentWorker(Dependency["Worker"]):
"""Async context manager for Worker dependency."""
async def __aenter__(self) -> Worker:
require_docket("CurrentWorker()")
# Check server instance first, fall back to ContextVar for mounted children
try:
worker = get_server()._worker
except RuntimeError:
worker = None
if worker is None:
worker = _current_worker.get()
if worker is None:
raise RuntimeError(
"No Worker instance found. Worker is only initialized when there are "
"task-enabled components (task=True). Add task=True to a component "
"to enable Docket infrastructure."
)
return worker
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
pass
def CurrentWorker() -> Worker:
"""Get the current Docket Worker instance managed by FastMCP.
This dependency provides access to the Worker instance that FastMCP
automatically creates for background task processing.
Returns:
A dependency that resolves to the active Worker instance
Raises:
RuntimeError: If not within a FastMCP server context
ImportError: If fastmcp[tasks] not installed
Example:
```python
from fastmcp_tasks.dependencies import CurrentWorker
@mcp.tool()
async def check_worker_status(worker: Worker = CurrentWorker()) -> str:
return f"Worker: {worker.name}"
```
"""
require_docket("CurrentWorker()")
return cast("Worker", _CurrentWorker())

View file

@ -0,0 +1,299 @@
"""The SEP-2663 tasks extension: `io.modelcontextprotocol/tasks`.
`TasksExtension` is the wire adapter that turns FastMCP's task engine into an
`io.modelcontextprotocol/tasks` server extension. Registering it enables
`task=True` tools:
```python
from fastmcp import FastMCP
from fastmcp_tasks import TasksExtension
mcp = FastMCP("Server")
mcp.add_extension(TasksExtension(url="redis://localhost:6379/0"))
@mcp.tool(task=True)
async def crunch(dataset: str) -> str:
...
```
The extension contributes the negotiated capability, the three additive request
methods (`tasks/get`, `tasks/update`, `tasks/cancel`), a `tools/call` interceptor
that decides whether to run a call as a task, and a lifespan that starts the
Docket backend/worker and installs the worker-side `Context` hooks core exposes.
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from datetime import timedelta
from typing import TYPE_CHECKING, Any
from mcp.server.context import ServerRequestContext
from mcp.shared.exceptions import MCPError
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from fastmcp.exceptions import NotFoundError
from fastmcp.server.dependencies import extract_version_spec
from fastmcp.server.extensions import (
MethodBinding,
ServerExtension,
read_client_extension_settings,
)
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.tasks import TASKS_EXTENSION_ID
from fastmcp.utilities.versions import VersionSpec
from fastmcp_tasks.creation import create_task
from fastmcp_tasks.handlers import tasks_cancel, tasks_get, tasks_update
from fastmcp_tasks.models import (
MISSING_REQUIRED_CLIENT_CAPABILITY,
CancelTaskParams,
CancelTaskResult,
GetTaskParams,
GetTaskResult,
UpdateTaskParams,
UpdateTaskResult,
missing_capability_error_data,
)
from fastmcp_tasks.settings import DocketSettings
if TYPE_CHECKING:
import mcp_types
from fastmcp.server.context import Context
from fastmcp.server.extensions import ToolCallContinuation, ToolCallOutcome
logger = get_logger(__name__)
# SEP-2663's request methods exist only at the 2026-07-28 era (the extensions
# mechanism itself is era-gated). Off that era the methods report as not found.
_TASK_METHOD_VERSIONS = frozenset(MODERN_PROTOCOL_VERSIONS)
class TasksExtension(ServerExtension):
"""FastMCP server extension implementing SEP-2663 background tasks.
Construct with backend/worker configuration; anything omitted falls back to
the ``FASTMCP_DOCKET_*`` environment defaults (unchanged from FastMCP 3), so
``TasksExtension()`` works out of the box on an env-configured deployment.
"""
identifier = TASKS_EXTENSION_ID
def __init__(
self,
*,
url: str | None = None,
name: str | None = None,
worker_name: str | None = None,
concurrency: int | None = None,
redelivery_timeout: timedelta | None = None,
reconnection_delay: timedelta | None = None,
minimum_check_interval: timedelta | None = None,
) -> None:
overrides: dict[str, Any] = {
"url": url,
"name": name,
"worker_name": worker_name,
"concurrency": concurrency,
"redelivery_timeout": redelivery_timeout,
"reconnection_delay": reconnection_delay,
"minimum_check_interval": minimum_check_interval,
}
self._settings = DocketSettings(
**{k: v for k, v in overrides.items() if v is not None}
)
@property
def docket_settings(self) -> DocketSettings:
"""The resolved Docket settings (backend URL, worker options)."""
return self._settings
def settings(self) -> dict[str, Any]:
"""The tasks extension advertises no per-extension settings."""
return {}
def methods(self) -> Sequence[MethodBinding]:
return [
MethodBinding(
method="tasks/get",
params_type=GetTaskParams,
handler=self._handle_get,
protocol_versions=_TASK_METHOD_VERSIONS,
),
MethodBinding(
method="tasks/update",
params_type=UpdateTaskParams,
handler=self._handle_update,
protocol_versions=_TASK_METHOD_VERSIONS,
),
MethodBinding(
method="tasks/cancel",
params_type=CancelTaskParams,
handler=self._handle_cancel,
protocol_versions=_TASK_METHOD_VERSIONS,
),
]
def _require_tasks_capability(self, ctx: ServerRequestContext[Any, Any]) -> None:
"""Reject a task method from a client that did not declare the extension.
SEP-2663: a client issuing `tasks/get`/`tasks/update`/`tasks/cancel`
without the tasks capability in the request's `_meta` gets -32003. A
client normally only holds a taskId because it declared the capability
on the creating `tools/call`, but the method-level check is an explicit
MUST, so enforce it here rather than assume.
"""
if read_client_extension_settings(ctx, TASKS_EXTENSION_ID) is None:
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message=(
"This request targets the tasks extension "
f"({TASKS_EXTENSION_ID}); the client did not declare it for "
"this request."
),
data=missing_capability_error_data(),
)
async def _handle_get(
self, ctx: ServerRequestContext[Any, Any], params: GetTaskParams
) -> GetTaskResult:
self._require_tasks_capability(ctx)
return await tasks_get(self.server, params.task_id)
async def _handle_update(
self, ctx: ServerRequestContext[Any, Any], params: UpdateTaskParams
) -> UpdateTaskResult:
self._require_tasks_capability(ctx)
return await tasks_update(self.server, params.task_id, params.input_responses)
async def _handle_cancel(
self, ctx: ServerRequestContext[Any, Any], params: CancelTaskParams
) -> CancelTaskResult:
self._require_tasks_capability(ctx)
return await tasks_cancel(self.server, params.task_id)
async def intercept_tool_call(
self,
params: mcp_types.CallToolRequestParams,
context: Context,
call_next: ToolCallContinuation,
) -> ToolCallOutcome:
"""Decide whether to run this ``tools/call`` as a task.
Consults the tool's ``TaskConfig`` mode and the client's per-request
opt-in: ``required`` always tasks (raising -32003 if the client did not
opt in), ``optional`` tasks only when the client opted in, ``forbidden``
never tasks. A non-task call passes straight through to the tool body.
"""
# Resolve the same version core would dispatch: a versioned tools/call
# carries its VersionSpec in the request _meta, so omitting it here would
# task the highest version even when the client targeted an older one
# (which may differ in task mode or implementation).
version_str = extract_version_spec(params.meta)
version = VersionSpec(eq=version_str) if version_str else None
try:
tool = await context.fastmcp.get_tool(params.name, version)
except NotFoundError:
tool = None
if tool is None or not tool.task_config.supports_tasks():
return await call_next()
# Extension negotiation exists only on the modern era: the SDK strips
# `capabilities.extensions` from pre-2026 handshakes, so a legacy client
# cannot have negotiated this extension — a `_meta` opt-in arriving on a
# handshake-era connection is treated as absent. This also keeps a
# `CreateTaskResult` off legacy connections, whose result validation
# does not admit it.
rc = context.request_context
on_modern_era = (
rc is not None and rc.protocol_version in MODERN_PROTOCOL_VERSIONS
)
opted_in = (
on_modern_era
and context.client_extension_settings(TASKS_EXTENSION_ID) is not None
)
mode = tool.task_config.mode
if mode == "required":
if not opted_in:
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message=(
f"Tool {tool.name!r} requires the tasks extension "
f"({TASKS_EXTENSION_ID}); the client did not declare it "
"for this request."
),
data=missing_capability_error_data(),
)
return await create_task(tool, params.arguments, context)
if mode == "optional" and opted_in:
return await create_task(tool, params.arguments, context)
return await call_next()
@asynccontextmanager
async def lifespan(self) -> AsyncIterator[None]:
"""Start the Docket backend/worker and install the worker-side hooks.
Installs core's background-context factory and worker-server resolver for
the duration so a worker's ``ctx`` (progress, server resolution) works,
then runs the Docket lifespan. The hooks are process-global and
refcounted: with several servers in one process (each its own
runtime-tree root), the hooks stay installed until the last tasks
extension shuts down, so one server's exit cannot strand another
server's in-flight workers.
"""
from fastmcp_tasks.lifespan import docket_lifespan
_install_worker_hooks()
try:
async with docket_lifespan(self.server, self._settings):
yield
finally:
_release_worker_hooks()
# The worker-side hooks core exposes are process-global, but several servers in
# one process may each run a TasksExtension (sibling roots in tests, or two
# apps sharing an interpreter). Refcount the installs so the hooks are cleared
# only when the last active extension lifespan exits. The installed callables
# are stateless module functions that resolve their target per task, so
# repeated installs are idempotent.
_active_worker_hook_holds: int = 0
def _install_worker_hooks() -> None:
from fastmcp.server.dependencies import (
set_background_context_factory,
set_worker_server_resolver,
)
from fastmcp_tasks import wire_production
from fastmcp_tasks.context import make_task_context, resolve_worker_server
global _active_worker_hook_holds
_active_worker_hook_holds += 1
set_background_context_factory(make_task_context)
set_worker_server_resolver(resolve_worker_server)
# Enable server-side production of the claimed CreateTaskResult on tools/call
# (the SDK ships only claim consumption). Refcounted independently but
# installed/released in lockstep with the worker hooks.
wire_production.install()
def _release_worker_hooks() -> None:
from fastmcp.server.dependencies import (
set_background_context_factory,
set_worker_server_resolver,
)
from fastmcp_tasks import wire_production
global _active_worker_hook_holds
_active_worker_hook_holds -= 1
if _active_worker_hook_holds <= 0:
_active_worker_hook_holds = 0
set_worker_server_resolver(None)
set_background_context_factory(None)
wire_production.uninstall()

View file

@ -0,0 +1,433 @@
"""SEP-2663 task query/management handlers: tasks/get, tasks/update, tasks/cancel.
Adapted from the SEP-1686 ``requests.py``. The three CRUD-ish handlers survive,
reshaped to the new wire:
- ``tasks/get`` merges the old ``tasks/get`` and ``tasks/result``: the finished
result is *inlined* into the response for a completed task, a JSON-RPC-shaped
``error`` for a failed one, and the outstanding ``inputRequests`` for a task
waiting on input.
- ``tasks/update`` is new: it delivers ``inputResponses`` to the in-task input
store, resuming a parked worker.
- ``tasks/cancel`` returns an empty ack (SEP-2663) instead of a task snapshot.
- ``tasks/list`` and ``tasks/result`` are gone (removed by SEP-2663).
The auth-scoped compound key is the authorization boundary: a request resolves a
task only under its own scope's Redis prefix, so a scope mismatch is
indistinguishable from a missing task (both raise -32602 "Task not found"),
which avoids leaking task existence across callers.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Literal
import mcp_types
from docket.execution import ExecutionState
from mcp.shared.exceptions import MCPError
from mcp_types import INVALID_PARAMS
from fastmcp.exceptions import NotFoundError
from fastmcp.tools.base import InputRequiredToolResult, Tool, ToolResult
from fastmcp.utilities.tasks import DEFAULT_POLL_INTERVAL_MS
from fastmcp.utilities.versions import VersionSpec
from fastmcp_tasks.context import get_task_scope
from fastmcp_tasks.creation import (
TASK_MAPPING_TTL_BUFFER_SECONDS,
enqueue_task_leg,
registered_component_for_key,
)
from fastmcp_tasks.input_store import (
acquire_update_lock,
acquire_update_lock_blocking,
clear_outstanding,
is_cancelled,
load_current_leg,
load_task_args,
mark_cancelled,
read_outstanding_inputs,
refresh_current_leg_ttl,
release_update_lock,
save_current_leg,
store_input_responses,
translate_responses,
)
from fastmcp_tasks.keys import (
leg_execution_key,
parse_task_key,
task_redis_prefix,
)
from fastmcp_tasks.models import (
CancelTaskResult,
GetTaskResult,
UpdateTaskResult,
)
if TYPE_CHECKING:
from docket import Docket
from fastmcp.server.server import FastMCP
# Docket execution state -> SEP-2663 task status. `input_required` is not a
# Docket state; it is derived from the in-task input store (see tasks_get).
DOCKET_TO_MCP_STATE: dict[ExecutionState, str] = {
ExecutionState.SCHEDULED: "working",
ExecutionState.QUEUED: "working",
ExecutionState.RUNNING: "working",
ExecutionState.COMPLETED: "completed",
ExecutionState.FAILED: "failed",
ExecutionState.CANCELLED: "cancelled",
}
def _task_not_found(task_id: str) -> MCPError:
"""The single "not found" error for missing, expired, or cross-scope ids.
Uses one message for all three so a caller cannot probe another scope's task
ids by distinguishing "not yours" from "does not exist".
"""
return MCPError(code=INVALID_PARAMS, message=f"Task {task_id} not found")
def _normalize_iso_timestamp(stored: str | None) -> str:
"""Return an ISO 8601 timestamp for createdAt, tolerating a missing value."""
if stored:
try:
return datetime.fromisoformat(stored.replace("Z", "+00:00")).isoformat()
except (ValueError, AttributeError):
pass
return datetime.now(timezone.utc).isoformat()
def _parse_key_version(key_suffix: str) -> tuple[str, str | None]:
"""Split a component key suffix into (name, version) on the last ``@``."""
if "@" not in key_suffix:
return key_suffix, None
name, version = key_suffix.rsplit("@", 1)
return name, version if version else None
def _ttl_ms(docket: Docket) -> int:
"""The task TTL in milliseconds, from Docket's execution TTL (server-set)."""
return int(docket.execution_ttl.total_seconds() * 1000)
def _task_key_ttl_seconds(docket: Docket) -> int:
"""Wall-clock TTL for a task's Redis metadata keys.
Docket's ``execution_ttl`` plus a buffer (matching task creation), so a key
written or refreshed now comfortably outlives the execution-retention
window. Sliding expiration on each poll keeps it alive for long legs.
"""
return int(docket.execution_ttl.total_seconds()) + TASK_MAPPING_TTL_BUFFER_SECONDS
async def _lookup_task(
docket: Docket, task_scope: str | None, task_id: str
) -> tuple[Any, str, int, str | None, int]:
"""Resolve a task's current-leg execution and metadata within the scope.
Returns ``(execution, base_task_key, leg_number, created_at,
poll_interval_ms)``. The execution is the *current leg* (the latest Docket
execution), which for a re-entered task differs from the base task key.
Raises the shared "not found" error when the scope-prefixed metadata is
absent or the current leg's execution has expired.
"""
prefix = task_redis_prefix(task_scope)
meta_key = docket.key(f"{prefix}:{task_id}")
created_at_key = docket.key(f"{prefix}:{task_id}:created_at")
poll_key = docket.key(f"{prefix}:{task_id}:poll_interval")
async with docket.redis() as redis:
# Docket's Redis client mirrors redis-py's variadic ``mget(*keys)`` at
# runtime; its type stub declares a single ``Sequence`` arg, so the
# positional form is correct but needs a targeted ignore.
values = await redis.mget(meta_key, created_at_key, poll_key) # ty: ignore[too-many-positional-arguments]
task_key_bytes, created_at_bytes, poll_bytes = values
base_task_key = task_key_bytes.decode("utf-8") if task_key_bytes else None
if not base_task_key:
raise _task_not_found(task_id)
current_leg_key, leg_number = await load_current_leg(docket, task_scope, task_id)
execution_key = current_leg_key or base_task_key
execution = await docket.get_execution(execution_key)
if not execution:
raise _task_not_found(task_id)
# Sliding expiration: an actively-polled task refreshes its routing keys so
# they never expire mid-execution — a resumed leg that runs longer than the
# keys' wall-clock TTL would otherwise strand `_lookup_task` on the base leg.
refresh_ttl = _task_key_ttl_seconds(docket)
async with docket.redis() as redis:
await redis.expire(meta_key, refresh_ttl)
await redis.expire(created_at_key, refresh_ttl)
await redis.expire(poll_key, refresh_ttl)
await refresh_current_leg_ttl(docket, task_scope, task_id, refresh_ttl)
created_at = created_at_bytes.decode("utf-8") if created_at_bytes else None
try:
poll_interval_ms = (
int(poll_bytes.decode("utf-8")) if poll_bytes else DEFAULT_POLL_INTERVAL_MS
)
except (ValueError, UnicodeDecodeError):
poll_interval_ms = DEFAULT_POLL_INTERVAL_MS
return execution, base_task_key, leg_number, created_at, poll_interval_ms
async def _resolve_tool(server: FastMCP, task_key: str) -> Tool:
"""Resolve the Tool a task ran, from its compound key (tools-only surface)."""
component_key = parse_task_key(task_key)["component_identifier"]
if not component_key.startswith("tool:"):
raise MCPError(
code=mcp_types.INTERNAL_ERROR,
message=f"Task component is not a tool: {component_key}",
)
name, version_str = _parse_key_version(component_key[len("tool:") :])
version = VersionSpec(eq=version_str) if version_str else None
try:
tool = await server.get_tool(name, version)
except NotFoundError:
tool = None
if tool is None:
raise MCPError(
code=mcp_types.INTERNAL_ERROR,
message=f"Component not found for task: {component_key}",
)
return tool
def _inline_result(tool: Tool, raw_value: Any) -> dict[str, Any]:
"""Convert a completed task's raw return into an inlined CallToolResult dict.
A completed task should never carry an ``InputRequiredResult``: a function
tool's guard returns are captured by the end-and-reenter wrapper (see
``input_loop.py``), which records the leg's outstanding requests and ends the
leg (returning ``None``), so ``tasks/get`` reports ``input_required`` rather
than inlining. Reaching here with a guard result means a component type the
wrapper does not wrap (e.g. a base ``Tool``) returned one, which the task
path cannot drive a safety net, not an expected path.
"""
if isinstance(raw_value, mcp_types.InputRequiredResult | InputRequiredToolResult):
raise MCPError(
code=mcp_types.INTERNAL_ERROR,
message=(
f"Tool {tool.name!r} returned an input-required result as a task, "
"but its component type is not driven by the in-task guard loop. "
"Guard-pattern tasks are supported for function tools."
),
)
# A raised tool error arrives as an is_error ToolResult the wrapper built
# (end-and-reenter G2); use it directly so isError round-trips. A normal
# return is converted through the tool's own result coercion.
if isinstance(raw_value, ToolResult):
mcp_result = raw_value.to_mcp_result()
else:
mcp_result = tool.convert_result(raw_value).to_mcp_result()
if isinstance(mcp_result, mcp_types.CallToolResult):
call_tool_result = mcp_result
elif isinstance(mcp_result, tuple):
content, structured_content = mcp_result
call_tool_result = mcp_types.CallToolResult(
content=content, structured_content=structured_content
)
else:
call_tool_result = mcp_types.CallToolResult(content=mcp_result)
return call_tool_result.model_dump(by_alias=True, mode="json", exclude_none=True)
async def tasks_get(server: FastMCP, task_id: str) -> GetTaskResult:
"""Handle ``tasks/get``: the detailed task with its result/error/inputs inlined."""
docket = server._docket
if docket is None:
raise _task_not_found(task_id)
task_scope = get_task_scope()
(
execution,
base_task_key,
leg_number,
created_at,
poll_interval_ms,
) = await _lookup_task(docket, task_scope, task_id)
await execution.sync()
created_at_iso = _normalize_iso_timestamp(created_at)
now_iso = datetime.now(timezone.utc).isoformat()
ttl_ms = _ttl_ms(docket)
def build(
status: Literal[
"working", "input_required", "completed", "failed", "cancelled"
],
**payload: Any,
) -> GetTaskResult:
return GetTaskResult(
task_id=task_id,
status=status,
created_at=created_at_iso,
last_updated_at=now_iso,
ttl_ms=ttl_ms,
poll_interval_ms=poll_interval_ms,
**payload,
)
# A logical cancellation wins over the underlying execution state: a task
# parked on input has a COMPLETED execution, so without this the branches
# below would report input_required (or completed) for a cancelled task.
if await is_cancelled(docket, task_scope, task_id):
return build("cancelled")
if execution.state == ExecutionState.COMPLETED:
# A guard leg ends its Docket execution and records outstanding input
# requests to Redis: a completed leg with outstanding requests is the
# task waiting for tasks/update (input_required), not a finished task.
outstanding = await read_outstanding_inputs(
docket, task_scope, task_id, leg_number
)
if outstanding:
return build("input_required", input_requests=outstanding)
raw_value = await execution.get_result(timeout=timedelta(seconds=0))
tool = await _resolve_tool(server, base_task_key)
return build("completed", result=_inline_result(tool, raw_value))
if execution.state == ExecutionState.FAILED:
message = "Task failed"
try:
await execution.get_result(timeout=timedelta(seconds=0))
# On a FAILED execution, get_result re-raises the exception the task
# itself raised — an arbitrary user-defined type, so no narrower catch
# exists. Its message becomes the task's error payload.
except Exception as error:
message = str(error)
return build(
"failed",
status_message=message,
error={"code": mcp_types.INTERNAL_ERROR, "message": message},
)
if execution.state == ExecutionState.CANCELLED:
return build("cancelled")
status_message = None
if execution.progress and execution.progress.message:
status_message = execution.progress.message
return build("working", status_message=status_message)
async def tasks_update(
server: FastMCP, task_id: str, input_responses: dict[str, Any]
) -> UpdateTaskResult:
"""Handle ``tasks/update``: answer a guard leg and re-enter the task.
The responses are keyed by the surfaced keys ``tasks/get`` reported. Unknown
or already-satisfied keys are ignored (SEP-2663). When at least one answer
matches the current leg's outstanding requests, they are translated to the
tool's own keys, stored for the next leg, and a fresh Docket execution (the
next leg) is enqueued with the task's arguments. The worker is never blocked;
re-entry is the whole mechanism. A stale or empty update is an idempotent
no-op.
"""
docket = server._docket
if docket is None:
raise _task_not_found(task_id)
task_scope = get_task_scope()
# Resolve within scope so a cross-scope update is a "not found", not a no-op.
_execution, base_task_key, leg_number, _created_at, _poll = await _lookup_task(
docket, task_scope, task_id
)
# Serialize concurrent updates for this task so two racing answers cannot
# each enqueue a next leg (double execution). A loser is an idempotent no-op.
if not await acquire_update_lock(docket, task_scope, task_id):
return UpdateTaskResult()
try:
# A cancelled task never re-enters: clearing outstanding on cancel makes
# translate return None already, but check explicitly so a cancel that
# races between this update's lookup and lock acquisition still wins.
if await is_cancelled(docket, task_scope, task_id):
return UpdateTaskResult()
translated = await translate_responses(
docket, task_scope, task_id, leg_number, input_responses
)
if translated is None:
# Nothing matched the current leg's outstanding requests: the leg was
# already answered, or the keys are unknown. Idempotent no-op.
return UpdateTaskResult()
# Store the answers for the next leg to read, then enqueue that leg.
# Ordering matters: the answers must be in Redis before the next leg's
# worker context loads them, and current_leg must not advance to an
# execution that is not yet durable — so enqueue (with its durable wait)
# precedes the pointer swap.
await store_input_responses(docket, task_scope, task_id, translated)
component = await registered_component_for_key(
server, parse_task_key(base_task_key)["component_identifier"]
)
raw_arguments = await load_task_args(docket, task_scope, task_id)
next_leg = leg_number + 1
next_leg_key = leg_execution_key(base_task_key, next_leg)
await enqueue_task_leg(server, docket, component, raw_arguments, next_leg_key)
await save_current_leg(
docket,
task_scope,
task_id,
next_leg_key,
next_leg,
_task_key_ttl_seconds(docket),
)
# The answered leg's surfaced keys are now superseded; drop them so they
# are never reused (SEP-2663 L350).
await clear_outstanding(docket, task_scope, task_id, leg_number)
return UpdateTaskResult()
finally:
await release_update_lock(docket, task_scope, task_id)
async def tasks_cancel(server: FastMCP, task_id: str) -> CancelTaskResult:
"""Handle ``tasks/cancel``: cooperatively cancel the task, empty ack.
A durable cancellation marker is recorded so the logical task reports
``cancelled`` and refuses re-entry even when it is parked on input whose
current Docket execution is already ``COMPLETED``, making ``docket.cancel``
on it a no-op. The current leg's outstanding requests are cleared so a
racing ``tasks/update`` naming them finds nothing, and the running
execution is still cancelled cooperatively for the ``working`` case.
Cancellation runs under the per-task update lock and re-resolves the leg
once held, so it never cancels a stale leg while ``tasks/update`` is
concurrently enqueuing the next one: whichever wins the lock runs to
completion before the other, and the update rechecks the marker under the
same lock. If the lock is wedged past its timeout, cancel proceeds
best-effort rather than hang.
"""
docket = server._docket
if docket is None:
raise _task_not_found(task_id)
task_scope = get_task_scope()
# Validate the task exists within scope before taking the lock.
await _lookup_task(docket, task_scope, task_id)
got_lock = await acquire_update_lock_blocking(docket, task_scope, task_id)
try:
# Re-resolve under the lock: an update that ran first has advanced the
# current leg, so this cancels the leg that is actually live now.
execution, _base_task_key, leg_number, _created_at, _poll = await _lookup_task(
docket, task_scope, task_id
)
ttl_seconds = int(docket.execution_ttl.total_seconds())
await mark_cancelled(docket, task_scope, task_id, ttl_seconds)
await clear_outstanding(docket, task_scope, task_id, leg_number)
await docket.cancel(execution.key)
finally:
if got_lock:
await release_update_lock(docket, task_scope, task_id)
return CancelTaskResult()

View file

@ -0,0 +1,200 @@
"""The end-and-reenter capture wrapper for guard-pattern task tools.
A guard tool asks for input by *returning* an `InputRequiredResult` rather than
awaiting `ctx.elicit()`. Foreground, each such return is one leg of a
multi-round-trip: the tool returns, the client answers, the framework re-invokes
the tool with the answers on `ctx.input_responses`. The tool body is written
once and is oblivious to how many legs it takes.
As a background task the leg boundary is a *worker* boundary. This wrapper runs
the tool body exactly once. If the body returns a real value, it is the leg's
result. If the body returns an `InputRequiredResult`, the wrapper records the
leg's outstanding requests (and any carried `request_state`) to Redis and
returns the Docket execution then completes and the worker is freed. The task
sits in `input_required` as durable state until the client answers via
`tasks/update`, which enqueues a fresh Docket execution (the next leg) that
re-runs this wrapper with the accumulated state injected onto `ctx`. No worker
is ever blocked awaiting input.
The wrapper preserves the wrapped callable's signature so Docket's dependency
injection still resolves the tool's parameters (its own args, `ctx`, and any
Docket-native dependencies) exactly as it would for the raw callable. The
per-leg state (`ctx.input_responses` / `ctx.request_state`) is injected by the
worker `Context` factory (`make_task_context`) before the body runs.
"""
from __future__ import annotations
import functools
import inspect
import logging
from typing import TYPE_CHECKING, Any
import mcp_types
from fastmcp.exceptions import FastMCPError
from fastmcp.tools.base import InputRequiredToolResult, ToolResult
from fastmcp_tasks.context import get_task_context, get_task_leg_number
from fastmcp_tasks.input_store import store_outstanding
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from docket import Docket
logger = logging.getLogger(__name__)
def _as_input_required(result: Any) -> mcp_types.InputRequiredResult | None:
"""Return the `InputRequiredResult` a guard leg produced, or None.
A tool body may return the bare `InputRequiredResult` or the
`InputRequiredToolResult` wrapper FastMCP uses foreground; both mean the same
ask.
"""
if isinstance(result, InputRequiredToolResult):
return result.input_required
if isinstance(result, mcp_types.InputRequiredResult):
return result
return None
def _serialize_requests(
input_requests: mcp_types.InputRequests,
) -> dict[str, dict[str, Any]]:
"""Dump each request to the wire payload surfaced for the client to answer."""
return {
key: request.model_dump(by_alias=True, mode="json", exclude_none=True)
for key, request in input_requests.items()
}
def _resolve_docket() -> Docket | None:
"""Resolve the active Docket from the current context or worker default."""
from fastmcp.server.dependencies import get_context
from fastmcp_tasks.dependencies import _current_docket
try:
docket = get_context().fastmcp._docket
except RuntimeError:
docket = None
if docket is None:
docket = _current_docket.get()
return docket
def _mask_error_details() -> bool:
"""The worker server's error-masking policy, mirroring the sync call path.
Resolves the owning server through ``get_server()`` (the worker-server
resolver) rather than ``get_context()``: a tool that raises without ever
requesting a ``ctx`` parameter has no active ``Context``, so reading the
policy off the context would silently fall back to the global default and
leak unmasked error text.
"""
import fastmcp
from fastmcp.server.dependencies import get_server
try:
return get_server()._mask_error_details
except RuntimeError:
return fastmcp.settings.mask_error_details
def _error_result(tool_name: str, exc: Exception) -> ToolResult:
"""An ``is_error`` result for a task tool that raised, mirroring foreground.
A raised tool error is a *completed* task carrying an error result, never a
``failed`` task (SEP-2663 reserves ``failed`` for protocol faults, and a live
``tools/call`` returns the same `isError` result). A `FastMCPError` (e.g.
``ToolError``) reaches the client verbatim, as the synchronous path re-raises
it unmasked; any other exception is masked per the server's policy.
"""
if isinstance(exc, FastMCPError):
message = str(exc)
elif _mask_error_details():
message = f"Error calling tool {tool_name!r}"
else:
message = f"Error calling tool {tool_name!r}: {exc}"
return ToolResult(
content=[mcp_types.TextContent(type="text", text=message)], is_error=True
)
def reentrant_task_fn(
fn: Callable[..., Awaitable[Any]],
tool_name: str,
) -> Callable[..., Awaitable[Any]]:
"""Wrap a task tool's callable to capture a guard leg's ask (end-and-reenter).
Signature-preserving, so Docket injects the wrapped callable's parameters
unchanged. The body runs exactly once: a real return is the leg's result; an
`InputRequiredResult` is captured to Redis (outstanding requests + carried
state) and the wrapper returns, ending the leg without blocking. The next
leg is enqueued by ``tasks/update`` when the client answers. A raised tool
error becomes a completed `is_error` result (not a failed task), matching the
synchronous `tools/call` path.
"""
@functools.wraps(fn)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
result = await fn(*args, **kwargs)
except FastMCPError as exc:
return _error_result(tool_name, exc)
except Exception as exc:
logger.exception("background task tool %r raised", tool_name)
return _error_result(tool_name, exc)
input_required = _as_input_required(result)
if input_required is None:
return result
requests = input_required.input_requests or {}
request_state = input_required.request_state
if not requests:
if request_state is None:
# Asks nothing and carries nothing — terminal, not a park.
return result
# State-only round: foreground re-invokes the tool after a backoff,
# carrying `request_state` forward with no client interaction. The
# tasked path has no self-continuation for that yet, so parking it
# (with no requests for the client to answer) would strand the task.
# Fail loudly rather than silently report a wrong completed result.
return _error_result(
tool_name,
FastMCPError(
"A background task returned a state-only "
"InputRequiredResult (request_state with no input_requests). "
"Checkpoint-style rounds that carry state without asking the "
"client anything are not yet supported for tasks; include at "
"least one input request, or run the tool synchronously."
),
)
task_context = get_task_context()
docket = _resolve_docket()
if task_context is None or docket is None:
logger.warning(
"guard leg produced an ask outside a task worker; returning it"
)
return result
await store_outstanding(
docket,
task_context.task_scope,
task_context.task_id,
get_task_leg_number(),
_serialize_requests(requests),
request_state,
)
# The leg ends here: the Docket execution completes and the worker is
# freed. The task is now input_required until tasks/update enqueues the
# next leg. Return None so the completed leg carries no stray result.
return None
# `functools.wraps` copies `__wrapped__`, so `inspect.signature` already
# unwraps to `fn`; set it explicitly too, so a dependency injector reading
# `__signature__` directly (rather than following `__wrapped__`) still sees
# the tool's real parameters.
wrapper.__signature__ = inspect.signature(fn) # ty: ignore[unresolved-attribute]
return wrapper

View file

@ -0,0 +1,505 @@
"""Per-task Redis state for SEP-2663 end-and-reenter input gathering.
A background task gathers client input by *ending a leg* and re-entering, never
by blocking a worker. When a `task=True` tool returns an `InputRequiredResult`,
the leg's Docket execution completes and the worker is freed; the task's state
lives here in Redis as `input_required`. When the client answers via
`tasks/update`, a fresh Docket execution (the next leg) re-runs the tool with the
accumulated state injected onto its `Context`. No worker ever waits for input.
This module owns the durable state each task carries between legs:
- **args** the original tool arguments, re-supplied to every leg.
- **current_leg / leg** the latest leg's Docket execution key and its number.
- **request_state** the opaque string a leg carried forward (SEP-2322).
- **input_responses** the typed answers the last `tasks/update` delivered,
translated to the tool's own request keys.
- **input:requests / input:map** the current leg's outstanding requests, keyed
by a server-minted surfaced key, plus the surfaced-key tool-key mapping.
Each surfaced request key is minted fresh with high-entropy suffix and never
reused after its response is delivered (SEP-2663 L350): a task that asks twice,
or a leg that requests several inputs at once, surfaces distinct, independently
answerable keys, and the tool reads its *own* keys on the next leg via the
translated `input_responses`.
"""
from __future__ import annotations
import asyncio
import json
import logging
import secrets
from typing import TYPE_CHECKING, Any, cast
import mcp_types
from fastmcp_tasks.keys import task_redis_prefix
if TYPE_CHECKING:
from docket import Docket
logger = logging.getLogger(__name__)
# How long a task's input state (outstanding requests and delivered responses)
# lives before expiring. With end-and-reenter no worker is held while a task is
# input_required, so this bounds only how long durable input state survives, not
# any worker slot.
INPUT_TTL_SECONDS = 3600
# Reconstruct a typed response from its stored `{"type": name, "data": dump}`
# form so a re-entered leg reads a real `ElicitResult` (etc.) on
# `ctx.input_responses`, matching the foreground guard contract.
_RESULT_TYPE_BY_NAME: dict[str, type[mcp_types.Result]] = {
"ElicitResult": mcp_types.ElicitResult,
"CreateMessageResult": mcp_types.CreateMessageResult,
"CreateMessageResultWithTools": mcp_types.CreateMessageResultWithTools,
"ListRootsResult": mcp_types.ListRootsResult,
}
# Map an outstanding request's wire method to the result type its answer
# validates into. Elicitation is the supported in-task input; the others are
# kept complete so a client that answers one is parsed rather than dropped.
_RESULT_TYPE_BY_METHOD: dict[str, type[mcp_types.Result]] = {
"elicitation/create": mcp_types.ElicitResult,
"sampling/createMessage": mcp_types.CreateMessageResult,
"roots/list": mcp_types.ListRootsResult,
}
def result_type_for_method(method: str) -> type[mcp_types.Result]:
"""The result type an outstanding request's answer validates into."""
return _RESULT_TYPE_BY_METHOD.get(method, mcp_types.ElicitResult)
def _prefix(docket: Docket, task_scope: str | None, task_id: str) -> str:
return f"{task_redis_prefix(task_scope)}:{task_id}"
def _args_key(docket: Docket, task_scope: str | None, task_id: str) -> str:
return docket.key(f"{_prefix(docket, task_scope, task_id)}:args")
def _current_leg_key(docket: Docket, task_scope: str | None, task_id: str) -> str:
return docket.key(f"{_prefix(docket, task_scope, task_id)}:current_leg")
def _leg_number_key(docket: Docket, task_scope: str | None, task_id: str) -> str:
return docket.key(f"{_prefix(docket, task_scope, task_id)}:leg")
def _request_state_key(docket: Docket, task_scope: str | None, task_id: str) -> str:
return docket.key(f"{_prefix(docket, task_scope, task_id)}:request_state")
def _input_responses_key(docket: Docket, task_scope: str | None, task_id: str) -> str:
return docket.key(f"{_prefix(docket, task_scope, task_id)}:input_responses")
def _requests_key(
docket: Docket, task_scope: str | None, task_id: str, leg: int
) -> str:
"""Redis hash of a leg's outstanding input requests, keyed by surfaced key.
Scoped by leg number so a re-entered leg's fresh requests never collide with
the answered leg's stale ones in the shared keyspace.
"""
return docket.key(f"{_prefix(docket, task_scope, task_id)}:input:{leg}:requests")
def _map_key(docket: Docket, task_scope: str | None, task_id: str, leg: int) -> str:
"""Redis hash mapping a leg's surfaced keys back to the tool's own keys."""
return docket.key(f"{_prefix(docket, task_scope, task_id)}:input:{leg}:map")
def _mint_surfaced_key(task_id: str) -> str:
"""Mint a unique surfaced key for one outstanding request (SEP-2663 L350).
Namespaced by the task id and suffixed with fresh entropy so no two
requests across legs or within one leg ever collide, and a key is never
reused after its response is delivered.
"""
return f"{task_id}:{secrets.token_hex(8)}"
def _decode(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, bytes):
return value.decode("utf-8")
return str(value)
# ---------------------------------------------------------------------------
# Task arguments and leg pointer (written at create, advanced at tasks/update)
# ---------------------------------------------------------------------------
async def save_task_args(
docket: Docket,
task_scope: str | None,
task_id: str,
arguments: dict[str, Any],
ttl_seconds: int,
) -> None:
"""Store the original tool arguments, re-supplied to every leg."""
async with docket.redis() as redis:
await redis.set(
_args_key(docket, task_scope, task_id),
json.dumps(arguments),
ex=ttl_seconds,
)
async def load_task_args(
docket: Docket, task_scope: str | None, task_id: str
) -> dict[str, Any]:
"""Load the stored tool arguments for a task's next leg."""
async with docket.redis() as redis:
raw = await redis.get(_args_key(docket, task_scope, task_id))
decoded = _decode(raw)
if not decoded:
return {}
parsed = json.loads(decoded)
return parsed if isinstance(parsed, dict) else {}
async def save_current_leg(
docket: Docket,
task_scope: str | None,
task_id: str,
leg_key: str,
leg_number: int,
ttl_seconds: int,
) -> None:
"""Record the latest leg's Docket execution key and its number."""
async with docket.redis() as redis:
await redis.set(
_current_leg_key(docket, task_scope, task_id), leg_key, ex=ttl_seconds
)
await redis.set(
_leg_number_key(docket, task_scope, task_id),
str(leg_number),
ex=ttl_seconds,
)
async def refresh_current_leg_ttl(
docket: Docket, task_scope: str | None, task_id: str, ttl_seconds: int
) -> None:
"""Extend the current-leg pointer's TTL (sliding expiration).
The pointer is written with a wall-clock TTL, but a leg's execution can run
longer than that a resumed guard leg especially. Refreshing on each poll
keeps the routing pointer alive for an actively-polled task no matter how
long the leg runs, so ``_lookup_task`` never falls back to the base leg
while the current leg is still executing.
"""
async with docket.redis() as redis:
await redis.expire(_current_leg_key(docket, task_scope, task_id), ttl_seconds)
await redis.expire(_leg_number_key(docket, task_scope, task_id), ttl_seconds)
async def load_current_leg(
docket: Docket, task_scope: str | None, task_id: str
) -> tuple[str | None, int]:
"""Return the current leg's execution key and number (defaults to 1)."""
async with docket.redis() as redis:
leg_key = _decode(
await redis.get(_current_leg_key(docket, task_scope, task_id))
)
leg_raw = _decode(await redis.get(_leg_number_key(docket, task_scope, task_id)))
try:
leg_number = int(leg_raw) if leg_raw else 1
except ValueError:
leg_number = 1
return leg_key, leg_number
# ---------------------------------------------------------------------------
# Outstanding requests (written by the capture wrapper, read by tasks/get)
# ---------------------------------------------------------------------------
async def store_outstanding(
docket: Docket,
task_scope: str | None,
task_id: str,
leg: int,
serialized_requests: dict[str, dict[str, Any]],
request_state: str | None,
ttl_seconds: int = INPUT_TTL_SECONDS,
) -> None:
"""Persist a leg's outstanding input requests plus its carried state.
``serialized_requests`` maps the tool's own request keys to serialized
``InputRequest`` payloads. Each is stored under a freshly minted surfaced
key, with the surfaced-key tool-key mapping recorded alongside so
``tasks/update`` can translate answers back. ``request_state`` is written
when the leg carried one and cleared otherwise, so it travels to the next
leg verbatim.
"""
requests_key = _requests_key(docket, task_scope, task_id, leg)
map_key = _map_key(docket, task_scope, task_id, leg)
state_key = _request_state_key(docket, task_scope, task_id)
async with docket.redis() as redis:
for tool_key, payload in serialized_requests.items():
surfaced = _mint_surfaced_key(task_id)
await redis.hset(requests_key, surfaced, json.dumps(payload))
await redis.hset(map_key, surfaced, tool_key)
await redis.expire(requests_key, ttl_seconds)
await redis.expire(map_key, ttl_seconds)
if request_state is not None:
await redis.set(state_key, request_state, ex=ttl_seconds)
else:
await redis.delete(state_key)
async def read_outstanding_inputs(
docket: Docket, task_scope: str | None, task_id: str, leg: int
) -> dict[str, Any]:
"""Return a leg's outstanding input requests, keyed by surfaced key.
Empty when the leg is not waiting on input. Consumed by ``tasks/get`` to
build the ``input_required`` status and its ``inputRequests`` snapshot.
"""
async with docket.redis() as redis:
raw = await redis.hgetall(_requests_key(docket, task_scope, task_id, leg))
outstanding: dict[str, Any] = {}
for key, value in raw.items():
key_str = _decode(key)
value_str = _decode(value)
if key_str is None or value_str is None:
continue
try:
outstanding[key_str] = json.loads(value_str)
except json.JSONDecodeError:
continue
return outstanding
async def _read_outstanding_map(
docket: Docket, task_scope: str | None, task_id: str, leg: int
) -> dict[str, str]:
"""Return the surfaced-key → tool-key mapping for a leg."""
async with docket.redis() as redis:
raw = await redis.hgetall(_map_key(docket, task_scope, task_id, leg))
mapping: dict[str, str] = {}
for key, value in raw.items():
key_str = _decode(key)
value_str = _decode(value)
if key_str is None or value_str is None:
continue
mapping[key_str] = value_str
return mapping
# ---------------------------------------------------------------------------
# Responses (written by tasks/update, read by the next leg's context factory)
# ---------------------------------------------------------------------------
async def translate_responses(
docket: Docket,
task_scope: str | None,
task_id: str,
leg: int,
responses: dict[str, Any],
) -> dict[str, mcp_types.Result] | None:
"""Translate a ``tasks/update`` payload into typed, tool-keyed responses.
``responses`` is keyed by the surfaced keys the client received for ``leg``.
Unknown or already-satisfied keys are ignored (SEP-2663). Each recognized
answer is validated into the result type its request maps to and re-keyed to
the tool's own request key. Returns ``None`` when nothing matched, so the
caller can treat a stale or empty update as an idempotent no-op.
"""
outstanding = await read_outstanding_inputs(docket, task_scope, task_id, leg)
if not outstanding:
return None
mapping = await _read_outstanding_map(docket, task_scope, task_id, leg)
translated: dict[str, mcp_types.Result] = {}
for surfaced_key, raw in responses.items():
payload = outstanding.get(surfaced_key)
if payload is None:
continue
tool_key = mapping.get(surfaced_key)
if tool_key is None:
continue
method = payload.get("method", "elicitation/create")
result_type = result_type_for_method(method)
translated[tool_key] = result_type.model_validate(raw)
return translated or None
async def store_input_responses(
docket: Docket,
task_scope: str | None,
task_id: str,
translated: dict[str, mcp_types.Result],
ttl_seconds: int = INPUT_TTL_SECONDS,
) -> None:
"""Store translated responses for the next leg to read via ``ctx``.
The responses are stored typed-but-serialized (``{"type", "data"}``) so the
next leg's context factory reconstructs real result objects keyed by the
tool's own request keys.
"""
stored = {
tool_key: {
"type": type(result).__name__,
"data": result.model_dump(by_alias=True, mode="json"),
}
for tool_key, result in translated.items()
}
async with docket.redis() as redis:
await redis.set(
_input_responses_key(docket, task_scope, task_id),
json.dumps(stored),
ex=ttl_seconds,
)
async def clear_outstanding(
docket: Docket, task_scope: str | None, task_id: str, leg: int
) -> None:
"""Drop a leg's outstanding requests and mapping once it has been answered.
The answered surfaced keys are never reused (a later leg mints its own), so
a duplicate ``tasks/update`` naming them finds nothing and is a no-op.
"""
async with docket.redis() as redis:
await redis.delete(_requests_key(docket, task_scope, task_id, leg))
await redis.delete(_map_key(docket, task_scope, task_id, leg))
def _cancelled_key(docket: Docket, task_scope: str | None, task_id: str) -> str:
return docket.key(f"{_prefix(docket, task_scope, task_id)}:cancelled")
async def mark_cancelled(
docket: Docket, task_scope: str | None, task_id: str, ttl_seconds: int
) -> None:
"""Record that a task was cancelled at the logical (not per-leg) level.
An ``input_required`` task's current Docket execution is already
``COMPLETED`` the outstanding-input record is what keeps it parked so
``docket.cancel`` on that execution is a no-op. This durable marker lets
``tasks/get`` report ``cancelled`` and ``tasks/update`` refuse to resume,
regardless of the underlying execution state. Expires with the task's TTL.
"""
async with docket.redis() as redis:
await redis.set(
_cancelled_key(docket, task_scope, task_id), b"1", ex=max(1, ttl_seconds)
)
async def is_cancelled(docket: Docket, task_scope: str | None, task_id: str) -> bool:
"""Whether the task was logically cancelled (see ``mark_cancelled``)."""
async with docket.redis() as redis:
return bool(await redis.exists(_cancelled_key(docket, task_scope, task_id)))
# How long the per-task update lock lives if its holder dies mid-update. A
# generous ceiling: a single tasks/update is fast, so the lock is normally held
# for milliseconds; the TTL only guards against a crashed holder.
_UPDATE_LOCK_TTL_SECONDS = 30
def _update_lock_key(docket: Docket, task_scope: str | None, task_id: str) -> str:
return docket.key(f"{_prefix(docket, task_scope, task_id)}:update_lock")
async def acquire_update_lock(
docket: Docket, task_scope: str | None, task_id: str
) -> bool:
"""Take the per-task update lock, or return False if one is already held.
Serializes concurrent ``tasks/update`` calls for a task so two racing
answers cannot each enqueue a next leg (double execution). A well-behaved
client polls sequentially and never contends; a loser is an idempotent
no-op, matching SEP-2663's "ignore already-satisfied" rule.
"""
async with docket.redis() as redis:
got = await redis.set(
_update_lock_key(docket, task_scope, task_id),
b"1",
nx=True,
ex=_UPDATE_LOCK_TTL_SECONDS,
)
return bool(got)
async def acquire_update_lock_blocking(
docket: Docket,
task_scope: str | None,
task_id: str,
*,
timeout: float = 5.0,
poll: float = 0.02,
) -> bool:
"""Wait for the per-task update lock, up to ``timeout`` seconds.
``tasks/cancel`` uses this to serialize with an in-flight ``tasks/update``:
it must not cancel a stale leg while an update concurrently enqueues the
next one. A single update is fast (milliseconds), so contention is brief;
returns False if the lock is still held at the deadline (a wedged holder),
letting the caller proceed best-effort rather than hang.
"""
loop = asyncio.get_event_loop()
deadline = loop.time() + timeout
while True:
if await acquire_update_lock(docket, task_scope, task_id):
return True
if loop.time() >= deadline:
return False
await asyncio.sleep(poll)
async def release_update_lock(
docket: Docket, task_scope: str | None, task_id: str
) -> None:
"""Release the per-task update lock."""
async with docket.redis() as redis:
await redis.delete(_update_lock_key(docket, task_scope, task_id))
async def load_pending_input(
docket: Docket, task_scope: str | None, task_id: str
) -> tuple[str | None, mcp_types.InputResponses | None]:
"""Load the per-leg state a re-entered leg reads via ``ctx``.
Returns ``(request_state, input_responses)``: the opaque state carried
forward and the typed answers keyed by the tool's own request keys. Both are
``None`` on the first leg (nothing has been asked yet).
"""
async with docket.redis() as redis:
state_raw = _decode(
await redis.get(_request_state_key(docket, task_scope, task_id))
)
responses_raw = _decode(
await redis.get(_input_responses_key(docket, task_scope, task_id))
)
responses: dict[str, mcp_types.Result] | None = None
if responses_raw:
parsed = json.loads(responses_raw)
if isinstance(parsed, dict):
responses = {}
for tool_key, entry in parsed.items():
if not isinstance(entry, dict):
continue
result_type = _RESULT_TYPE_BY_NAME.get(entry.get("type", ""))
if result_type is None:
continue
responses[tool_key] = result_type.model_validate(entry.get("data"))
# The reconstructed values are the concrete result types the tool asked for;
# `InputResponses` is that union keyed by request key. The `Result` element
# type erases that for the checker, so narrow at the return.
if responses is None:
return state_raw, None
return state_raw, cast("mcp_types.InputResponses", responses)

View file

@ -37,6 +37,44 @@ _AUTH_TAG = "auth"
_ANON_TAG = "anon"
_VALID_TAGS = (_AUTH_TAG, _ANON_TAG)
# Delimiter separating the stable base task key from a per-leg suffix. A single
# background task runs as a sequence of Docket executions (legs): the first leg
# uses the base key, and each re-entry (after the client answers input) enqueues
# a fresh execution under `{base}{_LEG_DELIMITER}{n}`. The base key encodes every
# segment with `quote(safe="")`, which percent-encodes `#` to `%23`, so a literal
# `#` never appears inside the base key and is an unambiguous leg boundary. All
# task-identity parsing strips the leg suffix, so the scope/task-id/component a
# leg resolves to are identical across every leg of the same task.
_LEG_DELIMITER = "#"
def leg_execution_key(base_task_key: str, leg: int) -> str:
"""Build the Docket execution key for a given leg of a task.
Leg 1 uses the bare base key (so existing single-leg behavior is unchanged);
later legs append `#leg{n}` so each re-entry is a distinct Docket execution
while still parsing back to the same task scope, id, and component.
"""
if leg <= 1:
return base_task_key
return f"{base_task_key}{_LEG_DELIMITER}leg{leg}"
def base_task_key(execution_key: str) -> str:
"""Strip any per-leg suffix, returning the stable base task key."""
return execution_key.split(_LEG_DELIMITER, 1)[0]
def leg_number_from_key(execution_key: str) -> int:
"""Return the leg number a Docket execution key encodes (leg 1 = base key)."""
_base, sep, suffix = execution_key.partition(_LEG_DELIMITER)
if not sep:
return 1
try:
return int(suffix.removeprefix("leg"))
except ValueError:
return 1
def build_task_key(
task_scope: str | None,
@ -97,6 +135,9 @@ def parse_task_key(task_key: str) -> TaskKeyParts:
>>> parse_task_key("anon:task456:tool:my_tool")
`{'task_scope': None, 'client_task_id': 'task456', 'task_type': 'tool', 'component_identifier': 'my_tool'}`
"""
# A per-leg execution key (`{base}#leg{n}`) parses to the same identity as
# its base: every leg of a task shares one scope, id, and component.
task_key = base_task_key(task_key)
tag, _, rest = task_key.partition(":")
if tag not in _VALID_TAGS or not rest:
raise ValueError(

View file

@ -0,0 +1,108 @@
"""Docket lifecycle for FastMCP background tasks.
Extracted from the SEP-1686 ``LifespanMixin._docket_lifespan`` and driven by
``TasksExtension.lifespan()``. Core's ``_extensions_lifespan`` already enters
this once per runtime tree at the root and defers on mounted children, and
``SharedContext`` plus the server ContextVar are established before extension
lifespans run so this no longer manages either. It starts Docket and a Worker
when there are task-enabled components, registers those components' callables,
and runs the worker (with the snapshot-restore dependency) until shutdown.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress
from typing import TYPE_CHECKING, Any
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
from fastmcp.server.server import FastMCP
from fastmcp_tasks.settings import DocketSettings
logger = get_logger(__name__)
@asynccontextmanager
async def docket_lifespan(
server: FastMCP, settings: DocketSettings
) -> AsyncIterator[None]:
"""Manage the Docket instance and Worker for background task execution.
Sets ``server._docket`` / ``server._worker`` for the duration and registers
each task-enabled component's callable, then runs the worker until the
context exits. A no-op if pydocket is unavailable or the server declares no
task-enabled components.
"""
from docket import Depends, Docket, Worker
import fastmcp
from fastmcp_tasks.components import register_component_with_docket
from fastmcp_tasks.context import restore_task_snapshot
from fastmcp_tasks.dependencies import (
_current_docket,
_current_worker,
is_docket_available,
)
if not is_docket_available():
yield
return
try:
candidates = list(await server.get_tasks())
except Exception as e:
logger.warning(f"Failed to collect task components: {e}")
if fastmcp.settings.mounted_components_raise_on_load_error:
raise
candidates = []
# get_tasks() applies server-level transforms that can inject non-task tools;
# re-filter by the actual task config (the recorded landmine).
task_components = [c for c in candidates if c.task_config.supports_tasks()]
if not task_components:
yield
return
async with Docket(name=settings.name, url=settings.url) as docket:
server._docket = docket
for component in task_components:
register_component_with_docket(component, docket)
docket_token = _current_docket.set(docket)
try:
worker_kwargs: dict[str, Any] = {
"concurrency": settings.concurrency,
"redelivery_timeout": settings.redelivery_timeout,
"reconnection_delay": settings.reconnection_delay,
"minimum_check_interval": settings.minimum_check_interval,
}
if settings.worker_name:
worker_kwargs["name"] = settings.worker_name
async with Worker(
docket,
dependencies=[Depends(restore_task_snapshot)],
**worker_kwargs,
) as worker:
server._worker = worker
worker_token = _current_worker.set(worker)
try:
worker_task = asyncio.create_task(worker.run_forever())
try:
yield
finally:
# End-and-reenter never parks a worker on input, so a
# task waiting for input holds no worker slot: cancelling
# run_forever drains promptly regardless of task state.
worker_task.cancel()
with suppress(asyncio.CancelledError):
await worker_task
finally:
_current_worker.reset(worker_token)
server._worker = None
finally:
_current_docket.reset(docket_token)
server._docket = None

View file

@ -0,0 +1,197 @@
"""SEP-2663 tasks-extension wire models.
The `io.modelcontextprotocol/tasks` extension (SEP-2663) defines its own wire
shapes, distinct from the SEP-1686 task types the MCP SDK still ships
(`mcp_types.Task` uses `ttl`/`pollInterval`; SEP-2663 uses `ttlMs`/`pollIntervalMs`
and a *flat* `CreateTaskResult` rather than a nested `{task: ...}`). These models
serialize to the SEP-2663 shapes and are validated against the vendored draft
JSON schema in the test suite.
A note on `_meta`: the draft schema composes result shapes as
`allOf[Result, Task]`, and the `Task` arm carries `additionalProperties: false`
without listing `_meta`. A `_meta` key therefore fails schema validation on those
results. These models leave `_meta` unset and rely on the runner's
`exclude_none=True` dump to omit it, so serialized instances validate cleanly.
`ttlMs` is required-but-nullable in the schema; in practice the engine always
emits a numeric value (Docket carries a default execution TTL), so the
`exclude_none` dump never drops it.
"""
from __future__ import annotations
from typing import Any, Literal
from mcp_types import RequestParams, Result
from pydantic import BaseModel, ConfigDict, Field
__all__ = [
"MISSING_REQUIRED_CLIENT_CAPABILITY",
"TaskStatus",
"CreateTaskResult",
"GetTaskResult",
"UpdateTaskResult",
"CancelTaskResult",
"GetTaskParams",
"UpdateTaskParams",
"CancelTaskParams",
"GetTaskRequest",
"UpdateTaskRequest",
"CancelTaskRequest",
"missing_capability_error_data",
]
#: JSON-RPC error code for "Missing Required Client Capability" (SEP-2663). A
#: tool whose task mode is `required` returns this when the client did not opt
#: the tasks extension in for the request.
MISSING_REQUIRED_CLIENT_CAPABILITY = -32003
TaskStatus = Literal["working", "input_required", "completed", "failed", "cancelled"]
class _TaskFields(BaseModel):
"""The flat task fields shared by every SEP-2663 task result shape.
Serializes to the schema's `Task` object (camelCase aliases, `ttlMs`
required-but-nullable). No `_meta`: the schema's `additionalProperties:
false` on the task arm forbids it (see module docstring).
"""
# Serialization aliases: the engine constructs these by field name and the
# runner dumps them to camelCase (`model_dump(by_alias=True)`). The
# claim-production wrap returns that dump unchanged, so no input alias is
# needed.
model_config = ConfigDict(populate_by_name=True)
task_id: str = Field(serialization_alias="taskId")
status: TaskStatus
created_at: str = Field(serialization_alias="createdAt")
last_updated_at: str = Field(serialization_alias="lastUpdatedAt")
ttl_ms: float | None = Field(serialization_alias="ttlMs")
status_message: str | None = Field(
default=None, serialization_alias="statusMessage"
)
poll_interval_ms: float | None = Field(
default=None, serialization_alias="pollIntervalMs"
)
class CreateTaskResult(_TaskFields):
"""Result of an augmented `tools/call` that the server ran as a task.
A flat merge of `Result` and `Task` (SEP-2663): the finished task stub the
client polls with `tasks/get`. Status is typically `working`.
`resultType` is the wire discriminator that distinguishes this from a
`CallToolResult` on the shared `tools/call` method: the modern result union
carries a required `resultType`, and the SDK's client-side `ResultClaim`
for tasks requires this model to pin it to `Literal["task"]`. The vendored
draft schema omits `resultType` from the task arm (its
`additionalProperties: false` forbids it) a schema-vs-protocol
contradiction reported upstream. Protocol interop requires the field, so we
emit it; only this shape needs it (the `tasks/*` methods each have a single
result type and bypass the discriminated union).
"""
result_type: Literal["task"] = Field(
default="task", serialization_alias="resultType"
)
class GetTaskResult(_TaskFields):
"""Result of `tasks/get`: the detailed task (`Result & DetailedTask`).
Carries exactly one of `result` (completed), `error` (failed), or
`input_requests` (input_required) alongside the flat task fields, matching
the schema's 5-status union. The three payload fields default to `None` and
are dropped from the wire dump for the statuses that do not use them.
`resultType` is `"complete"` (SEP-2663 L338): `tasks/get` itself completes
normally, whatever the task's own status. As with `CreateTaskResult`, the
draft schema's `additionalProperties: false` omits this field — a
contradiction reported upstream; protocol interop requires emitting it.
"""
result_type: Literal["complete"] = Field(
default="complete", serialization_alias="resultType"
)
result: dict[str, Any] | None = None
error: dict[str, Any] | None = None
input_requests: dict[str, Any] | None = Field(
default=None, serialization_alias="inputRequests"
)
class UpdateTaskResult(Result):
"""Acknowledgement for `tasks/update` (SEP-2663 `Result`, `resultType: "complete"`)."""
result_type: Literal["complete"] = Field(
default="complete", serialization_alias="resultType"
)
class CancelTaskResult(Result):
"""Acknowledgement for `tasks/cancel` (SEP-2663 `Result`, `resultType: "complete"`)."""
result_type: Literal["complete"] = Field(
default="complete", serialization_alias="resultType"
)
class GetTaskParams(RequestParams):
"""Params for `tasks/get` / `tasks/cancel`: the target task id."""
model_config = ConfigDict(populate_by_name=True)
task_id: str = Field(alias="taskId")
# `tasks/cancel` params are identical to `tasks/get` (just `taskId`).
CancelTaskParams = GetTaskParams
class UpdateTaskParams(RequestParams):
"""Params for `tasks/update`: task id plus the caller's input responses."""
model_config = ConfigDict(populate_by_name=True)
task_id: str = Field(alias="taskId")
input_responses: dict[str, Any] = Field(alias="inputResponses")
class GetTaskRequest(BaseModel):
"""`tasks/get` request envelope (used by tests and clients)."""
model_config = ConfigDict(populate_by_name=True)
method: Literal["tasks/get"] = "tasks/get"
params: GetTaskParams
class UpdateTaskRequest(BaseModel):
"""`tasks/update` request envelope."""
model_config = ConfigDict(populate_by_name=True)
method: Literal["tasks/update"] = "tasks/update"
params: UpdateTaskParams
class CancelTaskRequest(BaseModel):
"""`tasks/cancel` request envelope."""
model_config = ConfigDict(populate_by_name=True)
method: Literal["tasks/cancel"] = "tasks/cancel"
params: GetTaskParams
def missing_capability_error_data() -> dict[str, Any]:
"""Build the `data.requiredCapabilities` payload for a -32003 error.
A `required`-mode tool called without the client opting the tasks extension
in for the request returns this so the client learns which capability to
declare.
"""
from fastmcp.utilities.tasks import TASKS_EXTENSION_ID
return {"requiredCapabilities": {"extensions": {TASKS_EXTENSION_ID: {}}}}

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,165 @@
"""Docket worker settings for FastMCP background tasks.
Moved out of ``fastmcp.settings`` during the SEP-1686 -> SEP-2663 migration.
The ``FASTMCP_DOCKET_*`` environment prefix is unchanged so existing
deployments keep working. ``TasksExtension`` reads this configuration (its
constructor overrides the env defaults).
"""
from __future__ import annotations
import inspect
import os
from datetime import timedelta
from typing import Annotated
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
# Load the same dotenv source as core FastMCP settings, so a deployment that
# puts FASTMCP_DOCKET_* in `.env` (or a FASTMCP_ENV_FILE) configures the backend
# rather than silently falling back to memory://.
_ENV_FILE = os.getenv("FASTMCP_ENV_FILE", ".env")
class DocketSettings(BaseSettings):
"""Docket worker configuration."""
model_config = SettingsConfigDict(
env_prefix="FASTMCP_DOCKET_",
env_file=_ENV_FILE,
extra="ignore",
)
name: Annotated[
str,
Field(
description=inspect.cleandoc(
"""
Name for the Docket queue. All servers/workers sharing the same name
and backend URL will share a task queue.
"""
),
),
] = "fastmcp"
url: Annotated[
str,
Field(
description=inspect.cleandoc(
"""
URL for the Docket backend. Supports:
- memory:// - In-memory backend (single process only)
- redis://host:port/db - Redis/Valkey backend (distributed, multi-process)
Example: redis://localhost:6379/0
Default is memory:// for single-process scenarios. Use Redis or Valkey
when coordinating tasks across multiple processes (e.g., additional
workers via the fastmcp tasks CLI).
"""
),
),
] = "memory://"
worker_name: Annotated[
str | None,
Field(
description=inspect.cleandoc(
"""
Name for the Docket worker. If None, Docket will auto-generate
a unique worker name.
"""
),
),
] = None
concurrency: Annotated[
int,
Field(
description=inspect.cleandoc(
"""
Maximum number of tasks the worker can process concurrently.
"""
),
),
] = 10
redelivery_timeout: Annotated[
timedelta,
Field(
description=inspect.cleandoc(
"""
Task redelivery timeout. If a worker doesn't complete
a task within this time, the task will be redelivered to another
worker.
"""
),
),
] = timedelta(seconds=300)
reconnection_delay: Annotated[
timedelta,
Field(
description=inspect.cleandoc(
"""
Delay between reconnection attempts when the worker
loses connection to the Docket backend.
"""
),
),
] = timedelta(seconds=5)
minimum_check_interval: Annotated[
timedelta,
Field(
description=inspect.cleandoc(
"""
How frequently the worker polls for new tasks. Lower
values reduce latency for task pickup at the cost of
more CPU usage. The default of 50ms is a good balance;
increase for high-volume production deployments where
tasks are long-running.
"""
),
),
] = timedelta(milliseconds=50)
docket_settings = DocketSettings()
class TasksClientSettings(BaseSettings):
"""Client-side settings for driving background tasks.
Moved here from core ``fastmcp.settings`` during the SEP-1686 -> SEP-2663
migration: the entire client task-driving path now lives in
``fastmcp-tasks``, so its one tunable does too.
"""
model_config = SettingsConfigDict(
env_prefix="FASTMCP_TASKS_CLIENT_",
env_file=_ENV_FILE,
extra="ignore",
)
poll_interval: Annotated[
float,
Field(
description=inspect.cleandoc(
"""
Ceiling, in seconds, for the fallback poll backoff while the client
waits on a background task. Applies only when the server does not
advertise its own pollIntervalMs: in that case the client starts
polling fast (~20ms) and doubles up to this ceiling, so quick tasks
resolve promptly while long-running tasks don't hammer the server.
When the server advertises a pollIntervalMs, that interval is honored
exactly and this setting is ignored. Must be positive.
"""
),
gt=0,
),
] = 0.5
client_settings = TasksClientSettings()

View file

@ -0,0 +1,111 @@
"""Server-side production of the tasks extension's claimed `tools/call` result.
The MCP SDK ships the *consumption* half of SEP-2133 claimed results a client
`ResultClaim` resolves an extension's result shape on a core method — but not
the *production* half: nothing lets a server emit one. On the modern protocol
the runner revalidates every `tools/call` result against
`SERVER_RESULTS[("tools/call", "2026-07-28")]`, which admits only
`CallToolResult | InputRequiredResult`. A returned `CreateTaskResult` is coerced
through those `extra="ignore"` models and stripped to nothing the `taskId`
never reaches the client, so the tasks extension cannot create a task over the
wire even though its `tasks/*` methods (being custom methods) serialize freely.
This module supplies the missing production half. It wraps
`mcp_types.methods.serialize_server_result` which the runner looks up on the
module at call time so that a modern `tools/call` result tagged
`resultType: "task"` is validated against `CreateTaskResult` and dumped as-is,
routed by the discriminator rather than the ambiguous result union (an untagged
task dict would otherwise be swallowed by the all-optional `InputRequiredResult`
arm). Every other result delegates to the original serializer unchanged.
The wrap is process-global but inert for anything that is not a tasks server: a
server that never emits `resultType: "task"` never takes the task branch. It is
installed and reference-counted by `TasksExtension.lifespan()` so it is present
exactly while at least one tasks extension is running, and removed after the
last one stops. It is gated to modern protocol versions because claimed result
shapes exist only there.
Removal trigger: when the SDK grows a first-class server-side claim-production
API (mirroring the client `ResultClaim`), this wrap is deleted and
`TasksExtension` declares its produced claim through that API instead. See the
upstream report in the migration notes.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
import mcp_types.methods as _methods
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
_TASK_RESULT_TYPE = "task"
_TASK_AUGMENTED_METHOD = "tools/call"
# Sentinel distinguishing "caller passed no surface" (the runner's path, which we
# may divert) from an explicit surface another caller supplied (never diverted).
_STOCK: Any = object()
# The original module function, captured once. `None` until the first install.
_original_serialize: Any = None
_active_holds: int = 0
def _serialize_with_task_production(
method: str,
version: str,
data: Mapping[str, Any],
*,
surface: Any = _STOCK,
) -> dict[str, Any]:
"""Serialize a server result, letting a tagged task result through.
A modern `tools/call` result carrying `resultType: "task"` is returned as
the producer already dumped it, rather than being validated against and
stripped by the stock `CallToolResult | InputRequiredResult` surface. This
is the same bypass the runner already applies to custom-method results
(which skip surface validation entirely); the producer built this dict from
a validated `CreateTaskResult`, so its shape is already correct. Every other
result and any call that supplies an explicit `surface` delegates to the
SDK's original serializer unchanged.
"""
if (
surface is _STOCK
and method == _TASK_AUGMENTED_METHOD
and version in MODERN_PROTOCOL_VERSIONS
and isinstance(data, Mapping)
and data.get("resultType") == _TASK_RESULT_TYPE
):
return dict(data)
if surface is _STOCK:
return _original_serialize(method, version, data)
return _original_serialize(method, version, data, surface=surface)
def install() -> None:
"""Install the task claim-production wrap (reference-counted, idempotent).
Safe to call from every `TasksExtension.lifespan()`: the first call captures
and replaces the SDK serializer, later calls only bump the reference count.
"""
global _original_serialize, _active_holds
_active_holds += 1
if _original_serialize is not None:
return
_original_serialize = _methods.serialize_server_result
# Runtime attribute swap: the wrapper is call-compatible (it forwards
# `surface` when supplied and only diverts the runner's no-surface task
# path), but ty cannot verify a monkeypatch's signature match.
_methods.serialize_server_result = _serialize_with_task_production # ty: ignore[invalid-assignment]
def uninstall() -> None:
"""Release one hold; restore the SDK serializer when the last one exits."""
global _original_serialize, _active_holds
_active_holds -= 1
if _active_holds > 0:
return
_active_holds = 0
if _original_serialize is not None:
_methods.serialize_server_result = _original_serialize
_original_serialize = None

View file

@ -1,14 +1,21 @@
"""FastMCP tasks CLI for Docket task management."""
from __future__ import annotations
import asyncio
import sys
from typing import Annotated
from typing import TYPE_CHECKING, Annotated
import cyclopts
from rich.console import Console
from fastmcp.utilities.cli import load_and_merge_config
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.tasks import TASKS_EXTENSION_ID
from fastmcp_tasks.settings import DocketSettings
if TYPE_CHECKING:
from fastmcp.server.server import FastMCP
logger = get_logger("cli.tasks")
console = Console()
@ -19,7 +26,32 @@ tasks_app = cyclopts.App(
)
def check_distributed_backend() -> None:
def resolve_docket_settings(server: FastMCP) -> DocketSettings:
"""The effective Docket settings for `server`'s registered tasks extension.
Reads the *registered* `TasksExtension`'s resolved settings, not the
env-only module-level default: a server that configures
`TasksExtension(url="redis://...")` in code has settings the environment
alone cannot see, and checking those defaults instead would report the
wrong backend (see #4603 review — the CLI checked before the server, and
therefore the extension, was even loaded).
"""
extension = server._extensions.get(TASKS_EXTENSION_ID)
if extension is None:
console.print(
f"[bold red]✗ No tasks extension registered[/bold red]\n\n"
f"[cyan]{server.name}[/cyan] has no `TasksExtension` registered "
"(`mcp.add_extension(TasksExtension())`), so there is nothing for "
"this worker to serve."
)
sys.exit(1)
from fastmcp_tasks.extension import TasksExtension
assert isinstance(extension, TasksExtension)
return extension.docket_settings
def check_distributed_backend(settings: DocketSettings) -> None:
"""Check if Docket is configured with a distributed backend.
The CLI worker runs as a separate process, so it needs Redis/Valkey
@ -28,12 +60,8 @@ def check_distributed_backend() -> None:
Raises:
SystemExit: If using memory:// URL
"""
import fastmcp
docket_url = fastmcp.settings.docket.url
# Check for memory:// URL and provide helpful error
if docket_url.startswith("memory://"):
if settings.url.startswith("memory://"):
console.print(
"[bold red]✗ In-memory backend not supported by CLI[/bold red]\n\n"
"Your Docket configuration uses an in-memory backend (memory://) which\n"
@ -76,10 +104,6 @@ def worker(
fastmcp tasks worker server.py
fastmcp tasks worker examples/tasks/server.py
"""
import fastmcp
check_distributed_backend()
# Load server to get task functions
try:
config, _resolved_spec = load_and_merge_config(server_spec)
@ -89,15 +113,21 @@ def worker(
# Load the server
server = asyncio.run(config.source.load_server())
# Validate against the server's actual registered extension, not an
# env-only guess — a constructor-configured Redis URL isn't visible
# until the server (and its extension) has loaded.
settings = resolve_docket_settings(server)
check_distributed_backend(settings)
async def run_worker():
"""Enter server lifespan and camp forever."""
async with server._lifespan_manager():
console.print(
f"[bold green]✓[/bold green] Starting worker for [cyan]{server.name}[/cyan]"
)
console.print(f" Docket: {fastmcp.settings.docket.name}")
console.print(f" Backend: {fastmcp.settings.docket.url}")
console.print(f" Concurrency: {fastmcp.settings.docket.concurrency}")
console.print(f" Docket: {settings.name}")
console.print(f" Backend: {settings.url}")
console.print(f" Concurrency: {settings.concurrency}")
# Server's lifespan has started its worker - just camp here forever
while True:
@ -108,3 +138,9 @@ def worker(
except KeyboardInterrupt:
console.print("\n[yellow]Worker stopped[/yellow]")
sys.exit(0)
if __name__ == "__main__":
# Enables `python -m fastmcp_tasks.worker_cli worker <server>` for running an
# out-of-process worker now that core dropped the `fastmcp tasks` subcommand.
tasks_app()

View file

@ -0,0 +1,57 @@
[project]
name = "fastmcp-tasks"
dynamic = ["version", "dependencies"]
description = "Background task execution for FastMCP servers via the io.modelcontextprotocol/tasks extension (SEP-2663)."
authors = [{ name = "Jeremiah Lowin" }]
requires-python = ">=3.10"
readme = "README.md"
license = "Apache-2.0"
keywords = [
"mcp",
"fastmcp tasks",
"background tasks",
"model context protocol",
"fastmcp",
]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Typing :: Typed",
]
[project.urls]
Homepage = "https://gofastmcp.com"
Repository = "https://github.com/PrefectHQ/fastmcp"
Documentation = "https://gofastmcp.com"
[build-system]
requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"]
build-backend = "hatchling.build"
[tool.hatch.version]
source = "uv-dynamic-versioning"
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["fastmcp_tasks"]
[tool.uv-dynamic-versioning]
vcs = "git"
style = "pep440"
bump = true
fallback-version = "0.0.0"
[tool.hatch.metadata.hooks.uv-dynamic-versioning]
dependencies = [
"fastmcp-slim[server]=={{ version }}",
"pydocket>=0.20.0",
]

View file

@ -58,7 +58,7 @@ azure = ["fastmcp-slim[azure]=={{ version }}"]
code-mode = ["fastmcp-slim[code-mode]=={{ version }}"]
gemini = ["fastmcp-slim[gemini]=={{ version }}"]
openai = ["fastmcp-slim[openai]=={{ version }}"]
tasks = ["fastmcp-slim[tasks]=={{ version }}"]
tasks = ["fastmcp-tasks=={{ version }}"]
[tool.uv-dynamic-versioning]
vcs = "git"
@ -67,7 +67,7 @@ bump = true
fallback-version = "0.0.0"
[tool.uv.workspace]
members = ["fastmcp_slim", "fastmcp_remote"]
members = ["fastmcp_slim", "fastmcp_remote", "fastmcp_tasks"]
[tool.uv]
default-groups = ["dev"]
@ -108,6 +108,7 @@ dev = [
fastmcp = { workspace = true }
fastmcp-slim = { workspace = true }
fastmcp-remote = { workspace = true }
fastmcp-tasks = { workspace = true }
[tool.pytest.ini_options]
asyncio_mode = "auto"
@ -129,7 +130,7 @@ markers = [
"subprocess_heavy: marks tests that spawn a fresh Python interpreter which imports FastMCP. Each one costs a full interpreter's memory and startup, so they run serially alongside client_process tests rather than competing with parallel xdist workers.",
"conformance: marks MCP conformance tests (require Node.js/npx)",
]
pythonpath = ["fastmcp_slim", "fastmcp_remote"]
pythonpath = ["fastmcp_slim", "fastmcp_remote", "fastmcp_tasks"]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
@ -137,7 +138,7 @@ python_functions = ["test_*"]
addopts = ["--inline-snapshot=disable"]
[tool.ty.src]
include = ["fastmcp_slim", "fastmcp_remote", "tests", "examples"]
include = ["fastmcp_slim", "fastmcp_remote", "fastmcp_tasks", "tests", "examples"]
exclude = [
"**/node_modules",
"**/__pycache__",

View file

@ -1,9 +1,33 @@
"""Tests for the fastmcp tasks CLI."""
import pytest
from fastmcp_tasks.settings import DocketSettings
from fastmcp_tasks.worker_cli import (
check_distributed_backend,
resolve_docket_settings,
tasks_app,
)
from fastmcp.cli.tasks import check_distributed_backend, tasks_app
from fastmcp.utilities.tests import temporary_settings
from fastmcp import FastMCP
from fastmcp_tasks import TasksExtension
class TestResolveDocketSettings:
"""`resolve_docket_settings` reads the server's *registered* extension."""
def test_reads_the_registered_extensions_settings(self):
"""The constructor-configured URL is visible without any env var."""
mcp = FastMCP("t")
mcp.add_extension(TasksExtension(url="redis://example:6379/0"))
settings = resolve_docket_settings(mcp)
assert settings.url == "redis://example:6379/0"
def test_exits_when_no_tasks_extension_registered(self):
"""A server with no TasksExtension has nothing for the CLI to serve."""
mcp = FastMCP("t")
with pytest.raises(SystemExit) as exc_info:
resolve_docket_settings(mcp)
assert exc_info.value.code == 1
class TestCheckDistributedBackend:
@ -11,17 +35,17 @@ class TestCheckDistributedBackend:
def test_succeeds_with_redis_url(self):
"""Test that it succeeds with Redis URL."""
with temporary_settings(docket__url="redis://localhost:6379/0"):
check_distributed_backend()
settings = DocketSettings(url="redis://localhost:6379/0")
check_distributed_backend(settings)
def test_exits_with_helpful_error_for_memory_url(self):
"""Test that it exits with helpful error for memory:// URLs."""
with temporary_settings(docket__url="memory://test-123"):
with pytest.raises(SystemExit) as exc_info:
check_distributed_backend()
settings = DocketSettings(url="memory://test-123")
with pytest.raises(SystemExit) as exc_info:
check_distributed_backend(settings)
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
class TestWorkerCommand:

View file

@ -13,7 +13,6 @@ from pydantic import AnyUrl
import fastmcp
from fastmcp.client import Client
from fastmcp.client.tasks import TaskNotificationHandler
from fastmcp.client.transports import (
ClientTransport,
FastMCPTransport,
@ -886,32 +885,19 @@ async def test_client_list_dict_return_type():
assert result.data == [{"city": "NYC", "temp": 72}, {"city": "LA", "temp": 85}]
def test_client_new_resets_mutable_task_state(fastmcp_server):
"""Client.new() should not share mutable task tracking structures."""
client = Client(transport=FastMCPTransport(fastmcp_server))
def test_client_new_preserves_internal_task_extension(fastmcp_server):
"""Client.new() rebuilds the clone with the auto-registered tasks claim.
client._task_registry["task-1"] = lambda: None # type: ignore[assignment] # ty:ignore[invalid-assignment]
client._submitted_task_ids.add("task-1")
The tasks client extension (from fastmcp-tasks, imported above) is folded into
every Client automatically; a clone must carry it too so tasked calls still
resolve transparently on the clone.
"""
from fastmcp_tasks.client_models import ClientCreateTaskResult
client = Client(transport=FastMCPTransport(fastmcp_server))
assert ClientCreateTaskResult in client._claim_by_model
clone = client.new()
assert clone is not client
assert clone._task_registry == {}
assert clone._submitted_task_ids == set()
assert clone._task_registry is not client._task_registry
assert clone._submitted_task_ids is not client._submitted_task_ids
def test_client_new_rebinds_default_task_notification_handler(fastmcp_server):
"""Client.new() should bind the default task handler to the cloned client."""
client = Client(transport=FastMCPTransport(fastmcp_server))
handler = client._session_kwargs.get("message_handler")
assert isinstance(handler, TaskNotificationHandler)
clone = client.new()
clone_handler = clone._session_kwargs.get("message_handler")
assert isinstance(clone_handler, TaskNotificationHandler)
assert clone_handler is not handler
assert clone_handler._client_ref() is clone
assert ClientCreateTaskResult in clone._claim_by_model
assert clone._claim_by_model is not client._claim_by_model

View file

@ -42,14 +42,11 @@ class TestCacheConstruction:
def test_cache_none_is_disabled_by_default(self):
"""Caching is opt-in: the default `cache=None` builds no cache, so a legacy
connection is byte-identical to pre-v4 behavior (no handler wrapping)."""
from fastmcp.client.tasks import TaskNotificationHandler
client = Client(FastMCP("x"))
assert client._response_cache is None
# The message handler is the bare default, not a cache-evicting wrapper.
assert isinstance(
client._session_kwargs["message_handler"], TaskNotificationHandler
)
# No cache means no cache-evicting wrapper: the message handler is the
# bare default (None), not a wrapper.
assert client._session_kwargs.get("message_handler") is None
def test_cache_true_builds_default(self):
client = Client(FastMCP("x"), cache=True)

View file

@ -1 +0,0 @@
"""Configuration for client task tests."""

View file

@ -1,108 +0,0 @@
"""
Tests for client-side prompt task methods.
Tests the client's get_prompt_as_task method.
"""
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.tasks import PromptTask
@pytest.fixture
async def prompt_server():
"""Create a test server with background-enabled prompts."""
mcp = FastMCP("prompt-client-test")
@mcp.prompt(task=True)
async def analysis_prompt(topic: str, style: str = "formal") -> str:
"""Generate an analysis prompt."""
return f"Analyze {topic} in a {style} style"
@mcp.prompt(task=True)
async def creative_prompt(theme: str) -> str:
"""Generate a creative writing prompt."""
return f"Write a story about {theme}"
return mcp
async def test_get_prompt_as_task_returns_prompt_task(prompt_server):
"""get_prompt with task=True returns a PromptTask object."""
async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt("analysis_prompt", {"topic": "AI"}, task=True)
assert isinstance(task, PromptTask)
assert isinstance(task.task_id, str)
async def test_prompt_task_server_generated_id(prompt_server):
"""get_prompt with task=True gets server-generated task ID."""
async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt(
"creative_prompt",
{"theme": "future"},
task=True,
)
# Server should generate a UUID task ID
assert task.task_id is not None
assert isinstance(task.task_id, str)
# UUIDs have hyphens
assert "-" in task.task_id
@pytest.mark.xfail(
reason="SDK v2 has no `task` field on GetPromptRequestParams / "
"ReadResourceRequestParams; prompt/resource task submission is not "
"wire-expressible and always graceful-degrades (sdk-feedback #3).",
strict=True,
)
async def test_prompt_task_result_returns_get_prompt_result(prompt_server):
"""PromptTask.result() returns GetPromptResult."""
async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt(
"analysis_prompt", {"topic": "Robotics", "style": "casual"}, task=True
)
# Verify background execution
assert not task.returned_immediately
# Get result
result = await task.result()
# Result should be GetPromptResult
assert hasattr(result, "description")
assert hasattr(result, "messages")
# Check the rendered message content, not the description
assert len(result.messages) > 0
assert "Analyze Robotics" in result.messages[0].content.text
async def test_prompt_task_await_syntax(prompt_server):
"""PromptTask can be awaited directly."""
async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt("creative_prompt", {"theme": "ocean"}, task=True)
# Can await task directly
result = await task
assert "Write a story about ocean" in result.messages[0].content.text
async def test_prompt_task_status_and_wait(prompt_server):
"""PromptTask supports status() and wait() methods."""
async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt("analysis_prompt", {"topic": "Space"}, task=True)
# Check status
status = await task.status()
assert status.status in ["working", "completed"]
# Wait for completion
await task.wait(timeout=2.0)
# Get result
result = await task.result()
assert "Analyze Space" in result.messages[0].content.text

View file

@ -1,119 +0,0 @@
"""
Tests for client-side resource task methods.
Tests the client's read_resource_as_task method.
"""
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.tasks import ResourceTask
@pytest.fixture
async def resource_server():
"""Create a test server with background-enabled resources."""
mcp = FastMCP("resource-client-test")
@mcp.resource("file://document.txt", task=True)
async def document() -> str:
"""A document resource."""
return "Document content here"
@mcp.resource("file://data/{id}.json", task=True)
async def data_file(id: str) -> str:
"""A parameterized data resource."""
return f'{{"id": "{id}", "value": 42}}'
return mcp
async def test_read_resource_as_task_returns_resource_task(resource_server):
"""read_resource with task=True returns a ResourceTask object."""
async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://document.txt", task=True)
assert isinstance(task, ResourceTask)
assert isinstance(task.task_id, str)
async def test_resource_task_server_generated_id(resource_server):
"""read_resource with task=True gets server-generated task ID."""
async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://document.txt", task=True)
# Server should generate a UUID task ID
assert task.task_id is not None
assert isinstance(task.task_id, str)
# UUIDs have hyphens
assert "-" in task.task_id
@pytest.mark.xfail(
reason="SDK v2 has no `task` field on ReadResourceRequestParams, so "
"resource reads cannot be submitted as background tasks over the wire and "
"always graceful-degrade to immediate execution (sdk-feedback #3).",
strict=True,
)
async def test_resource_task_result_returns_read_resource_result(resource_server):
"""ResourceTask.result() returns list of ReadResourceContents."""
async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://document.txt", task=True)
# Verify background execution
assert not task.returned_immediately
# Get result
result = await task.result()
# Result should be list of ReadResourceContents
assert isinstance(result, list)
assert len(result) > 0
assert result[0].text == "Document content here"
async def test_resource_task_await_syntax(resource_server):
"""ResourceTask can be awaited directly."""
async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://document.txt", task=True)
# Can await task directly
result = await task
assert result[0].text == "Document content here"
@pytest.mark.xfail(
reason="SDK v2 has no `task` field on ReadResourceRequestParams, so "
"resource reads cannot be submitted as background tasks over the wire and "
"always graceful-degrade to immediate execution (sdk-feedback #3).",
strict=True,
)
async def test_resource_template_task(resource_server):
"""Resource templates work with task support."""
async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://data/999.json", task=True)
# Verify background execution
assert not task.returned_immediately
# Get result
result = await task.result()
assert '"id": "999"' in result[0].text
async def test_resource_task_status_and_wait(resource_server):
"""ResourceTask supports status() and wait() methods."""
async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://document.txt", task=True)
# Check status
status = await task.status()
assert status.status in ["working", "completed"]
# Wait for completion
await task.wait(timeout=2.0)
# Get result
result = await task.result()
assert "Document content" in result[0].text

View file

@ -1,281 +0,0 @@
"""
Tests for client-side handling of notifications/tasks/status (SEP-1686 lines 436-444).
Verifies that Task objects receive notifications, update their cache, wake up wait() calls,
and invoke user callbacks.
"""
import asyncio
import time
from collections.abc import Callable
from datetime import datetime, timezone
import pytest
from mcp_types import GetTaskResult
from fastmcp import FastMCP
from fastmcp.client import Client
async def _wait_until(condition: Callable[[], bool], timeout: float = 5.0) -> None:
"""Poll until condition() is true or timeout elapses.
Used in place of a fixed sleep when waiting for an async callback or
notification to be delivered/dispatched after the awaited call returns.
"""
deadline = time.monotonic() + timeout
while not condition() and time.monotonic() < deadline:
await asyncio.sleep(0.005)
@pytest.fixture
async def task_notification_server():
"""Server that sends task status notifications."""
mcp = FastMCP("task-notification-test")
@mcp.tool(task=True)
async def quick_task(value: int) -> int:
"""Quick background task with a brief, measurable delay (contrast with instant_task)."""
await asyncio.sleep(0.01)
return value * 2
@mcp.tool(task=True)
async def instant_task(value: int) -> int:
"""Background task that completes with no delay."""
return value * 2
@mcp.tool(task=True)
async def failing_task() -> str:
"""Task that fails."""
raise ValueError("Intentional failure")
return mcp
async def test_task_receives_status_notification(task_notification_server):
"""Task object receives and processes status notifications."""
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 5}, task=True)
# Wait for task to complete (notification should arrive)
status = await task.wait(timeout=2.0)
# Verify task completed
assert status.status == "completed"
async def test_status_cache_updated_by_notification(task_notification_server):
"""Cached status is updated when notification arrives."""
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 10}, task=True)
# Wait for completion (notification should update cache)
await task.wait(timeout=2.0)
# Status should be cached (no server call needed)
# Call status() twice - should return same cached object
status1 = await task.status()
status2 = await task.status()
# Should be the exact same object (from cache)
assert status1 is status2
assert status1.status == "completed"
async def test_callback_invoked_on_notification(task_notification_server):
"""User callback is invoked when notification arrives."""
callback_invocations = []
def status_callback(status: GetTaskResult):
"""Sync callback."""
callback_invocations.append(status)
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 7}, task=True)
# Register callback
task.on_status_change(status_callback)
# Wait for completion
await task.wait(timeout=2.0)
# Wait for the status this test actually asserts on. Waiting merely for
# "some callback fired" would be satisfied by the earlier `working`
# notification and race the `completed` one.
await _wait_until(
lambda: any(s.status == "completed" for s in callback_invocations)
)
# Callback should have been invoked at least once
assert len(callback_invocations) > 0
# Should have received completed status
completed_statuses = [s for s in callback_invocations if s.status == "completed"]
assert len(completed_statuses) > 0
async def test_async_callback_invoked(task_notification_server):
"""Async callback is invoked when notification arrives."""
callback_invocations = []
async def async_status_callback(status: GetTaskResult):
"""Async callback."""
await asyncio.sleep(0.01) # Simulate async work
callback_invocations.append(status)
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 3}, task=True)
# Register async callback
task.on_status_change(async_status_callback)
# Wait for completion
await task.wait(timeout=2.0)
# Give async callbacks time to complete
await _wait_until(lambda: len(callback_invocations) > 0)
# Async callback should have been invoked
assert len(callback_invocations) > 0
async def test_multiple_callbacks_all_invoked(task_notification_server):
"""Multiple callbacks are all invoked."""
callback1_calls = []
callback2_calls = []
def callback1(status: GetTaskResult):
callback1_calls.append(status.status)
def callback2(status: GetTaskResult):
callback2_calls.append(status.status)
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 8}, task=True)
task.on_status_change(callback1)
task.on_status_change(callback2)
await task.wait(timeout=2.0)
await _wait_until(lambda: bool(callback1_calls) and bool(callback2_calls))
# Both callbacks should have been invoked
assert len(callback1_calls) > 0
assert len(callback2_calls) > 0
async def test_callback_error_doesnt_break_notification(task_notification_server):
"""Callback errors don't prevent other callbacks from running."""
callback1_calls = []
callback2_calls = []
def failing_callback(status: GetTaskResult):
callback1_calls.append("called")
raise ValueError("Callback intentionally fails")
def working_callback(status: GetTaskResult):
callback2_calls.append(status.status)
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 12}, task=True)
task.on_status_change(failing_callback)
task.on_status_change(working_callback)
await task.wait(timeout=2.0)
await _wait_until(lambda: bool(callback1_calls) and bool(callback2_calls))
# Failing callback was called (and errored)
assert len(callback1_calls) > 0
# Working callback should still have been invoked
assert len(callback2_calls) > 0
async def test_wait_wakes_early_on_notification(task_notification_server):
"""wait() wakes up immediately when notification arrives, not after poll interval."""
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 15}, task=True)
# Record timing
start = time.time()
status = await task.wait(timeout=5.0)
elapsed = time.time() - start
# Should complete much faster than the fallback poll interval (500ms)
# With notifications, should be < 200ms for quick task
# Without notifications, would take 500ms+ due to polling
assert elapsed < 1.0 # Very generous bound
assert status.status == "completed"
async def test_notification_with_failed_task(task_notification_server):
"""Notifications work for failed tasks too."""
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("failing_task", {}, task=True)
with pytest.raises(Exception):
await task
# Should have cached the failed status from notification
status = await task.status()
assert status.status == "failed"
assert (
status.status_message is not None
) # Error details in statusMessage per spec
async def test_fast_task_completion_delivered_via_notification(
task_notification_server,
):
"""A near-instant task still delivers its completion via a status notification.
Regression test for the Docket subscribe() setup-window race: a task that
finishes before the pub/sub subscription goes live had its terminal state
publish lost, so no completion notification ever reached the client and
wait() fell back to a full poll interval. The server now reconciles the
execution against Redis to close that gap.
Callbacks fire only for received notifications client-side polling updates
the status cache directly without invoking them so a "completed" callback
proves the notification path (not the poll fallback) was exercised.
"""
received: list[str] = []
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("instant_task", {"value": 21}, task=True)
task.on_status_change(lambda status: received.append(status.status))
result = await task
assert result.data == 42
# Allow the completion notification to arrive and dispatch.
await _wait_until(lambda: "completed" in received)
assert "completed" in received
async def test_wait_returns_on_input_required(task_notification_server):
"""wait() should return immediately when task enters input_required, not hang."""
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 1}, task=True)
# Directly inject an input_required status into the cache and signal the event.
# SDK v2 types the Task timestamps as ISO 8601 strings.
now = datetime.now(timezone.utc).isoformat()
input_required_status = GetTaskResult(
task_id=task._task_id,
status="input_required",
status_message="Waiting for user input",
created_at=now,
last_updated_at=now,
ttl=None,
)
task._status_cache = input_required_status
if task._status_event is None:
task._status_event = asyncio.Event()
task._status_event.set()
# Should return immediately with input_required, not hang for 300s
status = await task.wait(timeout=2.0)
assert status.status == "input_required"

View file

@ -1,85 +0,0 @@
"""
Tests for client-side task protocol.
Generic protocol tests that use tools as test fixtures.
"""
import asyncio
from fastmcp import FastMCP
from fastmcp.client import Client
async def test_end_to_end_task_flow():
"""Complete end-to-end flow: submit, poll, retrieve."""
start_signal = asyncio.Event()
complete_signal = asyncio.Event()
mcp = FastMCP("protocol-test")
@mcp.tool(task=True)
async def controlled_tool(message: str) -> str:
"""Tool with controlled execution."""
start_signal.set()
await complete_signal.wait()
return f"Processed: {message}"
async with Client(mcp, mode="legacy") as client:
# Submit task
task = await client.call_tool(
"controlled_tool", {"message": "integration test"}, task=True
)
# Wait for execution to start
await asyncio.wait_for(start_signal.wait(), timeout=2.0)
# Check status while running
status = await task.status()
assert status.status in ["working"]
# Signal completion
complete_signal.set()
# Wait for task to finish and retrieve result
result = await task.result()
assert result.data == "Processed: integration test"
async def test_multiple_concurrent_tasks():
"""Multiple tasks can run concurrently."""
mcp = FastMCP("concurrent-test")
@mcp.tool(task=True)
async def multiply(a: int, b: int) -> int:
return a * b
async with Client(mcp, mode="legacy") as client:
# Submit multiple tasks
tasks = []
for i in range(5):
task = await client.call_tool("multiply", {"a": i, "b": 2}, task=True)
tasks.append((task, i * 2))
# Wait for all to complete and verify results
for task, expected in tasks:
result = await task.result()
assert result.data == expected
async def test_task_id_auto_generation():
"""Task IDs are auto-generated if not provided."""
mcp = FastMCP("id-test")
@mcp.tool(task=True)
async def echo(message: str) -> str:
return f"Echo: {message}"
async with Client(mcp, mode="legacy") as client:
# Submit without custom task ID
task_1 = await client.call_tool("echo", {"message": "first"}, task=True)
task_2 = await client.call_tool("echo", {"message": "second"}, task=True)
# Should generate different IDs
assert task_1.task_id != task_2.task_id
assert len(task_1.task_id) > 0
assert len(task_2.task_id) > 0

View file

@ -1,156 +0,0 @@
"""
Tests for client-side tool task methods.
Tests the client's tool-specific task functionality, parallel to
test_client_prompt_tasks.py and test_client_resource_tasks.py.
"""
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.tasks import ToolTask
from fastmcp.exceptions import ToolError
@pytest.fixture
async def tool_task_server():
"""Create a test server with task-enabled tools."""
mcp = FastMCP("tool-task-test")
@mcp.tool(task=True)
async def echo(message: str) -> str:
"""Echo back the message."""
return f"Echo: {message}"
@mcp.tool(task=True)
async def multiply(a: int, b: int) -> int:
"""Multiply two numbers."""
return a * b
return mcp
async def test_call_tool_as_task_returns_tool_task(tool_task_server):
"""call_tool with task=True returns a ToolTask object."""
async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("echo", {"message": "hello"}, task=True)
assert isinstance(task, ToolTask)
assert isinstance(task.task_id, str)
assert len(task.task_id) > 0
async def test_tool_task_server_generated_id(tool_task_server):
"""call_tool with task=True gets server-generated task ID."""
async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("echo", {"message": "test"}, task=True)
# Server should generate a UUID task ID
assert task.task_id is not None
assert isinstance(task.task_id, str)
# UUIDs have hyphens
assert "-" in task.task_id
async def test_tool_task_result_returns_call_tool_result(tool_task_server):
"""ToolTask.result() returns CallToolResult with tool data."""
async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("multiply", {"a": 6, "b": 7}, task=True)
assert not task.returned_immediately
result = await task.result()
assert result.data == 42
async def test_tool_task_await_syntax(tool_task_server):
"""Tool tasks can be awaited directly to get result."""
async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("multiply", {"a": 7, "b": 6}, task=True)
# Can await task directly (syntactic sugar for task.result())
result = await task
assert result.data == 42
async def test_tool_task_status_and_wait(tool_task_server):
"""ToolTask.status() returns GetTaskResult."""
async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("echo", {"message": "test"}, task=True)
status = await task.status()
assert status.task_id == task.task_id
assert status.status in ["working", "completed"]
# Wait for completion
await task.wait(timeout=2.0)
final_status = await task.status()
assert final_status.status == "completed"
async def test_immediate_tool_task_respects_raise_on_error_true():
"""Immediate task fallback should still raise ToolError when requested."""
mcp = FastMCP("immediate-tool-task-error")
@mcp.tool
def failing_tool() -> str:
raise ValueError("immediate task failure")
async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failing_tool", task=True, raise_on_error=True)
assert task.returned_immediately
with pytest.raises(
ToolError, match="does not support task-augmented execution"
):
await task.result()
async def test_immediate_tool_task_respects_raise_on_error_false():
"""Immediate task fallback should return error results when requested."""
mcp = FastMCP("immediate-tool-task-no-raise")
@mcp.tool
def failing_tool() -> str:
raise ValueError("immediate task failure")
async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failing_tool", task=True, raise_on_error=False)
assert task.returned_immediately
result = await task.result()
assert result.is_error is True
assert "does not support task-augmented execution" in str(result)
async def test_background_tool_task_respects_raise_on_error_true():
"""Background tasks should still raise ToolError by default on errors."""
mcp = FastMCP("background-tool-task-error")
@mcp.tool(task=True)
async def failing_tool() -> str:
raise ValueError("background task failure")
async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failing_tool", task=True, raise_on_error=True)
assert not task.returned_immediately
with pytest.raises(ToolError, match="background task failure"):
await task.result()
async def test_background_tool_task_respects_raise_on_error_false():
"""Background tasks should return error results when raise_on_error is disabled."""
mcp = FastMCP("background-tool-task-no-raise")
@mcp.tool(task=True)
async def failing_tool() -> str:
raise ValueError("background task failure")
async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failing_tool", task=True, raise_on_error=False)
assert not task.returned_immediately
result = await task.result()
assert result.is_error is True
assert "background task failure" in str(result)

View file

@ -1,90 +0,0 @@
"""Fallback poll cadence for client-side task waiting.
Two modes: a server-advertised pollInterval is honored exactly, while an
unadvertised one falls back to an exponential ramp up to the client setting.
"""
import pytest
from mcp_types import GetTaskResult
from pydantic import ValidationError
from fastmcp import Client, FastMCP
from fastmcp.client.tasks import MIN_POLL_INTERVAL, ToolTask
from fastmcp.settings import Settings
from fastmcp.utilities.tests import temporary_settings
@pytest.mark.parametrize("value", [0, -0.5, -1])
def test_non_positive_poll_interval_setting_is_rejected(value: float):
with pytest.raises(ValidationError):
Settings(client_task_poll_interval=value)
def test_positive_poll_interval_setting_is_accepted():
settings = Settings(client_task_poll_interval=0.25)
assert settings.client_task_poll_interval == 0.25
@pytest.fixture
def task() -> ToolTask:
client = Client(FastMCP())
return ToolTask(client=client, task_id="t1", tool_name="echo")
def _status(poll_interval: int | None) -> GetTaskResult:
return GetTaskResult(
task_id="t1",
status="working",
created_at="2026-01-01T00:00:00+00:00",
last_updated_at="2026-01-01T00:00:00+00:00",
ttl=None,
poll_interval=poll_interval,
)
@pytest.mark.parametrize("poll_interval", [2000, 30_000])
def test_advertised_interval_is_used_verbatim_without_backoff(
task: ToolTask, poll_interval: int
):
"""An advertised interval is the delay itself, not a ceiling to ramp toward."""
task._status_cache = _status(poll_interval)
expected = poll_interval / 1000
backoff = MIN_POLL_INTERVAL
for _ in range(5):
delay, backoff = task._next_poll_delay(backoff)
assert delay == expected
def test_large_advertised_interval_is_honored(task: ToolTask):
task._status_cache = _status(24 * 60 * 60 * 1000)
delay, _ = task._next_poll_delay(MIN_POLL_INTERVAL)
assert delay == 24 * 60 * 60
@pytest.mark.parametrize("poll_interval", [0, -1, -5000])
def test_non_positive_advertised_interval_is_floored(
task: ToolTask, poll_interval: int
):
"""A buggy or hostile server must not be able to spin the client."""
task._status_cache = _status(poll_interval)
delay, _ = task._next_poll_delay(MIN_POLL_INTERVAL)
assert delay == MIN_POLL_INTERVAL
def test_unadvertised_interval_ramps_up_to_setting(task: ToolTask):
task._status_cache = _status(None)
with temporary_settings(client_task_poll_interval=0.5):
delays = []
backoff = MIN_POLL_INTERVAL
for _ in range(7):
delay, backoff = task._next_poll_delay(backoff)
delays.append(delay)
assert delays == [0.02, 0.04, 0.08, 0.16, 0.32, 0.5, 0.5]
def test_missing_status_cache_ramps_from_floor(task: ToolTask):
delay, backoff = task._next_poll_delay(MIN_POLL_INTERVAL)
assert delay == MIN_POLL_INTERVAL
assert backoff == MIN_POLL_INTERVAL * 2

Some files were not shown because too many files have changed in this diff Show more