Update docs

This commit is contained in:
Jeremiah Lowin 2025-06-27 22:38:55 -04:00
commit c412a63d4e
7 changed files with 575 additions and 46 deletions

View file

@ -37,10 +37,13 @@ Execute a tool using `call_tool()` with the tool name and arguments:
async with client:
# Simple tool call
result = await client.call_tool("add", {"a": 5, "b": 3})
# result -> list[mcp.types.TextContent | mcp.types.ImageContent | ...]
# result -> CallToolResult with structured and unstructured data
# Access the result content
print(result[0].text) # Assuming TextContent, e.g., '8'
# Access structured data (automatically deserialized)
print(result.data) # 8 (int) or {"result": 8} for primitive types
# Access traditional content blocks
print(result.content[0].text) # "8" (TextContent)
```
### Advanced Execution Options
@ -72,21 +75,97 @@ async with client:
## Handling Results
Tool execution returns a list of content objects. The most common types are:
<VersionBadge version="2.10.0" />
- **`TextContent`**: Text-based results with a `.text` attribute
- **`ImageContent`**: Image data with image-specific attributes
- **`BlobContent`**: Binary data content
Tool execution returns a `CallToolResult` object with both structured and traditional content. FastMCP's standout feature is the `.data` property, which doesn't just provide raw JSON but actually hydrates complete Python objects including complex types like datetimes, UUIDs, and custom classes.
### CallToolResult Properties
<Card icon="code" title="CallToolResult Properties">
<ResponseField name=".data" type="Any">
**FastMCP exclusive**: Fully hydrated Python objects with complex type support (datetimes, UUIDs, custom classes). Goes beyond JSON to provide complete object reconstruction from output schemas.
</ResponseField>
<ResponseField name=".content" type="list[mcp.types.ContentBlock]">
Standard MCP content blocks (`TextContent`, `ImageContent`, `AudioContent`, etc.) available from all MCP servers.
</ResponseField>
<ResponseField name=".structured_content" type="dict[str, Any] | None">
Standard MCP structured JSON data as sent by the server, available from all MCP servers that support structured outputs.
</ResponseField>
<ResponseField name=".is_error" type="bool">
Boolean indicating if the tool execution failed.
</ResponseField>
</Card>
### Structured Data Access
FastMCP's `.data` property provides fully hydrated Python objects, not just JSON dictionaries. This includes complex type reconstruction:
```python
from datetime import datetime
from uuid import UUID
async with client:
result = await client.call_tool("get_weather", {"city": "London"})
for content in result:
if hasattr(content, 'text'):
print(f"Text result: {content.text}")
elif hasattr(content, 'data'):
print(f"Binary data: {len(content.data)} bytes")
# FastMCP reconstructs complete Python objects from the server's output schema
weather = result.data # Server-defined WeatherReport object
print(f"Temperature: {weather.temperature}°C at {weather.timestamp}")
print(f"Station: {weather.station_id}")
print(f"Humidity: {weather.humidity}%")
# The timestamp is a real datetime object, not a string!
assert isinstance(weather.timestamp, datetime)
assert isinstance(weather.station_id, UUID)
# Compare with raw structured JSON (standard MCP)
print(f"Raw JSON: {result.structured_content}")
# {"temperature": 20, "timestamp": "2024-01-15T14:30:00Z", "station_id": "123e4567-..."}
# Traditional content blocks (standard MCP)
print(f"Text content: {result.content[0].text}")
```
### Fallback Behavior
For tools without output schemas or when deserialization fails, `.data` will be `None`:
```python
async with client:
result = await client.call_tool("legacy_tool", {"param": "value"})
if result.data is not None:
# Structured output available and successfully deserialized
print(f"Structured: {result.data}")
else:
# No structured output or deserialization failed - use content blocks
for content in result.content:
if hasattr(content, 'text'):
print(f"Text result: {content.text}")
elif hasattr(content, 'data'):
print(f"Binary data: {len(content.data)} bytes")
```
### Primitive Type Unwrapping
<Tip>
FastMCP servers automatically wrap non-object results (like `int`, `str`, `bool`) in a `{"result": value}` structure to create valid structured outputs. FastMCP clients understand this convention and automatically unwrap the value in `.data` for convenience, so you get the original primitive value instead of a wrapper object.
</Tip>
```python
async with client:
result = await client.call_tool("calculate_sum", {"a": 5, "b": 3})
# FastMCP client automatically unwraps for convenience
print(result.data) # 8 (int) - the original value
# Raw structured content shows the server-side wrapping
print(result.structured_content) # {"result": 8}
# Other MCP clients would need to manually access ["result"]
# value = result.structured_content["result"] # Not needed with FastMCP!
```
## Error Handling
@ -101,14 +180,32 @@ from fastmcp.exceptions import ToolError
async with client:
try:
result = await client.call_tool("potentially_failing_tool", {"param": "value"})
print("Tool succeeded:", result)
print("Tool succeeded:", result.data)
except ToolError as e:
print(f"Tool failed: {e}")
```
### Manual Error Checking
For more granular control, use `call_tool_mcp()` which returns the raw MCP protocol object with an `isError` flag:
You can disable automatic error raising and manually check the result:
```python
async with client:
result = await client.call_tool(
"potentially_failing_tool",
{"param": "value"},
raise_on_error=False
)
if result.is_error:
print(f"Tool failed: {result.content[0].text}")
else:
print(f"Tool succeeded: {result.data}")
```
### Raw MCP Protocol Access
For complete control, use `call_tool_mcp()` which returns the raw MCP protocol object:
```python
async with client:
@ -119,6 +216,7 @@ async with client:
print(f"Tool failed: {result.content}")
else:
print(f"Tool succeeded: {result.content}")
# Note: No automatic deserialization with call_tool_mcp()
```
## Argument Handling

View file

@ -89,6 +89,7 @@ The `Tool.from_tool()` class method is the primary way to create a transformed t
- `description`: An optional description for the new tool.
- `transform_args`: A dictionary of `ArgTransform` objects, one for each argument you want to modify.
- `transform_fn`: An optional function that will be called instead of the parent tool's logic.
- `output_schema`: Control output schema and structured outputs (see [Output Schema Control](#output-schema-control)).
- `tags`: An optional set of tags for the new tool.
- `annotations`: An optional set of `ToolAnnotations` for the new tool.
- `serializer`: An optional function that will be called to serialize the result of the new tool.
@ -439,7 +440,44 @@ mcp.add_tool(new_tool)
<Tip>
In the above example, `**kwargs` receives the renamed argument `b`, not the original argument `y`. It is therefore recommended to use with `forward()`, not `forward_raw()`.
</Tip>
</Tip>
## Output Schema Control
<VersionBadge version="2.10.0" />
Transformed tools inherit output schemas from their parent by default, but you can control this behavior:
**Inherit from Parent (Default)**
```python
Tool.from_tool(parent_tool, name="renamed_tool")
```
The transformed tool automatically uses the parent tool's output schema and structured output behavior.
**Custom Output Schema**
```python
Tool.from_tool(parent_tool, output_schema={
"type": "object",
"properties": {"status": {"type": "string"}}
})
```
Provide your own schema that differs from the parent. The tool must return data matching this schema.
**Remove Output Schema**
```python
Tool.from_tool(parent_tool, output_schema=False)
```
Removes the output schema declaration. Automatic structured content still works for object-like returns (dict, dataclass, Pydantic models) but primitive types won't be structured.
**Full Control with Transform Functions**
```python
async def custom_output(**kwargs) -> ToolResult:
result = await forward(**kwargs)
return ToolResult(content=[...], structured_content={...})
Tool.from_tool(parent_tool, transform_fn=custom_output)
```
Use a transform function returning `ToolResult` for complete control over both content blocks and structured outputs.
## Common Patterns

View file

@ -288,28 +288,100 @@ 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:
FastMCP tools can return data in two complementary formats: **traditional content blocks** (like text and images) and **structured outputs** (machine-readable JSON). When you add return type annotations, FastMCP automatically generates **output schemas** to validate the structured data and enables clients to deserialize results back to Python objects.
- **`str`**: Sent as `TextContent`.
- **`dict`, `list`, Pydantic `BaseModel`**: Serialized to a JSON string and sent as `TextContent`.
- **`bytes`**: Base64 encoded and sent as `BlobResourceContents` (often within an `EmbeddedResource`).
- **`fastmcp.utilities.types.Image`**: A helper class for easily returning image data. Sent as `ImageContent`.
- **`fastmcp.utilities.types.Audio`**: A helper class for easily returning audio data. Sent as `AudioContent`.
- **`fastmcp.utilities.types.File`**: A helper class for easily returning binary data as base64-encoded content. Sent as `EmbeddedResource`.
- **A list of any of the above**: Automatically converts each item appropriately.
- **`None`**: Results in an empty response (no content is sent back to the client).
Understanding how these three concepts work together:
FastMCP will attempt to serialize other types to a string if possible.
- **Return Values**: What your Python function returns (determines both content blocks and structured data)
- **Structured Outputs**: JSON data sent alongside traditional content for machine processing
- **Output Schemas**: JSON Schema declarations that describe and validate the structured output format
#### Output Schemas
The following sections explain each concept in detail.
#### Content Blocks
FastMCP automatically converts tool return values into appropriate MCP content blocks:
- **`str`**: Sent as `TextContent`
- **`bytes`**: Base64 encoded and sent as `BlobResourceContents` (within an `EmbeddedResource`)
- **`fastmcp.utilities.types.Image`**: Sent as `ImageContent`
- **`fastmcp.utilities.types.Audio`**: Sent as `AudioContent`
- **`fastmcp.utilities.types.File`**: Sent as base64-encoded `EmbeddedResource`
- **A list of any of the above**: Converts each item appropriately
- **`None`**: Results in an empty response
#### Structured Output
<VersionBadge version="2.10.0" />
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.
The 6/18/2025 MCP spec update [introduced](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content) structured content, which is a new way to return data from tools. Structured content is a JSON object that is sent alongside traditional content. FastMCP automatically creates structured outputs alongside traditional content when your tool returns data that has a JSON object representation. This provides machine-readable JSON data that clients can deserialize back to Python objects.
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.
**Automatic Structured Content Rules:**
- **Object-like results** (`dict`, Pydantic models, dataclasses) → Always become structured content (even without output schema)
- **Non-object results** (`int`, `str`, `list`) → Only become structured content if there's an output schema to validate/serialize them
- **All results** → Always become traditional content blocks for backward compatibility
<Note>
This automatic behavior enables clients to receive machine-readable data alongside human-readable content without requiring explicit output schemas for object-like returns.
</Note>
##### Object-like Results (Automatic Structured Content)
<CodeGroup>
```python Dict Return (No Schema Needed)
@mcp.tool
def get_user_data(user_id: str) -> dict:
"""Get user data without type annotation."""
return {"name": "Alice", "age": 30, "active": True}
```
```json Traditional Content
"{\n \"name\": \"Alice\",\n \"age\": 30,\n \"active\": true\n}"
```
```json Structured Content (Automatic)
{
"name": "Alice",
"age": 30,
"active": true
}
```
</CodeGroup>
##### Non-object Results (Schema Required)
<CodeGroup>
```python Integer Return (No Schema)
@mcp.tool
def calculate_sum(a: int, b: int):
"""Calculate sum without return annotation."""
return a + b # Returns 8
```
```json Traditional Content Only
"8"
```
```python Integer Return (With Schema)
@mcp.tool
def calculate_sum(a: int, b: int) -> int:
"""Calculate sum with return annotation."""
return a + b # Returns 8
```
```json Traditional Content
"8"
```
```json Structured Content (From Schema)
{
"result": 8
}
```
</CodeGroup>
##### Complex Type Example
<CodeGroup>
```python Tool Definition
@ -334,19 +406,110 @@ def get_user_profile(user_id: str) -> Person:
{
"properties": {
"name": {"title": "Name", "type": "string"},
"age": {"title": "Age", "type": "integer"},
"age": {"title": "Age", "type": "integer"},
"email": {"title": "Email", "type": "string"}
},
"required": ["name", "age", "email"],
"title": "Person",
"type": "object"
}
```
```
```json Structured Output
{
"name": "Alice",
"age": 30,
"email": "alice@example.com"
}
```
</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.
#### Output Schemas
<VersionBadge version="2.10.0" />
The 6/18/2025 MCP spec update [introduced](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema) output schemas, which are a new way to describe the expected output format of a tool. When an output schema is provided, the tool *must* return structured output that matches the schema.
When you add return type annotations to your functions, FastMCP automatically generates JSON schemas that describe the expected output format. These schemas help MCP clients understand and validate the structured data they receive.
##### Primitive Type Wrapping
For primitive return types (like `int`, `str`, `bool`), FastMCP automatically wraps the result under a `"result"` key to create valid structured output:
<CodeGroup>
```python Primitive Return Type
@mcp.tool
def calculate_sum(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
```
```json Generated Schema (Wrapped)
{
"type": "object",
"properties": {
"result": {"type": "integer"}
},
"x-fastmcp-wrap-result": true
}
```
```json Structured Output
{
"result": 8
}
```
</CodeGroup>
##### Manual Schema Control
You can override the automatically generated schema by providing a custom `output_schema`:
```python
@mcp.tool(output_schema={
"type": "object",
"properties": {
"data": {"type": "string"},
"metadata": {"type": "object"}
}
})
def custom_schema_tool() -> dict:
"""Tool with custom output schema."""
return {"data": "Hello", "metadata": {"version": "1.0"}}
```
Schema generation works for most common types including basic types, collections, union types, Pydantic models, TypedDict structures, and dataclasses.
<Warning>
**Important Constraints**:
- Output schemas must be object types (`"type": "object"`)
- If you provide an output schema, your tool **must** return structured output that matches it
- However, you can provide structured output without an output schema (using `ToolResult`)
</Warning>
#### Full Control with ToolResult
For complete control over both traditional content and structured output, return a `ToolResult` object:
```python
from fastmcp.tools.tool import ToolResult
@mcp.tool
def advanced_tool() -> ToolResult:
"""Tool with full control over output."""
return ToolResult(
content=[TextContent(text="Human-readable summary")],
structured_content={"data": "value", "count": 42}
)
```
When returning `ToolResult`:
- You control exactly what content and structured data is sent
- Output schemas are optional - structured content can be provided without a schema
- Clients receive both traditional content blocks and structured data
<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.
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 but the tool will still function normally with traditional content.
</Note>
### Error Handling

View file

@ -79,9 +79,9 @@ class ToolResult:
structured_content = pydantic_core.to_jsonable_python(
structured_content
)
except pydantic_core.PydanticSerializationError:
except pydantic_core.PydanticSerializationError as e:
logger.error(
"Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization:"
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):
@ -280,15 +280,23 @@ class FunctionTool(Tool):
unstructured_result = _convert_to_content(result, serializer=self.serializer)
# Handle structured content based on output schema
structured_output = None
# First handle structured content based on output schema, if any
if self.output_schema is not None:
if self.output_schema.get("x-fastmcp-wrap-result"):
# Schema says wrap - always wrap in result key
structured_output = {"result": result}
else:
structured_output = result
else:
structured_output = None
# If no output schema, try to serialize the result. If it is a dict, use
# it as structured content. If it is not a dict, ignore it.
if structured_output is None:
try:
structured_output = pydantic_core.to_jsonable_python(result)
if not isinstance(structured_output, dict):
structured_output = None
except Exception:
pass
return ToolResult(
content=unstructured_result,

View file

@ -198,11 +198,12 @@ class TransformedTool(Tool):
This class represents a tool that has been created by transforming another tool.
It supports argument renaming, schema modification, custom function injection,
and provides context for the forward() and forward_raw() functions.
structured output control, and provides context for the forward() and forward_raw() functions.
The transformation can be purely schema-based (argument renaming, dropping, etc.)
or can include a custom function that uses forward() to call the parent tool
with transformed arguments.
with transformed arguments. Output schemas and structured outputs are automatically
inherited from the parent tool but can be overridden or disabled.
Attributes:
parent_tool: The original tool that this tool was transformed from.
@ -352,6 +353,10 @@ class TransformedTool(Tool):
description: New description. Defaults to parent's description.
tags: New tags. Defaults to parent's tags.
annotations: New annotations. Defaults to parent's annotations.
output_schema: Control output schema for structured outputs:
- None (default): Inherit from transform_fn if available, then parent tool
- dict: Use custom output schema
- False: Disable output schema and structured outputs
serializer: New serializer. Defaults to parent's serializer.
Returns:
@ -380,6 +385,26 @@ class TransformedTool(Tool):
Tool.from_tool(parent, transform_fn=flexible, transform_args={"a": "x"})
```
# Control structured outputs and schemas
```python
# Custom output schema
Tool.from_tool(parent, output_schema={
"type": "object",
"properties": {"status": {"type": "string"}}
})
# Disable structured outputs
Tool.from_tool(parent, output_schema=False)
# Return ToolResult for full control
async def custom_output(**kwargs) -> ToolResult:
result = await forward(**kwargs)
return ToolResult(
content=[TextContent(text="Summary")],
structured_content={"processed": True}
)
```
"""
transform_args = transform_args or {}

View file

@ -920,12 +920,12 @@ class TestToolOutputSchema:
mcp = FastMCP()
@mcp.tool(output_schema=None)
def f() -> dict[str, str]:
return {"message": "Hello, world!"}
def f() -> int:
return 42
async with Client(mcp) as client:
result = await client.call_tool("f", {})
assert json.loads(result.content[0].text) == {"message": "Hello, world!"} # type: ignore[attr-defined]
assert result.content[0].text == "42" # type: ignore[attr-defined]
assert result.structured_content is None
assert result.data is None

View file

@ -529,8 +529,8 @@ class TestToolFromFunctionOutputSchema:
tool = Tool.from_function(func, output_schema=custom_schema)
assert tool.output_schema == custom_schema
async def test_output_schema_false_disables_structured_content(self):
"""Test that output_schema=False disables structured content generation."""
async def test_output_schema_false_allows_automatic_structured_content(self):
"""Test that output_schema=False still allows automatic structured content for dict-like objects."""
def func() -> dict[str, str]:
return {"message": "Hello, world!"}
@ -539,7 +539,8 @@ class TestToolFromFunctionOutputSchema:
assert tool.output_schema is None
result = await tool.run({})
assert result.structured_content is None
# Dict objects automatically become structured content even without schema
assert result.structured_content == {"message": "Hello, world!"}
assert len(result.content) == 1
assert result.content[0].text == '{\n "message": "Hello, world!"\n}' # type: ignore[attr-defined]
@ -1028,3 +1029,199 @@ class TestConvertResultToContent:
1,
{"type": "text", "text": "hello", "annotations": None, "_meta": None},
]
class TestAutomaticStructuredContent:
"""Tests for automatic structured content generation based on return types."""
async def test_dict_return_creates_structured_content_without_schema(self):
"""Test that dict returns automatically create structured content even without output schema."""
def get_user_data(user_id: str) -> dict:
return {"name": "Alice", "age": 30, "active": True}
# No explicit output schema provided
tool = Tool.from_function(get_user_data)
result = await tool.run({"user_id": "123"})
# Should have both content and structured content
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.structured_content == {"name": "Alice", "age": 30, "active": True}
async def test_dataclass_return_creates_structured_content_without_schema(self):
"""Test that dataclass returns automatically create structured content even without output schema."""
@dataclass
class UserProfile:
name: str
age: int
email: str
def get_profile(user_id: str) -> UserProfile:
return UserProfile(name="Bob", age=25, email="bob@example.com")
# No explicit output schema, but dataclass should still create structured content
tool = Tool.from_function(get_profile, output_schema=False)
result = await tool.run({"user_id": "456"})
# Should have both content and structured content
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
# Dataclass should serialize to dict
assert result.structured_content == {
"name": "Bob",
"age": 25,
"email": "bob@example.com",
}
async def test_pydantic_model_return_creates_structured_content_without_schema(
self,
):
"""Test that Pydantic model returns automatically create structured content even without output schema."""
class UserData(BaseModel):
username: str
score: int
verified: bool
def get_user_stats(user_id: str) -> UserData:
return UserData(username="charlie", score=100, verified=True)
# Explicitly disable output schema to test automatic structured content
tool = Tool.from_function(get_user_stats, output_schema=False)
result = await tool.run({"user_id": "789"})
# Should have both content and structured content
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
# Pydantic model should serialize to dict
assert result.structured_content == {
"username": "charlie",
"score": 100,
"verified": True,
}
async def test_int_return_no_structured_content_without_schema(self):
"""Test that int returns don't create structured content without output schema."""
def calculate_sum(a: int, b: int):
"""No return annotation."""
return a + b
# No output schema
tool = Tool.from_function(calculate_sum)
result = await tool.run({"a": 5, "b": 3})
# Should only have content, no structured content
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "8"
assert result.structured_content is None
async def test_str_return_no_structured_content_without_schema(self):
"""Test that str returns don't create structured content without output schema."""
def get_greeting(name: str):
"""No return annotation."""
return f"Hello, {name}!"
# No output schema
tool = Tool.from_function(get_greeting)
result = await tool.run({"name": "World"})
# Should only have content, no structured content
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Hello, World!"
assert result.structured_content is None
async def test_list_return_no_structured_content_without_schema(self):
"""Test that list returns don't create structured content without output schema."""
def get_numbers():
"""No return annotation."""
return [1, 2, 3, 4, 5]
# No output schema
tool = Tool.from_function(get_numbers)
result = await tool.run({})
# Should only have content, no structured content
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.structured_content is None
async def test_int_return_with_schema_creates_structured_content(self):
"""Test that int returns DO create structured content when there's an output schema."""
def calculate_sum(a: int, b: int) -> int:
"""With return annotation."""
return a + b
# Output schema should be auto-generated from annotation
tool = Tool.from_function(calculate_sum)
assert tool.output_schema is not None
result = await tool.run({"a": 5, "b": 3})
# Should have both content and structured content
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "8"
assert result.structured_content == {"result": 8}
async def test_client_automatic_deserialization_with_dict_result(self):
"""Test that clients automatically deserialize dict results from structured content."""
from fastmcp import FastMCP
from fastmcp.client import Client
mcp = FastMCP()
@mcp.tool
def get_user_info(user_id: str) -> dict:
return {"name": "Alice", "age": 30, "active": True}
async with Client(mcp) as client:
result = await client.call_tool("get_user_info", {"user_id": "123"})
# Client should provide the deserialized data
assert result.data == {"name": "Alice", "age": 30, "active": True}
assert result.structured_content == {
"name": "Alice",
"age": 30,
"active": True,
}
assert len(result.content) == 1
async def test_client_automatic_deserialization_with_dataclass_result(self):
"""Test that clients automatically deserialize dataclass results from structured content."""
from fastmcp import FastMCP
from fastmcp.client import Client
mcp = FastMCP()
@dataclass
class UserProfile:
name: str
age: int
verified: bool
@mcp.tool
def get_profile(user_id: str) -> UserProfile:
return UserProfile(name="Bob", age=25, verified=True)
async with Client(mcp) as client:
result = await client.call_tool("get_profile", {"user_id": "456"})
# Client should deserialize back to a dataclass (type name will match)
assert result.data.__class__.__name__ == "UserProfile"
assert result.data.name == "Bob"
assert result.data.age == 25
assert result.data.verified is True