diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx
index 073a16f6d..862066bc7 100644
--- a/docs/servers/tools.mdx
+++ b/docs/servers/tools.mdx
@@ -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.
+
+
+ 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.
+
### 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.
diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py
index 5035e0918..0b8aee795 100644
--- a/src/fastmcp/server/dependencies.py
+++ b/src/fastmcp/server/dependencies.py
@@ -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
diff --git a/src/fastmcp/server/providers/filesystem_discovery.py b/src/fastmcp/server/providers/filesystem_discovery.py
index db8e0ce83..a79050264 100644
--- a/src/fastmcp/server/providers/filesystem_discovery.py
+++ b/src/fastmcp/server/providers/filesystem_discovery.py
@@ -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):
diff --git a/src/fastmcp/server/providers/local_provider/decorators/tools.py b/src/fastmcp/server/providers/local_provider/decorators/tools.py
index e7402dced..06bcc7277 100644
--- a/src/fastmcp/server/providers/local_provider/decorators/tools.py
+++ b/src/fastmcp/server/providers/local_provider/decorators/tools.py
@@ -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,
)
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index 2f00e2017..cedeedf85 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -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
diff --git a/src/fastmcp/tools/base.py b/src/fastmcp/tools/base.py
index cad5fae64..1e35d9493 100644
--- a/src/fastmcp/tools/base.py
+++ b/src/fastmcp/tools/base.py
@@ -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:
diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py
index 94e7a0b3e..ffac25c98 100644
--- a/src/fastmcp/tools/function_tool.py
+++ b/src/fastmcp/tools/function_tool.py
@@ -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 == "":
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
diff --git a/tests/tools/test_tool_run_in_thread.py b/tests/tools/test_tool_run_in_thread.py
new file mode 100644
index 000000000..96f661473
--- /dev/null
+++ b/tests/tools/test_tool_run_in_thread.py
@@ -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
diff --git a/tests/tools/tool/test_tool.py b/tests/tools/tool/test_tool.py
index 00bd5d9e4..002d41a88 100644
--- a/tests/tools/tool/test_tool.py
+++ b/tests/tools/tool/test_tool.py
@@ -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,
}
)