From c412a63d4e8dbc51f50ecedbf8cfbbc0692eb5c6 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Jun 2025 22:38:55 -0400 Subject: [PATCH] Update docs --- docs/clients/tools.mdx | 126 ++++++++++++-- docs/patterns/tool-transformation.mdx | 40 ++++- docs/servers/tools.mdx | 199 ++++++++++++++++++++-- src/fastmcp/tools/tool.py | 18 +- src/fastmcp/tools/tool_transform.py | 29 +++- tests/server/test_server_interactions.py | 6 +- tests/tools/test_tool.py | 203 ++++++++++++++++++++++- 7 files changed, 575 insertions(+), 46 deletions(-) diff --git a/docs/clients/tools.mdx b/docs/clients/tools.mdx index 3821725cb..68d4424bc 100644 --- a/docs/clients/tools.mdx +++ b/docs/clients/tools.mdx @@ -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: + -- **`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 + + + + **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. + + + + Standard MCP content blocks (`TextContent`, `ImageContent`, `AudioContent`, etc.) available from all MCP servers. + + + + Standard MCP structured JSON data as sent by the server, available from all MCP servers that support structured outputs. + + + + Boolean indicating if the tool execution failed. + + + +### 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 + + +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. + + +```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 diff --git a/docs/patterns/tool-transformation.mdx b/docs/patterns/tool-transformation.mdx index f736f791c..c9528282e 100644 --- a/docs/patterns/tool-transformation.mdx +++ b/docs/patterns/tool-transformation.mdx @@ -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) 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()`. - + + +## Output Schema Control + + + +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 diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 5c5e9cb94..7ba5535a4 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -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 -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 + + +This automatic behavior enables clients to receive machine-readable data alongside human-readable content without requiring explicit output schemas for object-like returns. + + +##### Object-like Results (Automatic Structured Content) + + +```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 +} +``` + + +##### Non-object Results (Schema Required) + + +```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 +} +``` + + +##### Complex Type Example ```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" +} +``` -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 + + + +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: + + +```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 +} +``` + + +##### 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. + + +**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`) + + +#### 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 -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. ### Error Handling diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index bd8d8a497..35dec76f6 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -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, diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index db842c519..61d9ecc9e 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -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 {} diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index c48e5800a..d953cd910 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -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 diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 67ebe9da7..f94e73fd3 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -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