Move task subsystem to fastmcp-tasks package, disconnect SEP-1686 wire from core

Engine modules (keys, context snapshot, docket lifespan, worker CLI,
client handles) move intact; SEP-1686 wire modules park in _legacy_wire
for adaptation to SEP-2663. Core keeps task=True declaration on tools
only and raises at serve time until the tasks extension is registered.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeremiah Lowin 2026-07-21 21:51:45 -04:00
commit 6fce4e538f
No known key found for this signature in database
119 changed files with 1191 additions and 3331 deletions

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

@ -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
@ -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

@ -282,7 +282,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

@ -80,7 +80,7 @@ For fine-grained control over task execution behavior, use `TaskConfig` instead
```python
from fastmcp import FastMCP
from fastmcp.server.tasks import TaskConfig
from fastmcp.utilities.tasks import TaskConfig
mcp = FastMCP("MyServer")
@ -113,7 +113,7 @@ When clients poll for task status, the server tells them how frequently to check
```python
from datetime import timedelta
from fastmcp import FastMCP
from fastmcp.server.tasks import TaskConfig
from fastmcp.utilities.tasks import TaskConfig
mcp = FastMCP("MyServer")
@ -241,7 +241,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")

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
@ -43,11 +42,6 @@ from mcp.client.extension import (
ResultClaim,
)
from mcp.client.session import ClientRequestContext, MessageHandlerFnT
from mcp_types import (
GetTaskResult,
TaskStatusNotification,
TaskStatusNotificationParams,
)
from mcp_types.methods import validate_server_result
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
from pydantic import AnyUrl, ValidationError
@ -67,7 +61,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 +73,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 +243,6 @@ class Client(
ClientResourcesMixin,
ClientPromptsMixin,
ClientToolsMixin,
ClientTaskManagementMixin,
):
"""
MCP client that delegates connection management to a Transport instance.
@ -500,12 +486,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(
@ -557,15 +541,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:
@ -724,26 +699,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(
@ -752,8 +717,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)}"
@ -1217,41 +1181,12 @@ 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
@ -1269,11 +1204,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
@ -1309,26 +1240,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

@ -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

@ -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

@ -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

@ -308,26 +308,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.
@ -338,18 +322,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
@ -1409,15 +1383,13 @@ class Context:
"_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,
# In-task elicitation is provided by the tasks extension (SEP-2663)
# from the `fastmcp-tasks` package. Core no longer ships the SEP-1686
# push relay this used to call.
raise RuntimeError(
"In-task elicitation requires the tasks extension. Install "
"'fastmcp[tasks]' and register the tasks extension via "
"mcp.add_extension(...)."
)
def _make_state_key(self, key: str) -> str:

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
@ -14,7 +15,6 @@ from collections.abc import AsyncGenerator, 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
@ -43,9 +43,6 @@ from fastmcp.utilities.async_utils import (
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
@ -143,15 +140,11 @@ __all__ = [
"AccessToken",
"CurrentAccessToken",
"CurrentContext",
"CurrentDocket",
"CurrentFastMCP",
"CurrentHeaders",
"CurrentRequest",
"CurrentWorker",
"FastMCPRequestContext",
"Progress",
"TaskContextInfo",
"TaskContextSnapshot",
"TokenClaim",
"bind_request_context",
"extract_version_spec",
@ -161,38 +154,17 @@ __all__ = [
"get_http_headers",
"get_http_request",
"get_server",
"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)
# --- Docket availability check ---
@ -231,43 +203,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 ---
@ -425,24 +360,12 @@ 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).
Returns:
The active FastMCP server
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
server_ref = _current_server.get()
if server_ref is None:
raise RuntimeError("No FastMCP server instance in context")
@ -456,8 +379,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
@ -470,33 +391,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
@ -565,8 +459,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.
@ -589,19 +482,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
@ -843,53 +723,24 @@ 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
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"
"Check `context.request_context` for None before accessing."
)
@ -966,118 +817,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

@ -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

@ -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,39 @@ 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
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 +186,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 +196,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 +213,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

@ -29,7 +29,6 @@ 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.utilities.async_utils import (
call_sync_fn_in_threadpool,
@ -127,9 +126,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 +216,9 @@ class MCPOperationsMixin:
self: FastMCP,
ctx: ServerRequestContext,
params: CallToolRequestParams,
) -> (
mcp_types.CallToolResult
| mcp_types.InputRequiredResult
| mcp_types.CreateTaskResult
):
) -> mcp_types.CallToolResult | mcp_types.InputRequiredResult:
"""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 +238,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 +263,6 @@ class MCPOperationsMixin:
is_error=True,
)
if isinstance(result, mcp_types.CreateTaskResult):
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 +286,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 +311,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 +342,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:

View file

@ -97,16 +97,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,
@ -1316,7 +1315,6 @@ class FastMCP(
return None
return max(authorized, key=version_sort_key)
@overload
async def call_tool(
self,
name: str,
@ -1324,29 +1322,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 +1333,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 +1397,6 @@ class FastMCP(
context.message.arguments or {},
version=version,
run_middleware=False,
task_meta=task_meta,
)
),
)
@ -1467,10 +1438,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 +1490,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 +1507,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 +1533,6 @@ class FastMCP(
str(context.message.uri),
version=version,
run_middleware=False,
task_meta=task_meta,
),
)
@ -1619,16 +1555,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 +1626,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 +1656,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 +1663,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 +1675,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 +1703,6 @@ class FastMCP(
context.message.arguments,
version=version,
run_middleware=False,
task_meta=task_meta,
),
)
@ -1816,10 +1720,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 +1927,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 +2026,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 +2055,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 +2070,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 +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[[AnyFunction], FunctionPrompt]
@ -2271,7 +2168,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,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

@ -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,6 +181,8 @@ class Settings(BaseSettings):
),
] = 5
# May move to the fastmcp-tasks package alongside the client task senders
# when client task support is rebuilt on the SEP-2663 extension.
client_task_poll_interval: Annotated[
float,
Field(

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
@ -396,87 +392,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.
@ -496,88 +491,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

@ -183,9 +183,9 @@ class ClientTaskManagementMixin:
# Server returned empty - fall back to client-side tracking
tasks = []
for task_id in list(self._submitted_task_ids)[:limit]:
for task_id in list(self._submitted_task_ids)[:limit]: # ty: ignore[unresolved-attribute]
try:
status = await self.get_task_status(task_id)
status = await self.get_task_status(task_id) # ty: ignore[unresolved-attribute]
tasks.append(status.model_dump(by_alias=True))
except MCPError:
# Task may have expired or been deleted, skip it

View file

@ -0,0 +1,14 @@
"""SEP-1686 wire layer, moved intact and awaiting Phase 3 adaptation.
Every module in this subpackage is the original SEP-1686-shaped wire code:
the four CRUD request handlers (`requests.py`), the task-submission handler
(`handlers.py`), the Docket-subscription status relay (`subscriptions.py`), the
Redis push relay for elicitation (`elicitation.py`, `notifications.py`), the
capability declaration (`capabilities.py`), and the mode-routing dispatcher
(`routing.py`).
It is disconnected from core nothing wires these handlers onto a server after
Phase 2. Phase 3 adapts this code in place to the SEP-2663 `tasks/get|update|cancel`
shape under its ported tests. Do not "improve" it here; the point of keeping it is
that it embodies operational lessons the rewrite must preserve.
"""

View file

@ -31,10 +31,7 @@ def get_task_capabilities() -> ServerTasksCapability | None:
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
from fastmcp_tasks.dependencies import is_docket_available
if not is_docket_available():
return None

View file

@ -24,9 +24,9 @@ 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
from fastmcp_tasks._legacy_wire.notifications import push_notification
from fastmcp_tasks.context import get_task_context, get_task_session_id
from fastmcp_tasks.keys import task_redis_prefix
logger = logging.getLogger(__name__)

View file

@ -15,20 +15,19 @@ 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 (
from fastmcp.server.dependencies import get_context
from fastmcp.tools.function_tool import _strict_input_validation
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.tasks import TaskMeta
from fastmcp_tasks.components import add_component_to_docket, coerce_task_arguments
from fastmcp_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
from fastmcp_tasks.dependencies import _current_docket
from fastmcp_tasks.keys import build_task_key, task_redis_prefix
if TYPE_CHECKING:
from fastmcp.prompts.base import Prompt
@ -78,8 +77,8 @@ async def submit_to_docket(
# 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()
arguments = coerce_task_arguments(
component, arguments, strict=_strict_input_validation()
)
# Generate server-side task ID per SEP-1686 final spec (line 375-377)
@ -185,16 +184,20 @@ async def submit_to_docket(
# `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]
await add_component_to_docket(
component, docket, None, fn_key=key, task_key=task_key
)
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]
await add_component_to_docket(
component, docket, arguments, fn_key=key, task_key=task_key
)
# 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
from fastmcp_tasks._legacy_wire.subscriptions import subscribe_to_task_updates
subscription_task = asyncio.create_task(
subscribe_to_task_updates(
@ -218,7 +221,7 @@ async def submit_to_docket(
connection.exit_stack.push_async_callback(_cancel_subscription)
# Deferred: notifications depends on docket at import time
from fastmcp.server.tasks.notifications import (
from fastmcp_tasks._legacy_wire.notifications import (
ensure_subscriber_running,
stop_subscriber,
)

View file

@ -223,7 +223,7 @@ async def _send_mcp_notification(
)
return
task_scope = related_task["task_scope"]
from fastmcp.server.tasks.elicitation import relay_elicitation
from fastmcp_tasks._legacy_wire.elicitation import relay_elicitation
task = asyncio.create_task(
relay_elicitation(session, task_scope, task_id, elicitation, fastmcp),

View file

@ -27,11 +27,11 @@ 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.tasks import DEFAULT_POLL_INTERVAL_MS, DEFAULT_TTL_MS
from fastmcp.utilities.versions import VersionSpec
from fastmcp_tasks.context import get_task_scope
from fastmcp_tasks.keys import parse_task_key, task_redis_prefix
if TYPE_CHECKING:
from fastmcp.server.server import FastMCP

View file

@ -11,8 +11,8 @@ 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
from fastmcp.utilities.tasks import TaskMeta
from fastmcp_tasks._legacy_wire.handlers import submit_to_docket
if TYPE_CHECKING:
from fastmcp.prompts.base import Prompt

View file

@ -16,10 +16,10 @@ 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
from fastmcp.utilities.tasks import DEFAULT_TTL_MS
from fastmcp_tasks._legacy_wire.requests import DOCKET_TO_MCP_STATE
from fastmcp_tasks.keys import parse_task_key, task_redis_prefix
if TYPE_CHECKING:
from docket import Docket

View file

@ -45,7 +45,7 @@ class TaskNotificationHandler(MessageHandler):
if isinstance(message, TaskStatusNotification):
client = self._client_ref()
if client:
client._handle_task_status_notification(message)
client._handle_task_status_notification(message) # ty: ignore[unresolved-attribute]
await super().dispatch(message)
@ -205,7 +205,7 @@ class Task(abc.ABC, Generic[TaskResultT]):
return cached
# Query server and cache the result
self._status_cache = await self._client.get_task_status(self._task_id)
self._status_cache = await self._client.get_task_status(self._task_id) # ty: ignore[unresolved-attribute]
return self._status_cache
@abc.abstractmethod
@ -287,7 +287,7 @@ class Task(abc.ABC, Generic[TaskResultT]):
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)
self._status_cache = await self._client.get_task_status(self._task_id) # ty: ignore[unresolved-attribute]
def _next_poll_delay(self, backoff: float) -> tuple[float, float]:
"""Delay before the next fallback poll, plus the backoff for the round after.
@ -340,7 +340,7 @@ class Task(abc.ABC, Generic[TaskResultT]):
# No server-side task to cancel
return
self._check_client_connected()
await self._client.cancel_task(self._task_id)
await self._client.cancel_task(self._task_id) # ty: ignore[unresolved-attribute]
# Invalidate cache to force fresh status fetch
self._status_cache = None
@ -426,7 +426,7 @@ class ToolTask(Task["CallToolResult"]):
await self._wait_terminal()
# Get the raw result (dict or CallToolResult)
raw_result = await self._client.get_task_result(self._task_id)
raw_result = await self._client.get_task_result(self._task_id) # ty: ignore[unresolved-attribute]
# Convert to CallToolResult if needed and parse
if isinstance(raw_result, dict):
@ -523,7 +523,7 @@ class PromptTask(Task[mcp_types.GetPromptResult]):
await self._wait_terminal()
# Get the raw MCP result
mcp_result = await self._client.get_task_result(self._task_id)
mcp_result = await self._client.get_task_result(self._task_id) # ty: ignore[unresolved-attribute]
# Parse as GetPromptResult
result = mcp_types.GetPromptResult.model_validate(mcp_result)
@ -595,7 +595,7 @@ class ResourceTask(
await self._wait_terminal()
# Get the raw MCP result
mcp_result = await self._client.get_task_result(self._task_id)
mcp_result = await self._client.get_task_result(self._task_id) # ty: ignore[unresolved-attribute]
# Parse as ReadResourceResult or extract contents
if isinstance(mcp_result, mcp_types.ReadResourceResult):

View file

@ -0,0 +1,169 @@
"""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 are preserved here verbatim as type-dispatched functions so Phase 3 can
wire them into ``TasksExtension`` without reconstructing the calling conventions.
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`` after the migration (SEP-2663 is
tools-only); the resource/prompt/template branches are retained for engine
completeness and Phase 3's decision, 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
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):
docket.register(component.fn, names=[component.key])
elif isinstance(component, Tool):
docket.register(component.run, 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,7 @@ 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 parse_task_key, task_redis_prefix
try:
from docket import TaskKey
@ -88,7 +88,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
@ -247,7 +247,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

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 Phase 3 rewires into ``TasksExtension``.
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,126 @@
"""Docket lifecycle for FastMCP background tasks.
Extracted from ``fastmcp.server.mixins.lifespan.LifespanMixin._docket_lifespan``
during the SEP-1686 -> SEP-2663 migration. The logic start Docket and a Worker
at the runtime-tree root when there are task-enabled components, register those
components' callables, and run the worker with the snapshot-restore dependency —
is preserved verbatim so Phase 3 can drive it from ``TasksExtension.lifespan()``.
Nothing in core calls this after Phase 2; it is engine code parked here for the
Phase 3 adapter.
"""
from __future__ import annotations
import asyncio
import weakref
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
logger = get_logger(__name__)
@asynccontextmanager
async def docket_lifespan(server: FastMCP) -> AsyncIterator[None]:
"""Manage the Docket instance and Worker for background task execution.
Docket infrastructure is only initialized if:
1. pydocket is installed (fastmcp[tasks] extra)
2. There are task-enabled components (task_config.mode != 'forbidden')
Sets ``server._docket`` / ``server._worker`` for the duration and registers
each task-enabled component's callable with the Docket, then runs the worker
until the context exits.
"""
from docket import Depends, Docket, Worker
import fastmcp
from fastmcp.server.dependencies import _current_server
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,
)
from fastmcp_tasks.settings import DocketSettings
docket_settings = DocketSettings()
# Set FastMCP server in ContextVar so CurrentFastMCP can access it
# (use weakref to avoid reference cycles)
server_token = _current_server.set(weakref.ref(server))
try:
if not is_docket_available():
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 server.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 not task_components:
yield
return
async with Docket(
name=docket_settings.name,
url=docket_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": docket_settings.concurrency,
"redelivery_timeout": docket_settings.redelivery_timeout,
"reconnection_delay": docket_settings.reconnection_delay,
"minimum_check_interval": docket_settings.minimum_check_interval,
}
if docket_settings.worker_name:
worker_kwargs["name"] = docket_settings.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:
server._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)
server._worker = None
finally:
_current_docket.reset(docket_token)
server._docket = None
finally:
_current_server.reset(server_token)

View file

@ -0,0 +1,122 @@
"""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. Phase 3 wires this configuration into
``TasksExtension``.
"""
from __future__ import annotations
import inspect
from datetime import timedelta
from typing import Annotated
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
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)
docket_settings = DocketSettings()

View file

@ -9,6 +9,7 @@ from rich.console import Console
from fastmcp.utilities.cli import load_and_merge_config
from fastmcp.utilities.logging import get_logger
from fastmcp_tasks.settings import docket_settings
logger = get_logger("cli.tasks")
console = Console()
@ -28,9 +29,7 @@ def check_distributed_backend() -> None:
Raises:
SystemExit: If using memory:// URL
"""
import fastmcp
docket_url = fastmcp.settings.docket.url
docket_url = docket_settings.url
# Check for memory:// URL and provide helpful error
if docket_url.startswith("memory://"):
@ -76,8 +75,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
@ -95,9 +92,9 @@ def worker(
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: {docket_settings.name}")
console.print(f" Backend: {docket_settings.url}")
console.print(f" Concurrency: {docket_settings.concurrency}")
# Server's lifespan has started its worker - just camp here forever
while True:

View file

@ -155,6 +155,11 @@ exclude = [
"examples/providers/sqlite", # needs aiosqlite
"examples/memory.py", # needs asyncpg, numpy, pydantic_ai, pgvector
"examples/get_file.py", # needs aiohttp
# Dormant SEP-1686 task tests: skipped at runtime pending the Phase 3
# TasksExtension (SEP-2663). They reference task APIs that are removed from
# core and return in the fastmcp-tasks extension, so they don't type-check
# against core until then. Drop this exclusion when Phase 3 lands.
"tests/tasks",
]
[tool.ty.environment]

View file

@ -1,10 +1,14 @@
"""Tests for the fastmcp tasks CLI."""
import pytest
from fastmcp_tasks.worker_cli import check_distributed_backend, tasks_app
from fastmcp.cli.tasks import check_distributed_backend, tasks_app
from fastmcp.utilities.tests import temporary_settings
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)
class TestCheckDistributedBackend:
"""Test the distributed backend checker function."""

View file

@ -7,13 +7,13 @@ from typing import Any, cast
import anyio
import pytest
from fastmcp_tasks.client import TaskNotificationHandler
from mcp import ClientSession, MCPError
from mcp_types import TextContent
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,22 +886,24 @@ async def test_client_list_dict_return_type():
assert result.data == [{"city": "NYC", "temp": 72}, {"city": "LA", "temp": 85}]
@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)")
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))
client._task_registry["task-1"] = lambda: None # type: ignore[assignment] # ty:ignore[invalid-assignment]
client._submitted_task_ids.add("task-1")
client._task_registry["task-1"] = lambda: None # type: ignore[assignment] # ty: ignore
client._submitted_task_ids.add("task-1") # ty: ignore
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
assert clone._task_registry == {} # ty: ignore
assert clone._submitted_task_ids == set() # ty: ignore
assert clone._task_registry is not client._task_registry # ty: ignore
assert clone._submitted_task_ids is not client._submitted_task_ids # ty: ignore
@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)")
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))

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

@ -2,6 +2,7 @@
import asyncio
import pytest
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
@ -9,6 +10,10 @@ from opentelemetry.trace import SpanKind
from fastmcp import Client, FastMCP
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)
def assert_propagating_client_span(
trace_exporter: InMemorySpanExporter,

View file

@ -142,6 +142,7 @@ def test_extension_populates_claim_by_model_index():
assert client._claim_by_model[ClaimedResult].result_type == CLAIMED_TYPE
@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)")
def test_binding_composes_with_internal_task_binding():
"""User binding is appended to (not replacing) the task-status binding."""
client = Client(FastMCP("srv"), extensions=[_DemoExtension()])
@ -153,6 +154,7 @@ def test_binding_composes_with_internal_task_binding():
assert methods[0] == TASK_STATUS_METHOD
@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)")
def test_no_extensions_leaves_only_task_binding():
"""Without extensions, only the internal task-status binding is registered."""
client = Client(FastMCP("srv"))
@ -163,6 +165,7 @@ def test_no_extensions_leaves_only_task_binding():
assert client._claim_by_model == {}
@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)")
def test_new_preserves_extension_composition():
"""new() rebuilds the clone with both the task binding and user bindings."""
client = Client(FastMCP("srv"), extensions=[_DemoExtension()])
@ -204,6 +207,7 @@ def test_result_claims_merge_with_extension_claims():
assert set(client._claim_by_model) == {ClaimedResult, ExtraClaimed}
@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)")
async def test_user_binding_clobbering_task_method_is_rejected():
"""A user extension binding the task-status method cannot silently replace it.
@ -233,6 +237,7 @@ async def test_user_binding_clobbering_task_method_is_rejected():
pass
@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)")
async def test_both_bindings_fire_against_live_server():
"""The internal task binding and a user extension binding both fire.
@ -263,8 +268,8 @@ async def test_both_bindings_fire_against_live_server():
# The user extension binding fires on the custom notification.
await client.call_tool("emit", {"value": 21})
# The internal task binding fires on the task-status notification.
task = await client.call_tool("background", {"value": 5}, task=True)
status = await task.wait(timeout=2.0)
task = await client.call_tool("background", {"value": 5}, task=True) # ty: ignore
status = await task.wait(timeout=2.0) # ty: ignore
# Give the custom-notification queue a moment to drain.
await asyncio.sleep(0.1)

View file

@ -18,6 +18,7 @@ def test_transport_repr_includes_server_name():
assert repr(transport) == "<FastMCPTransport(server='repr-test')>"
@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)")
@pytest.mark.timeout(10)
async def test_task_teardown_does_not_hang():
"""In-memory transport must tear down in under 2 seconds after a task call.

View file

@ -4,7 +4,6 @@ import secrets
import socket
import sys
from collections.abc import Callable, Generator
from datetime import timedelta
from pathlib import Path
from typing import Any
@ -115,17 +114,14 @@ def isolate_settings_home(_settings_home_root: Path):
per-test overhead (numbering, test-id sanitization, retention-policy
bookkeeping) for the ~99% of tests that never touch this directory.
Also sets a fast Docket polling interval for tests the default 50ms
is fine for production but still adds ~25ms average pickup latency per
task. 10ms makes task tests near-instant.
Docket settings moved to the fastmcp-tasks package, so they are no longer
overridden here.
"""
test_home = _settings_home_root / secrets.token_hex(8)
test_home.mkdir()
with temporary_settings(
home=test_home,
docket__minimum_check_interval=timedelta(milliseconds=10),
docket__url=f"memory://{secrets.token_hex(4)}",
client_disconnect_timeout=1,
):
yield

View file

@ -146,6 +146,7 @@ async def test_get_http_headers_excludes_content_type(sse_server: ASGIServer):
assert headers["x-custom-header"] == "should-be-included"
@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)")
async def test_background_task_can_read_snapshotted_request_headers():
"""Background tools can still access request headers via get_http_request()."""
server = FastMCP()
@ -164,6 +165,7 @@ async def test_background_task_can_read_snapshotted_request_headers():
assert result.data == "tenant-123"
@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)")
async def test_background_task_current_http_dependencies_restore_headers():
"""CurrentHeaders/CurrentRequest work in task workers without explicit Context."""
server = FastMCP()

View file

@ -355,9 +355,18 @@ class TestResponseCachingMiddlewareIntegration:
async def test_list_operations_preserve_component_metadata(self):
"""Base component fields should survive conversion through the cache."""
from fastmcp.server.extensions import ServerExtension
from fastmcp.utilities.tasks import TASKS_EXTENSION_ID
class _StubTasksExtension(ServerExtension):
identifier = TASKS_EXTENSION_ID
icon = mcp_types.Icon(src="https://example.com/component.png")
mcp = FastMCP("MetadataServer")
mcp.add_middleware(ResponseCachingMiddleware())
# A task-enabled tool requires the tasks extension to serve; register a
# stub so the metadata (execution.task_support) can be verified end-to-end.
mcp.add_extension(_StubTasksExtension())
@mcp.tool(icons=[icon], task=TaskConfig(mode="optional"))
async def greet() -> str:

View file

@ -598,6 +598,7 @@ class TestMountedServerDocketBehavior:
includes Docket creation.
"""
@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)")
async def test_mounted_server_does_not_have_docket(self):
"""Test that a mounted server doesn't create its own Docket.

View file

@ -6,9 +6,9 @@ import pytest
from fastmcp.server.providers.aggregate import AggregateProvider
from fastmcp.server.providers.base import Provider
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.server.transforms import Namespace
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.tasks import TaskConfig
class CustomTool(Tool):

View file

@ -17,8 +17,8 @@ from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.prompts.base import Prompt
from fastmcp.server.providers.local_provider import LocalProvider
from fastmcp.server.tasks import TaskConfig
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.tasks import TaskConfig
class TestLocalProviderStorage:

View file

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

View file

@ -1,287 +0,0 @@
"""
Tests for the explicit task_meta parameter on FastMCP.read_resource().
These tests verify that the task_meta parameter provides explicit control
over sync vs task execution for resources and resource templates.
"""
import pytest
from mcp.shared.exceptions import MCPError
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.resources.base import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.tasks.config import TaskMeta
class TestResourceTaskMetaParameter:
"""Tests for task_meta parameter on FastMCP.read_resource()."""
async def test_task_meta_none_returns_resource_result(self):
"""With task_meta=None (default), read_resource returns ResourceResult."""
server = FastMCP("test")
@server.resource("data://test")
async def simple_resource() -> str:
return "hello world"
result = await server.read_resource("data://test")
assert result.contents[0].content == "hello world"
async def test_task_meta_none_on_task_enabled_resource_still_returns_result(self):
"""Even for task=True resources, task_meta=None returns ResourceResult."""
server = FastMCP("test")
@server.resource("data://test", task=True)
async def task_enabled_resource() -> str:
return "hello world"
# Without task_meta, should execute synchronously
result = await server.read_resource("data://test")
assert result.contents[0].content == "hello world"
async def test_task_meta_on_forbidden_resource_raises_error(self):
"""Providing task_meta to a task=False resource raises MCPError."""
server = FastMCP("test")
@server.resource("data://test", task=False)
async def sync_only_resource() -> str:
return "hello"
with pytest.raises(MCPError) as exc_info:
await server.read_resource("data://test", task_meta=TaskMeta())
assert "does not support task-augmented execution" in str(exc_info.value)
async def test_task_meta_fn_key_enrichment_for_resource(self):
"""Verify that fn_key enrichment uses Resource.make_key()."""
resource_uri = "data://my-resource"
expected_key = Resource.make_key(resource_uri)
assert expected_key == "resource:data://my-resource"
async def test_task_meta_fn_key_enrichment_for_template(self):
"""Verify that fn_key enrichment uses ResourceTemplate.make_key()."""
template_pattern = "data://{id}"
expected_key = ResourceTemplate.make_key(template_pattern)
assert expected_key == "template:data://{id}"
class TestResourceTemplateTaslMeta:
"""Tests for task_meta with resource templates."""
async def test_template_task_meta_none_returns_resource_result(self):
"""With task_meta=None, template read returns ResourceResult."""
server = FastMCP("test")
@server.resource("item://{id}")
async def get_item(id: str) -> str:
return f"Item {id}"
result = await server.read_resource("item://42")
assert result.contents[0].content == "Item 42"
async def test_template_task_meta_on_task_enabled_template_returns_result(self):
"""Even for task=True templates, task_meta=None returns ResourceResult."""
server = FastMCP("test")
@server.resource("item://{id}", task=True)
async def get_item(id: str) -> str:
return f"Item {id}"
# Without task_meta, should execute synchronously
result = await server.read_resource("item://42")
assert result.contents[0].content == "Item 42"
async def test_template_task_meta_on_forbidden_template_raises_error(self):
"""Providing task_meta to a task=False template raises MCPError."""
server = FastMCP("test")
@server.resource("item://{id}", task=False)
async def sync_only_template(id: str) -> str:
return f"Item {id}"
with pytest.raises(MCPError) as exc_info:
await server.read_resource("item://42", task_meta=TaskMeta())
assert "does not support task-augmented execution" in str(exc_info.value)
class TestResourceTaskMetaClientIntegration:
"""Tests that task_meta works correctly with the Client for resources."""
async def test_client_read_resource_without_task_gets_immediate_result(self):
"""Client without task=True gets immediate result."""
server = FastMCP("test")
@server.resource("data://test", task=True)
async def immediate_resource() -> str:
return "hello"
async with Client(server, mode="legacy") as client:
result = await client.read_resource("data://test")
# Should get ReadResourceResult directly
assert "hello" in str(result)
async def test_client_read_resource_with_task_creates_task(self):
"""Client with task=True creates a background task."""
server = FastMCP("test")
@server.resource("data://test", task=True)
async def task_resource() -> str:
return "hello"
async with Client(server, mode="legacy") as client:
from fastmcp.client.tasks import ResourceTask
task = await client.read_resource("data://test", task=True)
assert isinstance(task, ResourceTask)
# Wait for result
result = await task.result()
assert "hello" in str(result)
async def test_client_read_template_with_task_creates_task(self):
"""Client with task=True on template creates a background task."""
server = FastMCP("test")
@server.resource("item://{id}", task=True)
async def get_item(id: str) -> str:
return f"Item {id}"
async with Client(server, mode="legacy") as client:
from fastmcp.client.tasks import ResourceTask
task = await client.read_resource("item://42", task=True)
assert isinstance(task, ResourceTask)
# Wait for result
result = await task.result()
assert "Item 42" in str(result)
class TestResourceTaskMetaDirectServerCall:
"""Tests for direct server read_resource calls with task_meta."""
async def test_resource_can_read_another_resource_with_task(self):
"""A resource can read another resource as a background task."""
server = FastMCP("test")
@server.resource("data://inner", task=True)
async def inner_resource() -> str:
return "inner data"
@server.tool
async def outer_tool() -> str:
# Read inner resource as background task
result = await server.read_resource("data://inner", task_meta=TaskMeta())
# Should get CreateTaskResult since we provided task_meta
return f"Created task: {result.task.task_id}"
async with Client(server, mode="legacy") as client:
result = await client.call_tool("outer_tool", {})
assert "Created task:" in str(result)
async def test_resource_can_read_another_resource_synchronously(self):
"""A resource can read another resource synchronously (no task_meta)."""
server = FastMCP("test")
@server.resource("data://inner", task=True)
async def inner_resource() -> str:
return "inner data"
@server.tool
async def outer_tool() -> str:
# Read inner resource synchronously (no task_meta)
result = await server.read_resource("data://inner")
# Should get ResourceResult directly
return f"Got result: {result.contents[0].content}"
async with Client(server, mode="legacy") as client:
result = await client.call_tool("outer_tool", {})
assert "Got result: inner data" in str(result)
async def test_resource_can_read_template_with_task(self):
"""A tool can read a resource template as a background task."""
server = FastMCP("test")
@server.resource("item://{id}", task=True)
async def get_item(id: str) -> str:
return f"Item {id}"
@server.tool
async def outer_tool() -> str:
result = await server.read_resource("item://99", task_meta=TaskMeta())
return f"Created task: {result.task.task_id}"
async with Client(server, mode="legacy") as client:
result = await client.call_tool("outer_tool", {})
assert "Created task:" in str(result)
async def test_resource_can_read_with_custom_ttl(self):
"""A tool can read a resource as a background task with custom TTL."""
server = FastMCP("test")
@server.resource("data://inner", task=True)
async def inner_resource() -> str:
return "inner data"
@server.tool
async def outer_tool() -> str:
custom_ttl = 45000 # 45 seconds
result = await server.read_resource(
"data://inner", task_meta=TaskMeta(ttl=custom_ttl)
)
return f"Task TTL: {result.task.ttl}"
async with Client(server, mode="legacy") as client:
result = await client.call_tool("outer_tool", {})
assert "Task TTL: 45000" in str(result)
class TestResourceTaskMetaTypeNarrowing:
"""Tests for type narrowing based on task_meta parameter."""
async def test_read_resource_without_task_meta_type_is_resource_result(self):
"""Calling read_resource without task_meta returns ResourceResult type."""
server = FastMCP("test")
@server.resource("data://test")
async def simple_resource() -> str:
return "hello"
# This should type-check as ResourceResult, not the union type
result = await server.read_resource("data://test")
# No isinstance check needed - type is narrowed by overload
content = result.contents[0].content
assert content == "hello"
async def test_read_resource_with_task_meta_type_is_create_task_result(self):
"""Calling read_resource with task_meta returns CreateTaskResult type."""
server = FastMCP("test")
@server.resource("data://test", task=True)
async def task_resource() -> str:
return "hello"
async with Client(server, mode="legacy") as client:
# Need to use client to get full task infrastructure
from fastmcp.client.tasks import ResourceTask
task = await client.read_resource("data://test", task=True)
assert isinstance(task, ResourceTask)
# For direct server call, we need the Client context for Docket
# This test verifies the overload works via client integration
result = await task.result()
assert "hello" in str(result)

View file

@ -1,103 +0,0 @@
"""
Tests for SEP-1686 background task support for prompts.
Tests that prompts with task=True can execute in background.
"""
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 FastMCP server with task-enabled prompts."""
mcp = FastMCP("prompt-test-server")
@mcp.prompt()
async def simple_prompt(topic: str) -> str:
"""A simple prompt template."""
return f"Write about: {topic}"
@mcp.prompt(task=True)
async def background_prompt(topic: str, depth: str = "detailed") -> str:
"""A prompt that can execute in background."""
return f"Write a {depth} analysis of: {topic}"
return mcp
async def test_synchronous_prompt_unchanged(prompt_server):
"""Prompts without task metadata execute synchronously as before."""
async with Client(prompt_server, mode="legacy") as client:
# Regular call without task metadata
result = await client.get_prompt("simple_prompt", {"topic": "AI"})
# Should execute immediately and return result
assert "Write about: AI" in str(result)
async def test_prompt_with_task_metadata_returns_immediately(prompt_server):
"""Prompts with task metadata return immediately with PromptTask object."""
async with Client(prompt_server, mode="legacy") as client:
# Call with task metadata
task = await client.get_prompt("background_prompt", {"topic": "AI"}, task=True)
# Should return a PromptTask object immediately
assert isinstance(task, PromptTask)
assert isinstance(task.task_id, str)
assert len(task.task_id) > 0
@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_executes_in_background(prompt_server):
"""Prompt task executes via Docket in background."""
async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt(
"background_prompt",
{"topic": "Machine Learning", "depth": "comprehensive"},
task=True,
)
# Verify background execution
assert not task.returned_immediately
# Get the result
result = await task.result()
assert "comprehensive" in result.messages[0].content.text.lower()
@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_forbidden_mode_prompt_rejects_task_calls(prompt_server):
"""Prompts with task=False (mode=forbidden) reject task-augmented calls."""
from mcp.shared.exceptions import MCPError
from mcp_types import METHOD_NOT_FOUND
@prompt_server.prompt(task=False) # Explicitly disable task support
async def sync_only_prompt(topic: str) -> str:
return f"Sync prompt: {topic}"
async with Client(prompt_server, mode="legacy") as client:
# Calling with task=True when task=False should raise MCPError
import pytest
with pytest.raises(MCPError) as exc_info:
await client.get_prompt("sync_only_prompt", {"topic": "test"}, task=True)
# New behavior: mode="forbidden" returns METHOD_NOT_FOUND error
assert exc_info.value.error.code == METHOD_NOT_FOUND
assert (
"does not support task-augmented execution" in exc_info.value.error.message
)

View file

@ -1,125 +0,0 @@
"""
Tests for SEP-1686 background task support for resources.
Tests that resources with task=True can execute in background.
"""
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 FastMCP server with task-enabled resources."""
mcp = FastMCP("resource-test-server")
@mcp.resource("file://data.txt")
async def simple_resource() -> str:
"""A simple resource."""
return "Simple content"
@mcp.resource("file://large.txt", task=True)
async def background_resource() -> str:
"""A resource that can execute in background."""
return "Large file content that takes time to load"
@mcp.resource("file://user/{user_id}/data.json", task=True)
async def template_resource(user_id: str) -> str:
"""A resource template that can execute in background."""
return f'{{"userId": "{user_id}", "data": "value"}}'
return mcp
async def test_synchronous_resource_unchanged(resource_server):
"""Resources without task metadata execute synchronously as before."""
async with Client(resource_server, mode="legacy") as client:
# Regular call without task metadata
result = await client.read_resource("file://data.txt")
# Should execute immediately and return result
assert "Simple content" in str(result)
async def test_resource_with_task_metadata_returns_immediately(resource_server):
"""Resources with task metadata return immediately with ResourceTask object."""
async with Client(resource_server, mode="legacy") as client:
# Call with task metadata
task = await client.read_resource("file://large.txt", task=True)
# Should return a ResourceTask object immediately
assert isinstance(task, ResourceTask)
assert isinstance(task.task_id, str)
assert len(task.task_id) > 0
@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_resource_task_executes_in_background(resource_server):
"""Resource task executes via Docket in background."""
async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://large.txt", task=True)
# Verify background execution
assert not task.returned_immediately
# Get the result
result = await task.result()
assert len(result) > 0
assert result[0].text == "Large file content that takes time to load"
@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_resource_template_with_task(resource_server):
"""Resource templates with task=True execute in background."""
async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://user/123/data.json", task=True)
# Verify background execution
assert not task.returned_immediately
# Get the result
result = await task.result()
assert '"userId": "123"' in result[0].text
@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_forbidden_mode_resource_rejects_task_calls(resource_server):
"""Resources with task=False (mode=forbidden) reject task-augmented calls."""
import pytest
from mcp.shared.exceptions import MCPError
from mcp_types import METHOD_NOT_FOUND
@resource_server.resource(
"file://sync.txt/", task=False
) # Explicitly disable task support
async def sync_only_resource() -> str:
return "Sync content"
async with Client(resource_server, mode="legacy") as client:
# Calling with task=True when task=False should raise MCPError
with pytest.raises(MCPError) as exc_info:
await client.read_resource("file://sync.txt", task=True)
# New behavior: mode="forbidden" returns METHOD_NOT_FOUND error
assert exc_info.value.error.code == METHOD_NOT_FOUND
assert (
"does not support task-augmented execution" in exc_info.value.error.message
)

View file

@ -9,7 +9,6 @@ from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.dependencies import CurrentContext, Depends, Shared
from fastmcp.server.context import Context
from fastmcp.server.dependencies import is_docket_available
from tests.conftest import make_server_request_context
HUZZAH = "huzzah!"
@ -786,9 +785,6 @@ class TestDependencyInjection:
monkeypatch.setattr(importlib.metadata, "version", fake_version)
assert dependencies.is_docket_available() is False
# The wrapper that actually failed in #3803 must now return None
# instead of raising ImportError on the inner import.
assert dependencies.get_task_context() is None
def test_is_docket_available_false_when_pydocket_not_installed(self, monkeypatch):
"""``is_docket_available()`` returns False when pydocket is absent."""
@ -835,7 +831,7 @@ class TestDependencyInjection:
def test_require_docket_passes_when_installed(self):
"""Test require_docket doesn't raise when docket is installed."""
from fastmcp.server.dependencies import require_docket
from fastmcp_tasks.dependencies import require_docket
require_docket("test feature")
@ -849,6 +845,8 @@ class TestDependencyInjection:
"""
import importlib.metadata
from fastmcp_tasks.dependencies import require_docket
from fastmcp.server import dependencies
original_version = importlib.metadata.version
@ -862,7 +860,7 @@ class TestDependencyInjection:
monkeypatch.setattr(importlib.metadata, "version", fake_version)
with pytest.raises(ImportError, match="pydocket 0.16.6 is installed"):
dependencies.require_docket("CurrentDocket()")
require_docket("CurrentDocket()")
def test_dependency_class_exists(self):
"""Test Dependency and Depends are importable from fastmcp."""
@ -1195,10 +1193,7 @@ class TestSharedDependencies:
)
assert call_count == 1
@pytest.mark.skipif(
not is_docket_available(),
reason="requires pydocket for the Docket/Worker lifespan path",
)
@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)")
async def test_shared_resolves_on_task_capable_server(self):
"""Shared() dependencies resolve on a normal request even when the server
has task-enabled components.

View file

@ -1158,6 +1158,7 @@ class TestTaskExecution:
background task has no such request, so returning a guard result from a task
is rejected with a clear error rather than silently yielding empty content."""
@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)")
async def test_guard_result_from_task_is_rejected(self):
mcp = FastMCP("guard-task")

View file

@ -27,11 +27,6 @@ from mcp.client import Client as SDKClient
from mcp.client.session import ClientRequestContext
from mcp.server import Server as LowLevelServer
from mcp.shared.exceptions import MCPError
from mcp_types import methods
from mcp_types.version import (
HANDSHAKE_PROTOCOL_VERSIONS,
MODERN_PROTOCOL_VERSIONS,
)
from pydantic import FileUrl
from fastmcp import Client as FastMCPClient
@ -543,117 +538,6 @@ async def test_logging_notification_still_flows_on_modern(push_server, mode):
assert _texts(result.content) == ["logged"]
# ---------------------------------------------------------------------------
# 4. Tasks: submission + tasks/get across the eras the _sdk_patches shim covers
# ---------------------------------------------------------------------------
@pytest.fixture
def task_server() -> FastMCP:
mcp = FastMCP("tasks")
@mcp.tool(task=True)
async def slow_add(a: int, b: int) -> int:
return a + b
return mcp
async def test_task_submission_and_get_on_legacy_latest(task_server):
"""Legacy-latest (2025-11-25): a task-augmented tools/call returns a
CreateTaskResult and tasks/get resolves it. This exercises the
_sdk_patches registry-widening shim at the 2025-11-25 tools/call surface.
Driven with the FastMCP client because the v2 SDK client's call_tool has no
`task=` parameter (verified: mcp.client.session.ClientSession.call_tool
exposes no task metadata arg) see item below.
"""
async with FastMCPClient(task_server, mode="legacy") as client:
assert client.initialize_result is not None
assert client.initialize_result.protocol_version == "2025-11-25"
task = await client.call_tool("slow_add", {"a": 2, "b": 3}, task=True)
assert task.task_id
assert not task.returned_immediately
await task.wait(timeout=3.0)
result = await task.result()
assert result.data == 5
@pytest.mark.xfail(
strict=True,
reason=(
"The v2 SDK high-level client (mcp.client.Client) and ClientSession "
"expose no `task=` parameter on call_tool, so a task-augmented "
"tools/call cannot be submitted through it at any era; a hand-built "
"raw CallToolRequest does not drive FastMCP's task path either. On "
"2026-07-28 tasks moved to the io.modelcontextprotocol/tasks extension "
"and CreateTaskResult is not part of the tools/call union, so the "
"_sdk_patches shim intentionally does not widen the modern row "
"(sdk-feedback.md #1). Remove once the SDK client supports task "
"submission."
),
)
async def test_task_submission_on_modern(task_server):
async with SDKClient(_server(task_server), mode="2026-07-28") as client:
params = types.CallToolRequestParams(
name="slow_add",
arguments={"a": 1, "b": 2},
task=types.TaskMetadata(ttl=60000),
)
result = await client.session.send_request(
types.CallToolRequest(params=params), types.CreateTaskResult
)
assert isinstance(result, types.CreateTaskResult)
# ---------------------------------------------------------------------------
# 4b. _sdk_patches registry gating: the SEP-1686 task shim widens ONLY the
# handshake-era rows and leaves the 2026-07-28 (extension-era) rows untouched.
# ---------------------------------------------------------------------------
def test_task_shim_widens_handshake_tools_call_rows():
"""Every handshake-era tools/call row gains a CreateTaskResult arm."""
from fastmcp._sdk_patches import get_union_arms
for version in HANDSHAKE_PROTOCOL_VERSIONS:
row = methods.SERVER_RESULTS[("tools/call", version)]
assert types.CreateTaskResult in get_union_arms(row), version
def test_task_shim_does_not_touch_modern_tools_call_row():
"""The 2026-07-28 tools/call row stays the unpatched MRTR union: tasks are
the io.modelcontextprotocol/tasks extension there, so CreateTaskResult must
not be injected."""
from fastmcp._sdk_patches import get_union_arms
row = methods.SERVER_RESULTS[("tools/call", "2026-07-28")]
arms = get_union_arms(row)
assert types.CreateTaskResult not in arms
# Unchanged from the SDK default: the 2026 mutually-recursive tool result
# (CallToolResult | InputRequiredResult), keyed by the version-specific types.
arm_names = {arm.__name__ for arm in arms}
assert arm_names == {"CallToolResult", "InputRequiredResult"}
@pytest.mark.parametrize(
"task_method",
["tasks/get", "tasks/result", "tasks/list", "tasks/cancel"],
)
def test_task_shim_registers_tasks_rows_only_for_handshake_eras(task_method):
"""tasks/* result rows exist for handshake-era versions and are absent for
the modern (extension) era."""
for version in HANDSHAKE_PROTOCOL_VERSIONS:
assert (task_method, version) in methods.SERVER_RESULTS, (task_method, version)
for version in MODERN_PROTOCOL_VERSIONS:
assert (task_method, version) not in methods.SERVER_RESULTS, (
task_method,
version,
)
# ---------------------------------------------------------------------------
# 5. Sessionless safety: session-id-keyed paths must not crash on 2026 in-memory
# ---------------------------------------------------------------------------

View file

@ -3,14 +3,19 @@
import asyncio
from contextlib import asynccontextmanager
import pytest
from docket import Docket
from docket.worker import Worker
from fastmcp_tasks.dependencies import CurrentDocket, CurrentWorker
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.dependencies import CurrentDocket, CurrentWorker
from fastmcp.server.dependencies import get_context
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)
HUZZAH = "huzzah!"

View file

@ -1,5 +1,6 @@
from typing import Any
import pytest
from mcp_types import Tool as MCPTool
from mcp_types import ToolAnnotations, ToolExecution
@ -220,6 +221,7 @@ async def test_tool_functionality_with_annotations():
assert result.data == {"name": "test_item", "value": 42}
@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)")
async def test_task_execution_auto_populated_for_task_enabled_tool():
"""Test that execution.task_support is automatically set when tool has task=True."""
mcp = FastMCP("Test Server")

0
tests/tasks/__init__.py Normal file
View file

View file

@ -0,0 +1 @@
"""Tests for MCP SEP-1686 background task client."""

View file

@ -0,0 +1,25 @@
"""Configuration for client task tests."""
import secrets
from pathlib import Path
import pytest
from fastmcp.utilities.tests import temporary_settings
@pytest.fixture(autouse=True)
def isolate_settings_home(_settings_home_root: Path):
"""Task-local override of the repo-wide ``isolate_settings_home`` fixture.
Docket configuration moved out of core ``Settings`` into
``fastmcp_tasks.settings.DocketSettings``, so the repo-wide fixture's
``docket__*`` kwargs no longer resolve against core settings. This
override keeps the per-test settings-home isolation while dropping the
removed docket kwargs.
"""
test_home = _settings_home_root / secrets.token_hex(8)
test_home.mkdir()
with temporary_settings(home=test_home, client_disconnect_timeout=1):
yield

View file

@ -16,6 +16,10 @@ from mcp_types import GetTaskResult
from fastmcp import FastMCP
from fastmcp.client import Client
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)
async def _wait_until(condition: Callable[[], bool], timeout: float = 5.0) -> None:
"""Poll until condition() is true or timeout elapses.

View file

@ -6,9 +6,15 @@ Generic protocol tests that use tools as test fixtures.
import asyncio
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)
async def test_end_to_end_task_flow():
"""Complete end-to-end flow: submit, poll, retrieve."""

View file

@ -6,12 +6,16 @@ test_client_prompt_tasks.py and test_client_resource_tasks.py.
"""
import pytest
from fastmcp_tasks.client import ToolTask
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.tasks import ToolTask
from fastmcp.exceptions import ToolError
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)
@pytest.fixture
async def tool_task_server():

View file

@ -5,14 +5,18 @@ unadvertised one falls back to an exponential ramp up to the client setting.
"""
import pytest
from fastmcp_tasks.client import MIN_POLL_INTERVAL, ToolTask
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
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)
@pytest.mark.parametrize("value", [0, -0.5, -1])
def test_non_positive_poll_interval_setting_is_rejected(value: float):

View file

@ -10,6 +10,10 @@ import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)
@pytest.fixture
async def task_server():

View file

@ -10,6 +10,10 @@ import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)
async def test_tool_task_result_cached_on_first_call():
"""First call caches result, subsequent calls return cached value."""

View file

@ -0,0 +1,25 @@
"""Configuration for server task tests."""
import secrets
from pathlib import Path
import pytest
from fastmcp.utilities.tests import temporary_settings
@pytest.fixture(autouse=True)
def isolate_settings_home(_settings_home_root: Path):
"""Task-local override of the repo-wide ``isolate_settings_home`` fixture.
Docket configuration moved out of core ``Settings`` into
``fastmcp_tasks.settings.DocketSettings``, so the repo-wide fixture's
``docket__*`` kwargs no longer resolve against core settings. This
override keeps the per-test settings-home isolation while dropping the
removed docket kwargs.
"""
test_home = _settings_home_root / secrets.token_hex(8)
test_home.mkdir()
with temporary_settings(home=test_home, client_disconnect_timeout=1):
yield

View file

@ -8,15 +8,21 @@ Regression tests for:
import asyncio
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.dependencies import Progress
from fastmcp.server.context import Context
from fastmcp.server.dependencies import (
Progress,
get_access_token,
get_http_headers,
)
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)
async def test_concurrent_foreground_tools_with_context():
"""Multiple concurrent tool calls sharing the same CurrentContext() default

View file

@ -14,6 +14,20 @@ from typing import Any, cast
from unittest.mock import AsyncMock, patch
import pytest
from fastmcp_tasks._legacy_wire.elicitation import handle_task_input
from fastmcp_tasks.context import (
TaskContextInfo,
TaskContextSnapshot,
_remember_snapshot,
_task_sessions,
get_task_scope,
get_task_session,
register_task_session,
)
from fastmcp_tasks.dependencies import CurrentDocket
from fastmcp_tasks.keys import (
task_redis_prefix,
)
from mcp import ServerSession
from mcp.server.auth.middleware.auth_context import auth_context_var
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
@ -29,7 +43,6 @@ from pydantic import BaseModel
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.elicitation import ElicitResult
from fastmcp.dependencies import CurrentDocket
from fastmcp.server.auth import AccessToken
from fastmcp.server.context import Context
from fastmcp.server.dependencies import get_access_token
@ -38,23 +51,13 @@ from fastmcp.server.elicitation import (
CancelledElicitation,
DeclinedElicitation,
)
from fastmcp.server.tasks.context import (
TaskContextInfo,
TaskContextSnapshot,
_remember_snapshot,
_task_sessions,
get_task_scope,
get_task_session,
register_task_session,
)
from fastmcp.server.tasks.elicitation import handle_task_input
from fastmcp.server.tasks.keys import (
task_redis_prefix,
)
# =============================================================================
# Unit tests: Context API surface (no Redis/Docket needed)
# =============================================================================
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)
class TestContextBackgroundTaskSupport:

View file

@ -12,9 +12,13 @@ import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.tasks import TaskConfig
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.tasks import TaskConfig
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)
class CustomTool(Tool):

View file

@ -9,14 +9,19 @@ import asyncio
import time
import mcp_types
import pytest
from fastmcp_tasks._legacy_wire.notifications import (
get_subscriber_count,
)
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.elicitation import ElicitResult
from fastmcp.server.context import Context
from fastmcp.server.elicitation import AcceptedElicitation
from fastmcp.server.tasks.notifications import (
get_subscriber_count,
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)

View file

@ -1,8 +1,14 @@
"""Tests for FastMCP Progress dependency."""
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.dependencies import Progress
from fastmcp.server.dependencies import Progress
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)
async def test_progress_in_immediate_execution():

View file

@ -11,6 +11,10 @@ import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)
@pytest.mark.timeout(10)
@pytest.mark.xfail(

View file

@ -12,6 +12,13 @@ from __future__ import annotations
from unittest.mock import patch
import pytest
from fastmcp_tasks.context import (
TaskContextSnapshot,
_recall_snapshot,
get_task_context,
restore_task_snapshot,
)
from mcp.server.auth.middleware.auth_context import auth_context_var
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
@ -19,11 +26,9 @@ from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.auth import AccessToken
from fastmcp.server.dependencies import get_access_token
from fastmcp.server.tasks.context import (
TaskContextSnapshot,
_recall_snapshot,
get_task_context,
restore_task_snapshot,
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)

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