diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index e38850ce0..32487890c 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -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(): diff --git a/docs/more/settings.mdx b/docs/more/settings.mdx index 92553c3f7..e602f0c0d 100644 --- a/docs/more/settings.mdx +++ b/docs/more/settings.mdx @@ -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. - - -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. - - -| 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 diff --git a/docs/servers/dependency-injection.mdx b/docs/servers/dependency-injection.mdx index 213fd81b7..555cfcb07 100644 --- a/docs/servers/dependency-injection.mdx +++ b/docs/servers/dependency-injection.mdx @@ -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") diff --git a/docs/servers/tasks.mdx b/docs/servers/tasks.mdx index e04d2495f..bd167c2fc 100644 --- a/docs/servers/tasks.mdx +++ b/docs/servers/tasks.mdx @@ -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") diff --git a/fastmcp_slim/fastmcp/__init__.py b/fastmcp_slim/fastmcp/__init__.py index 0e1c33bf0..170ffb0f4 100644 --- a/fastmcp_slim/fastmcp/__init__.py +++ b/fastmcp_slim/fastmcp/__init__.py @@ -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 diff --git a/fastmcp_slim/fastmcp/_sdk_patches.py b/fastmcp_slim/fastmcp/_sdk_patches.py deleted file mode 100644 index a77765cfc..000000000 --- a/fastmcp_slim/fastmcp/_sdk_patches.py +++ /dev/null @@ -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,) diff --git a/fastmcp_slim/fastmcp/cli/cli.py b/fastmcp_slim/fastmcp/cli/cli.py index fe7872a57..5513e3119 100644 --- a/fastmcp_slim/fastmcp/cli/cli.py +++ b/fastmcp_slim/fastmcp/cli/cli.py @@ -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") diff --git a/fastmcp_slim/fastmcp/client/client.py b/fastmcp_slim/fastmcp/client/client.py index c62429f29..74cb76055 100644 --- a/fastmcp_slim/fastmcp/client/client.py +++ b/fastmcp_slim/fastmcp/client/client.py @@ -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() diff --git a/fastmcp_slim/fastmcp/client/mixins/__init__.py b/fastmcp_slim/fastmcp/client/mixins/__init__.py index 323e20991..f0c8ff85e 100644 --- a/fastmcp_slim/fastmcp/client/mixins/__init__.py +++ b/fastmcp_slim/fastmcp/client/mixins/__init__.py @@ -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", ] diff --git a/fastmcp_slim/fastmcp/client/mixins/prompts.py b/fastmcp_slim/fastmcp/client/mixins/prompts.py index fba8ed3fe..df38292d0 100644 --- a/fastmcp_slim/fastmcp/client/mixins/prompts.py +++ b/fastmcp_slim/fastmcp/client/mixins/prompts.py @@ -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 - ) diff --git a/fastmcp_slim/fastmcp/client/mixins/resources.py b/fastmcp_slim/fastmcp/client/mixins/resources.py index 7bbbd84ed..c480b0687 100644 --- a/fastmcp_slim/fastmcp/client/mixins/resources.py +++ b/fastmcp_slim/fastmcp/client/mixins/resources.py @@ -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, - ) diff --git a/fastmcp_slim/fastmcp/client/mixins/tools.py b/fastmcp_slim/fastmcp/client/mixins/tools.py index d52e90cb9..40db1f21c 100644 --- a/fastmcp_slim/fastmcp/client/mixins/tools.py +++ b/fastmcp_slim/fastmcp/client/mixins/tools.py @@ -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, diff --git a/fastmcp_slim/fastmcp/decorators.py b/fastmcp_slim/fastmcp/decorators.py index 75dff25ac..b61a90c62 100644 --- a/fastmcp_slim/fastmcp/decorators.py +++ b/fastmcp_slim/fastmcp/decorators.py @@ -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 diff --git a/fastmcp_slim/fastmcp/dependencies.py b/fastmcp_slim/fastmcp/dependencies.py index 2aa8c145a..138486f88 100644 --- a/fastmcp_slim/fastmcp/dependencies.py +++ b/fastmcp_slim/fastmcp/dependencies.py @@ -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}") diff --git a/fastmcp_slim/fastmcp/prompts/base.py b/fastmcp_slim/fastmcp/prompts/base.py index f56243e57..d23ab0e0f 100644 --- a/fastmcp_slim/fastmcp/prompts/base.py +++ b/fastmcp_slim/fastmcp/prompts/base.py @@ -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", diff --git a/fastmcp_slim/fastmcp/prompts/function_prompt.py b/fastmcp_slim/fastmcp/prompts/function_prompt.py index 959bebd06..9685630b1 100644 --- a/fastmcp_slim/fastmcp/prompts/function_prompt.py +++ b/fastmcp_slim/fastmcp/prompts/function_prompt.py @@ -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 diff --git a/fastmcp_slim/fastmcp/resources/base.py b/fastmcp_slim/fastmcp/resources/base.py index abb835bc9..7210a8e63 100644 --- a/fastmcp_slim/fastmcp/resources/base.py +++ b/fastmcp_slim/fastmcp/resources/base.py @@ -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", diff --git a/fastmcp_slim/fastmcp/resources/function_resource.py b/fastmcp_slim/fastmcp/resources/function_resource.py index aa71508d9..6d7612939 100644 --- a/fastmcp_slim/fastmcp/resources/function_resource.py +++ b/fastmcp_slim/fastmcp/resources/function_resource.py @@ -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, ) diff --git a/fastmcp_slim/fastmcp/resources/template.py b/fastmcp_slim/fastmcp/resources/template.py index 2a6622b40..866eb940a 100644 --- a/fastmcp_slim/fastmcp/resources/template.py +++ b/fastmcp_slim/fastmcp/resources/template.py @@ -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, ) diff --git a/fastmcp_slim/fastmcp/server/context.py b/fastmcp_slim/fastmcp/server/context.py index ea4d2b228..594b59c00 100644 --- a/fastmcp_slim/fastmcp/server/context.py +++ b/fastmcp_slim/fastmcp/server/context.py @@ -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: diff --git a/fastmcp_slim/fastmcp/server/dependencies.py b/fastmcp_slim/fastmcp/server/dependencies.py index b39f36e20..f0e260cd1 100644 --- a/fastmcp_slim/fastmcp/server/dependencies.py +++ b/fastmcp_slim/fastmcp/server/dependencies.py @@ -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.""" diff --git a/fastmcp_slim/fastmcp/server/low_level.py b/fastmcp_slim/fastmcp/server/low_level.py index 1a9362649..851bd74a7 100644 --- a/fastmcp_slim/fastmcp/server/low_level.py +++ b/fastmcp_slim/fastmcp/server/low_level.py @@ -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: {}, diff --git a/fastmcp_slim/fastmcp/server/mixins/lifespan.py b/fastmcp_slim/fastmcp/server/mixins/lifespan.py index 5ea62a1fb..f460e1872 100644 --- a/fastmcp_slim/fastmcp/server/mixins/lifespan.py +++ b/fastmcp_slim/fastmcp/server/mixins/lifespan.py @@ -1,18 +1,16 @@ -"""Lifespan and Docket task infrastructure for FastMCP Server.""" +"""Lifespan infrastructure for FastMCP Server.""" from __future__ import annotations -import asyncio import weakref from collections.abc import AsyncIterator -from contextlib import AsyncExitStack, asynccontextmanager, suppress +from contextlib import AsyncExitStack, asynccontextmanager from contextvars import ContextVar -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import anyio from uncalled_for import SharedContext -import fastmcp from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: @@ -25,169 +23,64 @@ logger = get_logger(__name__) # Set True by `FastMCPProvider.lifespan` immediately before it enters the # wrapped (mounted) server's `_lifespan_manager`, and reset on exit. The -# mounted server's `_docket_lifespan` reads this and becomes a no-op so that -# Docket / Worker / SharedContext are not re-initialized — there's one set -# per runtime tree, owned by the root. +# mounted server's `_shared_context_lifespan` reads this and becomes a no-op so +# that SharedContext and the server ContextVar are not re-initialized — there's +# one set per runtime tree, owned by the root. Extension lifespans (e.g. the +# tasks extension's Docket/Worker) defer to the root the same way. # # Independent servers entered as siblings (e.g. via `AsyncExitStack` in the # same async context) are NOT in a parent/child relationship; the flag is not -# set in that case, so each independently establishes its own Docket and -# server context. +# set in that case, so each independently establishes its own server context. _lifespan_root_active: ContextVar[bool] = ContextVar( "fastmcp_lifespan_root_active", default=False ) class LifespanMixin: - """Mixin providing lifespan and Docket task infrastructure for FastMCP.""" + """Mixin providing lifespan infrastructure for FastMCP.""" @property def docket(self: FastMCP) -> Docket | None: - """The Docket instance owned by this server. + """The Docket instance owned by this server, if the tasks extension is active. - Returns the Docket that this server initialized as the root of a - runtime tree. Mounted children do not own their own Docket — they - share the root's via ``_current_docket`` ContextVar inheritance — - so accessing ``.docket`` on a mounted child returns None even while - its tasks run on the root's Docket. For "the Docket in scope right - now," prefer reading ``_current_docket`` directly or use the - ``CurrentDocket`` dependency injection. + Returns the Docket that the tasks extension initialized as the root of a + runtime tree, or None when no task backend is running. Mounted children do + not own their own Docket — they share the root's via ``_current_docket`` + ContextVar inheritance — so accessing ``.docket`` on a mounted child + returns None even while its tasks run on the root's Docket. """ return self._docket @asynccontextmanager - async def _docket_lifespan(self: FastMCP) -> AsyncIterator[None]: - """Manage Docket instance and Worker for background task execution. + async def _shared_context_lifespan(self: FastMCP) -> AsyncIterator[None]: + """Set up the process-level ``SharedContext`` and server ContextVar. - Docket is process-level, not server-level: only the first server in a - runtime tree starts Docket and the Worker. Mounted children entered - via ``FastMCPProvider.lifespan`` see ``_lifespan_root_active=True`` - (set by the provider before delegating to ``_lifespan_manager``) and - become no-ops, sharing the root's Docket via ``_current_docket``. + ``SharedContext`` backs app-scoped ``Shared()`` dependencies and is + process-level, not server-level: only the first server in a runtime tree + establishes it. Mounted children entered via ``FastMCPProvider.lifespan`` + see ``_lifespan_root_active=True`` (set by the provider before delegating + to ``_lifespan_manager``) and become no-ops, sharing the root's context + via ContextVars. Independent servers entered as siblings — for example two unrelated - ``FastMCP`` instances each entered through ``AsyncExitStack`` in the - same async context — are not in a parent/child relationship; no - provider has set the flag for them, so each runs the full root setup. - - Docket infrastructure is only initialized at the root if: - 1. pydocket is installed (fastmcp[tasks] extra) - 2. There are task-enabled components (task_config.mode != 'forbidden') - - Users with pydocket installed but no task-enabled components won't spin - up Docket / Worker infrastructure even at the root. + ``FastMCP`` instances each entered through ``AsyncExitStack`` in the same + async context — are not in a parent/child relationship; no provider has + set the flag for them, so each runs the full root setup. """ - # Nested entry: a parent in this runtime tree already owns Docket and - # SharedContext (the FastMCPProvider that mounted us set the flag). - # Stay out of their way and inherit via ContextVars. if _lifespan_root_active.get(): yield return - async with self._docket_lifespan_root(): - yield - - @asynccontextmanager - async def _docket_lifespan_root(self: FastMCP) -> AsyncIterator[None]: - """Root-only Docket lifecycle. See _docket_lifespan for the dispatch.""" - from fastmcp.server.dependencies import _current_server, is_docket_available + from fastmcp.server.dependencies import _current_server # Set FastMCP server in ContextVar so CurrentFastMCP can access it # (use weakref to avoid reference cycles) server_token = _current_server.set(weakref.ref(self)) - try: - # If docket is not available, skip task infrastructure but still - # set up SharedContext so Shared() dependencies work. - if not is_docket_available(): - async with SharedContext(): - self._capture_shared_context() - yield - return - - # Collect task-enabled components at startup with all transforms applied. - # Components must be available now to be registered with Docket workers; - # dynamically added components after startup won't be registered. - try: - task_components = list(await self.get_tasks()) - except Exception as e: - logger.warning(f"Failed to get tasks: {e}") - if fastmcp.settings.mounted_components_raise_on_load_error: - raise - task_components = [] - - # If no task-enabled components, skip Docket infrastructure but still - # set up SharedContext so Shared() dependencies work. - if not task_components: - async with SharedContext(): - self._capture_shared_context() - yield - return - - # Docket is available AND there are task-enabled components - from docket import Depends, Docket, Worker - - from fastmcp import settings - from fastmcp.server.dependencies import ( - _current_docket, - _current_worker, - ) - from fastmcp.server.tasks.context import restore_task_snapshot - - # Create Docket instance using configured name and URL - async with ( - SharedContext(), - Docket( - name=settings.docket.name, - url=settings.docket.url, - ) as docket, - ): + async with SharedContext(): self._capture_shared_context() - self._docket = docket - - # Register task-enabled components with Docket - for component in task_components: - component.register_with_docket(docket) - - docket_token = _current_docket.set(docket) - try: - # Build worker kwargs from settings - worker_kwargs: dict[str, Any] = { - "concurrency": settings.docket.concurrency, - "redelivery_timeout": settings.docket.redelivery_timeout, - "reconnection_delay": settings.docket.reconnection_delay, - "minimum_check_interval": settings.docket.minimum_check_interval, - } - if settings.docket.worker_name: - worker_kwargs["name"] = settings.docket.worker_name - - # Create and start Worker. The restore_task_snapshot - # worker-level dependency runs before every task so the - # per-task snapshot ContextVar is populated before user - # code or task-scoped dependencies observe it. - async with Worker( - docket, - dependencies=[Depends(restore_task_snapshot)], - **worker_kwargs, - ) as worker: - self._worker = worker - worker_token = _current_worker.set(worker) - try: - worker_task = asyncio.create_task(worker.run_forever()) - try: - yield - finally: - worker_task.cancel() - with suppress(asyncio.CancelledError): - await worker_task - finally: - _current_worker.reset(worker_token) - self._worker = None - finally: - _current_docket.reset(docket_token) - self._docket = None + yield finally: - # Reset server ContextVar _current_server.reset(server_token) @asynccontextmanager @@ -196,11 +89,10 @@ class LifespanMixin: Extension lifespans are entered once per runtime tree, at the root. A mounted child sees ``_lifespan_root_active`` set by its - ``FastMCPProvider`` and defers to the root, exactly as - ``_docket_lifespan`` does for the shared Docket: an extension whose - lifespan starts shared infrastructure (a task-queue backend and worker, - say) is therefore owned by the tree root, and mounted children reach it - through the same context rather than starting a second copy. + ``FastMCPProvider`` and defers to the root: an extension whose lifespan + starts shared infrastructure (a task-queue backend and worker, say) is + therefore owned by the tree root, and mounted children reach it through + the same context rather than starting a second copy. Extensions are entered in registration order; the ``AsyncExitStack`` exits them in reverse on teardown. @@ -214,6 +106,39 @@ class LifespanMixin: await stack.enter_async_context(extension.lifespan()) yield + async def _validate_task_extension_registered(self: FastMCP) -> None: + """Fail loudly if a task-enabled tool has no tasks extension registered. + + `task=True` on a tool is only an intent declaration; the engine that runs + it lives in the `fastmcp-tasks` package and is installed by registering a + `ServerExtension` whose identifier is `TASKS_EXTENSION_ID` + (`mcp.add_extension(...)`). A task-configured tool serving without that + extension would silently never run as a task — a correctness bug — so we + raise at serve time instead. + """ + from fastmcp.utilities.tasks import TASKS_EXTENSION_ID + + if TASKS_EXTENSION_ID in self._extensions: + return + + candidates = list(await self.get_tasks()) + + # ``get_tasks()`` applies server-level transforms, which can inject + # non-task tools (e.g. ResourcesAsTools' synthetic list/read tools) into + # the result, so re-filter by the actual task config here — mirroring the + # guard the old per-component docket registration applied. + task_components = [c for c in candidates if c.task_config.supports_tasks()] + if not task_components: + return + + names = ", ".join(sorted(c.name for c in task_components)) + raise RuntimeError( + f"Task-enabled tools ({names}) require the tasks extension, but no " + f"extension with identifier {TASKS_EXTENSION_ID!r} is registered. " + "Install it with `pip install 'fastmcp[tasks]'` and register it via " + "`mcp.add_extension(TasksExtension(...))`." + ) + def _capture_shared_context(self: FastMCP) -> None: """Snapshot the live ``SharedContext`` ContextVar values. @@ -261,7 +186,7 @@ class LifespanMixin: stack = AsyncExitStack() try: user_lifespan_result = await stack.enter_async_context(self._lifespan(self)) - await stack.enter_async_context(self._docket_lifespan()) + await stack.enter_async_context(self._shared_context_lifespan()) await stack.enter_async_context(self._extensions_lifespan()) self._lifespan_result = user_lifespan_result @@ -271,6 +196,8 @@ class LifespanMixin: for provider in self.providers: await stack.enter_async_context(provider.lifespan()) + await self._validate_task_extension_registered() + self._started.set() try: yield @@ -286,74 +213,3 @@ class LifespanMixin: if self._lifespan_ref_count == 0: self._lifespan_result_set = False self._lifespan_result = None - - def _setup_task_protocol_handlers(self: FastMCP) -> None: - """Register SEP-1686 task protocol handlers with SDK. - - Only registers handlers if docket is installed. Without docket, - task protocol requests will return "method not found" errors. - """ - from fastmcp.server.dependencies import is_docket_available - - if not is_docket_available(): - return - - from mcp.server.context import ServerRequestContext - from mcp_types import ( - CancelTaskRequestParams, - GetTaskPayloadRequestParams, - GetTaskRequestParams, - PaginatedRequestParams, - ) - - from fastmcp.server.dependencies import bind_request_context - from fastmcp.server.tasks.requests import ( - tasks_cancel_handler, - tasks_get_handler, - tasks_list_handler, - tasks_result_handler, - ) - - # v2 handlers take (ctx, params) and return the bare result model. - - async def handle_get_task( - ctx: ServerRequestContext, params: GetTaskRequestParams - ) -> Any: - with bind_request_context(ctx): - p = params.model_dump(by_alias=True, exclude_none=True) - return await tasks_get_handler(self, p) - - async def handle_get_task_result( - ctx: ServerRequestContext, params: GetTaskPayloadRequestParams - ) -> Any: - with bind_request_context(ctx): - p = params.model_dump(by_alias=True, exclude_none=True) - return await tasks_result_handler(self, p) - - async def handle_list_tasks( - ctx: ServerRequestContext, params: PaginatedRequestParams | None - ) -> Any: - with bind_request_context(ctx): - p = ( - params.model_dump(by_alias=True, exclude_none=True) - if params - else {} - ) - return await tasks_list_handler(self, p) - - async def handle_cancel_task( - ctx: ServerRequestContext, params: CancelTaskRequestParams - ) -> Any: - with bind_request_context(ctx): - p = params.model_dump(by_alias=True, exclude_none=True) - return await tasks_cancel_handler(self, p) - - s = self._mcp_server - s.add_request_handler("tasks/get", GetTaskRequestParams, handle_get_task) - s.add_request_handler( - "tasks/result", GetTaskPayloadRequestParams, handle_get_task_result - ) - s.add_request_handler("tasks/list", PaginatedRequestParams, handle_list_tasks) - s.add_request_handler( - "tasks/cancel", CancelTaskRequestParams, handle_cancel_task - ) diff --git a/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py b/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py index f0d245d50..3bc8e96ba 100644 --- a/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py +++ b/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py @@ -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( diff --git a/fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py b/fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py index 0f05c798e..7cb9d9213 100644 --- a/fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py +++ b/fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py @@ -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() | { diff --git a/fastmcp_slim/fastmcp/server/providers/filesystem_discovery.py b/fastmcp_slim/fastmcp/server/providers/filesystem_discovery.py index 0c1340ef5..56c55109a 100644 --- a/fastmcp_slim/fastmcp/server/providers/filesystem_discovery.py +++ b/fastmcp_slim/fastmcp/server/providers/filesystem_discovery.py @@ -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) diff --git a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/prompts.py b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/prompts.py index ba1621875..53e4a7080 100644 --- a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/prompts.py +++ b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/prompts.py @@ -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, ) diff --git a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/resources.py b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/resources.py index 75d23a967..477833c11 100644 --- a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/resources.py +++ b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/resources.py @@ -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, diff --git a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py index dcf1c0a2d..cc82b7dec 100644 --- a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py +++ b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py @@ -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: diff --git a/fastmcp_slim/fastmcp/server/providers/openapi/components.py b/fastmcp_slim/fastmcp/server/providers/openapi/components.py index 3b981226e..3cb36abcf 100644 --- a/fastmcp_slim/fastmcp/server/providers/openapi/components.py +++ b/fastmcp_slim/fastmcp/server/providers/openapi/components.py @@ -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 diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py index 78c45d434..866322177 100644 --- a/fastmcp_slim/fastmcp/server/providers/proxy.py +++ b/fastmcp_slim/fastmcp/server/providers/proxy.py @@ -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: diff --git a/fastmcp_slim/fastmcp/server/providers/skills/skill_provider.py b/fastmcp_slim/fastmcp/server/providers/skills/skill_provider.py index 0550e9042..747fecb1c 100644 --- a/fastmcp_slim/fastmcp/server/providers/skills/skill_provider.py +++ b/fastmcp_slim/fastmcp/server/providers/skills/skill_provider.py @@ -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) diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py index fce62550b..1308013ad 100644 --- a/fastmcp_slim/fastmcp/server/server.py +++ b/fastmcp_slim/fastmcp/server/server.py @@ -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, ) diff --git a/fastmcp_slim/fastmcp/server/tasks/__init__.py b/fastmcp_slim/fastmcp/server/tasks/__init__.py deleted file mode 100644 index 008332db5..000000000 --- a/fastmcp_slim/fastmcp/server/tasks/__init__.py +++ /dev/null @@ -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", -] diff --git a/fastmcp_slim/fastmcp/server/tasks/config.py b/fastmcp_slim/fastmcp/server/tasks/config.py deleted file mode 100644 index b7fe2c50b..000000000 --- a/fastmcp_slim/fastmcp/server/tasks/config.py +++ /dev/null @@ -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", -] diff --git a/fastmcp_slim/fastmcp/settings.py b/fastmcp_slim/fastmcp/settings.py index 312309a52..17cd884c8 100644 --- a/fastmcp_slim/fastmcp/settings.py +++ b/fastmcp_slim/fastmcp/settings.py @@ -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( diff --git a/fastmcp_slim/fastmcp/tools/base.py b/fastmcp_slim/fastmcp/tools/base.py index 9a5f9c6a2..e41a39583 100644 --- a/fastmcp_slim/fastmcp/tools/base.py +++ b/fastmcp_slim/fastmcp/tools/base.py @@ -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, diff --git a/fastmcp_slim/fastmcp/tools/function_tool.py b/fastmcp_slim/fastmcp/tools/function_tool.py index 70719d3f9..1e2e2b6a2 100644 --- a/fastmcp_slim/fastmcp/tools/function_tool.py +++ b/fastmcp_slim/fastmcp/tools/function_tool.py @@ -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: ... diff --git a/fastmcp_slim/fastmcp/utilities/components.py b/fastmcp_slim/fastmcp/utilities/components.py index 0fc8ea4bd..b59ac9b73 100644 --- a/fastmcp_slim/fastmcp/utilities/components.py +++ b/fastmcp_slim/fastmcp/utilities/components.py @@ -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. diff --git a/fastmcp_slim/fastmcp/utilities/tasks.py b/fastmcp_slim/fastmcp/utilities/tasks.py index 0886dacbb..b6cd44e84 100644 --- a/fastmcp_slim/fastmcp/utilities/tasks.py +++ b/fastmcp_slim/fastmcp/utilities/tasks.py @@ -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) diff --git a/fastmcp_slim/fastmcp/client/mixins/task_management.py b/fastmcp_tasks/fastmcp_tasks/_client_task_management.py similarity index 99% rename from fastmcp_slim/fastmcp/client/mixins/task_management.py rename to fastmcp_tasks/fastmcp_tasks/_client_task_management.py index 634a47435..8b3617d3f 100644 --- a/fastmcp_slim/fastmcp/client/mixins/task_management.py +++ b/fastmcp_tasks/fastmcp_tasks/_client_task_management.py @@ -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 diff --git a/fastmcp_tasks/fastmcp_tasks/_legacy_wire/__init__.py b/fastmcp_tasks/fastmcp_tasks/_legacy_wire/__init__.py new file mode 100644 index 000000000..57bbff5ee --- /dev/null +++ b/fastmcp_tasks/fastmcp_tasks/_legacy_wire/__init__.py @@ -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. +""" diff --git a/fastmcp_slim/fastmcp/server/tasks/capabilities.py b/fastmcp_tasks/fastmcp_tasks/_legacy_wire/capabilities.py similarity index 86% rename from fastmcp_slim/fastmcp/server/tasks/capabilities.py rename to fastmcp_tasks/fastmcp_tasks/_legacy_wire/capabilities.py index d2ed14ff4..42367668c 100644 --- a/fastmcp_slim/fastmcp/server/tasks/capabilities.py +++ b/fastmcp_tasks/fastmcp_tasks/_legacy_wire/capabilities.py @@ -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 diff --git a/fastmcp_slim/fastmcp/server/tasks/elicitation.py b/fastmcp_tasks/fastmcp_tasks/_legacy_wire/elicitation.py similarity index 98% rename from fastmcp_slim/fastmcp/server/tasks/elicitation.py rename to fastmcp_tasks/fastmcp_tasks/_legacy_wire/elicitation.py index d9a6e6df2..95798e1f1 100644 --- a/fastmcp_slim/fastmcp/server/tasks/elicitation.py +++ b/fastmcp_tasks/fastmcp_tasks/_legacy_wire/elicitation.py @@ -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__) diff --git a/fastmcp_slim/fastmcp/server/tasks/handlers.py b/fastmcp_tasks/fastmcp_tasks/_legacy_wire/handlers.py similarity index 92% rename from fastmcp_slim/fastmcp/server/tasks/handlers.py rename to fastmcp_tasks/fastmcp_tasks/_legacy_wire/handlers.py index 4b2ac1740..2ff531c66 100644 --- a/fastmcp_slim/fastmcp/server/tasks/handlers.py +++ b/fastmcp_tasks/fastmcp_tasks/_legacy_wire/handlers.py @@ -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, ) diff --git a/fastmcp_slim/fastmcp/server/tasks/notifications.py b/fastmcp_tasks/fastmcp_tasks/_legacy_wire/notifications.py similarity index 99% rename from fastmcp_slim/fastmcp/server/tasks/notifications.py rename to fastmcp_tasks/fastmcp_tasks/_legacy_wire/notifications.py index 9a662cd95..1affd89af 100644 --- a/fastmcp_slim/fastmcp/server/tasks/notifications.py +++ b/fastmcp_tasks/fastmcp_tasks/_legacy_wire/notifications.py @@ -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), diff --git a/fastmcp_slim/fastmcp/server/tasks/requests.py b/fastmcp_tasks/fastmcp_tasks/_legacy_wire/requests.py similarity index 98% rename from fastmcp_slim/fastmcp/server/tasks/requests.py rename to fastmcp_tasks/fastmcp_tasks/_legacy_wire/requests.py index d04d1f25d..5f1f72a0f 100644 --- a/fastmcp_slim/fastmcp/server/tasks/requests.py +++ b/fastmcp_tasks/fastmcp_tasks/_legacy_wire/requests.py @@ -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 diff --git a/fastmcp_slim/fastmcp/server/tasks/routing.py b/fastmcp_tasks/fastmcp_tasks/_legacy_wire/routing.py similarity index 95% rename from fastmcp_slim/fastmcp/server/tasks/routing.py rename to fastmcp_tasks/fastmcp_tasks/_legacy_wire/routing.py index 97839eff3..b8d7f0f4d 100644 --- a/fastmcp_slim/fastmcp/server/tasks/routing.py +++ b/fastmcp_tasks/fastmcp_tasks/_legacy_wire/routing.py @@ -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 diff --git a/fastmcp_slim/fastmcp/server/tasks/subscriptions.py b/fastmcp_tasks/fastmcp_tasks/_legacy_wire/subscriptions.py similarity index 98% rename from fastmcp_slim/fastmcp/server/tasks/subscriptions.py rename to fastmcp_tasks/fastmcp_tasks/_legacy_wire/subscriptions.py index f227f9667..05526a6f4 100644 --- a/fastmcp_slim/fastmcp/server/tasks/subscriptions.py +++ b/fastmcp_tasks/fastmcp_tasks/_legacy_wire/subscriptions.py @@ -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 diff --git a/fastmcp_slim/fastmcp/client/tasks.py b/fastmcp_tasks/fastmcp_tasks/client.py similarity index 98% rename from fastmcp_slim/fastmcp/client/tasks.py rename to fastmcp_tasks/fastmcp_tasks/client.py index ead182372..5e4c0502d 100644 --- a/fastmcp_slim/fastmcp/client/tasks.py +++ b/fastmcp_tasks/fastmcp_tasks/client.py @@ -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): diff --git a/fastmcp_tasks/fastmcp_tasks/components.py b/fastmcp_tasks/fastmcp_tasks/components.py new file mode 100644 index 000000000..537e6e645 --- /dev/null +++ b/fastmcp_tasks/fastmcp_tasks/components.py @@ -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 diff --git a/fastmcp_slim/fastmcp/server/tasks/context.py b/fastmcp_tasks/fastmcp_tasks/context.py similarity index 98% rename from fastmcp_slim/fastmcp/server/tasks/context.py rename to fastmcp_tasks/fastmcp_tasks/context.py index 8462ad5a7..c18e33309 100644 --- a/fastmcp_slim/fastmcp/server/tasks/context.py +++ b/fastmcp_tasks/fastmcp_tasks/context.py @@ -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 diff --git a/fastmcp_tasks/fastmcp_tasks/dependencies.py b/fastmcp_tasks/fastmcp_tasks/dependencies.py new file mode 100644 index 000000000..bb082483d --- /dev/null +++ b/fastmcp_tasks/fastmcp_tasks/dependencies.py @@ -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()) diff --git a/fastmcp_slim/fastmcp/server/tasks/keys.py b/fastmcp_tasks/fastmcp_tasks/keys.py similarity index 100% rename from fastmcp_slim/fastmcp/server/tasks/keys.py rename to fastmcp_tasks/fastmcp_tasks/keys.py diff --git a/fastmcp_tasks/fastmcp_tasks/lifespan.py b/fastmcp_tasks/fastmcp_tasks/lifespan.py new file mode 100644 index 000000000..45df1a7c5 --- /dev/null +++ b/fastmcp_tasks/fastmcp_tasks/lifespan.py @@ -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) diff --git a/fastmcp_tasks/fastmcp_tasks/settings.py b/fastmcp_tasks/fastmcp_tasks/settings.py new file mode 100644 index 000000000..57b6970a4 --- /dev/null +++ b/fastmcp_tasks/fastmcp_tasks/settings.py @@ -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() diff --git a/fastmcp_slim/fastmcp/cli/tasks.py b/fastmcp_tasks/fastmcp_tasks/worker_cli.py similarity index 91% rename from fastmcp_slim/fastmcp/cli/tasks.py rename to fastmcp_tasks/fastmcp_tasks/worker_cli.py index 23ddc6e58..d39ddfce8 100644 --- a/fastmcp_slim/fastmcp/cli/tasks.py +++ b/fastmcp_tasks/fastmcp_tasks/worker_cli.py @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 71bad7539..46174c506 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/tests/cli/test_tasks.py b/tests/cli/test_tasks.py index 8da5f80c9..a2ea42ede 100644 --- a/tests/cli/test_tasks.py +++ b/tests/cli/test_tasks.py @@ -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.""" diff --git a/tests/client/client/test_client.py b/tests/client/client/test_client.py index 3cd031c5a..bfe74f68c 100644 --- a/tests/client/client/test_client.py +++ b/tests/client/client/test_client.py @@ -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)) diff --git a/tests/client/client/test_response_cache.py b/tests/client/client/test_response_cache.py index 40f583106..1d769ccdc 100644 --- a/tests/client/client/test_response_cache.py +++ b/tests/client/client/test_response_cache.py @@ -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) diff --git a/tests/client/tasks/conftest.py b/tests/client/tasks/conftest.py deleted file mode 100644 index 29d0c9a10..000000000 --- a/tests/client/tasks/conftest.py +++ /dev/null @@ -1 +0,0 @@ -"""Configuration for client task tests.""" diff --git a/tests/client/tasks/test_client_prompt_tasks.py b/tests/client/tasks/test_client_prompt_tasks.py deleted file mode 100644 index 069c44c3b..000000000 --- a/tests/client/tasks/test_client_prompt_tasks.py +++ /dev/null @@ -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 diff --git a/tests/client/tasks/test_client_resource_tasks.py b/tests/client/tasks/test_client_resource_tasks.py deleted file mode 100644 index 44ab5f826..000000000 --- a/tests/client/tasks/test_client_resource_tasks.py +++ /dev/null @@ -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 diff --git a/tests/client/telemetry/test_client_task_tracing.py b/tests/client/telemetry/test_client_task_tracing.py index 6af938d40..ad5081e7d 100644 --- a/tests/client/telemetry/test_client_task_tracing.py +++ b/tests/client/telemetry/test_client_task_tracing.py @@ -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, diff --git a/tests/client/test_client_extensions.py b/tests/client/test_client_extensions.py index 6ad8b26f3..8681fc817 100644 --- a/tests/client/test_client_extensions.py +++ b/tests/client/test_client_extensions.py @@ -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) diff --git a/tests/client/transports/test_memory_transport.py b/tests/client/transports/test_memory_transport.py index a67784c89..5bbe3563e 100644 --- a/tests/client/transports/test_memory_transport.py +++ b/tests/client/transports/test_memory_transport.py @@ -18,6 +18,7 @@ def test_transport_repr_includes_server_name(): assert repr(transport) == "" +@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. diff --git a/tests/conftest.py b/tests/conftest.py index 98f1c6aa9..84487e445 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py index d03b3df90..531b8d1f6 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -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() diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index 45201c640..b45961bcf 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -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: diff --git a/tests/server/mount/test_advanced.py b/tests/server/mount/test_advanced.py index 5daeb65b8..89f590e63 100644 --- a/tests/server/mount/test_advanced.py +++ b/tests/server/mount/test_advanced.py @@ -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. diff --git a/tests/server/providers/test_base_provider.py b/tests/server/providers/test_base_provider.py index 38db55e08..035dec6d7 100644 --- a/tests/server/providers/test_base_provider.py +++ b/tests/server/providers/test_base_provider.py @@ -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): diff --git a/tests/server/providers/test_local_provider.py b/tests/server/providers/test_local_provider.py index ebc74f90c..6096e4509 100644 --- a/tests/server/providers/test_local_provider.py +++ b/tests/server/providers/test_local_provider.py @@ -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: diff --git a/tests/server/tasks/conftest.py b/tests/server/tasks/conftest.py deleted file mode 100644 index 496053bfa..000000000 --- a/tests/server/tasks/conftest.py +++ /dev/null @@ -1 +0,0 @@ -"""Configuration for server task tests.""" diff --git a/tests/server/tasks/test_resource_task_meta_parameter.py b/tests/server/tasks/test_resource_task_meta_parameter.py deleted file mode 100644 index 1a5caeffd..000000000 --- a/tests/server/tasks/test_resource_task_meta_parameter.py +++ /dev/null @@ -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) diff --git a/tests/server/tasks/test_task_prompts.py b/tests/server/tasks/test_task_prompts.py deleted file mode 100644 index 1054d3cee..000000000 --- a/tests/server/tasks/test_task_prompts.py +++ /dev/null @@ -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 - ) diff --git a/tests/server/tasks/test_task_resources.py b/tests/server/tasks/test_task_resources.py deleted file mode 100644 index acb136281..000000000 --- a/tests/server/tasks/test_task_resources.py +++ /dev/null @@ -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 - ) diff --git a/tests/server/test_dependencies.py b/tests/server/test_dependencies.py index 818cbaca4..1916c3428 100644 --- a/tests/server/test_dependencies.py +++ b/tests/server/test_dependencies.py @@ -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. diff --git a/tests/server/test_mrtr_guards.py b/tests/server/test_mrtr_guards.py index d6938d7e3..4855e2c5a 100644 --- a/tests/server/test_mrtr_guards.py +++ b/tests/server/test_mrtr_guards.py @@ -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") diff --git a/tests/server/test_protocol_eras.py b/tests/server/test_protocol_eras.py index 1da476c4c..972ba1733 100644 --- a/tests/server/test_protocol_eras.py +++ b/tests/server/test_protocol_eras.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/server/test_server_docket.py b/tests/server/test_server_docket.py index 7d0b7f5c0..ee11a5b60 100644 --- a/tests/server/test_server_docket.py +++ b/tests/server/test_server_docket.py @@ -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!" diff --git a/tests/server/test_tool_annotations.py b/tests/server/test_tool_annotations.py index e839d5689..3c66040bd 100644 --- a/tests/server/test_tool_annotations.py +++ b/tests/server/test_tool_annotations.py @@ -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") diff --git a/tests/tasks/__init__.py b/tests/tasks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tasks/client/__init__.py b/tests/tasks/client/__init__.py new file mode 100644 index 000000000..24bcd2c25 --- /dev/null +++ b/tests/tasks/client/__init__.py @@ -0,0 +1 @@ +"""Tests for MCP SEP-1686 background task client.""" diff --git a/tests/tasks/client/conftest.py b/tests/tasks/client/conftest.py new file mode 100644 index 000000000..226fada94 --- /dev/null +++ b/tests/tasks/client/conftest.py @@ -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 diff --git a/tests/client/tasks/test_client_task_notifications.py b/tests/tasks/client/test_client_task_notifications.py similarity index 99% rename from tests/client/tasks/test_client_task_notifications.py rename to tests/tasks/client/test_client_task_notifications.py index 3e05a2055..f93365caa 100644 --- a/tests/client/tasks/test_client_task_notifications.py +++ b/tests/tasks/client/test_client_task_notifications.py @@ -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. diff --git a/tests/client/tasks/test_client_task_protocol.py b/tests/tasks/client/test_client_task_protocol.py similarity index 95% rename from tests/client/tasks/test_client_task_protocol.py rename to tests/tasks/client/test_client_task_protocol.py index 343e69bf4..4d5d77bda 100644 --- a/tests/client/tasks/test_client_task_protocol.py +++ b/tests/tasks/client/test_client_task_protocol.py @@ -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.""" diff --git a/tests/client/tasks/test_client_tool_tasks.py b/tests/tasks/client/test_client_tool_tasks.py similarity index 97% rename from tests/client/tasks/test_client_tool_tasks.py rename to tests/tasks/client/test_client_tool_tasks.py index 2bccedccb..4a3d9d379 100644 --- a/tests/client/tasks/test_client_tool_tasks.py +++ b/tests/tasks/client/test_client_tool_tasks.py @@ -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(): diff --git a/tests/client/tasks/test_poll_interval.py b/tests/tasks/client/test_poll_interval.py similarity index 94% rename from tests/client/tasks/test_poll_interval.py rename to tests/tasks/client/test_poll_interval.py index afc08f7c9..0c33cca4d 100644 --- a/tests/client/tasks/test_poll_interval.py +++ b/tests/tasks/client/test_poll_interval.py @@ -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): diff --git a/tests/client/tasks/test_task_context_validation.py b/tests/tasks/client/test_task_context_validation.py similarity index 98% rename from tests/client/tasks/test_task_context_validation.py rename to tests/tasks/client/test_task_context_validation.py index 2b6a76832..4eda41739 100644 --- a/tests/client/tasks/test_task_context_validation.py +++ b/tests/tasks/client/test_task_context_validation.py @@ -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(): diff --git a/tests/client/tasks/test_task_result_caching.py b/tests/tasks/client/test_task_result_caching.py similarity index 99% rename from tests/client/tasks/test_task_result_caching.py rename to tests/tasks/client/test_task_result_caching.py index 183014c8e..e0cf7b880 100644 --- a/tests/client/tasks/test_task_result_caching.py +++ b/tests/tasks/client/test_task_result_caching.py @@ -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.""" diff --git a/tests/server/tasks/__init__.py b/tests/tasks/server/__init__.py similarity index 100% rename from tests/server/tasks/__init__.py rename to tests/tasks/server/__init__.py diff --git a/tests/tasks/server/conftest.py b/tests/tasks/server/conftest.py new file mode 100644 index 000000000..70ea6754b --- /dev/null +++ b/tests/tasks/server/conftest.py @@ -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 diff --git a/tests/server/tasks/test_concurrent_dependencies.py b/tests/tasks/server/test_concurrent_dependencies.py similarity index 98% rename from tests/server/tasks/test_concurrent_dependencies.py rename to tests/tasks/server/test_concurrent_dependencies.py index 19f977a5c..10db2860a 100644 --- a/tests/server/tasks/test_concurrent_dependencies.py +++ b/tests/tasks/server/test_concurrent_dependencies.py @@ -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 diff --git a/tests/server/tasks/test_context_background_task.py b/tests/tasks/server/test_context_background_task.py similarity index 98% rename from tests/server/tasks/test_context_background_task.py rename to tests/tasks/server/test_context_background_task.py index 42f92fa30..7f4bece2a 100644 --- a/tests/server/tasks/test_context_background_task.py +++ b/tests/tasks/server/test_context_background_task.py @@ -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: diff --git a/tests/server/tasks/test_custom_subclass_tasks.py b/tests/tasks/server/test_custom_subclass_tasks.py similarity index 97% rename from tests/server/tasks/test_custom_subclass_tasks.py rename to tests/tasks/server/test_custom_subclass_tasks.py index 80233e8fc..cd6e87a39 100644 --- a/tests/server/tasks/test_custom_subclass_tasks.py +++ b/tests/tasks/server/test_custom_subclass_tasks.py @@ -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): diff --git a/tests/server/tasks/test_notifications.py b/tests/tasks/server/test_notifications.py similarity index 96% rename from tests/server/tasks/test_notifications.py rename to tests/tasks/server/test_notifications.py index 4961c0822..32c6c90bc 100644 --- a/tests/server/tasks/test_notifications.py +++ b/tests/tasks/server/test_notifications.py @@ -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)" ) diff --git a/tests/server/tasks/test_progress_dependency.py b/tests/tasks/server/test_progress_dependency.py similarity index 96% rename from tests/server/tasks/test_progress_dependency.py rename to tests/tasks/server/test_progress_dependency.py index 5be2649c7..3cf751eb0 100644 --- a/tests/server/tasks/test_progress_dependency.py +++ b/tests/tasks/server/test_progress_dependency.py @@ -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(): diff --git a/tests/server/tasks/test_server_tasks_parameter.py b/tests/tasks/server/test_server_tasks_parameter.py similarity index 99% rename from tests/server/tasks/test_server_tasks_parameter.py rename to tests/tasks/server/test_server_tasks_parameter.py index 45b777d6a..3f79a4820 100644 --- a/tests/server/tasks/test_server_tasks_parameter.py +++ b/tests/tasks/server/test_server_tasks_parameter.py @@ -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( diff --git a/tests/server/tasks/test_snapshot_restore.py b/tests/tasks/server/test_snapshot_restore.py similarity index 96% rename from tests/server/tasks/test_snapshot_restore.py rename to tests/tasks/server/test_snapshot_restore.py index 09a5241d1..9e30f0314 100644 --- a/tests/server/tasks/test_snapshot_restore.py +++ b/tests/tasks/server/test_snapshot_restore.py @@ -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)" ) diff --git a/tests/server/tasks/test_sync_function_task_disabled.py b/tests/tasks/server/test_sync_function_task_disabled.py similarity index 98% rename from tests/server/tasks/test_sync_function_task_disabled.py rename to tests/tasks/server/test_sync_function_task_disabled.py index c5255b0b4..d6147f95f 100644 --- a/tests/server/tasks/test_sync_function_task_disabled.py +++ b/tests/tasks/server/test_sync_function_task_disabled.py @@ -12,6 +12,10 @@ from fastmcp.prompts.function_prompt import FunctionPrompt from fastmcp.resources.function_resource import FunctionResource from fastmcp.tools.function_tool import FunctionTool +pytestmark = pytest.mark.skip( + reason="Phase 3: requires TasksExtension (SEP-2663 adapter)" +) + async def test_sync_tool_with_explicit_task_true_raises(): """Sync tool with task=True raises ValueError.""" diff --git a/tests/server/tasks/test_task_capabilities.py b/tests/tasks/server/test_task_capabilities.py similarity index 94% rename from tests/server/tasks/test_task_capabilities.py rename to tests/tasks/server/test_task_capabilities.py index e504cee53..a79bd753d 100644 --- a/tests/server/tasks/test_task_capabilities.py +++ b/tests/tasks/server/test_task_capabilities.py @@ -5,9 +5,15 @@ Verifies that the server correctly advertises task support. Task protocol is now always enabled. """ +import pytest +from fastmcp_tasks._legacy_wire.capabilities import get_task_capabilities + from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.server.tasks import get_task_capabilities + +pytestmark = pytest.mark.skip( + reason="Phase 3: requires TasksExtension (SEP-2663 adapter)" +) async def test_capabilities_include_tasks(): diff --git a/tests/server/tasks/test_task_config.py b/tests/tasks/server/test_task_config.py similarity index 97% rename from tests/server/tasks/test_task_config.py rename to tests/tasks/server/test_task_config.py index e10d7cff2..ba9cc8cb7 100644 --- a/tests/server/tasks/test_task_config.py +++ b/tests/tasks/server/test_task_config.py @@ -15,8 +15,8 @@ from mcp_types import Tool as MCPTool from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.exceptions import ToolError -from fastmcp.server.tasks import TaskConfig from fastmcp.tools.base import Tool +from fastmcp.utilities.tasks import TaskConfig class TestTaskConfigNormalization: @@ -83,6 +83,7 @@ class TestTaskConfigNormalization: assert tool2.task_config.mode == "optional" +@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)") class TestToolModeEnforcement: """Test mode enforcement for tools.""" @@ -159,6 +160,7 @@ class TestToolModeEnforcement: assert result.data == "optional result" +@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)") class TestResourceModeEnforcement: """Test mode enforcement for resources.""" @@ -217,6 +219,7 @@ class TestResourceModeEnforcement: assert "forbidden content" in str(result) +@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)") class TestPromptModeEnforcement: """Test mode enforcement for prompts.""" @@ -276,6 +279,7 @@ class TestPromptModeEnforcement: assert "forbidden message" in str(result.messages[0].content) +@pytest.mark.skip(reason="Phase 3: requires TasksExtension (SEP-2663 adapter)") class TestToolExecutionMetadata: """Test that ToolExecution.task_support is set correctly in tool metadata.""" diff --git a/tests/server/tasks/test_task_dependencies.py b/tests/tasks/server/test_task_dependencies.py similarity index 97% rename from tests/server/tasks/test_task_dependencies.py rename to tests/tasks/server/test_task_dependencies.py index 1745f5e49..87304963d 100644 --- a/tests/server/tasks/test_task_dependencies.py +++ b/tests/tasks/server/test_task_dependencies.py @@ -9,11 +9,17 @@ from contextlib import asynccontextmanager from typing import Any, cast import pytest +from fastmcp_tasks.dependencies import CurrentDocket +from uncalled_for import Depends from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.dependencies import CurrentDocket, CurrentFastMCP, Depends from fastmcp.exceptions import ToolError +from fastmcp.server.dependencies import CurrentFastMCP + +pytestmark = pytest.mark.skip( + reason="Phase 3: requires TasksExtension (SEP-2663 adapter)" +) @pytest.fixture diff --git a/tests/server/tasks/test_task_elicitation_relay.py b/tests/tasks/server/test_task_elicitation_relay.py similarity index 98% rename from tests/server/tasks/test_task_elicitation_relay.py rename to tests/tasks/server/test_task_elicitation_relay.py index bf8d6e8b9..770b28801 100644 --- a/tests/server/tasks/test_task_elicitation_relay.py +++ b/tests/tasks/server/test_task_elicitation_relay.py @@ -13,6 +13,7 @@ These tests use Client(mcp, mode="legacy") with the real memory:// Docket backen import asyncio from dataclasses import dataclass +import pytest from pydantic import BaseModel from fastmcp import FastMCP @@ -25,6 +26,10 @@ from fastmcp.server.elicitation import ( DeclinedElicitation, ) +pytestmark = pytest.mark.skip( + reason="Phase 3: requires TasksExtension (SEP-2663 adapter)" +) + class TestElicitationRelay: """E2E tests for elicitation flowing through the standard MCP protocol.""" diff --git a/tests/server/tasks/test_task_keys.py b/tests/tasks/server/test_task_keys.py similarity index 99% rename from tests/server/tasks/test_task_keys.py rename to tests/tasks/server/test_task_keys.py index 06a64f8f1..7414cfdba 100644 --- a/tests/server/tasks/test_task_keys.py +++ b/tests/tasks/server/test_task_keys.py @@ -9,8 +9,7 @@ the Docket-key prefix and the Redis-key prefix. """ import pytest - -from fastmcp.server.tasks.keys import ( +from fastmcp_tasks.keys import ( build_task_key, get_client_task_id_from_key, parse_task_key, diff --git a/tests/server/tasks/test_task_meta_parameter.py b/tests/tasks/server/test_task_meta_parameter.py similarity index 98% rename from tests/server/tasks/test_task_meta_parameter.py rename to tests/tasks/server/test_task_meta_parameter.py index bea973aba..4e81ad8b7 100644 --- a/tests/server/tasks/test_task_meta_parameter.py +++ b/tests/tasks/server/test_task_meta_parameter.py @@ -12,8 +12,12 @@ from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.exceptions import ToolError from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.server.tasks.config import TaskMeta from fastmcp.tools.base import Tool, ToolResult +from fastmcp.utilities.tasks import TaskMeta + +pytestmark = pytest.mark.skip( + reason="Phase 3: requires TasksExtension (SEP-2663 adapter)" +) class TestTaskMetaParameter: diff --git a/tests/server/tasks/test_task_metadata.py b/tests/tasks/server/test_task_metadata.py similarity index 95% rename from tests/server/tasks/test_task_metadata.py rename to tests/tasks/server/test_task_metadata.py index 2bfdf4b13..a3cbb282c 100644 --- a/tests/server/tasks/test_task_metadata.py +++ b/tests/tasks/server/test_task_metadata.py @@ -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 metadata_server(): diff --git a/tests/server/tasks/test_task_methods.py b/tests/tasks/server/test_task_methods.py similarity index 98% rename from tests/server/tasks/test_task_methods.py rename to tests/tasks/server/test_task_methods.py index 12c17f585..c99b8071c 100644 --- a/tests/server/tasks/test_task_methods.py +++ b/tests/tasks/server/test_task_methods.py @@ -13,6 +13,10 @@ from mcp.shared.exceptions import MCPError 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 endpoint_server(): diff --git a/tests/server/tasks/test_task_mount.py b/tests/tasks/server/test_task_mount.py similarity index 99% rename from tests/server/tasks/test_task_mount.py rename to tests/tasks/server/test_task_mount.py index e984f4969..9b4b28108 100644 --- a/tests/server/tasks/test_task_mount.py +++ b/tests/tasks/server/test_task_mount.py @@ -11,6 +11,7 @@ import time import mcp_types as mt import pytest from docket import Docket +from fastmcp_tasks.dependencies import CurrentDocket from mcp_types import Tool as MCPTool from mcp_types import ToolExecution @@ -18,11 +19,15 @@ from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.prompts.base import PromptResult from fastmcp.resources.base import ResourceResult -from fastmcp.server.dependencies import CurrentDocket, CurrentFastMCP +from fastmcp.server.dependencies import CurrentFastMCP from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.server.providers.proxy import ProxyTool -from fastmcp.server.tasks import TaskConfig from fastmcp.tools.base import ToolResult +from fastmcp.utilities.tasks import TaskConfig + +pytestmark = pytest.mark.skip( + reason="Phase 3: requires TasksExtension (SEP-2663 adapter)" +) @pytest.fixture(autouse=True) diff --git a/tests/server/tasks/test_task_protocol.py b/tests/tasks/server/test_task_protocol.py similarity index 96% rename from tests/server/tasks/test_task_protocol.py rename to tests/tasks/server/test_task_protocol.py index f47648618..08461bb9a 100644 --- a/tests/server/tasks/test_task_protocol.py +++ b/tests/tasks/server/test_task_protocol.py @@ -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_enabled_server(): diff --git a/tests/server/tasks/test_task_proxy.py b/tests/tasks/server/test_task_proxy.py similarity index 98% rename from tests/server/tasks/test_task_proxy.py rename to tests/tasks/server/test_task_proxy.py index c8abd5c5d..3d4219bac 100644 --- a/tests/server/tasks/test_task_proxy.py +++ b/tests/tasks/server/test_task_proxy.py @@ -19,6 +19,10 @@ from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport from fastmcp.server import create_proxy +pytestmark = pytest.mark.skip( + reason="Phase 3: requires TasksExtension (SEP-2663 adapter)" +) + @pytest.fixture def backend_server() -> FastMCP: diff --git a/tests/server/tasks/test_task_return_types.py b/tests/tasks/server/test_task_return_types.py similarity index 99% rename from tests/server/tasks/test_task_return_types.py rename to tests/tasks/server/test_task_return_types.py index a8255a5a7..ddd80777c 100644 --- a/tests/server/tasks/test_task_return_types.py +++ b/tests/tasks/server/test_task_return_types.py @@ -20,6 +20,10 @@ from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.utilities.types import Audio, File, Image +pytestmark = pytest.mark.skip( + reason="Phase 3: requires TasksExtension (SEP-2663 adapter)" +) + class UserData(BaseModel): """Example structured output.""" diff --git a/tests/server/tasks/test_task_security.py b/tests/tasks/server/test_task_security.py similarity index 98% rename from tests/server/tasks/test_task_security.py rename to tests/tasks/server/test_task_security.py index 605382894..2ee18c6db 100644 --- a/tests/server/tasks/test_task_security.py +++ b/tests/tasks/server/test_task_security.py @@ -15,6 +15,10 @@ from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.server.auth import AccessToken +pytestmark = pytest.mark.skip( + reason="Phase 3: requires TasksExtension (SEP-2663 adapter)" +) + @pytest.fixture def task_server(): diff --git a/tests/server/tasks/test_task_status_notifications.py b/tests/tasks/server/test_task_status_notifications.py similarity index 98% rename from tests/server/tasks/test_task_status_notifications.py rename to tests/tasks/server/test_task_status_notifications.py index 1b1629d74..3487148f7 100644 --- a/tests/server/tasks/test_task_status_notifications.py +++ b/tests/tasks/server/test_task_status_notifications.py @@ -16,6 +16,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 notification_server(): diff --git a/tests/server/tasks/test_task_tools.py b/tests/tasks/server/test_task_tools.py similarity index 98% rename from tests/server/tasks/test_task_tools.py rename to tests/tasks/server/test_task_tools.py index c9d269bf7..15b4358c9 100644 --- a/tests/server/tasks/test_task_tools.py +++ b/tests/tasks/server/test_task_tools.py @@ -10,15 +10,19 @@ import functools import mcp_types import pytest +from fastmcp_tasks.client import ToolTask from pydantic import BaseModel from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.messages import MessageHandler -from fastmcp.client.tasks import ToolTask from fastmcp.exceptions import ToolError from fastmcp.tools.function_tool import _resolve_param_hints +pytestmark = pytest.mark.skip( + reason="Phase 3: requires TasksExtension (SEP-2663 adapter)" +) + @pytest.fixture async def tool_server(): diff --git a/tests/server/tasks/test_task_ttl.py b/tests/tasks/server/test_task_ttl.py similarity index 97% rename from tests/server/tasks/test_task_ttl.py rename to tests/tasks/server/test_task_ttl.py index 0bb23a238..4464f8de3 100644 --- a/tests/server/tasks/test_task_ttl.py +++ b/tests/tasks/server/test_task_ttl.py @@ -12,6 +12,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 keepalive_server(): diff --git a/tests/test_settings.py b/tests/test_settings.py index 3052a0d35..09d014a66 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,42 +1,6 @@ import pytest -from fastmcp import settings from fastmcp.settings import Settings -from fastmcp.utilities.tests import temporary_settings - - -def test_get_setting_reads_nested_values(): - test_settings = Settings() - - assert test_settings.get_setting("docket__name") == "fastmcp" - assert test_settings.get_setting("docket__redelivery_timeout__seconds") == 300 - - -def test_set_setting_updates_nested_values(): - test_settings = Settings() - - test_settings.set_setting("docket__name", "worker-queue") - - assert test_settings.docket.name == "worker-queue" - assert test_settings.get_setting("docket__name") == "worker-queue" - - -def test_temporary_settings_restores_nested_values(): - original_name = settings.get_setting("docket__name") - - with temporary_settings(docket__name="temporary-queue"): - assert settings.get_setting("docket__name") == "temporary-queue" - - assert settings.get_setting("docket__name") == original_name - - -def test_get_setting_raises_for_missing_nested_parent(): - test_settings = Settings() - - with pytest.raises(AttributeError) as exc_info: - test_settings.get_setting("docket__missing__value") - - assert str(exc_info.value) == "Setting missing does not exist." def test_http_host_origin_protection_defaults_to_false(): diff --git a/tests/tools/tool/test_argument_validation.py b/tests/tools/tool/test_argument_validation.py index 8f41cea5d..a526eae3f 100644 --- a/tests/tools/tool/test_argument_validation.py +++ b/tests/tools/tool/test_argument_validation.py @@ -86,23 +86,32 @@ class TestToolBodyErrors: class TestTaskArgumentValidation: - """The task-execution path (coerce_task_arguments) converts arg errors too.""" + """The task-execution path (coerce_task_arguments) converts arg errors too. + + The coercion logic moved to ``fastmcp_tasks.components`` during the + SEP-1686 -> SEP-2663 migration, keyed by component type instead of being a + method on the component. + """ def test_coerce_task_arguments_wrong_type(self): + from fastmcp_tasks.components import coerce_task_arguments + def tool_fn(n: int) -> int: return n tool = Tool.from_function(tool_fn) with pytest.raises(ValidationError): - tool.coerce_task_arguments({"n": "not-an-int"}) + coerce_task_arguments(tool, {"n": "not-an-int"}) def test_coerce_task_arguments_constraint_violation(self): + from fastmcp_tasks.components import coerce_task_arguments + def tool_fn(n: Annotated[int, Field(le=10)]) -> int: return n tool = Tool.from_function(tool_fn) with pytest.raises(ValidationError): - tool.coerce_task_arguments({"n": 20}) + coerce_task_arguments(tool, {"n": 20}) class TestValidCallsStillWork: