fastmcp/fastmcp_slim/fastmcp/tools/function_tool.py
Jeremiah Lowin 3f746b91fc
Merge remote-tracking branch 'origin/main' into claude/mcp-background-tasks-v2-0f883f
# Conflicts:
#	fastmcp_slim/fastmcp/server/dependencies.py
2026-07-23 19:32:52 -04:00

627 lines
24 KiB
Python

"""Standalone @tool decorator for FastMCP."""
from __future__ import annotations
import functools
import inspect
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from functools import lru_cache
from types import MethodType
from typing import (
Annotated,
Any,
Literal,
Protocol,
TypeVar,
cast,
get_type_hints,
overload,
runtime_checkable,
)
import anyio
import mcp_types
from mcp.shared.exceptions import MCPError
from mcp_types import Icon, ToolAnnotations
from pydantic import Field, TypeAdapter
from pydantic import ValidationError as PydanticValidationError
from pydantic.json_schema import SkipJsonSchema
from fastmcp.decorators import get_fastmcp_meta
from fastmcp.exceptions import ValidationError
from fastmcp.tools.base import (
InputRequiredToolResult,
Tool,
ToolResult,
)
from fastmcp.tools.function_parsing import ParsedFunction, _is_object_schema
from fastmcp.utilities.async_utils import (
call_sync_fn_in_threadpool,
is_coroutine_function,
)
from fastmcp.utilities.authorization import AuthCheck
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.tasks import TaskConfig
from fastmcp.utilities.types import (
NotSet,
NotSetT,
get_cached_typeadapter,
)
logger = get_logger(__name__)
class _ToolBodyError(Exception):
"""Marks a ``pydantic.ValidationError`` raised while executing a tool's body.
Pydantic validates a tool's arguments *before* invoking the body, so a bare
``pydantic.ValidationError`` surfacing from the call adapter is unambiguously
an argument-validation failure (a bad call). Errors a tool raises from its
own body — e.g. constructing a model from upstream data — are a different
class of problem (a server-side bug) that must not be reclassified as a bad
call. We wrap the body so those are tagged and can be told apart. See #4128.
"""
@lru_cache(maxsize=5000)
def _wrap_body_errors(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Wrap ``fn`` so a ``pydantic.ValidationError`` raised by its body is
re-raised as ``_ToolBodyError``.
The wrapper preserves ``fn``'s signature and annotations so the cached
``TypeAdapter`` validates arguments identically — only body execution is
affected. Argument validation happens before the wrapper is called, so it
keeps raising a bare ``pydantic.ValidationError``.
"""
if is_coroutine_function(fn):
async def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return await fn(*args, **kwargs)
except PydanticValidationError as e:
raise _ToolBodyError from e
else:
def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return fn(*args, **kwargs)
except PydanticValidationError as e:
raise _ToolBodyError from e
# Mirror the original callable so TypeAdapter builds the identical schema and
# binds arguments the same way. Annotations must cover every signature
# parameter or pydantic's call-schema generation raises KeyError — so prefer
# resolved type hints (handles string/forward refs) but fall back to the
# signature's own annotations, which is the only source for callables like
# functools.partial that carry no __annotations__.
try:
resolved_hints = get_type_hints(fn, include_extras=True)
except Exception:
resolved_hints = {}
sig = inspect.signature(fn)
annotations: dict[str, Any] = {}
for param_name, param in sig.parameters.items():
if param_name in resolved_hints:
annotations[param_name] = resolved_hints[param_name]
elif param.annotation is not inspect.Parameter.empty:
annotations[param_name] = param.annotation
if "return" in resolved_hints:
annotations["return"] = resolved_hints["return"]
elif sig.return_annotation is not inspect.Signature.empty:
annotations["return"] = sig.return_annotation
wrapper.__signature__ = sig # type: ignore[attr-defined] # ty: ignore[invalid-assignment]
wrapper.__annotations__ = annotations
wrapper.__name__ = getattr(fn, "__name__", "wrapper")
wrapper.__doc__ = getattr(fn, "__doc__", None)
wrapper.__module__ = getattr(fn, "__module__", wrapper.__module__)
wrapper.__qualname__ = getattr(fn, "__qualname__", wrapper.__qualname__)
return wrapper
def _strict_input_validation() -> bool:
"""Whether the running server enforces strict argument validation.
Reads ``strict_input_validation`` off the active request's ``FastMCP``
instance. Returns ``False`` outside a request context (e.g. a tool invoked
directly in tests), preserving the default coercing behavior.
"""
from fastmcp.server.context import _current_context
context = _current_context.get(None)
if context is None:
return False
return context.fastmcp.strict_input_validation
F = TypeVar("F", bound=Callable[..., Any])
@runtime_checkable
class DecoratedTool(Protocol):
"""Protocol for functions decorated with @tool."""
__fastmcp__: ToolMeta
def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
@dataclass(frozen=True, kw_only=True)
class ToolMeta:
"""Metadata attached to functions by the @tool decorator."""
type: Literal["tool"] = field(default="tool", init=False)
name: str | None = None
version: str | int | None = None
title: str | None = None
description: str | None = None
icons: list[Icon] | None = None
tags: set[str] | None = None
output_schema: dict[str, Any] | NotSetT | None = NotSet
annotations: ToolAnnotations | None = None
meta: dict[str, Any] | None = None
app: Any = None
task: bool | TaskConfig | None = None
timeout: float | None = None
auth: AuthCheck | list[AuthCheck] | None = None
enabled: bool = True
run_in_thread: bool = True
def _resolve_param_hints(fn: Callable[..., Any]) -> dict[str, Any]:
"""Resolve a callable's parameter type hints, tolerating partials.
``get_type_hints`` rejects ``functools.partial`` objects (and other
non-function callables), which the synchronous TypeAdapter path handles
natively. For those, resolve hints against the underlying function and keep
only the parameters that remain in the partially-bound signature.
"""
try:
return get_type_hints(fn, include_extras=True)
except TypeError:
target = fn
while isinstance(target, functools.partial):
target = target.func
try:
resolved = get_type_hints(target, include_extras=True)
except TypeError:
return {}
return {
name: resolved[name]
for name in inspect.signature(fn).parameters
if name in resolved
}
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(
cls,
fn: Callable[..., Any],
*,
metadata: ToolMeta | None = None,
# Keep individual params for backwards compat
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
tags: set[str] | None = None,
annotations: ToolAnnotations | None = None,
output_schema: dict[str, Any] | NotSetT | None = NotSet,
meta: dict[str, Any] | None = None,
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.
Args:
fn: The function to wrap
metadata: ToolMeta object with all configuration. If provided,
individual parameters must not be passed.
name, title, etc.: Individual parameters for backwards compatibility.
Cannot be used together with metadata parameter.
"""
# Check mutual exclusion
individual_params_provided = (
any(
x is not None and x is not NotSet
for x in [
name,
version,
title,
description,
icons,
tags,
annotations,
meta,
task,
timeout,
auth,
run_in_thread,
]
)
or output_schema is not NotSet
)
if metadata is not None and individual_params_provided:
raise TypeError(
"Cannot pass both 'metadata' and individual parameters to from_function(). "
"Use metadata alone or individual parameters alone."
)
if metadata is None and not individual_params_provided:
fmeta = get_fastmcp_meta(fn)
if isinstance(fmeta, ToolMeta):
metadata = fmeta
# Build metadata from kwargs if not provided
if metadata is None:
metadata = ToolMeta(
name=name,
version=version,
title=title,
description=description,
icons=icons,
tags=tags,
output_schema=output_schema,
annotations=annotations,
meta=meta,
task=task,
timeout=timeout,
auth=auth,
run_in_thread=True if run_in_thread is None else run_in_thread,
)
parsed_fn = ParsedFunction.from_function(fn)
func_name = metadata.name or parsed_fn.name
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:
task_config = TaskConfig(mode="forbidden")
elif isinstance(task_value, bool):
task_config = TaskConfig.from_bool(task_value)
else:
task_config = task_value
task_config.validate_function(fn, func_name)
# Handle output_schema
if isinstance(metadata.output_schema, NotSetT):
final_output_schema = parsed_fn.output_schema
else:
final_output_schema = metadata.output_schema
if final_output_schema is not None and isinstance(final_output_schema, dict):
if not _is_object_schema(final_output_schema):
raise ValueError(
f"Output schemas must represent object types due to MCP spec limitations. "
f"Received: {final_output_schema!r}"
)
return cls(
fn=parsed_fn.fn,
return_type=parsed_fn.return_type,
name=metadata.name or parsed_fn.name,
version=str(metadata.version) if metadata.version is not None else None,
title=metadata.title,
description=metadata.description
if metadata.description is not None
else parsed_fn.description,
icons=metadata.icons,
parameters=parsed_fn.input_schema,
output_schema=final_output_schema,
annotations=metadata.annotations,
tags=metadata.tags or set(),
meta=metadata.meta,
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.
A tool body may return an `InputRequiredResult` (SEP-2322) to ask the
client for input. Under the stateless multi-round-trip protocol that ask
is the full result of this leg, so it is wrapped in an
`InputRequiredToolResult` (a `ToolResult` subclass) rather than
serialized as content; the ask flows through the middleware chain as an
ordinary result and the wire handler returns it to the client unmodified.
"""
from fastmcp.server.dependencies import without_injected_parameters
wrapper_fn = without_injected_parameters(
self.fn, run_in_thread=self.run_in_thread
)
# Tag pydantic errors raised by the body so they can be distinguished
# from argument-validation errors (which pydantic raises first). See #4128.
exec_fn = _wrap_body_errors(wrapper_fn)
type_adapter = get_cached_typeadapter(exec_fn)
exec_is_async = is_coroutine_function(wrapper_fn)
strict = _strict_input_validation()
result = await self._run_body(
type_adapter, exec_is_async, arguments, strict=strict
)
# An `InputRequiredResult` is the full result of this multi-round-trip
# leg (SEP-2322), not tool-output data: wrap it in an
# `InputRequiredToolResult` so it flows through the middleware chain as
# an ordinary result instead of being serialized as content. The wire
# handler reads it back out (see `_on_call_tool`).
if isinstance(result, mcp_types.InputRequiredResult):
return InputRequiredToolResult(result)
return self.convert_result(result)
async def _run_body(
self,
type_adapter: TypeAdapter[Any],
exec_is_async: bool,
arguments: dict[str, Any],
*,
strict: bool,
) -> Any:
"""Validate arguments and execute the body, applying any timeout."""
try:
if self.timeout is not None:
try:
with anyio.fail_after(self.timeout):
result = await self._execute(
type_adapter, exec_is_async, arguments, strict=strict
)
except TimeoutError:
logger.warning(
f"Tool '{self.name}' timed out after {self.timeout}s. "
f"Consider using task=True for long-running operations. "
f"See https://gofastmcp.com/servers/tasks"
)
raise MCPError(
code=-32000,
message=f"Tool '{self.name}' execution timed out after {self.timeout}s",
) from None
else:
result = await self._execute(
type_adapter, exec_is_async, arguments, strict=strict
)
except PydanticValidationError as e:
# Body errors are re-raised as _ToolBodyError, so a bare pydantic
# ValidationError here is an argument-validation failure (a bad call).
# Convert it to fastmcp's ValidationError so the middleware chain and
# downstream error taxonomy (e.g. Sentry filters) can treat it as a
# client error rather than a server bug.
raise ValidationError(str(e), log_level=logging.WARNING) from e
except _ToolBodyError as e:
# The tool's own body raised a pydantic ValidationError. Surface the
# original so it is treated as a server-side error, hiding the
# internal sentinel while preserving the error's own chained cause.
original = e.__cause__
assert original is not None
raise original from original.__cause__
return result
async def _execute(
self,
type_adapter: TypeAdapter[Any],
exec_is_async: bool,
arguments: dict[str, Any],
*,
strict: bool = False,
) -> Any:
"""Validate arguments and execute the tool body.
Argument validation runs first and raises a bare
``pydantic.ValidationError`` on bad input. Body execution (awaiting the
result and materializing generators) is wrapped so any pydantic error it
raises is tagged as ``_ToolBodyError``.
When ``strict`` is set (server-level ``strict_input_validation``),
pydantic validates in strict mode, so lax coercions such as the JSON
string ``"10"`` into an ``int`` are rejected rather than coerced.
"""
# Combining timeout with run_in_thread=False on a sync function is
# rejected at registration (see FunctionTool.from_function), so this only
# needs to handle async and threadpool-sync under a timeout.
if exec_is_async:
# Argument validation is synchronous; the body runs on await below.
result = type_adapter.validate_python(arguments, strict=strict)
elif self.run_in_thread:
# Sync function: run in threadpool to avoid blocking the event loop.
result = await call_sync_fn_in_threadpool(
type_adapter.validate_python, arguments, strict=strict
)
else:
result = type_adapter.validate_python(arguments, strict=strict)
try:
if inspect.isawaitable(result):
result = await result
# Materialize generators (here, so slow generators are still bound by
# any configured timeout scope).
return await self._materialize_generator(result)
except PydanticValidationError as e:
# A pydantic error from awaiting the result or materializing a
# generator is body execution, not argument validation.
raise _ToolBodyError from e
@staticmethod
async def _materialize_generator(result: Any) -> Any:
"""Consume generators/async generators into lists.
Without this, async generators pass through as objects (repr string),
and sync generators get consumed during text serialization but are
exhausted by the time structured content is built.
"""
if inspect.isasyncgen(result):
return [item async for item in result]
if inspect.isgenerator(result):
return list(result)
return result
@overload
def tool(fn: F) -> F: ...
@overload
def tool(
name_or_fn: str,
*,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | NotSetT | None = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
run_in_thread: bool = True,
) -> Callable[[F], F]: ...
@overload
def tool(
name_or_fn: None = None,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | NotSetT | None = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
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(
name_or_fn: str | Callable[..., Any] | None = None,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | NotSetT | None = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | 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)
if isinstance(name_or_fn, classmethod):
raise TypeError(
"To decorate a classmethod, use @classmethod above @tool. "
"See https://gofastmcp.com/servers/tools#using-with-methods"
)
def attach_metadata(fn: F, tool_name: str | None) -> F:
metadata = ToolMeta(
name=tool_name,
version=version,
title=title,
description=description,
icons=icons,
tags=tags,
output_schema=output_schema,
annotations=annotations,
meta=meta,
task=task,
timeout=timeout,
auth=auth,
run_in_thread=run_in_thread,
)
target = fn.__func__ if isinstance(fn, staticmethod | MethodType) else fn
cast(Any, target).__fastmcp__ = metadata
return fn
def decorator(fn: F, tool_name: str | None) -> F:
return attach_metadata(fn, tool_name)
if inspect.isroutine(name_or_fn):
return decorator(name_or_fn, name)
elif isinstance(name_or_fn, str):
if name is not None:
raise TypeError("Cannot specify name both as first argument and keyword")
tool_name = name_or_fn
elif name_or_fn is None:
tool_name = name
else:
raise TypeError(f"Invalid first argument: {type(name_or_fn)}")
def wrapper(fn: F) -> F:
return decorator(fn, tool_name)
return wrapper