Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 38 additions & 27 deletions docs/servers/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ Use `async def` when your tool needs to perform operations that might wait for e

### Return Values

#### Output Conversion

FastMCP automatically converts the value returned by your function into the appropriate MCP content format for the client:

- **`str`**: Sent as `TextContent`.
Expand All @@ -264,43 +266,52 @@ FastMCP automatically converts the value returned by your function into the appr

FastMCP will attempt to serialize other types to a string if possible.

<Tip>
At this time, FastMCP responds only to your tool's return *value*, not its return *annotation*.
</Tip>
#### Output Schemas

```python
from fastmcp import FastMCP
from fastmcp.utilities.types import Image
import io
<VersionBadge version="2.10.0" />

try:
from PIL import Image as PILImage
except ImportError:
raise ImportError("Please install the `pillow` library to run this example.")
FastMCP will automatically generate MCP [output schemas](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema) for your tools based on their return type annotations. This helps MCP clients understand what type of data to expect from your tool, enabling better validation and type safety.

mcp = FastMCP("Image Demo")
When you add a return type annotation to your tool function, FastMCP will generate a JSON schema describing the expected output format and include it in the tool definition sent to MCP clients.

@mcp.tool
def generate_image(width: int, height: int, color: str) -> Image:
"""Generates a solid color image."""
# Create image using Pillow
img = PILImage.new("RGB", (width, height), color=color)
<CodeGroup>
```python Tool Definition
from dataclasses import dataclass
from fastmcp import FastMCP

# Save to a bytes buffer
buffer = io.BytesIO()
img.save(buffer, format="PNG")
img_bytes = buffer.getvalue()
mcp = FastMCP()

# Return using FastMCP's Image helper
return Image(data=img_bytes, format="png")
@dataclass
class Person:
name: str
age: int
email: str

@mcp.tool
def do_nothing() -> None:
"""This tool performs an action but returns no data."""
print("Performing a side effect...")
return None
def get_user_profile(user_id: str) -> Person:
"""Get a user's profile information."""
return Person(name="Alice", age=30, email="[email protected]")
```

```json Generated Output Schema
{
"properties": {
"name": {"title": "Name", "type": "string"},
"age": {"title": "Age", "type": "integer"},
"email": {"title": "Email", "type": "string"}
},
"required": ["name", "age", "email"],
"title": "Person",
"type": "object"
}
```
</CodeGroup>
The output schema is automatically generated for most common types including basic types, collections, union types, Pydantic models, TypedDict structures, and dataclasses. For FastMCP's special types (`Image`, `Audio`, `File`), the output schema reflects their MCP equivalents rather than the FastMCP wrapper types.

<Note>
If your return type annotation cannot be converted to a JSON schema (e.g., complex custom classes without Pydantic support), the output schema will be omitted from the tool definition. The tool will still function normally, but clients won't receive type information about the expected output.
</Note>

### Error Handling

<VersionBadge version="2.4.1" />
Expand Down
72 changes: 55 additions & 17 deletions src/fastmcp/tools/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
import json
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Annotated, Any

import mcp.types
import pydantic_core
from mcp.types import ContentBlock, TextContent, ToolAnnotations
from mcp.types import Tool as MCPTool
from pydantic import Field
from pydantic import Field, PydanticSchemaGenerationError

import fastmcp
from fastmcp.server.dependencies import get_context
Expand All @@ -20,8 +21,11 @@
Audio,
File,
Image,
NotSet,
NotSetT,
find_kwarg_by_type,
get_cached_typeadapter,
replace_type,
)

if TYPE_CHECKING:
Expand All @@ -37,19 +41,27 @@ def default_serializer(data: Any) -> str:
class Tool(FastMCPComponent):
"""Internal tool registration info."""

parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
annotations: ToolAnnotations | None = Field(
default=None, description="Additional annotations about the tool"
)
serializer: Callable[[Any], str] | None = Field(
default=None, description="Optional custom serializer for tool results"
)
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
serializer: Annotated[
Callable[[Any], str] | None,
Field(description="Optional custom serializer for tool results"),
] = None

def to_mcp_tool(self, **overrides: Any) -> MCPTool:
kwargs = {
"name": self.name,
"description": self.description,
"inputSchema": self.parameters,
"outputSchema": self.output_schema,
"annotations": self.annotations,
}
return MCPTool(**kwargs | overrides)
Expand All @@ -62,6 +74,7 @@ def from_function(
tags: set[str] | None = None,
annotations: ToolAnnotations | None = None,
exclude_args: list[str] | None = None,
output_schema: dict[str, Any] | None | NotSetT = NotSet,
serializer: Callable[[Any], str] | None = None,
enabled: bool | None = None,
) -> FunctionTool:
Expand All @@ -73,6 +86,7 @@ def from_function(
tags=tags,
annotations=annotations,
exclude_args=exclude_args,
output_schema=output_schema,
serializer=serializer,
enabled=enabled,
)
Expand Down Expand Up @@ -121,6 +135,7 @@ def from_function(
tags: set[str] | None = None,
annotations: ToolAnnotations | None = None,
exclude_args: list[str] | None = None,
output_schema: dict[str, Any] | None | NotSetT = NotSet,
serializer: Callable[[Any], str] | None = None,
enabled: bool | None = None,
) -> FunctionTool:
Expand All @@ -131,13 +146,17 @@ def from_function(
if name is None and parsed_fn.name == "<lambda>":
raise ValueError("You must provide a name for lambda functions")

if isinstance(output_schema, NotSetT):
output_schema = parsed_fn.output_schema

return cls(
fn=parsed_fn.fn,
name=name or parsed_fn.name,
description=description or parsed_fn.description,
parameters=parsed_fn.parameters,
tags=tags or set(),
parameters=parsed_fn.input_schema,
output_schema=output_schema,
annotations=annotations,
tags=tags or set(),
serializer=serializer,
enabled=enabled if enabled is not None else True,
)
Expand Down Expand Up @@ -194,7 +213,8 @@ class ParsedFunction:
fn: Callable[..., Any]
name: str
description: str | None
parameters: dict[str, Any]
input_schema: dict[str, Any]
output_schema: dict[str, Any] | None

@classmethod
def from_function(
Expand Down Expand Up @@ -240,22 +260,40 @@ def from_function(
if isinstance(fn, staticmethod):
fn = fn.__func__

type_adapter = get_cached_typeadapter(fn)
schema = type_adapter.json_schema()

prune_params: list[str] = []
context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
if context_kwarg:
prune_params.append(context_kwarg)
if exclude_args:
prune_params.extend(exclude_args)

schema = compress_schema(schema, prune_params=prune_params)
input_type_adapter = get_cached_typeadapter(fn)
input_schema = input_type_adapter.json_schema()
input_schema = compress_schema(input_schema, prune_params=prune_params)

output_schema = None
output_type = inspect.signature(fn).return_annotation
if output_type is not inspect._empty:
try:
replaced_output_type = replace_type(
output_type,
{
Image: mcp.types.ImageContent,
Audio: mcp.types.AudioContent,
File: mcp.types.EmbeddedResource,
},
)
output_type_adapter = get_cached_typeadapter(replaced_output_type)
output_schema = output_type_adapter.json_schema()
except PydanticSchemaGenerationError:
logger.debug(f"Unable to generate schema for type {output_type!r}")

return cls(
fn=fn,
name=fn_name,
description=fn_doc,
parameters=schema,
input_schema=input_schema,
output_schema=output_schema,
)


Expand Down
25 changes: 11 additions & 14 deletions src/fastmcp/tools/tool_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,17 @@
from collections.abc import Callable
from contextvars import ContextVar
from dataclasses import dataclass
from types import EllipsisType
from typing import Any, Literal

from mcp.types import ContentBlock, ToolAnnotations
from pydantic import ConfigDict

from fastmcp.tools.tool import ParsedFunction, Tool
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import get_cached_typeadapter
from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter

logger = get_logger(__name__)

NotSet = ...


# Context variable to store current transformed tool
_current_tool: ContextVar[TransformedTool | None] = ContextVar(
Expand Down Expand Up @@ -131,14 +128,14 @@ class ArgTransform:
ArgTransform(name="new_name", description="New desc", default=None, type=int)
"""

name: str | EllipsisType = NotSet
description: str | EllipsisType = NotSet
default: Any | EllipsisType = NotSet
default_factory: Callable[[], Any] | EllipsisType = NotSet
type: Any | EllipsisType = NotSet
name: str | NotSetT = NotSet
description: str | NotSetT = NotSet
default: Any | NotSetT = NotSet
default_factory: Callable[[], Any] | NotSetT = NotSet
type: Any | NotSetT = NotSet
hide: bool = False
required: Literal[True] | EllipsisType = NotSet
examples: Any | EllipsisType = NotSet
required: Literal[True] | NotSetT = NotSet
examples: Any | NotSetT = NotSet

def __post_init__(self):
"""Validate that only one of default or default_factory is provided."""
Expand Down Expand Up @@ -334,7 +331,7 @@ async def flexible(**kwargs) -> str:
has_kwargs = cls._function_has_kwargs(transform_fn)

# Validate function parameters against transformed schema
fn_params = set(parsed_fn.parameters.get("properties", {}).keys())
fn_params = set(parsed_fn.input_schema.get("properties", {}).keys())
transformed_params = set(schema.get("properties", {}).keys())

if not has_kwargs:
Expand All @@ -351,7 +348,7 @@ async def flexible(**kwargs) -> str:
# ArgTransform takes precedence over function signature
# Start with function schema as base, then override with transformed schema
final_schema = cls._merge_schema_with_precedence(
parsed_fn.parameters, schema
parsed_fn.input_schema, schema
)
else:
# With **kwargs, function can access all transformed params
Expand All @@ -360,7 +357,7 @@ async def flexible(**kwargs) -> str:

# Start with function schema as base, then override with transformed schema
final_schema = cls._merge_schema_with_precedence(
parsed_fn.parameters, schema
parsed_fn.input_schema, schema
)

# Additional validation: check for naming conflicts after transformation
Expand Down
Loading
Loading