Jeremiah Lowin commited on
Commit
d4466da
·
1 Parent(s): 5e0db17

Update docs

Browse files
docs/clients/tools.mdx CHANGED
@@ -37,10 +37,13 @@ Execute a tool using `call_tool()` with the tool name and arguments:
37
  async with client:
38
  # Simple tool call
39
  result = await client.call_tool("add", {"a": 5, "b": 3})
40
- # result -> list[mcp.types.TextContent | mcp.types.ImageContent | ...]
41
 
42
- # Access the result content
43
- print(result[0].text) # Assuming TextContent, e.g., '8'
 
 
 
44
  ```
45
 
46
  ### Advanced Execution Options
@@ -72,21 +75,97 @@ async with client:
72
 
73
  ## Handling Results
74
 
75
- Tool execution returns a list of content objects. The most common types are:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
- - **`TextContent`**: Text-based results with a `.text` attribute
78
- - **`ImageContent`**: Image data with image-specific attributes
79
- - **`BlobContent`**: Binary data content
 
 
 
 
 
80
 
81
  ```python
 
 
 
82
  async with client:
83
  result = await client.call_tool("get_weather", {"city": "London"})
84
 
85
- for content in result:
86
- if hasattr(content, 'text'):
87
- print(f"Text result: {content.text}")
88
- elif hasattr(content, 'data'):
89
- print(f"Binary data: {len(content.data)} bytes")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  ```
91
 
92
  ## Error Handling
@@ -101,14 +180,32 @@ from fastmcp.exceptions import ToolError
101
  async with client:
102
  try:
103
  result = await client.call_tool("potentially_failing_tool", {"param": "value"})
104
- print("Tool succeeded:", result)
105
  except ToolError as e:
106
  print(f"Tool failed: {e}")
107
  ```
108
 
109
  ### Manual Error Checking
110
 
111
- For more granular control, use `call_tool_mcp()` which returns the raw MCP protocol object with an `isError` flag:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
  ```python
114
  async with client:
@@ -119,6 +216,7 @@ async with client:
119
  print(f"Tool failed: {result.content}")
120
  else:
121
  print(f"Tool succeeded: {result.content}")
 
122
  ```
123
 
124
  ## Argument Handling
 
37
  async with client:
38
  # Simple tool call
39
  result = await client.call_tool("add", {"a": 5, "b": 3})
40
+ # result -> CallToolResult with structured and unstructured data
41
 
42
+ # Access structured data (automatically deserialized)
43
+ print(result.data) # 8 (int) or {"result": 8} for primitive types
44
+
45
+ # Access traditional content blocks
46
+ print(result.content[0].text) # "8" (TextContent)
47
  ```
48
 
49
  ### Advanced Execution Options
 
75
 
76
  ## Handling Results
77
 
78
+ <VersionBadge version="2.10.0" />
79
+
80
+ 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.
81
+
82
+ ### CallToolResult Properties
83
+
84
+ <Card icon="code" title="CallToolResult Properties">
85
+ <ResponseField name=".data" type="Any">
86
+ **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.
87
+ </ResponseField>
88
+
89
+ <ResponseField name=".content" type="list[mcp.types.ContentBlock]">
90
+ Standard MCP content blocks (`TextContent`, `ImageContent`, `AudioContent`, etc.) available from all MCP servers.
91
+ </ResponseField>
92
+
93
+ <ResponseField name=".structured_content" type="dict[str, Any] | None">
94
+ Standard MCP structured JSON data as sent by the server, available from all MCP servers that support structured outputs.
95
+ </ResponseField>
96
 
97
+ <ResponseField name=".is_error" type="bool">
98
+ Boolean indicating if the tool execution failed.
99
+ </ResponseField>
100
+ </Card>
101
+
102
+ ### Structured Data Access
103
+
104
+ FastMCP's `.data` property provides fully hydrated Python objects, not just JSON dictionaries. This includes complex type reconstruction:
105
 
106
  ```python
107
+ from datetime import datetime
108
+ from uuid import UUID
109
+
110
  async with client:
111
  result = await client.call_tool("get_weather", {"city": "London"})
112
 
113
+ # FastMCP reconstructs complete Python objects from the server's output schema
114
+ weather = result.data # Server-defined WeatherReport object
115
+ print(f"Temperature: {weather.temperature}°C at {weather.timestamp}")
116
+ print(f"Station: {weather.station_id}")
117
+ print(f"Humidity: {weather.humidity}%")
118
+
119
+ # The timestamp is a real datetime object, not a string!
120
+ assert isinstance(weather.timestamp, datetime)
121
+ assert isinstance(weather.station_id, UUID)
122
+
123
+ # Compare with raw structured JSON (standard MCP)
124
+ print(f"Raw JSON: {result.structured_content}")
125
+ # {"temperature": 20, "timestamp": "2024-01-15T14:30:00Z", "station_id": "123e4567-..."}
126
+
127
+ # Traditional content blocks (standard MCP)
128
+ print(f"Text content: {result.content[0].text}")
129
+ ```
130
+
131
+ ### Fallback Behavior
132
+
133
+ For tools without output schemas or when deserialization fails, `.data` will be `None`:
134
+
135
+ ```python
136
+ async with client:
137
+ result = await client.call_tool("legacy_tool", {"param": "value"})
138
+
139
+ if result.data is not None:
140
+ # Structured output available and successfully deserialized
141
+ print(f"Structured: {result.data}")
142
+ else:
143
+ # No structured output or deserialization failed - use content blocks
144
+ for content in result.content:
145
+ if hasattr(content, 'text'):
146
+ print(f"Text result: {content.text}")
147
+ elif hasattr(content, 'data'):
148
+ print(f"Binary data: {len(content.data)} bytes")
149
+ ```
150
+
151
+ ### Primitive Type Unwrapping
152
+
153
+ <Tip>
154
+ 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.
155
+ </Tip>
156
+
157
+ ```python
158
+ async with client:
159
+ result = await client.call_tool("calculate_sum", {"a": 5, "b": 3})
160
+
161
+ # FastMCP client automatically unwraps for convenience
162
+ print(result.data) # 8 (int) - the original value
163
+
164
+ # Raw structured content shows the server-side wrapping
165
+ print(result.structured_content) # {"result": 8}
166
+
167
+ # Other MCP clients would need to manually access ["result"]
168
+ # value = result.structured_content["result"] # Not needed with FastMCP!
169
  ```
170
 
171
  ## Error Handling
 
180
  async with client:
181
  try:
182
  result = await client.call_tool("potentially_failing_tool", {"param": "value"})
183
+ print("Tool succeeded:", result.data)
184
  except ToolError as e:
185
  print(f"Tool failed: {e}")
186
  ```
187
 
188
  ### Manual Error Checking
189
 
190
+ You can disable automatic error raising and manually check the result:
191
+
192
+ ```python
193
+ async with client:
194
+ result = await client.call_tool(
195
+ "potentially_failing_tool",
196
+ {"param": "value"},
197
+ raise_on_error=False
198
+ )
199
+
200
+ if result.is_error:
201
+ print(f"Tool failed: {result.content[0].text}")
202
+ else:
203
+ print(f"Tool succeeded: {result.data}")
204
+ ```
205
+
206
+ ### Raw MCP Protocol Access
207
+
208
+ For complete control, use `call_tool_mcp()` which returns the raw MCP protocol object:
209
 
210
  ```python
211
  async with client:
 
216
  print(f"Tool failed: {result.content}")
217
  else:
218
  print(f"Tool succeeded: {result.content}")
219
+ # Note: No automatic deserialization with call_tool_mcp()
220
  ```
221
 
222
  ## Argument Handling
docs/patterns/tool-transformation.mdx CHANGED
@@ -89,6 +89,7 @@ The `Tool.from_tool()` class method is the primary way to create a transformed t
89
  - `description`: An optional description for the new tool.
90
  - `transform_args`: A dictionary of `ArgTransform` objects, one for each argument you want to modify.
91
  - `transform_fn`: An optional function that will be called instead of the parent tool's logic.
 
92
  - `tags`: An optional set of tags for the new tool.
93
  - `annotations`: An optional set of `ToolAnnotations` for the new tool.
94
  - `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)
439
 
440
  <Tip>
441
  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()`.
442
- </Tip>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
 
444
  ## Common Patterns
445
 
 
89
  - `description`: An optional description for the new tool.
90
  - `transform_args`: A dictionary of `ArgTransform` objects, one for each argument you want to modify.
91
  - `transform_fn`: An optional function that will be called instead of the parent tool's logic.
92
+ - `output_schema`: Control output schema and structured outputs (see [Output Schema Control](#output-schema-control)).
93
  - `tags`: An optional set of tags for the new tool.
94
  - `annotations`: An optional set of `ToolAnnotations` for the new tool.
95
  - `serializer`: An optional function that will be called to serialize the result of the new tool.
 
440
 
441
  <Tip>
442
  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()`.
443
+ </Tip>
444
+
445
+ ## Output Schema Control
446
+
447
+ <VersionBadge version="2.10.0" />
448
+
449
+ Transformed tools inherit output schemas from their parent by default, but you can control this behavior:
450
+
451
+ **Inherit from Parent (Default)**
452
+ ```python
453
+ Tool.from_tool(parent_tool, name="renamed_tool")
454
+ ```
455
+ The transformed tool automatically uses the parent tool's output schema and structured output behavior.
456
+
457
+ **Custom Output Schema**
458
+ ```python
459
+ Tool.from_tool(parent_tool, output_schema={
460
+ "type": "object",
461
+ "properties": {"status": {"type": "string"}}
462
+ })
463
+ ```
464
+ Provide your own schema that differs from the parent. The tool must return data matching this schema.
465
+
466
+ **Remove Output Schema**
467
+ ```python
468
+ Tool.from_tool(parent_tool, output_schema=False)
469
+ ```
470
+ 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.
471
+
472
+ **Full Control with Transform Functions**
473
+ ```python
474
+ async def custom_output(**kwargs) -> ToolResult:
475
+ result = await forward(**kwargs)
476
+ return ToolResult(content=[...], structured_content={...})
477
+
478
+ Tool.from_tool(parent_tool, transform_fn=custom_output)
479
+ ```
480
+ Use a transform function returning `ToolResult` for complete control over both content blocks and structured outputs.
481
 
482
  ## Common Patterns
483
 
docs/servers/tools.mdx CHANGED
@@ -288,28 +288,100 @@ Use `async def` when your tool needs to perform operations that might wait for e
288
 
289
  ### Return Values
290
 
291
- #### Output Conversion
292
 
293
- FastMCP automatically converts the value returned by your function into the appropriate MCP content format for the client:
294
 
295
- - **`str`**: Sent as `TextContent`.
296
- - **`dict`, `list`, Pydantic `BaseModel`**: Serialized to a JSON string and sent as `TextContent`.
297
- - **`bytes`**: Base64 encoded and sent as `BlobResourceContents` (often within an `EmbeddedResource`).
298
- - **`fastmcp.utilities.types.Image`**: A helper class for easily returning image data. Sent as `ImageContent`.
299
- - **`fastmcp.utilities.types.Audio`**: A helper class for easily returning audio data. Sent as `AudioContent`.
300
- - **`fastmcp.utilities.types.File`**: A helper class for easily returning binary data as base64-encoded content. Sent as `EmbeddedResource`.
301
- - **A list of any of the above**: Automatically converts each item appropriately.
302
- - **`None`**: Results in an empty response (no content is sent back to the client).
303
 
304
- FastMCP will attempt to serialize other types to a string if possible.
 
 
305
 
306
- #### Output Schemas
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
 
308
  <VersionBadge version="2.10.0" />
309
 
310
- 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.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
 
312
- 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.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
 
314
  <CodeGroup>
315
  ```python Tool Definition
@@ -334,19 +406,110 @@ def get_user_profile(user_id: str) -> Person:
334
  {
335
  "properties": {
336
  "name": {"title": "Name", "type": "string"},
337
- "age": {"title": "Age", "type": "integer"},
338
  "email": {"title": "Email", "type": "string"}
339
  },
340
  "required": ["name", "age", "email"],
341
  "title": "Person",
342
  "type": "object"
343
  }
344
- ```
 
 
 
 
 
 
 
 
345
  </CodeGroup>
346
- 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.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
 
348
  <Note>
349
- 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.
350
  </Note>
351
 
352
  ### Error Handling
 
288
 
289
  ### Return Values
290
 
 
291
 
292
+ 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.
293
 
294
+ Understanding how these three concepts work together:
 
 
 
 
 
 
 
295
 
296
+ - **Return Values**: What your Python function returns (determines both content blocks and structured data)
297
+ - **Structured Outputs**: JSON data sent alongside traditional content for machine processing
298
+ - **Output Schemas**: JSON Schema declarations that describe and validate the structured output format
299
 
300
+ The following sections explain each concept in detail.
301
+
302
+ #### Content Blocks
303
+
304
+ FastMCP automatically converts tool return values into appropriate MCP content blocks:
305
+
306
+ - **`str`**: Sent as `TextContent`
307
+ - **`bytes`**: Base64 encoded and sent as `BlobResourceContents` (within an `EmbeddedResource`)
308
+ - **`fastmcp.utilities.types.Image`**: Sent as `ImageContent`
309
+ - **`fastmcp.utilities.types.Audio`**: Sent as `AudioContent`
310
+ - **`fastmcp.utilities.types.File`**: Sent as base64-encoded `EmbeddedResource`
311
+ - **A list of any of the above**: Converts each item appropriately
312
+ - **`None`**: Results in an empty response
313
+
314
+ #### Structured Output
315
 
316
  <VersionBadge version="2.10.0" />
317
 
318
+ 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.
319
+
320
+ **Automatic Structured Content Rules:**
321
+ - **Object-like results** (`dict`, Pydantic models, dataclasses) → Always become structured content (even without output schema)
322
+ - **Non-object results** (`int`, `str`, `list`) → Only become structured content if there's an output schema to validate/serialize them
323
+ - **All results** → Always become traditional content blocks for backward compatibility
324
+
325
+ <Note>
326
+ This automatic behavior enables clients to receive machine-readable data alongside human-readable content without requiring explicit output schemas for object-like returns.
327
+ </Note>
328
+
329
+ ##### Object-like Results (Automatic Structured Content)
330
+
331
+ <CodeGroup>
332
+ ```python Dict Return (No Schema Needed)
333
+ @mcp.tool
334
+ def get_user_data(user_id: str) -> dict:
335
+ """Get user data without type annotation."""
336
+ return {"name": "Alice", "age": 30, "active": True}
337
+ ```
338
 
339
+ ```json Traditional Content
340
+ "{\n \"name\": \"Alice\",\n \"age\": 30,\n \"active\": true\n}"
341
+ ```
342
+
343
+ ```json Structured Content (Automatic)
344
+ {
345
+ "name": "Alice",
346
+ "age": 30,
347
+ "active": true
348
+ }
349
+ ```
350
+ </CodeGroup>
351
+
352
+ ##### Non-object Results (Schema Required)
353
+
354
+ <CodeGroup>
355
+ ```python Integer Return (No Schema)
356
+ @mcp.tool
357
+ def calculate_sum(a: int, b: int):
358
+ """Calculate sum without return annotation."""
359
+ return a + b # Returns 8
360
+ ```
361
+
362
+ ```json Traditional Content Only
363
+ "8"
364
+ ```
365
+
366
+ ```python Integer Return (With Schema)
367
+ @mcp.tool
368
+ def calculate_sum(a: int, b: int) -> int:
369
+ """Calculate sum with return annotation."""
370
+ return a + b # Returns 8
371
+ ```
372
+
373
+ ```json Traditional Content
374
+ "8"
375
+ ```
376
+
377
+ ```json Structured Content (From Schema)
378
+ {
379
+ "result": 8
380
+ }
381
+ ```
382
+ </CodeGroup>
383
+
384
+ ##### Complex Type Example
385
 
386
  <CodeGroup>
387
  ```python Tool Definition
 
406
  {
407
  "properties": {
408
  "name": {"title": "Name", "type": "string"},
409
+ "age": {"title": "Age", "type": "integer"},
410
  "email": {"title": "Email", "type": "string"}
411
  },
412
  "required": ["name", "age", "email"],
413
  "title": "Person",
414
  "type": "object"
415
  }
416
+ ```
417
+
418
+ ```json Structured Output
419
+ {
420
+ "name": "Alice",
421
+ "age": 30,
422
+ "email": "alice@example.com"
423
+ }
424
+ ```
425
  </CodeGroup>
426
+
427
+ #### Output Schemas
428
+
429
+ <VersionBadge version="2.10.0" />
430
+
431
+ 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.
432
+
433
+ 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.
434
+
435
+ ##### Primitive Type Wrapping
436
+
437
+ For primitive return types (like `int`, `str`, `bool`), FastMCP automatically wraps the result under a `"result"` key to create valid structured output:
438
+
439
+ <CodeGroup>
440
+ ```python Primitive Return Type
441
+ @mcp.tool
442
+ def calculate_sum(a: int, b: int) -> int:
443
+ """Add two numbers together."""
444
+ return a + b
445
+ ```
446
+
447
+ ```json Generated Schema (Wrapped)
448
+ {
449
+ "type": "object",
450
+ "properties": {
451
+ "result": {"type": "integer"}
452
+ },
453
+ "x-fastmcp-wrap-result": true
454
+ }
455
+ ```
456
+
457
+ ```json Structured Output
458
+ {
459
+ "result": 8
460
+ }
461
+ ```
462
+ </CodeGroup>
463
+
464
+ ##### Manual Schema Control
465
+
466
+ You can override the automatically generated schema by providing a custom `output_schema`:
467
+
468
+ ```python
469
+ @mcp.tool(output_schema={
470
+ "type": "object",
471
+ "properties": {
472
+ "data": {"type": "string"},
473
+ "metadata": {"type": "object"}
474
+ }
475
+ })
476
+ def custom_schema_tool() -> dict:
477
+ """Tool with custom output schema."""
478
+ return {"data": "Hello", "metadata": {"version": "1.0"}}
479
+ ```
480
+
481
+ Schema generation works for most common types including basic types, collections, union types, Pydantic models, TypedDict structures, and dataclasses.
482
+
483
+ <Warning>
484
+ **Important Constraints**:
485
+ - Output schemas must be object types (`"type": "object"`)
486
+ - If you provide an output schema, your tool **must** return structured output that matches it
487
+ - However, you can provide structured output without an output schema (using `ToolResult`)
488
+ </Warning>
489
+
490
+ #### Full Control with ToolResult
491
+
492
+ For complete control over both traditional content and structured output, return a `ToolResult` object:
493
+
494
+ ```python
495
+ from fastmcp.tools.tool import ToolResult
496
+
497
+ @mcp.tool
498
+ def advanced_tool() -> ToolResult:
499
+ """Tool with full control over output."""
500
+ return ToolResult(
501
+ content=[TextContent(text="Human-readable summary")],
502
+ structured_content={"data": "value", "count": 42}
503
+ )
504
+ ```
505
+
506
+ When returning `ToolResult`:
507
+ - You control exactly what content and structured data is sent
508
+ - Output schemas are optional - structured content can be provided without a schema
509
+ - Clients receive both traditional content blocks and structured data
510
 
511
  <Note>
512
+ 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.
513
  </Note>
514
 
515
  ### Error Handling
src/fastmcp/tools/tool.py CHANGED
@@ -79,9 +79,9 @@ class ToolResult:
79
  structured_content = pydantic_core.to_jsonable_python(
80
  structured_content
81
  )
82
- except pydantic_core.PydanticSerializationError:
83
  logger.error(
84
- "Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization:"
85
  )
86
  raise
87
  if not isinstance(structured_content, dict):
@@ -280,15 +280,23 @@ class FunctionTool(Tool):
280
 
281
  unstructured_result = _convert_to_content(result, serializer=self.serializer)
282
 
283
- # Handle structured content based on output schema
 
284
  if self.output_schema is not None:
285
  if self.output_schema.get("x-fastmcp-wrap-result"):
286
  # Schema says wrap - always wrap in result key
287
  structured_output = {"result": result}
288
  else:
289
  structured_output = result
290
- else:
291
- structured_output = None
 
 
 
 
 
 
 
292
 
293
  return ToolResult(
294
  content=unstructured_result,
 
79
  structured_content = pydantic_core.to_jsonable_python(
80
  structured_content
81
  )
82
+ except pydantic_core.PydanticSerializationError as e:
83
  logger.error(
84
+ f"Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization: {e}"
85
  )
86
  raise
87
  if not isinstance(structured_content, dict):
 
280
 
281
  unstructured_result = _convert_to_content(result, serializer=self.serializer)
282
 
283
+ structured_output = None
284
+ # First handle structured content based on output schema, if any
285
  if self.output_schema is not None:
286
  if self.output_schema.get("x-fastmcp-wrap-result"):
287
  # Schema says wrap - always wrap in result key
288
  structured_output = {"result": result}
289
  else:
290
  structured_output = result
291
+ # If no output schema, try to serialize the result. If it is a dict, use
292
+ # it as structured content. If it is not a dict, ignore it.
293
+ if structured_output is None:
294
+ try:
295
+ structured_output = pydantic_core.to_jsonable_python(result)
296
+ if not isinstance(structured_output, dict):
297
+ structured_output = None
298
+ except Exception:
299
+ pass
300
 
301
  return ToolResult(
302
  content=unstructured_result,
src/fastmcp/tools/tool_transform.py CHANGED
@@ -198,11 +198,12 @@ class TransformedTool(Tool):
198
 
199
  This class represents a tool that has been created by transforming another tool.
200
  It supports argument renaming, schema modification, custom function injection,
201
- and provides context for the forward() and forward_raw() functions.
202
 
203
  The transformation can be purely schema-based (argument renaming, dropping, etc.)
204
  or can include a custom function that uses forward() to call the parent tool
205
- with transformed arguments.
 
206
 
207
  Attributes:
208
  parent_tool: The original tool that this tool was transformed from.
@@ -352,6 +353,10 @@ class TransformedTool(Tool):
352
  description: New description. Defaults to parent's description.
353
  tags: New tags. Defaults to parent's tags.
354
  annotations: New annotations. Defaults to parent's annotations.
 
 
 
 
355
  serializer: New serializer. Defaults to parent's serializer.
356
 
357
  Returns:
@@ -380,6 +385,26 @@ class TransformedTool(Tool):
380
 
381
  Tool.from_tool(parent, transform_fn=flexible, transform_args={"a": "x"})
382
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  """
384
  transform_args = transform_args or {}
385
 
 
198
 
199
  This class represents a tool that has been created by transforming another tool.
200
  It supports argument renaming, schema modification, custom function injection,
201
+ structured output control, and provides context for the forward() and forward_raw() functions.
202
 
203
  The transformation can be purely schema-based (argument renaming, dropping, etc.)
204
  or can include a custom function that uses forward() to call the parent tool
205
+ with transformed arguments. Output schemas and structured outputs are automatically
206
+ inherited from the parent tool but can be overridden or disabled.
207
 
208
  Attributes:
209
  parent_tool: The original tool that this tool was transformed from.
 
353
  description: New description. Defaults to parent's description.
354
  tags: New tags. Defaults to parent's tags.
355
  annotations: New annotations. Defaults to parent's annotations.
356
+ output_schema: Control output schema for structured outputs:
357
+ - None (default): Inherit from transform_fn if available, then parent tool
358
+ - dict: Use custom output schema
359
+ - False: Disable output schema and structured outputs
360
  serializer: New serializer. Defaults to parent's serializer.
361
 
362
  Returns:
 
385
 
386
  Tool.from_tool(parent, transform_fn=flexible, transform_args={"a": "x"})
387
  ```
388
+
389
+ # Control structured outputs and schemas
390
+ ```python
391
+ # Custom output schema
392
+ Tool.from_tool(parent, output_schema={
393
+ "type": "object",
394
+ "properties": {"status": {"type": "string"}}
395
+ })
396
+
397
+ # Disable structured outputs
398
+ Tool.from_tool(parent, output_schema=False)
399
+
400
+ # Return ToolResult for full control
401
+ async def custom_output(**kwargs) -> ToolResult:
402
+ result = await forward(**kwargs)
403
+ return ToolResult(
404
+ content=[TextContent(text="Summary")],
405
+ structured_content={"processed": True}
406
+ )
407
+ ```
408
  """
409
  transform_args = transform_args or {}
410
 
tests/server/test_server_interactions.py CHANGED
@@ -920,12 +920,12 @@ class TestToolOutputSchema:
920
  mcp = FastMCP()
921
 
922
  @mcp.tool(output_schema=None)
923
- def f() -> dict[str, str]:
924
- return {"message": "Hello, world!"}
925
 
926
  async with Client(mcp) as client:
927
  result = await client.call_tool("f", {})
928
- assert json.loads(result.content[0].text) == {"message": "Hello, world!"} # type: ignore[attr-defined]
929
  assert result.structured_content is None
930
  assert result.data is None
931
 
 
920
  mcp = FastMCP()
921
 
922
  @mcp.tool(output_schema=None)
923
+ def f() -> int:
924
+ return 42
925
 
926
  async with Client(mcp) as client:
927
  result = await client.call_tool("f", {})
928
+ assert result.content[0].text == "42" # type: ignore[attr-defined]
929
  assert result.structured_content is None
930
  assert result.data is None
931
 
tests/tools/test_tool.py CHANGED
@@ -529,8 +529,8 @@ class TestToolFromFunctionOutputSchema:
529
  tool = Tool.from_function(func, output_schema=custom_schema)
530
  assert tool.output_schema == custom_schema
531
 
532
- async def test_output_schema_false_disables_structured_content(self):
533
- """Test that output_schema=False disables structured content generation."""
534
 
535
  def func() -> dict[str, str]:
536
  return {"message": "Hello, world!"}
@@ -539,7 +539,8 @@ class TestToolFromFunctionOutputSchema:
539
  assert tool.output_schema is None
540
 
541
  result = await tool.run({})
542
- assert result.structured_content is None
 
543
  assert len(result.content) == 1
544
  assert result.content[0].text == '{\n "message": "Hello, world!"\n}' # type: ignore[attr-defined]
545
 
@@ -1028,3 +1029,199 @@ class TestConvertResultToContent:
1028
  1,
1029
  {"type": "text", "text": "hello", "annotations": None, "_meta": None},
1030
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
  tool = Tool.from_function(func, output_schema=custom_schema)
530
  assert tool.output_schema == custom_schema
531
 
532
+ async def test_output_schema_false_allows_automatic_structured_content(self):
533
+ """Test that output_schema=False still allows automatic structured content for dict-like objects."""
534
 
535
  def func() -> dict[str, str]:
536
  return {"message": "Hello, world!"}
 
539
  assert tool.output_schema is None
540
 
541
  result = await tool.run({})
542
+ # Dict objects automatically become structured content even without schema
543
+ assert result.structured_content == {"message": "Hello, world!"}
544
  assert len(result.content) == 1
545
  assert result.content[0].text == '{\n "message": "Hello, world!"\n}' # type: ignore[attr-defined]
546
 
 
1029
  1,
1030
  {"type": "text", "text": "hello", "annotations": None, "_meta": None},
1031
  ]
1032
+
1033
+
1034
+ class TestAutomaticStructuredContent:
1035
+ """Tests for automatic structured content generation based on return types."""
1036
+
1037
+ async def test_dict_return_creates_structured_content_without_schema(self):
1038
+ """Test that dict returns automatically create structured content even without output schema."""
1039
+
1040
+ def get_user_data(user_id: str) -> dict:
1041
+ return {"name": "Alice", "age": 30, "active": True}
1042
+
1043
+ # No explicit output schema provided
1044
+ tool = Tool.from_function(get_user_data)
1045
+
1046
+ result = await tool.run({"user_id": "123"})
1047
+
1048
+ # Should have both content and structured content
1049
+ assert len(result.content) == 1
1050
+ assert isinstance(result.content[0], TextContent)
1051
+ assert result.structured_content == {"name": "Alice", "age": 30, "active": True}
1052
+
1053
+ async def test_dataclass_return_creates_structured_content_without_schema(self):
1054
+ """Test that dataclass returns automatically create structured content even without output schema."""
1055
+
1056
+ @dataclass
1057
+ class UserProfile:
1058
+ name: str
1059
+ age: int
1060
+ email: str
1061
+
1062
+ def get_profile(user_id: str) -> UserProfile:
1063
+ return UserProfile(name="Bob", age=25, email="bob@example.com")
1064
+
1065
+ # No explicit output schema, but dataclass should still create structured content
1066
+ tool = Tool.from_function(get_profile, output_schema=False)
1067
+
1068
+ result = await tool.run({"user_id": "456"})
1069
+
1070
+ # Should have both content and structured content
1071
+ assert len(result.content) == 1
1072
+ assert isinstance(result.content[0], TextContent)
1073
+ # Dataclass should serialize to dict
1074
+ assert result.structured_content == {
1075
+ "name": "Bob",
1076
+ "age": 25,
1077
+ "email": "bob@example.com",
1078
+ }
1079
+
1080
+ async def test_pydantic_model_return_creates_structured_content_without_schema(
1081
+ self,
1082
+ ):
1083
+ """Test that Pydantic model returns automatically create structured content even without output schema."""
1084
+
1085
+ class UserData(BaseModel):
1086
+ username: str
1087
+ score: int
1088
+ verified: bool
1089
+
1090
+ def get_user_stats(user_id: str) -> UserData:
1091
+ return UserData(username="charlie", score=100, verified=True)
1092
+
1093
+ # Explicitly disable output schema to test automatic structured content
1094
+ tool = Tool.from_function(get_user_stats, output_schema=False)
1095
+
1096
+ result = await tool.run({"user_id": "789"})
1097
+
1098
+ # Should have both content and structured content
1099
+ assert len(result.content) == 1
1100
+ assert isinstance(result.content[0], TextContent)
1101
+ # Pydantic model should serialize to dict
1102
+ assert result.structured_content == {
1103
+ "username": "charlie",
1104
+ "score": 100,
1105
+ "verified": True,
1106
+ }
1107
+
1108
+ async def test_int_return_no_structured_content_without_schema(self):
1109
+ """Test that int returns don't create structured content without output schema."""
1110
+
1111
+ def calculate_sum(a: int, b: int):
1112
+ """No return annotation."""
1113
+ return a + b
1114
+
1115
+ # No output schema
1116
+ tool = Tool.from_function(calculate_sum)
1117
+
1118
+ result = await tool.run({"a": 5, "b": 3})
1119
+
1120
+ # Should only have content, no structured content
1121
+ assert len(result.content) == 1
1122
+ assert isinstance(result.content[0], TextContent)
1123
+ assert result.content[0].text == "8"
1124
+ assert result.structured_content is None
1125
+
1126
+ async def test_str_return_no_structured_content_without_schema(self):
1127
+ """Test that str returns don't create structured content without output schema."""
1128
+
1129
+ def get_greeting(name: str):
1130
+ """No return annotation."""
1131
+ return f"Hello, {name}!"
1132
+
1133
+ # No output schema
1134
+ tool = Tool.from_function(get_greeting)
1135
+
1136
+ result = await tool.run({"name": "World"})
1137
+
1138
+ # Should only have content, no structured content
1139
+ assert len(result.content) == 1
1140
+ assert isinstance(result.content[0], TextContent)
1141
+ assert result.content[0].text == "Hello, World!"
1142
+ assert result.structured_content is None
1143
+
1144
+ async def test_list_return_no_structured_content_without_schema(self):
1145
+ """Test that list returns don't create structured content without output schema."""
1146
+
1147
+ def get_numbers():
1148
+ """No return annotation."""
1149
+ return [1, 2, 3, 4, 5]
1150
+
1151
+ # No output schema
1152
+ tool = Tool.from_function(get_numbers)
1153
+
1154
+ result = await tool.run({})
1155
+
1156
+ # Should only have content, no structured content
1157
+ assert len(result.content) == 1
1158
+ assert isinstance(result.content[0], TextContent)
1159
+ assert result.structured_content is None
1160
+
1161
+ async def test_int_return_with_schema_creates_structured_content(self):
1162
+ """Test that int returns DO create structured content when there's an output schema."""
1163
+
1164
+ def calculate_sum(a: int, b: int) -> int:
1165
+ """With return annotation."""
1166
+ return a + b
1167
+
1168
+ # Output schema should be auto-generated from annotation
1169
+ tool = Tool.from_function(calculate_sum)
1170
+ assert tool.output_schema is not None
1171
+
1172
+ result = await tool.run({"a": 5, "b": 3})
1173
+
1174
+ # Should have both content and structured content
1175
+ assert len(result.content) == 1
1176
+ assert isinstance(result.content[0], TextContent)
1177
+ assert result.content[0].text == "8"
1178
+ assert result.structured_content == {"result": 8}
1179
+
1180
+ async def test_client_automatic_deserialization_with_dict_result(self):
1181
+ """Test that clients automatically deserialize dict results from structured content."""
1182
+ from fastmcp import FastMCP
1183
+ from fastmcp.client import Client
1184
+
1185
+ mcp = FastMCP()
1186
+
1187
+ @mcp.tool
1188
+ def get_user_info(user_id: str) -> dict:
1189
+ return {"name": "Alice", "age": 30, "active": True}
1190
+
1191
+ async with Client(mcp) as client:
1192
+ result = await client.call_tool("get_user_info", {"user_id": "123"})
1193
+
1194
+ # Client should provide the deserialized data
1195
+ assert result.data == {"name": "Alice", "age": 30, "active": True}
1196
+ assert result.structured_content == {
1197
+ "name": "Alice",
1198
+ "age": 30,
1199
+ "active": True,
1200
+ }
1201
+ assert len(result.content) == 1
1202
+
1203
+ async def test_client_automatic_deserialization_with_dataclass_result(self):
1204
+ """Test that clients automatically deserialize dataclass results from structured content."""
1205
+ from fastmcp import FastMCP
1206
+ from fastmcp.client import Client
1207
+
1208
+ mcp = FastMCP()
1209
+
1210
+ @dataclass
1211
+ class UserProfile:
1212
+ name: str
1213
+ age: int
1214
+ verified: bool
1215
+
1216
+ @mcp.tool
1217
+ def get_profile(user_id: str) -> UserProfile:
1218
+ return UserProfile(name="Bob", age=25, verified=True)
1219
+
1220
+ async with Client(mcp) as client:
1221
+ result = await client.call_tool("get_profile", {"user_id": "456"})
1222
+
1223
+ # Client should deserialize back to a dataclass (type name will match)
1224
+ assert result.data.__class__.__name__ == "UserProfile"
1225
+ assert result.data.name == "Bob"
1226
+ assert result.data.age == 25
1227
+ assert result.data.verified is True