From 47d3044b060b61ccecdddb66306136a3f4e04450 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Fri, 5 Dec 2025 11:39:25 -0500 Subject: [PATCH] Remove enable_docket setting; Docket is now always on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docket provides background task execution and is now always available for all FastMCP servers. Only `enable_tasks` remains to control the SEP-1686 task protocol support. Changes: - Remove `enable_docket` setting and related validation - Docket/Worker lifecycle is always active in server lifespan - CurrentDocket and CurrentWorker dependencies work without config - Add server readiness signaling via `_started` event - Fix test timing issues with proper port probing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/servers/tasks.mdx | 3 - examples/tasks/.envrc | 3 - examples/tasks/README.md | 1 - src/fastmcp/cli/tasks.py | 22 ++--- src/fastmcp/client/transports.py | 2 +- src/fastmcp/server/dependencies.py | 42 ++------- src/fastmcp/server/server.py | 91 +++++++++---------- src/fastmcp/server/tasks/handlers.py | 4 +- src/fastmcp/settings.py | 24 +---- src/fastmcp/utilities/tests.py | 44 ++++++++- tests/cli/test_tasks.py | 33 ++----- tests/client/tasks/conftest.py | 9 +- .../proxy/test_stateful_proxy_client.py | 3 +- tests/server/tasks/conftest.py | 12 +-- .../server/tasks/test_progress_dependency.py | 21 ----- .../tasks/test_server_tasks_parameter.py | 20 +--- tests/server/tasks/test_task_capabilities.py | 61 +------------ tests/server/test_logging.py | 6 +- tests/server/test_mount.py | 6 +- tests/server/test_server_docket.py | 38 ++------ tests/server/test_server_lifespan.py | 8 +- 21 files changed, 150 insertions(+), 303 deletions(-) diff --git a/docs/servers/tasks.mdx b/docs/servers/tasks.mdx index 2ca24948e..f4fd8c508 100644 --- a/docs/servers/tasks.mdx +++ b/docs/servers/tasks.mdx @@ -55,11 +55,8 @@ Background tasks require explicit opt-in: | Environment Variable | Default | Description | |---------------------|---------|-------------| | `FASTMCP_ENABLE_TASKS` | `false` | Enable the MCP task protocol | -| `FASTMCP_ENABLE_DOCKET` | `false` | Enable the Docket task system | | `FASTMCP_DOCKET_URL` | `memory://` | Backend URL (`memory://` or `redis://host:port/db`) | -Both `ENABLE_TASKS` and `ENABLE_DOCKET` must be `true` for background tasks to work. - You can also set a server-wide default in the constructor: ```python diff --git a/examples/tasks/.envrc b/examples/tasks/.envrc index 08830292b..c9afbca5b 100644 --- a/examples/tasks/.envrc +++ b/examples/tasks/.envrc @@ -2,9 +2,6 @@ # This file is loaded by direnv (https://direnv.net/) when you cd into this directory # Run `direnv allow` to enable automatic environment loading -# Enable Docket support for background task execution -export FASTMCP_ENABLE_DOCKET=true - # Enable MCP SEP-1686 task protocol support export FASTMCP_ENABLE_TASKS=true diff --git a/examples/tasks/README.md b/examples/tasks/README.md index ed0cd5be0..f6275009b 100644 --- a/examples/tasks/README.md +++ b/examples/tasks/README.md @@ -51,7 +51,6 @@ fastmcp tasks worker server.py | Variable | Default | Description | |----------|---------|-------------| -| `FASTMCP_ENABLE_DOCKET` | `false` | Enable Docket task system | | `FASTMCP_ENABLE_TASKS` | `false` | Enable MCP task protocol (SEP-1686) | | `FASTMCP_DOCKET_URL` | `memory://` | Docket backend URL | diff --git a/src/fastmcp/cli/tasks.py b/src/fastmcp/cli/tasks.py index b5eceb4e9..931802d0f 100644 --- a/src/fastmcp/cli/tasks.py +++ b/src/fastmcp/cli/tasks.py @@ -19,25 +19,17 @@ tasks_app = cyclopts.App( ) -def check_docket_enabled() -> None: - """Check if Docket is enabled with a distributed backend. +def check_distributed_backend() -> None: + """Check if Docket is configured with a distributed backend. + + The CLI worker runs as a separate process, so it needs Redis/Valkey + to coordinate with the main server process. Raises: - SystemExit: If Docket isn't enabled or using memory:// URL + SystemExit: If using memory:// URL """ import fastmcp - # Check if Docket is enabled - if not fastmcp.settings.enable_docket: - console.print( - "[bold red]✗ Docket not enabled[/bold red]\n\n" - "Docket task support is not enabled.\n\n" - "To enable Docket, set the environment variable:\n" - " [cyan]export FASTMCP_ENABLE_DOCKET=true[/cyan]\n\n" - "Then try again." - ) - sys.exit(1) - docket_url = fastmcp.settings.docket.url # Check for memory:// URL and provide helpful error @@ -86,7 +78,7 @@ def worker( """ import fastmcp - check_docket_enabled() + check_distributed_backend() # Load server to get task functions try: diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 0fb4be0c7..424df2d7e 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -859,7 +859,7 @@ class FastMCPTransport(ClientTransport): experimental_capabilities = {} if fastmcp.settings.enable_tasks: - # Declare SEP-1686 task support (enable_tasks requires enable_docket via validator) + # Declare SEP-1686 task support experimental_capabilities["tasks"] = { "tools": True, "prompts": True, diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index 51abc1f9c..31b170c79 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -297,21 +297,12 @@ class _CurrentDocket(Dependency): """Internal dependency class for CurrentDocket.""" async def __aenter__(self) -> Docket: - import fastmcp - - # Check if flag is enabled - if not fastmcp.settings.enable_docket: - raise RuntimeError( - "Docket support is not enabled. " - "Set FASTMCP_ENABLE_DOCKET=true to enable Docket support." - ) - # Get Docket from ContextVar (set by _docket_lifespan) docket = _current_docket.get() if docket is None: raise RuntimeError( - "No Docket instance found. This should not happen when " - "FASTMCP_ENABLE_DOCKET is enabled." + "No Docket instance found. Docket is only available within " + "a running FastMCP server context." ) return docket @@ -321,16 +312,13 @@ def CurrentDocket() -> Docket: """Get the current Docket instance managed by FastMCP. This dependency provides access to the Docket instance that FastMCP - automatically creates when Docket support is enabled. - - Requires: - - FASTMCP_ENABLE_DOCKET=true + automatically creates for background task scheduling. Returns: A dependency that resolves to the active Docket instance Raises: - RuntimeError: If flag not enabled (during resolution) + RuntimeError: If not within a FastMCP server context Example: ```python @@ -349,19 +337,11 @@ class _CurrentWorker(Dependency): """Internal dependency class for CurrentWorker.""" async def __aenter__(self) -> Worker: - import fastmcp - - if not fastmcp.settings.enable_docket: - raise RuntimeError( - "Docket support is not enabled. " - "Set FASTMCP_ENABLE_DOCKET=true to enable Docket support." - ) - worker = _current_worker.get() if worker is None: raise RuntimeError( - "No Worker instance found. This should not happen when " - "FASTMCP_ENABLE_DOCKET is enabled." + "No Worker instance found. Worker is only available within " + "a running FastMCP server context." ) return worker @@ -371,16 +351,13 @@ def CurrentWorker() -> Worker: """Get the current Docket Worker instance managed by FastMCP. This dependency provides access to the Worker instance that FastMCP - automatically creates when Docket support is enabled. - - Requires: - - FASTMCP_ENABLE_DOCKET=true + automatically creates for background task processing. Returns: A dependency that resolves to the active Worker instance Raises: - RuntimeError: If flag not enabled (during resolution) + RuntimeError: If not within a FastMCP server context Example: ```python @@ -463,8 +440,7 @@ class Progress(DocketProgress): docket = _current_docket.get() if docket is None: raise RuntimeError( - "Progress dependency requires Docket to be enabled. " - "Set FASTMCP_ENABLE_DOCKET=true" + "Progress dependency requires a FastMCP server context." ) from None # Return in-memory progress for immediate execution diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 64dc3884e..4ee3e97f7 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -19,6 +19,7 @@ from contextlib import ( AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager, + suppress, ) from dataclasses import dataclass from functools import partial @@ -213,6 +214,7 @@ class FastMCP(Generic[LifespanResultT]): self._lifespan: LifespanCallable[LifespanResultT] = lifespan or default_lifespan self._lifespan_result: LifespanResultT | None = None self._lifespan_result_set: bool = False + self._started: asyncio.Event = asyncio.Event() # Generate random ID if no name provided self._mcp_server: LowLevelServer[LifespanResultT, Any] = LowLevelServer[ @@ -374,34 +376,16 @@ class FastMCP(Generic[LifespanResultT]): return self._docket @asynccontextmanager - async def _docket_lifespan( - self, user_lifespan_result: LifespanResultT - ) -> AsyncIterator[LifespanResultT]: - """Manage Docket instance and Worker when experimental support is enabled. - - Args: - user_lifespan_result: The result from the user's lifespan function - - Yields: - User's lifespan result (Docket is managed via ContextVar, not lifespan result) - """ + async def _docket_lifespan(self) -> AsyncIterator[None]: + """Manage Docket instance and Worker for background task execution.""" from fastmcp import settings - from fastmcp.server.dependencies import _current_docket, _current_worker - - # Validate configuration - if settings.enable_tasks and not settings.enable_docket: - raise RuntimeError( - "Server requires enable_docket=True when enable_tasks=True. " - "Task protocol support needs Docket for background execution." - ) - - if not settings.enable_docket: - # Docket support not enabled, pass through user lifespan result - yield user_lifespan_result - return # Set FastMCP server in ContextVar so CurrentFastMCP can access it (use weakref to avoid reference cycles) - from fastmcp.server.dependencies import _current_server + from fastmcp.server.dependencies import ( + _current_docket, + _current_server, + _current_worker, + ) server_token = _current_server.set(weakref.ref(self)) @@ -414,9 +398,10 @@ class FastMCP(Generic[LifespanResultT]): # Store on server instance for cross-task access (FastMCPTransport) self._docket = docket - # Register task-enabled tools/prompts/resources with Docket - tools = await self.get_tools() - for tool in tools.values(): + # Register local task-enabled tools/prompts/resources with Docket + for tool in self._tool_manager._tools.values(): + if not hasattr(tool, "fn"): + continue supports_task = ( tool.task if tool.task is not None @@ -425,8 +410,9 @@ class FastMCP(Generic[LifespanResultT]): if supports_task: docket.register(tool.fn) - prompts = await self.get_prompts() - for prompt in prompts.values(): + for prompt in self._prompt_manager._prompts.values(): + if not hasattr(prompt, "fn"): + continue supports_task = ( prompt.task if prompt.task is not None @@ -435,8 +421,9 @@ class FastMCP(Generic[LifespanResultT]): if supports_task: docket.register(prompt.fn) - resources = await self.get_resources() - for resource in resources.values(): + for resource in self._resource_manager._resources.values(): + if not hasattr(resource, "fn"): + continue supports_task = ( resource.task if resource.task is not None @@ -445,6 +432,17 @@ class FastMCP(Generic[LifespanResultT]): if supports_task: docket.register(resource.fn) + for template in self._resource_manager._templates.values(): + if not hasattr(template, "fn"): + continue + supports_task = ( + template.task + if template.task is not None + else self._support_tasks_by_default + ) + if supports_task: + docket.register(template.fn) + # Set Docket in ContextVar so CurrentDocket can access it docket_token = _current_docket.set(docket) try: @@ -457,22 +455,19 @@ class FastMCP(Generic[LifespanResultT]): if settings.docket.worker_name: worker_kwargs["name"] = settings.docket.worker_name - # Create and start Worker, then task group for run_forever() - async with ( - Worker(docket, **worker_kwargs) as worker, # type: ignore[arg-type] - anyio.create_task_group() as tg, - ): + # Create and start Worker + async with Worker(docket, **worker_kwargs) as worker: # type: ignore[arg-type] # Set Worker in ContextVar so CurrentWorker can access it worker_token = _current_worker.set(worker) try: - # Start worker as background task - tg.start_soon(worker.run_forever) - + worker_task = asyncio.create_task(worker.run_forever()) try: - yield user_lifespan_result + yield finally: - # Cancel task group when exiting (cancels worker) - tg.cancel_scope.cancel() + # Cancel worker task on exit + worker_task.cancel() + with suppress(asyncio.CancelledError): + await worker_task finally: _current_worker.reset(worker_token) finally: @@ -492,9 +487,9 @@ class FastMCP(Generic[LifespanResultT]): async with ( self._lifespan(self) as user_lifespan_result, - self._docket_lifespan(user_lifespan_result) as lifespan_result, + self._docket_lifespan(), ): - self._lifespan_result = lifespan_result + self._lifespan_result = user_lifespan_result self._lifespan_result_set = True async with AsyncExitStack[bool | None]() as stack: @@ -503,7 +498,11 @@ class FastMCP(Generic[LifespanResultT]): cm=server.server._lifespan_manager() ) - yield + self._started.set() + try: + yield + finally: + self._started.clear() self._lifespan_result_set = False self._lifespan_result = None diff --git a/src/fastmcp/server/tasks/handlers.py b/src/fastmcp/server/tasks/handlers.py index 14edca901..e8be0b477 100644 --- a/src/fastmcp/server/tasks/handlers.py +++ b/src/fastmcp/server/tasks/handlers.py @@ -61,7 +61,7 @@ async def handle_tool_as_task( raise McpError( ErrorData( code=INTERNAL_ERROR, - message="Background tasks require Docket. Set FASTMCP_ENABLE_DOCKET=true", + message="Background tasks require a running FastMCP server context", ) ) @@ -169,7 +169,7 @@ async def handle_prompt_as_task( raise McpError( ErrorData( code=INTERNAL_ERROR, - message="Background tasks require Docket. Set FASTMCP_ENABLE_DOCKET=true", + message="Background tasks require a running FastMCP server context", ) ) diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 9ba30e437..982f0f2f1 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -209,21 +209,7 @@ class Settings(BaseSettings): experimental: ExperimentalSettings = ExperimentalSettings() - # Docket/Tasks settings - enable_docket: Annotated[ - bool, - Field( - description=inspect.cleandoc( - """ - Enable Docket support for background task execution. - When enabled, FastMCP will create a Docket instance with a Worker - available via dependency injection. This allows tools, prompts, and - resources to schedule background work using CurrentDocket(). - """ - ), - ), - ] = False - + # Tasks settings enable_tasks: Annotated[ bool, Field( @@ -231,11 +217,11 @@ class Settings(BaseSettings): """ Enable MCP SEP-1686 task protocol support for background execution. - Server-side: Requires enable_docket=True (validated at server startup). - Advertises task capabilities and handles task/* protocol methods. + Server-side: Advertises task capabilities and handles task/* protocol + methods. Tools, prompts, and resources marked with task=True will + execute in the background via Docket. - Client-side: Advertises task capability to servers. No Docket needed - on client side. + Client-side: Advertises task capability to servers. """ ), ), diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py index 45db76ab7..1a4a12b05 100644 --- a/src/fastmcp/utilities/tests.py +++ b/src/fastmcp/utilities/tests.py @@ -22,6 +22,36 @@ if TYPE_CHECKING: from fastmcp.server.server import FastMCP +async def _wait_for_port( + host: str, port: int, timeout: float = 5.0, interval: float = 0.01 +) -> None: + """Wait for a port to accept connections. + + Args: + host: Host to connect to + port: Port to probe + timeout: Maximum time to wait in seconds + interval: Time between connection attempts + """ + import asyncio + + start = asyncio.get_running_loop().time() + while True: + try: + _, writer = await asyncio.wait_for( + asyncio.open_connection(host, port), timeout=interval + ) + writer.close() + await writer.wait_closed() + return + except (OSError, asyncio.TimeoutError): + if asyncio.get_running_loop().time() - start > timeout: + raise TimeoutError( + f"Port {port} on {host} not available after {timeout}s" + ) from None + await asyncio.sleep(interval) + + @contextmanager def temporary_settings(**kwargs: Any): """ @@ -209,16 +239,20 @@ async def run_server_async( ) ) - # Give the server a moment to start - await asyncio.sleep(0.1) + # Wait for server lifespan to be ready + await server._started.wait() + + # Wait for HTTP port to be listening (lifespan is ready but uvicorn + # may not have bound yet) + await _wait_for_port(host, port) try: yield f"http://{host}:{port}{path}" finally: - # Cleanup: cancel the task + # Cleanup: cancel the task with timeout to avoid hanging on Windows server_task.cancel() - with suppress(asyncio.CancelledError): - await server_task + with suppress(asyncio.CancelledError, asyncio.TimeoutError): + await asyncio.wait_for(server_task, timeout=2.0) @contextmanager diff --git a/tests/cli/test_tasks.py b/tests/cli/test_tasks.py index d0b7994af..a48c44ff7 100644 --- a/tests/cli/test_tasks.py +++ b/tests/cli/test_tasks.py @@ -2,38 +2,23 @@ import pytest -from fastmcp.cli.tasks import check_docket_enabled, tasks_app +from fastmcp.cli.tasks import check_distributed_backend, tasks_app from fastmcp.utilities.tests import temporary_settings -class TestCheckDocketEnabled: - """Test the Docket enabled checker function.""" +class TestCheckDistributedBackend: + """Test the distributed backend checker function.""" - def test_succeeds_when_docket_enabled_with_redis(self): - """Test that it succeeds when Docket is enabled with Redis.""" - with temporary_settings( - enable_docket=True, - docket__url="redis://localhost:6379/0", - ): - check_docket_enabled() - - def test_exits_when_docket_not_enabled(self): - """Test that it exits with helpful error when Docket not enabled.""" - with temporary_settings(enable_docket=False): - with pytest.raises(SystemExit) as exc_info: - check_docket_enabled() - - assert isinstance(exc_info.value, SystemExit) - assert exc_info.value.code == 1 + def test_succeeds_with_redis_url(self): + """Test that it succeeds with Redis URL.""" + with temporary_settings(docket__url="redis://localhost:6379/0"): + check_distributed_backend() def test_exits_with_helpful_error_for_memory_url(self): """Test that it exits with helpful error for memory:// URLs.""" - with temporary_settings( - enable_docket=True, - docket__url="memory://test-123", - ): + with temporary_settings(docket__url="memory://test-123"): with pytest.raises(SystemExit) as exc_info: - check_docket_enabled() + check_distributed_backend() assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 diff --git a/tests/client/tasks/conftest.py b/tests/client/tasks/conftest.py index 4a004de95..a08438659 100644 --- a/tests/client/tasks/conftest.py +++ b/tests/client/tasks/conftest.py @@ -6,10 +6,7 @@ from fastmcp.utilities.tests import temporary_settings @pytest.fixture(autouse=True) -async def enable_docket_and_tasks(): - """Enable Docket and task protocol support for all client task tests.""" - with temporary_settings( - enable_docket=True, - enable_tasks=True, - ): +async def enable_tasks(): + """Enable task protocol support for all client task tests.""" + with temporary_settings(enable_tasks=True): yield diff --git a/tests/server/proxy/test_stateful_proxy_client.py b/tests/server/proxy/test_stateful_proxy_client.py index 9db4114d6..e7dc3cb47 100644 --- a/tests/server/proxy/test_stateful_proxy_client.py +++ b/tests/server/proxy/test_stateful_proxy_client.py @@ -58,8 +58,7 @@ async def stateless_server(stateful_proxy_server: FastMCP): host="127.0.0.1", port=port, stateless_http=True ) ) - async with Client(transport=url) as client: - assert await client.ping() + await stateful_proxy_server._started.wait() yield url task.cancel() try: diff --git a/tests/server/tasks/conftest.py b/tests/server/tasks/conftest.py index 59bf024f6..a527ae70f 100644 --- a/tests/server/tasks/conftest.py +++ b/tests/server/tasks/conftest.py @@ -6,16 +6,12 @@ from fastmcp.utilities.tests import temporary_settings @pytest.fixture(autouse=True) -async def enable_docket_and_tasks(): - """Enable Docket and task protocol support for all task tests.""" - with temporary_settings( - enable_docket=True, - enable_tasks=True, - ): - # Verify both are enabled +async def enable_tasks(): + """Enable task protocol support for all task tests.""" + with temporary_settings(enable_tasks=True): + # Verify enabled import fastmcp - assert fastmcp.settings.enable_docket, "Docket should be enabled after fixture" assert fastmcp.settings.enable_tasks, "Tasks should be enabled after fixture" yield diff --git a/tests/server/tasks/test_progress_dependency.py b/tests/server/tasks/test_progress_dependency.py index 9c1b8ddcb..d6b121ba7 100644 --- a/tests/server/tasks/test_progress_dependency.py +++ b/tests/server/tasks/test_progress_dependency.py @@ -1,11 +1,8 @@ """Tests for FastMCP Progress dependency.""" -import pytest - from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.dependencies import Progress -from fastmcp.utilities.tests import temporary_settings async def test_progress_in_immediate_execution(): @@ -111,24 +108,6 @@ async def test_progress_status_message_in_background_task(): assert result.content[0].text == "done" -async def test_progress_fails_without_docket(): - """Test Progress dependency fails when Docket is not enabled.""" - with temporary_settings(enable_docket=False, enable_tasks=False): - mcp = FastMCP("test") - - @mcp.tool() - async def test_tool(progress: Progress = Progress()) -> str: - return "done" - - async with Client(mcp) as client: - with pytest.raises(Exception) as exc_info: - await client.call_tool("test_tool", {}) - - error_str = str(exc_info.value) - assert "Failed to resolve dependency" in error_str - assert "progress" in error_str - - async def test_inmemory_progress_state(): """Test that in-memory progress stores and returns state correctly.""" mcp = FastMCP("test") diff --git a/tests/server/tasks/test_server_tasks_parameter.py b/tests/server/tasks/test_server_tasks_parameter.py index cc6c38b1c..2a87434f6 100644 --- a/tests/server/tasks/test_server_tasks_parameter.py +++ b/tests/server/tasks/test_server_tasks_parameter.py @@ -81,10 +81,7 @@ async def test_server_tasks_false_defaults_all_components(): async def test_server_tasks_none_uses_settings(): """Server with tasks=None (or omitted) uses global settings.""" # Test with enable_tasks=True in settings - with temporary_settings( - enable_docket=True, - enable_tasks=True, - ): + with temporary_settings(enable_tasks=True): mcp = FastMCP("test") # tasks=None, should use settings @mcp.tool() @@ -97,10 +94,7 @@ async def test_server_tasks_none_uses_settings(): assert not tool_task.returned_immediately # Test with enable_tasks=False in settings - with temporary_settings( - enable_docket=True, - enable_tasks=False, - ): + with temporary_settings(enable_tasks=False): mcp2 = FastMCP("test2") # tasks=None, should use settings @mcp2.tool() @@ -249,10 +243,7 @@ async def test_mixed_explicit_and_inherited(): async def test_server_tasks_parameter_sets_component_defaults(): """Server tasks parameter sets component defaults but global settings gate protocol.""" # Server tasks=True sets component defaults, but enable_tasks must be True - with temporary_settings( - enable_docket=True, - enable_tasks=True, - ): + with temporary_settings(enable_tasks=True): mcp = FastMCP("test", tasks=True) @mcp.tool() @@ -265,10 +256,7 @@ async def test_server_tasks_parameter_sets_component_defaults(): assert not tool_task.returned_immediately # Server tasks=False sets component defaults - with temporary_settings( - enable_docket=True, - enable_tasks=True, - ): + with temporary_settings(enable_tasks=True): mcp2 = FastMCP("test2", tasks=False) @mcp2.tool() diff --git a/tests/server/tasks/test_task_capabilities.py b/tests/server/tasks/test_task_capabilities.py index 8e7177402..4ad861445 100644 --- a/tests/server/tasks/test_task_capabilities.py +++ b/tests/server/tasks/test_task_capabilities.py @@ -4,8 +4,6 @@ Tests for SEP-1686 task capabilities declaration. Verifies that the server correctly advertises task support based on settings. """ -import pytest - from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.utilities.tests import temporary_settings @@ -13,10 +11,7 @@ from fastmcp.utilities.tests import temporary_settings async def test_capabilities_include_tasks_when_enabled(): """Server capabilities include tasks when enable_tasks=True.""" - with temporary_settings( - enable_docket=True, - enable_tasks=True, - ): + with temporary_settings(enable_tasks=True): mcp = FastMCP("capability-test") @mcp.tool() @@ -40,10 +35,7 @@ async def test_capabilities_include_tasks_when_enabled(): async def test_capabilities_exclude_tasks_when_disabled(): """Server capabilities do NOT include tasks when enable_tasks=False.""" - with temporary_settings( - enable_docket=True, - enable_tasks=False, - ): + with temporary_settings(enable_tasks=False): mcp = FastMCP("capability-test") @mcp.tool() @@ -59,51 +51,9 @@ async def test_capabilities_exclude_tasks_when_disabled(): assert "tasks" not in init_result.capabilities.experimental -async def test_capabilities_exclude_tasks_when_docket_disabled(): - """Server capabilities do NOT include tasks when enable_docket=False.""" - with temporary_settings( - enable_docket=False, - enable_tasks=False, - ): - mcp = FastMCP("capability-test") - - @mcp.tool() - def test_tool() -> str: - return "test" - - async with Client(mcp) as client: - # Get server initialization result - init_result = client.initialize_result - - # Verify tasks capability is NOT present - if init_result.capabilities.experimental: - assert "tasks" not in init_result.capabilities.experimental - - -async def test_enable_tasks_requires_enable_docket(): - """Setting enable_tasks=True without enable_docket=True raises error at server startup.""" - with temporary_settings( - enable_docket=False, - enable_tasks=True, - ): - mcp = FastMCP("config-test") - - @mcp.tool() - async def test_tool() -> str: - return "test" - - # Should fail when trying to start server (during lifespan) - with pytest.raises(RuntimeError, match="requires.*enable_docket.*enable_tasks"): - async with Client(mcp): - pass # Should never reach here - - async def test_client_advertises_task_capability_when_enabled(): """Client advertises experimental.tasks capability when enable_tasks=True.""" - with temporary_settings( - enable_docket=True, - enable_tasks=True, - ): + with temporary_settings(enable_tasks=True): mcp = FastMCP("client-cap-test") @mcp.tool() @@ -117,10 +67,7 @@ async def test_client_advertises_task_capability_when_enabled(): async def test_client_does_not_advertise_tasks_when_disabled(): """Client does NOT use custom session when enable_tasks=False.""" - with temporary_settings( - enable_docket=True, - enable_tasks=False, - ): + with temporary_settings(enable_tasks=False): mcp = FastMCP("no-tasks-client-test") @mcp.tool() diff --git a/tests/server/test_logging.py b/tests/server/test_logging.py index 1a3c09a63..cfc376c0b 100644 --- a/tests/server/test_logging.py +++ b/tests/server/test_logging.py @@ -36,7 +36,7 @@ async def test_uvicorn_logging_default_level( server_task = asyncio.create_task( mcp_server.run_http_async(log_level=test_log_level, port=8003) ) - await asyncio.sleep(0.01) + await mcp_server._started.wait() mock_uvicorn_config_constructor.assert_called_once() _, kwargs_config = mock_uvicorn_config_constructor.call_args @@ -96,7 +96,7 @@ async def test_uvicorn_logging_with_custom_log_config( uvicorn_config={"log_config": sample_log_config}, port=8004 ) ) - await asyncio.sleep(0.01) + await mcp_server._started.wait() mock_uvicorn_config_constructor.assert_called_once() _, kwargs_config = mock_uvicorn_config_constructor.call_args @@ -159,7 +159,7 @@ async def test_uvicorn_logging_custom_log_config_overrides_log_level_param( port=8005, ) ) - await asyncio.sleep(0.01) + await mcp_server._started.wait() mock_uvicorn_config_constructor.assert_called_once() _, kwargs_config = mock_uvicorn_config_constructor.call_args diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index 844fd2464..0ab7dcb55 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -950,10 +950,8 @@ class TestAsProxyKwarg: async with Client(mcp) as client: await client.call_tool("hello", {}) - assert len(lifespan_check) > 0 - # in the present implementation the sub server will be invoked 3 times - # to call its tool - assert lifespan_check.count("start") >= 2 + # Lifespan is entered exactly once and kept alive by Docket worker + assert lifespan_check == ["start"] class TestResourceNamePrefixing: diff --git a/tests/server/test_server_docket.py b/tests/server/test_server_docket.py index 442d72517..44fe050ff 100644 --- a/tests/server/test_server_docket.py +++ b/tests/server/test_server_docket.py @@ -3,43 +3,19 @@ import asyncio from contextlib import asynccontextmanager -import pytest from docket import Docket from docket.worker import Worker from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.dependencies import CurrentDocket, CurrentWorker -from fastmcp.exceptions import ToolError from fastmcp.server.dependencies import get_context -from fastmcp.utilities.tests import temporary_settings HUZZAH = "huzzah!" -@pytest.fixture(autouse=True) -def enable_docket(): - """Enable Docket support for all tests in this suite.""" - with temporary_settings(enable_docket=True): - yield - - -async def test_docket_disabled(): - """Verify that Docket errors when flag is disabled.""" - with temporary_settings(enable_docket=False): - mcp = FastMCP("test-server") - - @mcp.tool() - def needs_docket(docket: Docket = CurrentDocket()) -> str: - return f"Got docket: {type(docket).__name__}" - - async with Client(mcp) as client: - with pytest.raises(ToolError, match="Failed to resolve dependency"): - await client.call_tool("needs_docket", {}) - - -async def test_current_docket_with_flag_enabled(): - """CurrentDocket dependency works when experimental flag is enabled.""" +async def test_current_docket(): + """CurrentDocket dependency provides access to Docket instance.""" mcp = FastMCP("test-server") @mcp.tool() @@ -52,8 +28,8 @@ async def test_current_docket_with_flag_enabled(): assert HUZZAH in str(result) -async def test_current_worker_with_flag_enabled(): - """CurrentWorker dependency works when experimental flag is enabled.""" +async def test_current_worker(): + """CurrentWorker dependency provides access to Worker instance.""" mcp = FastMCP("test-server") @mcp.tool() @@ -100,7 +76,7 @@ async def test_worker_executes_background_tasks(): async def test_current_docket_in_resource(): - """CurrentDocket works in resources when flag is enabled.""" + """CurrentDocket works in resources.""" mcp = FastMCP("test-server") @mcp.resource("docket://info") @@ -114,7 +90,7 @@ async def test_current_docket_in_resource(): async def test_current_docket_in_prompt(): - """CurrentDocket works in prompts when flag is enabled.""" + """CurrentDocket works in prompts.""" mcp = FastMCP("test-server") @mcp.prompt() @@ -128,7 +104,7 @@ async def test_current_docket_in_prompt(): async def test_current_docket_in_resource_template(): - """CurrentDocket works in resource templates when flag is enabled.""" + """CurrentDocket works in resource templates.""" mcp = FastMCP("test-server") @mcp.resource("docket://tasks/{task_id}") diff --git a/tests/server/test_server_lifespan.py b/tests/server/test_server_lifespan.py index a4373a4a7..e09198e61 100644 --- a/tests/server/test_server_lifespan.py +++ b/tests/server/test_server_lifespan.py @@ -17,9 +17,11 @@ class TestServerLifespan: @asynccontextmanager async def server_lifespan(mcp: FastMCP) -> AsyncIterator[dict[str, Any]]: - _ = lifespan_events.append("enter") - yield {"initialized": True} - _ = lifespan_events.append("exit") + lifespan_events.append("enter") + try: + yield {"initialized": True} + finally: + lifespan_events.append("exit") mcp = FastMCP("TestServer", lifespan=server_lifespan)