diff --git a/pyproject.toml b/pyproject.toml index 5d3e1cda4..96f20d4b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "mcp>=1.24.0", "openapi-pydantic>=0.5.1", "platformdirs>=4.0.0", - "pydocket>=0.16.0", + "pydocket>=0.16.3", "rich>=13.9.4", "cyclopts>=4.0.0", "authlib>=1.6.5", @@ -187,3 +187,4 @@ reportConstantRedefinition = false [tool.codespell] ignore-words-list = "asend,shttp,te" + diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 28914943b..d11631002 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -470,12 +470,9 @@ class FastMCP(Generic[LifespanResultT]): try: yield finally: - # Cancel worker task on exit with timeout to prevent hanging worker_task.cancel() - with suppress( - asyncio.CancelledError, asyncio.TimeoutError - ): - await asyncio.wait_for(worker_task, timeout=2.0) + with suppress(asyncio.CancelledError): + await worker_task finally: _current_worker.reset(worker_token) finally: diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index baf275677..0ddc071bc 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -1,6 +1,7 @@ import asyncio import json import sys +from contextlib import suppress from unittest.mock import AsyncMock, call import pytest @@ -107,8 +108,8 @@ async def nested_server(): from fastmcp.utilities.http import find_available_port - server = create_test_server() - mcp_app = server.http_app(path="/final/mcp") + mcp_server = create_test_server() + mcp_app = mcp_server.http_app(path="/final/mcp") # Nest the app under multiple mounts to test URL resolution inner = Starlette(routes=[Mount("/nest-inner", app=mcp_app)]) @@ -125,20 +126,19 @@ async def nested_server(): port=port, log_level="critical", ws="websockets-sansio", + timeout_graceful_shutdown=0, ) - # Use the simple asyncio pattern - server_task = asyncio.create_task(uvicorn.Server(config).serve()) + uvicorn_server = uvicorn.Server(config) + server_task = asyncio.create_task(uvicorn_server.serve()) await asyncio.sleep(0.1) yield f"http://127.0.0.1:{port}/nest-outer/nest-inner/final/mcp" - # Cleanup - server_task.cancel() - try: - await server_task - except asyncio.CancelledError: - pass + # Cleanup: signal uvicorn to shutdown, then cancel the task + uvicorn_server.should_exit = True + with suppress(asyncio.CancelledError, asyncio.TimeoutError): + await asyncio.wait_for(server_task, timeout=2.0) async def test_ping(streamable_http_server: str): diff --git a/tests/client/transports/test_uv_transport.py b/tests/client/transports/test_uv_transport.py index 020f084ba..2917045b8 100644 --- a/tests/client/transports/test_uv_transport.py +++ b/tests/client/transports/test_uv_transport.py @@ -5,10 +5,15 @@ from pathlib import Path import pytest +import fastmcp from fastmcp.client import Client from fastmcp.client.client import CallToolResult -from fastmcp.client.transports import ( - UvStdioTransport, +from fastmcp.client.transports import StdioTransport, UvStdioTransport + +# Detect if running from dev install to use local source instead of PyPI +_is_dev_install = "dev" in fastmcp.__version__ +_fastmcp_src_dir = ( + Path(__file__).parent.parent.parent.parent if _is_dev_install else None ) @@ -79,15 +84,32 @@ async def test_uv_transport_module(): main_file = module_dir / "__main__.py" _ = main_file.write_text(main_script) - client: Client[UvStdioTransport] = Client( - transport=UvStdioTransport( + # In dev installs, use --with-editable to install local source. + # In releases, use --with to install from PyPI. + if _is_dev_install and _fastmcp_src_dir: + transport: StdioTransport = StdioTransport( + command="uv", + args=[ + "run", + "--directory", + tmpdir, + "--with-editable", + str(_fastmcp_src_dir), + "--module", + "my_module", + ], + keep_alive=False, + ) + else: + transport = UvStdioTransport( with_packages=["fastmcp"], command="my_module", module=True, project_directory=Path(tmpdir), keep_alive=False, ) - ) + + client: Client[StdioTransport] = Client(transport=transport) async with client: result: CallToolResult = await client.call_tool("add", {"x": 1, "y": 2}) diff --git a/tests/server/tasks/test_task_methods.py b/tests/server/tasks/test_task_methods.py index ba8b6e64d..ee971b2e7 100644 --- a/tests/server/tasks/test_task_methods.py +++ b/tests/server/tasks/test_task_methods.py @@ -172,3 +172,46 @@ async def test_task_cancellation_workflow(endpoint_server): # Task should be in cancelled state status = await task.status() assert status.status == "cancelled" + + +async def test_task_cancellation_interrupts_running_coroutine(endpoint_server): + """Task cancellation actually interrupts the running coroutine. + + This verifies that when a task is cancelled, the underlying asyncio + coroutine receives CancelledError rather than continuing to completion. + Requires pydocket >= 0.16.2. + + See: https://github.com/jlowin/fastmcp/issues/2679 + """ + started = asyncio.Event() + was_interrupted = asyncio.Event() + completed_normally = asyncio.Event() + + @endpoint_server.tool(task=True) + async def interruptible_tool() -> str: + started.set() + try: + await asyncio.sleep(60) + completed_normally.set() + return "completed" + except asyncio.CancelledError: + was_interrupted.set() + raise + + async with Client(endpoint_server) as client: + task = await client.call_tool("interruptible_tool", {}, task=True) + + # Wait for the tool to actually start executing + await asyncio.wait_for(started.wait(), timeout=5.0) + + # Cancel the task + await task.cancel() + + # Wait for cancellation to propagate + await asyncio.wait_for(was_interrupted.wait(), timeout=5.0) + + # The coroutine should have been interrupted, not completed normally + assert was_interrupted.is_set(), "Task was not interrupted by cancellation" + assert not completed_normally.is_set(), ( + "Task completed instead of being cancelled" + ) diff --git a/uv.lock b/uv.lock index 08c5f65c3..7293eaaf8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.11'", @@ -752,7 +752,7 @@ requires-dist = [ { name = "platformdirs", specifier = ">=4.0.0" }, { name = "py-key-value-aio", extras = ["disk", "keyring", "memory"], specifier = ">=0.3.0,<0.4.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, - { name = "pydocket", specifier = ">=0.16.0" }, + { name = "pydocket", specifier = ">=0.16.3" }, { name = "pyperclip", specifier = ">=1.9.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "rich", specifier = ">=13.9.4" }, @@ -1775,7 +1775,7 @@ wheels = [ [[package]] name = "pydocket" -version = "0.16.0" +version = "0.16.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cloudpickle" }, @@ -1792,9 +1792,9 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/d3/ff57fb8ba8180c621b17270a272ee8821a64962887c42962c5a342ab82a9/pydocket-0.16.0.tar.gz", hash = "sha256:675e829a7ea6e978fdbbe0ace342f24c8ec7cf8ed5980694215418e59ff86005", size = 286316, upload-time = "2025-12-18T15:14:53.139Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/c5/61dcfce4d50b66a3f09743294d37fab598b81bb0975054b7f732da9243ec/pydocket-0.16.3.tar.gz", hash = "sha256:78e9da576de09e9f3f410d2471ef1c679b7741ddd21b586c97a13872b69bd265", size = 297080, upload-time = "2025-12-23T23:37:33.32Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/74/dcf56132e03a24e2591c5f2391902c871e5f1248e0fc175b9f1792e4afaf/pydocket-0.16.0-py3-none-any.whl", hash = "sha256:3d7d0a805bcb68a3a03d96d3c6ba8e607584ed14b0fe3879333a8fff652dda92", size = 61064, upload-time = "2025-12-18T15:14:51.506Z" }, + { url = "https://files.pythonhosted.org/packages/2c/94/93b7f5981aa04f922e0d9ce7326a4587866ec7e39f7c180ffcf408e66ee8/pydocket-0.16.3-py3-none-any.whl", hash = "sha256:e2b50925356e7cd535286255195458ac7bba15f25293356651b36d223db5dd7c", size = 67087, upload-time = "2025-12-23T23:37:31.829Z" }, ] [[package]]