mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
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:
parent
242850c0f3
commit
6fce4e538f
119 changed files with 1191 additions and 3331 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 model→claim 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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
):
|
||||
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
|
||||
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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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() | {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: ...
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
14
fastmcp_tasks/fastmcp_tasks/_legacy_wire/__init__.py
Normal file
14
fastmcp_tasks/fastmcp_tasks/_legacy_wire/__init__.py
Normal 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.
|
||||
"""
|
||||
|
|
@ -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
|
||||
|
|
@ -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__)
|
||||
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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),
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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):
|
||||
169
fastmcp_tasks/fastmcp_tasks/components.py
Normal file
169
fastmcp_tasks/fastmcp_tasks/components.py
Normal 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
|
||||
|
|
@ -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
|
||||
184
fastmcp_tasks/fastmcp_tasks/dependencies.py
Normal file
184
fastmcp_tasks/fastmcp_tasks/dependencies.py
Normal 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())
|
||||
126
fastmcp_tasks/fastmcp_tasks/lifespan.py
Normal file
126
fastmcp_tasks/fastmcp_tasks/lifespan.py
Normal 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)
|
||||
122
fastmcp_tasks/fastmcp_tasks/settings.py
Normal file
122
fastmcp_tasks/fastmcp_tasks/settings.py
Normal 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()
|
||||
|
|
@ -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:
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
"""Configuration for client task tests."""
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
"""Configuration for server task tests."""
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
0
tests/tasks/__init__.py
Normal file
1
tests/tasks/client/__init__.py
Normal file
1
tests/tasks/client/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Tests for MCP SEP-1686 background task client."""
|
||||
25
tests/tasks/client/conftest.py
Normal file
25
tests/tasks/client/conftest.py
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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."""
|
||||
|
|
@ -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():
|
||||
|
|
@ -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):
|
||||
|
|
@ -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():
|
||||
|
|
@ -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."""
|
||||
25
tests/tasks/server/conftest.py
Normal file
25
tests/tasks/server/conftest.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
@ -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):
|
||||
|
|
@ -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)"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -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():
|
||||
|
|
@ -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(
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue