60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
269 lines
9.1 KiB
Python
269 lines
9.1 KiB
Python
"""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
|