Add run_in_thread opt-out for sync tools with thread affinity (#4010)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jeremiah Lowin 2026-04-22 10:31:44 -04:00 committed by GitHub
commit 74efa32edf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 398 additions and 5 deletions

View file

@ -132,6 +132,10 @@ def search_products_implementation(query: str, category: str | None = None) -> l
Optional JSON schema for the tool's output. When provided, the tool must return structured output matching this schema. If not provided, FastMCP automatically generates a schema from the function's return type annotation. See [Output Schemas](#output-schemas) for details.
</ParamField>
<ParamField body="run_in_thread" type="bool" default="True">
Applies to sync tool functions only. When `True` (default), sync functions are dispatched to a thread pool so they don't block the event loop. Set to `False` to run the function inline on the event loop thread — useful for libraries with thread affinity like Windows COM (`pywin32`, `uiautomation`, `comtypes`), `tkinter`, or certain GPU/driver bindings. Ignored for async functions, which always run on the event loop. See [Thread affinity](#thread-affinity) for details.
</ParamField>
</Card>
### Using with Methods
@ -175,6 +179,28 @@ def slow_tool(x: int) -> int:
For I/O-bound operations like network requests or database queries, async tools are still preferred since they're more efficient than threadpool dispatch. Use sync tools when working with synchronous libraries or for simple operations where the threading overhead doesn't matter.
### Thread affinity
This section applies to sync tools only. Async tools already run on the event loop and are not affected.
Some libraries bind state to the thread they're first used from and break when called from a different thread. The most common case is Windows COM — libraries like `uiautomation`, `comtypes`, and parts of `pywin32` require `CoInitialize` to have been called on the current thread, and worker-pool threads don't initialize COM by default. Similar constraints apply to `tkinter`, some GPU bindings (CUDA contexts), and certain hardware drivers.
For these cases, pass `run_in_thread=False` so FastMCP invokes the sync function inline on the event loop thread instead of dispatching it to a worker:
```python
import uiautomation as auto
@mcp.tool(run_in_thread=False)
def list_windows() -> list[str]:
"""List desktop windows via Windows UI Automation (COM)."""
desktop = auto.GetRootControl()
return [w.Name for w in desktop.GetChildren()[:5]]
```
The tradeoff is that the event loop is blocked for the duration of the call — other in-flight requests wait until the tool returns. Keep `run_in_thread=False` reserved for tools that genuinely need thread affinity, and prefer short-running calls in that path.
Inline sync calls have no cancellation checkpoints, so `timeout` cannot interrupt them. Combining `timeout` with `run_in_thread=False` on a sync function is rejected at registration — drop one or the other.
## Arguments
By default, FastMCP converts Python functions into MCP tools by inspecting the function's signature and type annotations. This allows you to use standard Python type annotations for your tools. In general, the framework strives to "just work": idiomatic Python behaviors like parameter defaults and type annotations are automatically translated into MCP schemas. However, there are a number of ways to customize the behavior of your tools.

View file

@ -536,7 +536,9 @@ def get_access_token() -> AccessToken | None:
@lru_cache(maxsize=5000)
def without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]:
def without_injected_parameters(
fn: Callable[..., Any], *, run_in_thread: bool = True
) -> Callable[..., Any]:
"""Create a wrapper function without injected parameters.
Returns a wrapper that excludes Context and Docket dependency parameters,
@ -550,6 +552,10 @@ def without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]:
Args:
fn: Original function with Context and/or dependencies
run_in_thread: For sync ``fn``, whether to dispatch the call to a worker
thread after resolving dependencies. Defaults to True. Set to False
to call ``fn`` inline on the event loop thread required for
thread-affinity libraries (e.g. Windows COM). Ignored for async fns.
Returns:
Async wrapper function without injected parameters
@ -583,13 +589,19 @@ def without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]:
async with resolve_dependencies(fn, user_kwargs) as resolved_kwargs:
if fn_is_async:
return await fn(**resolved_kwargs)
else:
elif run_in_thread:
# Run sync functions in threadpool to avoid blocking the event loop
result = await call_sync_fn_in_threadpool(fn, **resolved_kwargs)
# Handle sync wrappers that return awaitables (e.g., partial(async_fn))
if inspect.isawaitable(result):
result = await result
return result
else:
# Call inline on the event loop thread (thread affinity opt-in).
result = fn(**resolved_kwargs)
if inspect.isawaitable(result):
result = await result
return result
# Resolve string annotations (from `from __future__ import annotations`) using
# the original function's module context. The wrapper's __globals__ points to

View file

@ -295,7 +295,9 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]:
task=resolved_task,
exclude_args=meta.exclude_args,
serializer=meta.serializer,
timeout=meta.timeout,
auth=meta.auth,
run_in_thread=meta.run_in_thread,
)
components.append(tool)
elif isinstance(meta, ResourceMeta):

View file

@ -165,6 +165,7 @@ class ToolDecoratorMixin:
serializer=fmeta.serializer,
timeout=fmeta.timeout,
auth=fmeta.auth,
run_in_thread=fmeta.run_in_thread,
)
else:
tool = Tool.from_function(tool)
@ -194,6 +195,7 @@ class ToolDecoratorMixin:
serializer: ToolResultSerializerType | None = None, # Deprecated
timeout: float | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
run_in_thread: bool = True,
) -> F: ...
@overload
@ -216,6 +218,7 @@ class ToolDecoratorMixin:
serializer: ToolResultSerializerType | None = None, # Deprecated
timeout: float | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
run_in_thread: bool = True,
) -> Callable[[F], F]: ...
# NOTE: This method mirrors fastmcp.tools.tool() but adds registration,
@ -241,6 +244,7 @@ class ToolDecoratorMixin:
serializer: ToolResultSerializerType | None = None, # Deprecated
timeout: float | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
run_in_thread: bool = True,
) -> (
Callable[[AnyFunction], FunctionTool]
| FunctionTool
@ -345,6 +349,7 @@ class ToolDecoratorMixin:
task=resolved_task,
timeout=timeout,
auth=auth,
run_in_thread=run_in_thread,
)
self._add_component(tool_obj)
if not enabled:
@ -370,6 +375,7 @@ class ToolDecoratorMixin:
timeout=timeout,
auth=auth,
enabled=enabled,
run_in_thread=run_in_thread,
)
target = fn.__func__ if hasattr(fn, "__func__") else fn
target.__fastmcp__ = metadata # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
@ -413,4 +419,5 @@ class ToolDecoratorMixin:
serializer=serializer,
timeout=timeout,
auth=auth,
run_in_thread=run_in_thread,
)

View file

@ -1613,6 +1613,7 @@ class FastMCP(
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
run_in_thread: bool = True,
) -> F: ...
@overload
@ -1634,6 +1635,7 @@ class FastMCP(
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
run_in_thread: bool = True,
) -> Callable[[F], F]: ...
def tool(
@ -1654,6 +1656,7 @@ class FastMCP(
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
run_in_thread: bool = True,
) -> (
Callable[[AnyFunction], FunctionTool]
| FunctionTool
@ -1731,6 +1734,7 @@ class FastMCP(
task=task if task is not None else self._support_tasks_by_default,
timeout=timeout,
auth=auth,
run_in_thread=run_in_thread,
)
return result

View file

@ -233,6 +233,7 @@ class Tool(FastMCPComponent):
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
run_in_thread: bool | None = None,
) -> FunctionTool:
"""Create a Tool from a function."""
from fastmcp.tools.function_tool import FunctionTool
@ -253,6 +254,7 @@ class Tool(FastMCPComponent):
task=task,
timeout=timeout,
auth=auth,
run_in_thread=run_in_thread,
)
async def run(self, arguments: dict[str, Any]) -> ToolResult:

View file

@ -85,11 +85,27 @@ class ToolMeta:
timeout: float | None = None
auth: AuthCheck | list[AuthCheck] | None = None
enabled: bool = True
run_in_thread: bool = True
class FunctionTool(Tool):
fn: SkipJsonSchema[Callable[..., Any]]
return_type: Annotated[SkipJsonSchema[Any], Field(exclude=True)] = None
run_in_thread: Annotated[
bool,
Field(
description=(
"Applies to sync tool functions only. When True (default), sync "
"functions are dispatched to a worker thread so they don't block "
"the event loop. Set to False to run the sync function inline on "
"the event loop thread — useful for libraries with thread "
"affinity (e.g. Windows COM, tkinter). Ignored for async functions, "
"which always run on the event loop. Cannot be combined with "
"`timeout` on a sync function: inline calls have no cancellation "
"checkpoints, so the timeout would be a silent no-op."
)
),
] = True
@classmethod
def from_function(
@ -112,6 +128,7 @@ class FunctionTool(Tool):
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
run_in_thread: bool | None = None,
) -> FunctionTool:
"""Create a FunctionTool from a function.
@ -139,6 +156,7 @@ class FunctionTool(Tool):
serializer,
timeout,
auth,
run_in_thread,
]
)
or output_schema is not NotSet
@ -168,6 +186,7 @@ class FunctionTool(Tool):
serializer=serializer,
timeout=timeout,
auth=auth,
run_in_thread=True if run_in_thread is None else run_in_thread,
)
if metadata.serializer is not None and fastmcp.settings.deprecation_warnings:
@ -193,6 +212,26 @@ class FunctionTool(Tool):
if func_name == "<lambda>":
raise ValueError("You must provide a name for lambda functions")
# Inline sync execution has no cancellation checkpoints, so
# anyio.fail_after cannot preempt the call — the timeout would be
# silently ignored. Reject the combination so users make an
# explicit choice. Async generators are async even though
# is_coroutine_function returns False for them; the generator's
# iteration has checkpoints, so timeout enforcement still works.
if (
metadata.timeout is not None
and not metadata.run_in_thread
and not is_coroutine_function(fn)
and not inspect.isasyncgenfunction(fn)
):
raise ValueError(
f"Tool {func_name!r}: timeout cannot be enforced when "
"run_in_thread=False on a sync function. Inline execution has "
"no cancellation checkpoints, so the timeout would be a no-op. "
"Either drop the timeout or remove run_in_thread=False and "
"accept worker-thread dispatch."
)
# Normalize task to TaskConfig
task_value = metadata.task
if task_value is None:
@ -235,14 +274,20 @@ class FunctionTool(Tool):
task_config=task_config,
timeout=metadata.timeout,
auth=metadata.auth,
run_in_thread=metadata.run_in_thread,
)
async def run(self, arguments: dict[str, Any]) -> ToolResult:
"""Run the tool with arguments."""
wrapper_fn = without_injected_parameters(self.fn)
wrapper_fn = without_injected_parameters(
self.fn, run_in_thread=self.run_in_thread
)
type_adapter = get_cached_typeadapter(wrapper_fn)
# Apply timeout if configured
# Apply timeout if configured. Combining timeout with
# run_in_thread=False on a sync function is rejected at
# registration (see FunctionTool.from_function), so the timeout
# path here only needs to handle async and threadpool-sync.
if self.timeout is not None:
try:
with anyio.fail_after(self.timeout):
@ -276,12 +321,16 @@ class FunctionTool(Tool):
# No timeout: use existing execution path
if is_coroutine_function(wrapper_fn):
result = await type_adapter.validate_python(arguments)
else:
elif self.run_in_thread:
result = await call_sync_fn_in_threadpool(
type_adapter.validate_python, arguments
)
if inspect.isawaitable(result):
result = await result
else:
result = type_adapter.validate_python(arguments)
if inspect.isawaitable(result):
result = await result
result = await self._materialize_generator(result)
return self.convert_result(result)
@ -356,6 +405,7 @@ def tool(
serializer: Any | None = None,
timeout: float | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
run_in_thread: bool = True,
) -> Callable[[F], F]: ...
@overload
def tool(
@ -375,6 +425,7 @@ def tool(
serializer: Any | None = None,
timeout: float | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
run_in_thread: bool = True,
) -> Callable[[F], F]: ...
@ -395,11 +446,22 @@ def tool(
serializer: Any | None = None,
timeout: float | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
run_in_thread: bool = True,
) -> Any:
"""Standalone decorator to mark a function as an MCP tool.
Returns the original function with metadata attached. Register with a server
using mcp.add_tool().
Args:
run_in_thread: Applies to sync tool functions only. When True (default),
the sync function is dispatched to a worker thread so it does not
block the event loop. Set to False to run the function inline on the
event loop thread useful for libraries with thread affinity
(e.g. Windows COM via `uiautomation`/`comtypes`/`pywin32`, `tkinter`,
some GPU/driver bindings). Ignored for async functions. Cannot be
combined with `timeout` on a sync function: inline calls have no
cancellation checkpoints, so the timeout would be a silent no-op.
"""
if isinstance(annotations, dict):
annotations = ToolAnnotations(**annotations)
@ -427,6 +489,7 @@ def tool(
serializer=serializer,
timeout=timeout,
auth=auth,
run_in_thread=run_in_thread,
)
return FunctionTool.from_function(fn, metadata=tool_meta)
@ -446,6 +509,7 @@ def tool(
serializer=serializer,
timeout=timeout,
auth=auth,
run_in_thread=run_in_thread,
)
target = fn.__func__ if hasattr(fn, "__func__") else fn
target.__fastmcp__ = metadata

View file

@ -0,0 +1,268 @@
"""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

View file

@ -49,6 +49,7 @@ class TestToolFromFunction:
"mode": "forbidden",
"poll_interval": timedelta(seconds=5),
},
"run_in_thread": True,
}
)
@ -100,6 +101,7 @@ class TestToolFromFunction:
"mode": "forbidden",
"poll_interval": timedelta(seconds=5),
},
"run_in_thread": True,
}
)
@ -137,6 +139,7 @@ class TestToolFromFunction:
"mode": "forbidden",
"poll_interval": timedelta(seconds=5),
},
"run_in_thread": True,
}
)
@ -174,6 +177,7 @@ class TestToolFromFunction:
"mode": "forbidden",
"poll_interval": timedelta(seconds=5),
},
"run_in_thread": True,
}
)
@ -220,6 +224,7 @@ class TestToolFromFunction:
"mode": "forbidden",
"poll_interval": timedelta(seconds=5),
},
"run_in_thread": True,
}
)
@ -287,6 +292,7 @@ class TestToolFromFunction:
"mode": "forbidden",
"poll_interval": timedelta(seconds=5),
},
"run_in_thread": True,
}
)
@ -323,6 +329,7 @@ class TestToolFromFunction:
"mode": "forbidden",
"poll_interval": timedelta(seconds=5),
},
"run_in_thread": True,
}
)
@ -381,6 +388,7 @@ class TestToolFromFunction:
"mode": "forbidden",
"poll_interval": timedelta(seconds=5),
},
"run_in_thread": True,
}
)