"""Tests for the run_in_thread flag on sync tools. Sync tools default to running on a worker thread so they don't block the event loop. ``run_in_thread=False`` opts out and runs them inline on the event loop thread — useful for libraries with thread affinity (Windows COM, tkinter, etc.). """ from __future__ import annotations import asyncio import threading from collections.abc import AsyncIterator import pytest from mcp_types import TextContent from fastmcp import Context, FastMCP from fastmcp.tools.base import Tool async def _loop_thread_id() -> int: return threading.get_ident() class TestRunInThread: async def test_sync_default_runs_in_worker_thread(self): """Default sync dispatch runs on a thread distinct from the loop's.""" mcp = FastMCP() loop_tid = await _loop_thread_id() @mcp.tool def where_am_i() -> int: return threading.get_ident() result = await mcp.call_tool("where_am_i") assert result.structured_content is not None tid = result.structured_content["result"] assert tid != loop_tid async def test_sync_run_in_thread_false_runs_on_loop_thread(self): """run_in_thread=False runs the sync fn on the event loop thread.""" mcp = FastMCP() loop_tid = await _loop_thread_id() @mcp.tool(run_in_thread=False) def where_am_i() -> int: return threading.get_ident() result = await mcp.call_tool("where_am_i") assert result.structured_content is not None assert result.structured_content["result"] == loop_tid async def test_sync_with_context_runs_on_loop_thread(self): """run_in_thread=False must also apply to sync tools with injected Context (or Depends). Without this, without_injected_parameters() wraps the sync fn into an async wrapper that unconditionally offloads to the thread pool — silently defeating run_in_thread=False for the primary thread-affinity use case (COM/tkinter tools that also want a Context for logging). """ mcp = FastMCP() loop_tid = await _loop_thread_id() @mcp.tool(run_in_thread=False) def where_am_i(ctx: Context) -> int: # Context is injected; returning threading.get_ident() verifies # dispatch thread, not schema generation. assert ctx is not None return threading.get_ident() result = await mcp.call_tool("where_am_i") assert result.structured_content is not None assert result.structured_content["result"] == loop_tid def test_sync_run_in_thread_false_rejects_timeout(self): """Combining timeout with run_in_thread=False on a sync fn is rejected. Inline execution has no cancellation checkpoints, so anyio.fail_after cannot preempt the call — accepting the combination would silently render the timeout a no-op. We force users to make an explicit choice. """ mcp = FastMCP() with pytest.raises(ValueError, match="timeout cannot be enforced"): @mcp.tool(run_in_thread=False, timeout=5.0) def blocked() -> str: return "unreachable" async def test_async_tool_allows_timeout_and_run_in_thread_false(self): """run_in_thread is a no-op for async fns, so pairing with timeout is fine.""" mcp = FastMCP() @mcp.tool(run_in_thread=False, timeout=5.0) async def ok() -> str: return "ok" result = await mcp.call_tool("ok") assert isinstance(result.content[0], TextContent) assert result.content[0].text == "ok" def test_async_generator_allowed_with_timeout_and_run_in_thread_false(self): """Async generators are async even though is_coroutine_function is False. Registration must not over-block this shape — the generator's iteration has await points, so timeout enforcement still works. """ from fastmcp.tools.base import Tool async def stream() -> AsyncIterator[str]: yield "a" yield "b" # Must not raise. Tool.from_function(stream, timeout=5.0, run_in_thread=False) async def test_async_tool_unaffected_by_run_in_thread_flag(self): """The flag is a no-op for async tools (they already run on the loop).""" mcp = FastMCP() loop_tid = await _loop_thread_id() @mcp.tool(run_in_thread=False) async def where_am_i() -> int: return threading.get_ident() result = await mcp.call_tool("where_am_i") assert result.structured_content is not None assert result.structured_content["result"] == loop_tid async def test_run_in_thread_false_blocks_other_tasks(self): """A sync tool with run_in_thread=False blocks the event loop. This documents the tradeoff: while the tool runs inline, no other task on the loop makes progress. Contrast with the default path, where the sync call is offloaded and concurrent tasks continue. """ mcp = FastMCP() @mcp.tool(run_in_thread=False) def blocking() -> str: import time time.sleep(0.2) return "done" ticks = 0 async def tick() -> None: nonlocal ticks while True: await asyncio.sleep(0.02) ticks += 1 task = asyncio.create_task(tick()) try: result = await mcp.call_tool("blocking") finally: task.cancel() with pytest.raises(asyncio.CancelledError): await task assert isinstance(result.content[0], TextContent) assert result.content[0].text == "done" # With inline execution, ticks should be near zero — the 200ms sleep # blocks the loop. Under a thread pool (default), ticks would be ~10. assert ticks <= 2 async def test_default_threadpool_permits_concurrency(self): """Sanity check: the default path does not block the loop.""" mcp = FastMCP() @mcp.tool def blocking() -> str: import time time.sleep(0.2) return "done" ticks = 0 async def tick() -> None: nonlocal ticks while True: await asyncio.sleep(0.02) ticks += 1 task = asyncio.create_task(tick()) try: result = await mcp.call_tool("blocking") finally: task.cancel() with pytest.raises(asyncio.CancelledError): await task assert isinstance(result.content[0], TextContent) assert result.content[0].text == "done" assert ticks >= 5 class TestRunInThreadViaStandaloneDecorator: async def test_standalone_tool_decorator_accepts_run_in_thread(self): from fastmcp.tools.function_tool import tool as tool_decorator @tool_decorator(run_in_thread=False) def fn() -> int: return threading.get_ident() mcp = FastMCP() mcp.add_tool(fn) loop_tid = await _loop_thread_id() result = await mcp.call_tool("fn") assert result.structured_content is not None assert result.structured_content["result"] == loop_tid class TestRunInThreadViaFileSystemProvider: async def test_filesystem_provider_respects_run_in_thread(self, tmp_path): """Tools discovered by FileSystemProvider honor run_in_thread=False. FileSystemProvider extends LocalProvider and registers filesystem- discovered tools via add_tool(), which reads ToolMeta.run_in_thread attached by the standalone @tool decorator. """ from fastmcp.server.providers import FileSystemProvider (tmp_path / "where.py").write_text( "import threading\n" "from fastmcp.tools import tool\n\n" "@tool(run_in_thread=False)\n" "def where_am_i() -> int:\n" " return threading.get_ident()\n" ) provider = FileSystemProvider(tmp_path) mcp = FastMCP(providers=[provider]) loop_tid = await _loop_thread_id() result = await mcp.call_tool("where_am_i") assert result.structured_content is not None assert result.structured_content["result"] == loop_tid async def test_filesystem_provider_forwards_timeout(self, tmp_path): """Filesystem discovery forwards `timeout` from ToolMeta. Previously dropped, which let sync tools discovered via the filesystem bypass both timeout enforcement and the registration-time guard against combining timeout with run_in_thread=False. """ from fastmcp.server.providers import FileSystemProvider (tmp_path / "t.py").write_text( "from fastmcp.tools import tool\n\n" "@tool(timeout=5.0)\n" "def quick() -> str:\n" " return 'ok'\n" ) provider = FileSystemProvider(tmp_path) discovered = [ c for c in provider._components.values() if isinstance(c, Tool) and c.name == "quick" ] assert len(discovered) == 1 assert discovered[0].timeout == 5.0