mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
601 lines
21 KiB
Python
601 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import inspect
|
|
from collections.abc import Callable
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Annotated,
|
|
Any,
|
|
ClassVar,
|
|
)
|
|
|
|
import mcp_types
|
|
import pydantic_core
|
|
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
|
|
from mcp_types import (
|
|
CallToolResult,
|
|
ContentBlock,
|
|
Icon,
|
|
TextContent,
|
|
ToolAnnotations,
|
|
ToolExecution,
|
|
)
|
|
from mcp_types import Tool as MCPTool
|
|
from pydantic import (
|
|
BaseModel,
|
|
Field,
|
|
PrivateAttr,
|
|
PydanticSchemaGenerationError,
|
|
model_validator,
|
|
)
|
|
from pydantic.json_schema import SkipJsonSchema
|
|
|
|
from fastmcp.utilities.authorization import AuthCheck
|
|
from fastmcp.utilities.components import FastMCPComponent
|
|
from fastmcp.utilities.logging import get_logger
|
|
from fastmcp.utilities.prefab import (
|
|
is_prefab_app,
|
|
is_prefab_component,
|
|
prefab_app_from_component,
|
|
)
|
|
from fastmcp.utilities.tasks import TaskConfig
|
|
from fastmcp.utilities.types import (
|
|
Audio,
|
|
File,
|
|
Image,
|
|
NotSet,
|
|
NotSetT,
|
|
get_cached_typeadapter,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from fastmcp.tools.function_tool import FunctionTool
|
|
from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
|
|
|
|
# Re-export from function_tool module
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
_JSONABLE_ADAPTER = get_cached_typeadapter(Any)
|
|
|
|
|
|
def _default_title(name: str) -> str:
|
|
"""Derive a display title from a tool name.
|
|
|
|
The MCP spec says clients should fall back to `name` for display when
|
|
`title` is absent, but some clients (e.g. ChatGPT) instead drop the tool
|
|
entirely. Always emitting a title avoids depending on that fallback.
|
|
"""
|
|
return name.replace("_", " ").replace("-", " ").title()
|
|
|
|
|
|
def default_serializer(data: Any) -> str:
|
|
return _JSONABLE_ADAPTER.dump_json(data, fallback=str).decode()
|
|
|
|
|
|
def _serialize_to_jsonable(data: Any, annotation: Any = Any) -> Any:
|
|
"""Serialize through Pydantic, falling back for unsupported annotations."""
|
|
if (
|
|
annotation is inspect.Signature.empty
|
|
or annotation is None
|
|
or annotation is Any
|
|
or annotation is ...
|
|
or isinstance(annotation, str)
|
|
):
|
|
adapter = _JSONABLE_ADAPTER
|
|
else:
|
|
try:
|
|
return get_cached_typeadapter(annotation).dump_python(data, mode="json")
|
|
except PydanticSchemaGenerationError:
|
|
adapter = _JSONABLE_ADAPTER
|
|
|
|
return adapter.dump_python(data, mode="json")
|
|
|
|
|
|
class ToolResult(BaseModel):
|
|
_raw_mcp_result: CallToolResult | None = PrivateAttr(default=None)
|
|
|
|
content: list[ContentBlock] = Field(
|
|
description="List of content blocks for the tool result"
|
|
)
|
|
structured_content: dict[str, Any] | None = Field(
|
|
default=None, description="Structured content matching the tool's output schema"
|
|
)
|
|
meta: dict[str, Any] | None = Field(
|
|
default=None, description="Runtime metadata about the tool execution"
|
|
)
|
|
is_error: bool = Field(
|
|
default=False,
|
|
description="Whether this result represents a tool execution error. "
|
|
"When True, it maps to CallToolResult.is_error so the error is returned "
|
|
"to the client rather than raised.",
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
content: list[ContentBlock] | Any | None = None,
|
|
structured_content: dict[str, Any] | Any | None = None,
|
|
meta: dict[str, Any] | None = None,
|
|
is_error: bool = False,
|
|
):
|
|
if content is None and structured_content is None:
|
|
raise ValueError("Either content or structured_content must be provided")
|
|
elif content is None:
|
|
content = structured_content
|
|
|
|
converted_content: list[ContentBlock] = _convert_to_content(result=content)
|
|
|
|
if structured_content is not None:
|
|
# Convert Prefab types to their wire-format envelope before
|
|
# generic serialization, so the renderer gets the right shape.
|
|
if is_prefab_app(structured_content):
|
|
structured_content = _prefab_to_json(structured_content)
|
|
elif is_prefab_component(structured_content):
|
|
structured_content = _prefab_to_json(
|
|
prefab_app_from_component(structured_content)
|
|
)
|
|
|
|
try:
|
|
structured_content = _serialize_to_jsonable(structured_content)
|
|
except pydantic_core.PydanticSerializationError as e:
|
|
logger.error(
|
|
f"Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization: {e}"
|
|
)
|
|
raise
|
|
if not isinstance(structured_content, dict):
|
|
raise ValueError(
|
|
"structured_content must be a dict or None. "
|
|
f"Got {type(structured_content).__name__}: {structured_content!r}. "
|
|
"Tools should wrap non-dict values based on their output_schema."
|
|
)
|
|
|
|
super().__init__(
|
|
content=converted_content,
|
|
structured_content=structured_content,
|
|
meta=meta,
|
|
is_error=is_error,
|
|
)
|
|
|
|
@classmethod
|
|
def from_mcp_result(cls, result: CallToolResult) -> ToolResult:
|
|
"""Wrap a protocol result while preserving its exact wire representation."""
|
|
tool_result = cls(
|
|
content=result.content,
|
|
structured_content=result.structured_content,
|
|
meta=result.meta,
|
|
is_error=result.is_error,
|
|
)
|
|
tool_result._raw_mcp_result = result
|
|
return tool_result
|
|
|
|
def to_mcp_result(
|
|
self,
|
|
) -> (
|
|
list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | CallToolResult
|
|
):
|
|
if self._raw_mcp_result is not None:
|
|
return self._raw_mcp_result
|
|
|
|
# An error result must round-trip through CallToolResult so isError
|
|
# reaches the client; the plain content/tuple returns can't carry it.
|
|
if self.meta is not None or self.is_error:
|
|
return CallToolResult(
|
|
structured_content=self.structured_content,
|
|
content=self.content,
|
|
is_error=self.is_error,
|
|
_meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field
|
|
)
|
|
if self.structured_content is None:
|
|
return self.content
|
|
return self.content, self.structured_content
|
|
|
|
|
|
class InputRequiredToolResult(ToolResult):
|
|
"""The full result of a single multi-round-trip leg (SEP-2322).
|
|
|
|
The protocol is stateless: each MRTR leg is a complete request→response
|
|
cycle. When a guard tool returns an `InputRequiredResult` from its body to
|
|
ask the client for input, that ask is the *legitimate result* of this tool
|
|
call — not a pause, not an error, not a third control-flow outcome. FastMCP
|
|
wraps it in this `ToolResult` subclass so it flows through the middleware
|
|
chain as an ordinary return value: `call_next(...)` returns it, default
|
|
middleware completes normally on the leg, and middleware authors can
|
|
identify an ask with a simple `isinstance(result, InputRequiredToolResult)`
|
|
check.
|
|
|
|
Invariant: the wrapped `InputRequiredResult` is never serialized as tool
|
|
content. `content` is always empty; the wire handler (`_on_call_tool`)
|
|
reads `.input_required` and returns it to the runner as the
|
|
`input_required` result. Do not read `.content` / `.structured_content` on
|
|
this subclass — they carry nothing.
|
|
"""
|
|
|
|
input_required: mcp_types.InputRequiredResult = Field(
|
|
description="The client-input request this leg resolved to (SEP-2322)"
|
|
)
|
|
|
|
def __init__(self, input_required: mcp_types.InputRequiredResult) -> None:
|
|
# Bypass ToolResult's content-conversion __init__: an input-required
|
|
# leg carries no tool content (see the invariant above), and
|
|
# `input_required` is a required field ToolResult.__init__ can't set.
|
|
BaseModel.__init__(
|
|
self,
|
|
content=[],
|
|
structured_content=None,
|
|
meta=None,
|
|
is_error=False,
|
|
input_required=input_required,
|
|
)
|
|
|
|
|
|
class Tool(FastMCPComponent):
|
|
"""Internal tool registration info."""
|
|
|
|
KEY_PREFIX: ClassVar[str] = "tool"
|
|
|
|
return_type: Annotated[SkipJsonSchema[Any], Field(exclude=True)] = None
|
|
parameters: Annotated[
|
|
dict[str, Any], Field(description="JSON schema for tool parameters")
|
|
]
|
|
output_schema: Annotated[
|
|
dict[str, Any] | None, Field(description="JSON schema for tool output")
|
|
] = None
|
|
annotations: Annotated[
|
|
ToolAnnotations | None,
|
|
Field(description="Additional annotations about the tool"),
|
|
] = None
|
|
execution: Annotated[
|
|
ToolExecution | None,
|
|
Field(description="Task execution configuration (SEP-1686)"),
|
|
] = None
|
|
auth: Annotated[
|
|
SkipJsonSchema[AuthCheck | list[AuthCheck] | None],
|
|
Field(description="Authorization checks for this tool", exclude=True),
|
|
] = None
|
|
timeout: Annotated[
|
|
float | None,
|
|
Field(
|
|
description="Execution timeout in seconds. If None, no timeout is applied."
|
|
),
|
|
] = None
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_tool_name(self) -> Tool:
|
|
"""Validate tool name according to MCP specification (SEP-986)."""
|
|
validate_and_warn_tool_name(self.name)
|
|
return self
|
|
|
|
def to_mcp_tool(
|
|
self,
|
|
**overrides: Any,
|
|
) -> MCPTool:
|
|
"""Convert the FastMCP tool to an MCP tool."""
|
|
# Title precedence follows the effective (post-override) values, so a
|
|
# caller renaming or re-annotating a tool doesn't get a stale title.
|
|
name = overrides.get("name", self.name)
|
|
annotations = overrides.get("annotations", self.annotations)
|
|
if isinstance(annotations, dict):
|
|
annotations = ToolAnnotations(**annotations)
|
|
|
|
if self.title:
|
|
title = self.title
|
|
elif annotations and annotations.title:
|
|
title = annotations.title
|
|
else:
|
|
title = _default_title(name)
|
|
|
|
mcp_tool = MCPTool(
|
|
name=name,
|
|
title=overrides.get("title", title),
|
|
description=overrides.get("description", self.description),
|
|
input_schema=overrides.get("inputSchema", self.parameters),
|
|
output_schema=overrides.get("outputSchema", self.output_schema),
|
|
icons=overrides.get("icons", self.icons),
|
|
annotations=annotations,
|
|
execution=overrides.get("execution", self.execution),
|
|
_meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field
|
|
"_meta", self.get_meta()
|
|
),
|
|
)
|
|
|
|
if (
|
|
self.task_config.supports_tasks()
|
|
and "execution" not in overrides
|
|
and not self.execution
|
|
):
|
|
mcp_tool.execution = ToolExecution(task_support=self.task_config.mode)
|
|
|
|
return mcp_tool
|
|
|
|
@classmethod
|
|
def from_function(
|
|
cls,
|
|
fn: Callable[..., Any],
|
|
*,
|
|
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 Tool from a function."""
|
|
from fastmcp.tools.function_tool import FunctionTool
|
|
|
|
return FunctionTool.from_function(
|
|
fn=fn,
|
|
name=name,
|
|
version=version,
|
|
title=title,
|
|
description=description,
|
|
icons=icons,
|
|
tags=tags,
|
|
annotations=annotations,
|
|
output_schema=output_schema,
|
|
meta=meta,
|
|
task=task,
|
|
timeout=timeout,
|
|
auth=auth,
|
|
run_in_thread=run_in_thread,
|
|
)
|
|
|
|
async def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
"""
|
|
Run the tool with arguments.
|
|
|
|
This method is not implemented in the base Tool class and must be
|
|
implemented by subclasses.
|
|
|
|
`run()` can EITHER return a list of ContentBlocks, or a tuple of
|
|
(list of ContentBlocks, dict of structured output).
|
|
|
|
A tool that requests client input (SEP-2322 multi-round-trip) does so by
|
|
returning an `InputRequiredResult` from its body; the run machinery wraps
|
|
that in an `InputRequiredToolResult` — a `ToolResult` subclass — so it
|
|
stays inside the declared `ToolResult` result type and flows through the
|
|
middleware chain as an ordinary result (see `FunctionTool.run`).
|
|
"""
|
|
raise NotImplementedError("Subclasses must implement run()")
|
|
|
|
def convert_result(self, raw_value: Any) -> ToolResult:
|
|
"""Convert a raw result to ToolResult.
|
|
|
|
Handles ToolResult passthrough and converts raw values using the tool's
|
|
attributes (output_schema) for proper conversion.
|
|
"""
|
|
if isinstance(raw_value, ToolResult):
|
|
return raw_value
|
|
|
|
if isinstance(raw_value, CallToolResult):
|
|
return ToolResult.from_mcp_result(raw_value)
|
|
|
|
if is_prefab_app(raw_value):
|
|
return _prefab_to_tool_result(
|
|
raw_value,
|
|
fastmcp_app_name=_get_fastmcp_app_name(self),
|
|
)
|
|
if is_prefab_component(raw_value):
|
|
return _prefab_to_tool_result(
|
|
prefab_app_from_component(raw_value),
|
|
fastmcp_app_name=_get_fastmcp_app_name(self),
|
|
)
|
|
|
|
content = _convert_to_content(raw_value)
|
|
|
|
# Bytes can't be represented as structured JSON content
|
|
if isinstance(raw_value, bytes):
|
|
return ToolResult(content=content)
|
|
|
|
is_content_result = isinstance(
|
|
raw_value, ContentBlock | Audio | Image | File
|
|
) or (
|
|
isinstance(raw_value, list | tuple)
|
|
and any(
|
|
isinstance(item, ContentBlock | Audio | Image | File)
|
|
for item in raw_value
|
|
)
|
|
)
|
|
|
|
# Skip structured content for ContentBlock types only if no output_schema
|
|
# (if output_schema exists, MCP SDK requires structured_content)
|
|
if self.output_schema is None and is_content_result:
|
|
return ToolResult(content=content)
|
|
|
|
try:
|
|
structured = _serialize_to_jsonable(raw_value, self.return_type)
|
|
except (pydantic_core.PydanticSerializationError, UnicodeDecodeError):
|
|
return ToolResult(content=content)
|
|
|
|
if not is_content_result:
|
|
content = _convert_to_content(structured)
|
|
|
|
if self.output_schema is None:
|
|
# No schema - only use structured_content for dicts
|
|
if isinstance(structured, dict):
|
|
return ToolResult(content=content, structured_content=structured)
|
|
return ToolResult(content=content)
|
|
|
|
# Has output_schema - wrap if x-fastmcp-wrap-result is set
|
|
wrap_result = self.output_schema.get("x-fastmcp-wrap-result")
|
|
return ToolResult(
|
|
content=content,
|
|
structured_content={"result": structured} if wrap_result else structured,
|
|
meta={"fastmcp": {"wrap_result": True}} if wrap_result else None,
|
|
)
|
|
|
|
async def _run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
"""Server entry point for tool execution.
|
|
|
|
The server calls this method instead of ``run()`` directly so that
|
|
subclasses can customize dispatch. For example, ``FastMCPProviderTool``
|
|
overrides this to delegate to child-server middleware.
|
|
"""
|
|
return await self.run(arguments)
|
|
|
|
@classmethod
|
|
def from_tool(
|
|
cls,
|
|
tool: Tool | Callable[..., Any],
|
|
*,
|
|
name: str | None = None,
|
|
title: str | NotSetT | None = NotSet,
|
|
description: str | NotSetT | None = NotSet,
|
|
tags: set[str] | None = None,
|
|
annotations: ToolAnnotations | NotSetT | None = NotSet,
|
|
output_schema: dict[str, Any] | NotSetT | None = NotSet,
|
|
meta: dict[str, Any] | NotSetT | None = NotSet,
|
|
transform_args: dict[str, ArgTransform] | None = None,
|
|
transform_fn: Callable[..., Any] | None = None,
|
|
) -> TransformedTool:
|
|
from fastmcp.tools.tool_transform import TransformedTool
|
|
|
|
tool = cls._ensure_tool(tool)
|
|
|
|
return TransformedTool.from_tool(
|
|
tool=tool,
|
|
transform_fn=transform_fn,
|
|
name=name,
|
|
title=title,
|
|
transform_args=transform_args,
|
|
description=description,
|
|
tags=tags,
|
|
annotations=annotations,
|
|
output_schema=output_schema,
|
|
meta=meta,
|
|
)
|
|
|
|
@classmethod
|
|
def _ensure_tool(cls, tool: Tool | Callable[..., Any]) -> Tool:
|
|
"""Coerce a callable into a Tool, respecting @tool decorator metadata."""
|
|
if isinstance(tool, Tool):
|
|
return tool
|
|
|
|
from fastmcp.decorators import get_fastmcp_meta
|
|
from fastmcp.tools.function_tool import FunctionTool, ToolMeta
|
|
|
|
fmeta = get_fastmcp_meta(tool)
|
|
if isinstance(fmeta, ToolMeta):
|
|
return FunctionTool.from_function(tool, metadata=fmeta)
|
|
|
|
return cls.from_function(tool)
|
|
|
|
def get_span_attributes(self) -> dict[str, Any]:
|
|
return super().get_span_attributes() | {
|
|
"fastmcp.component.type": "tool",
|
|
"fastmcp.provider.type": "LocalProvider",
|
|
}
|
|
|
|
|
|
def _convert_to_single_content_block(
|
|
item: Any,
|
|
) -> ContentBlock:
|
|
if isinstance(item, ContentBlock):
|
|
return item
|
|
|
|
if isinstance(item, Image):
|
|
return item.to_image_content()
|
|
|
|
if isinstance(item, Audio):
|
|
return item.to_audio_content()
|
|
|
|
if isinstance(item, File):
|
|
return item.to_resource_content()
|
|
|
|
if isinstance(item, str):
|
|
return TextContent(type="text", text=item)
|
|
|
|
if isinstance(item, bytes):
|
|
try:
|
|
return TextContent(type="text", text=item.decode("utf-8"))
|
|
except UnicodeDecodeError:
|
|
import base64
|
|
|
|
return TextContent(type="text", text=base64.b64encode(item).decode("ascii"))
|
|
|
|
return TextContent(type="text", text=default_serializer(item))
|
|
|
|
|
|
_PREFAB_TEXT_FALLBACK = "[Rendered Prefab UI]"
|
|
|
|
|
|
def _get_tool_resolver(app_name: str | None = None) -> Callable[..., str] | None:
|
|
"""Get the Prefab peer-reference resolver bound to an app name."""
|
|
try:
|
|
from fastmcp.apps.app import _make_resolver
|
|
|
|
return _make_resolver(app_name)
|
|
except ImportError:
|
|
return None
|
|
|
|
|
|
def _prefab_to_json(app: Any, fastmcp_app_name: str | None = None) -> dict[str, Any]:
|
|
"""Serialize a PrefabApp, addressing its peer-tool references by identity.
|
|
|
|
The resolver writes each reference as ``<hash>_<local_name>``, and the
|
|
identity behind it is recorded in the payload's meta so that servers
|
|
can re-address the reference on the way out without losing track of
|
|
what it points at.
|
|
"""
|
|
from fastmcp.server.providers.prefab_payload import annotate_payload_identities
|
|
|
|
data = app.to_json(tool_resolver=_get_tool_resolver(fastmcp_app_name))
|
|
return annotate_payload_identities(data)
|
|
|
|
|
|
def _get_fastmcp_app_name(tool: Tool) -> str | None:
|
|
"""Read the FastMCPApp name from a tool's metadata, if present."""
|
|
meta = tool.meta
|
|
if not meta:
|
|
return None
|
|
fastmcp_meta = meta.get("fastmcp")
|
|
if isinstance(fastmcp_meta, dict):
|
|
app = fastmcp_meta.get("app")
|
|
if isinstance(app, str):
|
|
return app
|
|
return None
|
|
|
|
|
|
def _prefab_to_tool_result(app: Any, fastmcp_app_name: str | None = None) -> ToolResult:
|
|
"""Convert a PrefabApp to a FastMCP ToolResult."""
|
|
return ToolResult(
|
|
content=[TextContent(type="text", text=_PREFAB_TEXT_FALLBACK)],
|
|
structured_content=_prefab_to_json(app, fastmcp_app_name=fastmcp_app_name),
|
|
)
|
|
|
|
|
|
def _convert_to_content(
|
|
result: Any,
|
|
) -> list[ContentBlock]:
|
|
"""Convert a result to a sequence of content objects."""
|
|
|
|
if result is None:
|
|
return []
|
|
|
|
if not isinstance(result, (list | tuple)):
|
|
return [_convert_to_single_content_block(result)]
|
|
|
|
# If all items are ContentBlocks, return them as is
|
|
if all(isinstance(item, ContentBlock) for item in result):
|
|
return result
|
|
|
|
# If any item is a ContentBlock, convert non-ContentBlock items to TextContent
|
|
# without aggregating them
|
|
if any(isinstance(item, ContentBlock | Image | Audio | File) for item in result):
|
|
return [
|
|
_convert_to_single_content_block(item)
|
|
if not isinstance(item, ContentBlock)
|
|
else item
|
|
for item in result
|
|
]
|
|
# If none of the items are ContentBlocks, aggregate all items into a single TextContent
|
|
return [TextContent(type="text", text=default_serializer(result))]
|
|
|
|
|
|
__all__ = ["InputRequiredToolResult", "Tool", "ToolResult"]
|