Jeremiah Lowin commited on
Commit
34a5c49
·
unverified ·
2 Parent(s): 7e7d69e42909f0

Merge branch 'main' into auth

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. docs/clients/client.mdx +1 -1
  2. docs/clients/tools.mdx +112 -14
  3. docs/patterns/tool-transformation.mdx +39 -1
  4. docs/servers/tools.mdx +209 -35
  5. pyproject.toml +1 -0
  6. src/fastmcp/client/client.py +68 -14
  7. src/fastmcp/contrib/component_manager/README.md +170 -0
  8. src/fastmcp/contrib/component_manager/__init__.py +4 -0
  9. src/fastmcp/contrib/component_manager/component_manager.py +186 -0
  10. src/fastmcp/contrib/component_manager/component_service.py +225 -0
  11. src/fastmcp/contrib/component_manager/example.py +59 -0
  12. src/fastmcp/server/low_level.py +4 -2
  13. src/fastmcp/server/openapi.py +8 -7
  14. src/fastmcp/server/proxy.py +13 -8
  15. src/fastmcp/server/server.py +13 -7
  16. src/fastmcp/tools/tool.py +203 -21
  17. src/fastmcp/tools/tool_manager.py +3 -5
  18. src/fastmcp/tools/tool_transform.py +126 -27
  19. src/fastmcp/utilities/json_schema_type.py +646 -0
  20. src/fastmcp/utilities/openapi.py +10 -0
  21. src/fastmcp/utilities/types.py +53 -19
  22. tests/auth/test_oauth_client.py +3 -1
  23. tests/client/test_client.py +4 -3
  24. tests/client/test_notifications.py +6 -6
  25. tests/client/test_openapi.py +2 -2
  26. tests/client/test_roots.py +1 -3
  27. tests/client/test_sampling.py +3 -6
  28. tests/client/test_stdio.py +9 -9
  29. tests/client/test_streamable_http.py +1 -5
  30. tests/contrib/test_bulk_tool_caller.py +4 -1
  31. tests/contrib/test_component_manager.py +743 -0
  32. tests/deprecated/test_mount_import_arg_order.py +2 -2
  33. tests/server/http/test_http_dependencies.py +4 -6
  34. tests/server/openapi/test_openapi.py +32 -12
  35. tests/server/openapi/test_openapi_path_parameters.py +2 -11
  36. tests/server/test_import_server.py +6 -6
  37. tests/server/test_mount.py +49 -36
  38. tests/server/test_proxy.py +8 -6
  39. tests/server/test_server.py +51 -29
  40. tests/server/test_server_interactions.py +349 -73
  41. tests/server/test_tool_annotations.py +1 -5
  42. tests/server/test_tool_exclude_args.py +1 -7
  43. tests/test_examples.py +12 -12
  44. tests/tools/test_tool.py +698 -10
  45. tests/tools/test_tool_manager.py +42 -17
  46. tests/tools/test_tool_transform.py +321 -44
  47. tests/utilities/openapi/test_openapi.py +23 -0
  48. tests/utilities/test_json_schema_type.py +1441 -0
  49. tests/utilities/test_mcp_config.py +2 -2
  50. tests/utilities/test_types.py +27 -0
docs/clients/client.mdx CHANGED
@@ -109,7 +109,7 @@ config = {
109
  },
110
  "local_server": {
111
  # Local stdio server
112
- "transport": "stdio"
113
  "command": "python",
114
  "args": ["./server.py", "--verbose"],
115
  "env": {"DEBUG": "true"},
 
109
  },
110
  "local_server": {
111
  # Local stdio server
112
+ "transport": "stdio",
113
  "command": "python",
114
  "args": ["./server.py", "--verbose"],
115
  "env": {"DEBUG": "true"},
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,55 +288,229 @@ Use `async def` when your tool needs to perform operations that might wait for e
288
 
289
  ### Return Values
290
 
291
- FastMCP automatically converts the value returned by your function into the appropriate MCP content format for the client:
292
 
293
- - **`str`**: Sent as `TextContent`.
294
- - **`dict`, `list`, Pydantic `BaseModel`**: Serialized to a JSON string and sent as `TextContent`.
295
- - **`bytes`**: Base64 encoded and sent as `BlobResourceContents` (often within an `EmbeddedResource`).
296
- - **`fastmcp.utilities.types.Image`**: A helper class for easily returning image data. Sent as `ImageContent`.
297
- - **`fastmcp.utilities.types.Audio`**: A helper class for easily returning audio data. Sent as `AudioContent`.
298
- - **`fastmcp.utilities.types.File`**: A helper class for easily returning binary data as base64-encoded content. Sent as `EmbeddedResource`.
299
- - **A list of any of the above**: Automatically converts each item appropriately.
300
- - **`None`**: Results in an empty response (no content is sent back to the client).
301
 
302
- FastMCP will attempt to serialize other types to a string if possible.
303
 
304
- <Tip>
305
- At this time, FastMCP responds only to your tool's return *value*, not its return *annotation*.
306
- </Tip>
307
 
308
- ```python
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  from fastmcp import FastMCP
310
- from fastmcp.utilities.types import Image
311
- import io
312
 
313
- try:
314
- from PIL import Image as PILImage
315
- except ImportError:
316
- raise ImportError("Please install the `pillow` library to run this example.")
317
 
318
- mcp = FastMCP("Image Demo")
 
 
 
 
319
 
320
  @mcp.tool
321
- def generate_image(width: int, height: int, color: str) -> Image:
322
- """Generates a solid color image."""
323
- # Create image using Pillow
324
- img = PILImage.new("RGB", (width, height), color=color)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
 
326
- # Save to a bytes buffer
327
- buffer = io.BytesIO()
328
- img.save(buffer, format="PNG")
329
- img_bytes = buffer.getvalue()
330
 
331
- # Return using FastMCP's Image helper
332
- return Image(data=img_bytes, format="png")
333
 
 
 
334
  @mcp.tool
335
- def do_nothing() -> None:
336
- """This tool performs an action but returns no data."""
337
- print("Performing a side effect...")
338
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
 
341
  ### Error Handling
342
 
 
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
388
+ from dataclasses import dataclass
389
  from fastmcp import FastMCP
 
 
390
 
391
+ mcp = FastMCP()
 
 
 
392
 
393
+ @dataclass
394
+ class Person:
395
+ name: str
396
+ age: int
397
+ email: str
398
 
399
  @mcp.tool
400
+ def get_user_profile(user_id: str) -> Person:
401
+ """Get a user's profile information."""
402
+ return Person(name="Alice", age=30, email="alice@example.com")
403
+ ```
404
+
405
+ ```json Generated Output Schema
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
516
 
pyproject.toml CHANGED
@@ -12,6 +12,7 @@ dependencies = [
12
  "rich>=13.9.4",
13
  "typer>=0.15.2",
14
  "authlib>=1.5.2",
 
15
  ]
16
  requires-python = ">=3.10"
17
  readme = "README.md"
 
12
  "rich>=13.9.4",
13
  "typer>=0.15.2",
14
  "authlib>=1.5.2",
15
+ "pydantic[email]>=2.11.7",
16
  ]
17
  requires-python = ">=3.10"
18
  readme = "README.md"
src/fastmcp/client/client.py CHANGED
@@ -1,6 +1,9 @@
 
 
1
  import asyncio
2
  import datetime
3
  from contextlib import AsyncExitStack, asynccontextmanager
 
4
  from pathlib import Path
5
  from typing import Any, Generic, Literal, cast, overload
6
 
@@ -10,7 +13,6 @@ import mcp.types
10
  import pydantic_core
11
  from exceptiongroup import catch
12
  from mcp import ClientSession
13
- from mcp.types import ContentBlock
14
  from pydantic import AnyUrl
15
 
16
  import fastmcp
@@ -30,7 +32,10 @@ from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
30
  from fastmcp.exceptions import ToolError
31
  from fastmcp.server import FastMCP
32
  from fastmcp.utilities.exceptions import get_catch_handlers
 
 
33
  from fastmcp.utilities.mcp_config import MCPConfig
 
34
 
35
  from .transports import (
36
  ClientTransportT,
@@ -56,6 +61,8 @@ __all__ = [
56
  "ProgressHandler",
57
  ]
58
 
 
 
59
 
60
  class Client(Generic[ClientTransportT]):
61
  """
@@ -99,34 +106,39 @@ class Client(Generic[ClientTransportT]):
99
  cls,
100
  transport: ClientTransportT,
101
  **kwargs: Any,
102
- ) -> "Client[ClientTransportT]": ...
103
 
104
  @overload
105
  def __new__(
106
  cls, transport: AnyUrl, **kwargs
107
- ) -> "Client[SSETransport|StreamableHttpTransport]": ...
108
 
109
  @overload
110
  def __new__(
111
  cls, transport: FastMCP | FastMCP1Server, **kwargs
112
- ) -> "Client[FastMCPTransport]": ...
113
 
114
  @overload
115
  def __new__(
116
  cls, transport: Path, **kwargs
117
- ) -> "Client[PythonStdioTransport|NodeStdioTransport]": ...
118
 
119
  @overload
120
  def __new__(
121
  cls, transport: MCPConfig | dict[str, Any], **kwargs
122
- ) -> "Client[MCPConfigTransport]": ...
123
 
124
  @overload
125
  def __new__(
126
  cls, transport: str, **kwargs
127
- ) -> "Client[PythonStdioTransport|NodeStdioTransport|SSETransport|StreamableHttpTransport]": ...
128
-
129
- def __new__(cls, transport, **kwargs) -> "Client":
 
 
 
 
 
130
  instance = super().__new__(cls)
131
  return instance
132
 
@@ -675,7 +687,8 @@ class Client(Generic[ClientTransportT]):
675
  arguments: dict[str, Any] | None = None,
676
  timeout: datetime.timedelta | float | int | None = None,
677
  progress_handler: ProgressHandler | None = None,
678
- ) -> list[ContentBlock]:
 
679
  """Call a tool on the server.
680
 
681
  Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
@@ -687,8 +700,13 @@ class Client(Generic[ClientTransportT]):
687
  progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
688
 
689
  Returns:
690
- list[mcp.types.TextContent | mcp.types.ImageContent | mcp.types.AudioContent | mcp.types.EmbeddedResource]:
691
- The content returned by the tool.
 
 
 
 
 
692
 
693
  Raises:
694
  ToolError: If the tool call results in an error.
@@ -700,7 +718,43 @@ class Client(Generic[ClientTransportT]):
700
  timeout=timeout,
701
  progress_handler=progress_handler,
702
  )
703
- if result.isError:
 
704
  msg = cast(mcp.types.TextContent, result.content[0]).text
705
  raise ToolError(msg)
706
- return result.content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
  import asyncio
4
  import datetime
5
  from contextlib import AsyncExitStack, asynccontextmanager
6
+ from dataclasses import dataclass
7
  from pathlib import Path
8
  from typing import Any, Generic, Literal, cast, overload
9
 
 
13
  import pydantic_core
14
  from exceptiongroup import catch
15
  from mcp import ClientSession
 
16
  from pydantic import AnyUrl
17
 
18
  import fastmcp
 
32
  from fastmcp.exceptions import ToolError
33
  from fastmcp.server import FastMCP
34
  from fastmcp.utilities.exceptions import get_catch_handlers
35
+ from fastmcp.utilities.json_schema_type import json_schema_to_type
36
+ from fastmcp.utilities.logging import get_logger
37
  from fastmcp.utilities.mcp_config import MCPConfig
38
+ from fastmcp.utilities.types import get_cached_typeadapter
39
 
40
  from .transports import (
41
  ClientTransportT,
 
61
  "ProgressHandler",
62
  ]
63
 
64
+ logger = get_logger(__name__)
65
+
66
 
67
  class Client(Generic[ClientTransportT]):
68
  """
 
106
  cls,
107
  transport: ClientTransportT,
108
  **kwargs: Any,
109
+ ) -> Client[ClientTransportT]: ...
110
 
111
  @overload
112
  def __new__(
113
  cls, transport: AnyUrl, **kwargs
114
+ ) -> Client[SSETransport | StreamableHttpTransport]: ...
115
 
116
  @overload
117
  def __new__(
118
  cls, transport: FastMCP | FastMCP1Server, **kwargs
119
+ ) -> Client[FastMCPTransport]: ...
120
 
121
  @overload
122
  def __new__(
123
  cls, transport: Path, **kwargs
124
+ ) -> Client[PythonStdioTransport | NodeStdioTransport]: ...
125
 
126
  @overload
127
  def __new__(
128
  cls, transport: MCPConfig | dict[str, Any], **kwargs
129
+ ) -> Client[MCPConfigTransport]: ...
130
 
131
  @overload
132
  def __new__(
133
  cls, transport: str, **kwargs
134
+ ) -> Client[
135
+ PythonStdioTransport
136
+ | NodeStdioTransport
137
+ | SSETransport
138
+ | StreamableHttpTransport
139
+ ]: ...
140
+
141
+ def __new__(cls, transport, **kwargs) -> Client:
142
  instance = super().__new__(cls)
143
  return instance
144
 
 
687
  arguments: dict[str, Any] | None = None,
688
  timeout: datetime.timedelta | float | int | None = None,
689
  progress_handler: ProgressHandler | None = None,
690
+ raise_on_error: bool = True,
691
+ ) -> CallToolResult:
692
  """Call a tool on the server.
693
 
694
  Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
 
700
  progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
701
 
702
  Returns:
703
+ CallToolResult:
704
+ The content returned by the tool. If the tool returns structured
705
+ outputs, they are returned as a dataclass (if an output schema
706
+ is available) or a dictionary; otherwise, a list of content
707
+ blocks is returned. Note: to receive both structured and
708
+ unstructured outputs, use call_tool_mcp instead and access the
709
+ raw result object.
710
 
711
  Raises:
712
  ToolError: If the tool call results in an error.
 
718
  timeout=timeout,
719
  progress_handler=progress_handler,
720
  )
721
+ data = None
722
+ if result.isError and raise_on_error:
723
  msg = cast(mcp.types.TextContent, result.content[0]).text
724
  raise ToolError(msg)
725
+ elif result.structuredContent:
726
+ try:
727
+ if name not in self.session._tool_output_schemas:
728
+ await self.session.list_tools()
729
+ if name in self.session._tool_output_schemas:
730
+ output_schema = self.session._tool_output_schemas.get(name)
731
+ if output_schema:
732
+ if output_schema.get("x-fastmcp-wrap-result"):
733
+ output_schema = output_schema.get("properties", {}).get(
734
+ "result"
735
+ )
736
+ structured_content = result.structuredContent.get("result")
737
+ else:
738
+ structured_content = result.structuredContent
739
+ output_type = json_schema_to_type(output_schema)
740
+ type_adapter = get_cached_typeadapter(output_type)
741
+ data = type_adapter.validate_python(structured_content)
742
+ else:
743
+ data = result.structuredContent
744
+ except Exception as e:
745
+ logger.error(f"Error parsing structured content: {e}")
746
+
747
+ return CallToolResult(
748
+ content=result.content,
749
+ structured_content=result.structuredContent,
750
+ data=data,
751
+ is_error=result.isError,
752
+ )
753
+
754
+
755
+ @dataclass
756
+ class CallToolResult:
757
+ content: list[mcp.types.ContentBlock]
758
+ structured_content: dict[str, Any] | None
759
+ data: Any = None
760
+ is_error: bool = False
src/fastmcp/contrib/component_manager/README.md ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Component Manager – Contrib Module for FastMCP
2
+
3
+ The **Component Manager** provides a unified API for enabling and disabling tools, resources, and prompts at runtime in a FastMCP server. This module is useful for dynamic control over which components are active, enabling advanced features like feature toggling, admin interfaces, or automation workflows.
4
+
5
+ ---
6
+
7
+ ## 🔧 Features
8
+
9
+ - Enable/disable **tools**, **resources**, and **prompts** via HTTP endpoints.
10
+ - Supports **local** and **mounted (server)** components.
11
+ - Customizable **API root path**.
12
+ - Optional **Auth scopes** for secured access.
13
+ - Fully integrates with FastMCP with minimal configuration.
14
+
15
+ ---
16
+
17
+ ## 📦 Installation
18
+
19
+ This module is part of the `fastmcp.contrib` package. No separate installation is required if you're already using **FastMCP**.
20
+
21
+ ---
22
+
23
+ ## 🚀 Usage
24
+
25
+ ### Basic Setup
26
+
27
+ ```python
28
+ from fastmcp import FastMCP
29
+ from fastmcp.contrib.component_manager import set_up_component_manager
30
+
31
+ mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.")
32
+ set_up_component_manager(server=mcp)
33
+ ```
34
+
35
+ ---
36
+
37
+ ## 🔗 API Endpoints
38
+
39
+ All endpoints are registered at `/` by default, or under the custom path if one is provided.
40
+
41
+ ### Tools
42
+
43
+ ```http
44
+ POST /tools/{tool_name}/enable
45
+ POST /tools/{tool_name}/disable
46
+ ```
47
+
48
+ ### Resources
49
+
50
+ ```http
51
+ POST /resources/{uri:path}/enable
52
+ POST /resources/{uri:path}/disable
53
+ ```
54
+
55
+ * Supports template URIs as well
56
+ ```http
57
+ POST /resources/example://test/{id}/enable
58
+ POST /resources/example://test/{id}/disable
59
+ ```
60
+
61
+ ### Prompts
62
+
63
+ ```http
64
+ POST /prompts/{prompt_name}/enable
65
+ POST /prompts/{prompt_name}/disable
66
+ ```
67
+ ---
68
+
69
+ #### 🧪 Example Response
70
+
71
+ ```http
72
+ HTTP/1.1 200 OK
73
+ Content-Type: application/json
74
+
75
+ {
76
+ "message": "Disabled tool: example_tool"
77
+ }
78
+
79
+ ```
80
+
81
+ ---
82
+
83
+ ## ⚙️ Configuration Options
84
+
85
+ ### Custom Root Path
86
+
87
+ To mount the API under a different path:
88
+
89
+ ```python
90
+ set_up_component_manager(server=mcp, path="/admin")
91
+ ```
92
+
93
+ ### Securing Endpoints with Auth Scopes
94
+
95
+ If your server uses authentication:
96
+
97
+ ```python
98
+ mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth)
99
+ set_up_component_manager(server=mcp, required_scopes=["write", "read"])
100
+ ```
101
+
102
+ ---
103
+
104
+ ## 🧪 Example: Enabling a Tool with Curl
105
+
106
+ ```bash
107
+ curl -X POST \
108
+ -H "Authorization: Bearer YOUR_TOKEN_HERE" \
109
+ -H "Content-Type: application/json" \
110
+ http://localhost:8001/tools/example_tool/enable
111
+ ```
112
+
113
+ ---
114
+
115
+ ## 🧱 Working with Mounted Servers
116
+
117
+ You can also combine different configurations when working with mounted servers — for example, using different scopes:
118
+
119
+ ```python
120
+ mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth)
121
+ set_up_component_manager(server=mcp, required_scopes=["mcp:write"])
122
+
123
+ mounted = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth)
124
+ set_up_component_manager(server=mounted, required_scopes=["mounted:write"])
125
+
126
+ mcp.mount(server=mounted, prefix="mo")
127
+ ```
128
+
129
+ This allows you to grant different levels of access:
130
+
131
+ ```bash
132
+ # Accessing the main server gives you control over both local and mounted components
133
+ curl -X POST \
134
+ -H "Authorization: Bearer YOUR_TOKEN_HERE" \
135
+ -H "Content-Type: application/json" \
136
+ http://localhost:8001/tools/mo_example_tool/enable
137
+
138
+ # Accessing the mounted server gives you control only over its own components
139
+ curl -X POST \
140
+ -H "Authorization: Bearer YOUR_TOKEN_HERE" \
141
+ -H "Content-Type: application/json" \
142
+ http://localhost:8002/tools/example_tool/enable
143
+ ```
144
+
145
+ ---
146
+
147
+ ## ⚙️ How It Works
148
+
149
+ - `set_up_component_manager()` registers API routes for tools, resources, and prompts.
150
+ - The `ComponentService` class exposes async methods to enable/disable components.
151
+ - Each endpoint returns a success message in JSON or a 404 error if the component isn't found.
152
+
153
+ ---
154
+
155
+ ## 🧩 Extending
156
+
157
+ You can subclass `ComponentService` for custom behavior or mount its routes elsewhere as needed.
158
+
159
+ ---
160
+
161
+ ## Maintenance Notice
162
+
163
+ This module is not officially maintained by the core FastMCP team. It is an independent extension developed by [gorocode](https://github.com/gorocode).
164
+
165
+ If you encounter any issues or wish to contribute, please feel free to open an issue or submit a pull request, and kindly notify me. I'd love to stay up to date.
166
+
167
+
168
+ ## 📄 License
169
+
170
+ This module follows the license of the main [FastMCP](https://github.com/jlowin/fastmcp) project.
src/fastmcp/contrib/component_manager/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .component_manager import set_up_component_manager
2
+ from .component_service import ComponentService
3
+
4
+ __all__ = ["set_up_component_manager", "ComponentService"]
src/fastmcp/contrib/component_manager/component_manager.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Routes and helpers for managing tools, resources, and prompts in FastMCP.
3
+ Provides endpoints for enabling/disabling components via HTTP, with optional authentication scopes.
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware
9
+ from starlette.applications import Starlette
10
+ from starlette.exceptions import HTTPException as StarletteHTTPException
11
+ from starlette.requests import Request
12
+ from starlette.responses import JSONResponse
13
+ from starlette.routing import Mount, Route
14
+
15
+ from fastmcp.contrib.component_manager.component_service import ComponentService
16
+ from fastmcp.exceptions import NotFoundError
17
+ from fastmcp.server.server import FastMCP
18
+
19
+
20
+ def set_up_component_manager(
21
+ server: FastMCP, path: str = "/", required_scopes: list[str] | None = None
22
+ ):
23
+ """Set up routes for enabling/disabling tools, resources, and prompts.
24
+ Args:
25
+ server: The FastMCP server instance
26
+ path: Path used to mount all component-related routes on the server
27
+ required_scopes: Optional list of scopes required for these routes. Applies only if authentication is enabled.
28
+ """
29
+
30
+ service = ComponentService(server)
31
+ routes: list[Route] = []
32
+ mounts: list[Mount] = []
33
+ route_configs = {
34
+ "tool": {
35
+ "param": "tool_name",
36
+ "enable": service._enable_tool,
37
+ "disable": service._disable_tool,
38
+ },
39
+ "resource": {
40
+ "param": "uri:path",
41
+ "enable": service._enable_resource,
42
+ "disable": service._disable_resource,
43
+ },
44
+ "prompt": {
45
+ "param": "prompt_name",
46
+ "enable": service._enable_prompt,
47
+ "disable": service._disable_prompt,
48
+ },
49
+ }
50
+
51
+ if required_scopes is None:
52
+ routes.extend(build_component_manager_endpoints(route_configs, path))
53
+ else:
54
+ if path != "/":
55
+ mounts.append(
56
+ build_component_manager_mount(route_configs, path, required_scopes)
57
+ )
58
+ else:
59
+ mounts.append(
60
+ build_component_manager_mount(
61
+ {"tool": route_configs["tool"]}, "/tools", required_scopes
62
+ )
63
+ )
64
+ mounts.append(
65
+ build_component_manager_mount(
66
+ {"resource": route_configs["resource"]},
67
+ "/resources",
68
+ required_scopes,
69
+ )
70
+ )
71
+ mounts.append(
72
+ build_component_manager_mount(
73
+ {"prompt": route_configs["prompt"]}, "/prompts", required_scopes
74
+ )
75
+ )
76
+
77
+ server._additional_http_routes.extend(routes)
78
+ server._additional_http_routes.extend(mounts)
79
+
80
+
81
+ def make_endpoint(action, component, config):
82
+ """
83
+ Factory for creating Starlette endpoint functions for enabling/disabling a component.
84
+ Args:
85
+ action: 'enable' or 'disable'
86
+ component: The component type (e.g., 'tool', 'resource', or 'prompt')
87
+ config: Dict with param and handler functions for the component
88
+ Returns:
89
+ An async endpoint function for Starlette.
90
+ """
91
+
92
+ async def endpoint(request: Request):
93
+ name = request.path_params[config["param"].split(":")[0]]
94
+
95
+ try:
96
+ await config[action](name)
97
+ return JSONResponse(
98
+ {"message": f"{action.capitalize()}d {component}: {name}"}
99
+ )
100
+ except NotFoundError:
101
+ raise StarletteHTTPException(
102
+ status_code=404,
103
+ detail=f"Unknown {component}: {name}",
104
+ )
105
+
106
+ return endpoint
107
+
108
+
109
+ def make_route(action, component, config, required_scopes, root_path) -> Route:
110
+ """
111
+ Creates a Starlette Route for enabling/disabling a component.
112
+ Args:
113
+ action: 'enable' or 'disable'
114
+ component: The component type
115
+ config: Dict with param and handler functions
116
+ required_scopes: Optional list of required auth scopes
117
+ root_path: The base path for the route
118
+ Returns:
119
+ A Starlette Route object.
120
+ """
121
+ endpoint = make_endpoint(action, component, config)
122
+
123
+ if required_scopes is not None and root_path in [
124
+ "/tools",
125
+ "/resources",
126
+ "/prompts",
127
+ ]:
128
+ path = f"/{{{config['param']}}}/{action}"
129
+ else:
130
+ if root_path != "/" and required_scopes is None:
131
+ path = f"{root_path}/{component}s/{{{config['param']}}}/{action}"
132
+ else:
133
+ path = f"/{component}s/{{{config['param']}}}/{action}"
134
+
135
+ return Route(path, endpoint=endpoint, methods=["POST"])
136
+
137
+
138
+ def build_component_manager_endpoints(
139
+ route_configs, root_path, required_scopes=None
140
+ ) -> list[Route]:
141
+ """
142
+ Build a list of Starlette Route objects for all components/actions.
143
+ Args:
144
+ route_configs: Dict describing component types and their handlers
145
+ root_path: The base path for the routes
146
+ required_scopes: Optional list of required auth scopes
147
+ Returns:
148
+ List of Starlette Route objects for component management.
149
+ """
150
+ component_management_routes: list[Route] = []
151
+
152
+ for component in route_configs:
153
+ config: dict[str, Any] = route_configs[component]
154
+ for action in ["enable", "disable"]:
155
+ component_management_routes.append(
156
+ make_route(action, component, config, required_scopes, root_path)
157
+ )
158
+
159
+ return component_management_routes
160
+
161
+
162
+ def build_component_manager_mount(route_configs, root_path, required_scopes) -> Mount:
163
+ """
164
+ Build a Starlette Mount with authentication for component management routes.
165
+ Args:
166
+ route_configs: Dict describing component types and their handlers
167
+ root_path: The base path for the mount
168
+ required_scopes: List of required auth scopes
169
+ Returns:
170
+ A Starlette Mount object with authentication middleware.
171
+ """
172
+ component_management_routes: list[Route] = []
173
+
174
+ for component in route_configs:
175
+ config: dict[str, Any] = route_configs[component]
176
+ for action in ["enable", "disable"]:
177
+ component_management_routes.append(
178
+ make_route(action, component, config, required_scopes, root_path)
179
+ )
180
+
181
+ return Mount(
182
+ f"{root_path}",
183
+ app=RequireAuthMiddleware(
184
+ Starlette(routes=component_management_routes), required_scopes
185
+ ),
186
+ )
src/fastmcp/contrib/component_manager/component_service.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ComponentService: Provides async management of tools, resources, and prompts for FastMCP servers.
3
+ Handles enabling/disabling components both locally and across mounted servers.
4
+ """
5
+
6
+ from fastmcp.exceptions import NotFoundError
7
+ from fastmcp.prompts.prompt import Prompt
8
+ from fastmcp.resources.resource import Resource
9
+ from fastmcp.resources.template import ResourceTemplate
10
+ from fastmcp.server.server import FastMCP, has_resource_prefix, remove_resource_prefix
11
+ from fastmcp.tools.tool import Tool
12
+ from fastmcp.utilities.logging import get_logger
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ class ComponentService:
18
+ """Service for managing components like tools, resources, and prompts."""
19
+
20
+ def __init__(self, server: FastMCP):
21
+ self._server = server
22
+ self._tool_manager = server._tool_manager
23
+ self._resource_manager = server._resource_manager
24
+ self._prompt_manager = server._prompt_manager
25
+
26
+ async def _enable_tool(self, key: str) -> Tool:
27
+ """Handle 'enableTool' requests.
28
+
29
+ Args:
30
+ key: The key of the tool to enable
31
+
32
+ Returns:
33
+ The tool that was enabled
34
+ """
35
+ logger.debug("Enabling tool: %s", key)
36
+
37
+ # 1. Check local tools first. The server will have already applied its filter.
38
+ if key in self._server._tool_manager._tools:
39
+ tool: Tool = await self._server.get_tool(key)
40
+ tool.enable()
41
+ return tool
42
+
43
+ # 2. Check mounted servers using the filtered protocol path.
44
+ for mounted in reversed(self._tool_manager._mounted_servers):
45
+ if mounted.prefix:
46
+ if key.startswith(f"{mounted.prefix}_"):
47
+ tool_key = key.removeprefix(f"{mounted.prefix}_")
48
+ mounted_service = ComponentService(mounted.server)
49
+ tool = await mounted_service._enable_tool(tool_key)
50
+ return tool
51
+ else:
52
+ continue
53
+ raise NotFoundError(f"Unknown tool: {key}")
54
+
55
+ async def _disable_tool(self, key: str) -> Tool:
56
+ """Handle 'disableTool' requests.
57
+
58
+ Args:
59
+ key: The key of the tool to disable
60
+
61
+ Returns:
62
+ The tool that was disabled
63
+ """
64
+ logger.debug("Disable tool: %s", key)
65
+
66
+ # 1. Check local tools first. The server will have already applied its filter.
67
+ if key in self._server._tool_manager._tools:
68
+ tool: Tool = await self._server.get_tool(key)
69
+ tool.disable()
70
+ return tool
71
+
72
+ # 2. Check mounted servers using the filtered protocol path.
73
+ for mounted in reversed(self._tool_manager._mounted_servers):
74
+ if mounted.prefix:
75
+ if key.startswith(f"{mounted.prefix}_"):
76
+ tool_key = key.removeprefix(f"{mounted.prefix}_")
77
+ mounted_service = ComponentService(mounted.server)
78
+ tool = await mounted_service._disable_tool(tool_key)
79
+ return tool
80
+ else:
81
+ continue
82
+ raise NotFoundError(f"Unknown tool: {key}")
83
+
84
+ async def _enable_resource(self, key: str) -> Resource | ResourceTemplate:
85
+ """Handle 'enableResource' requests.
86
+
87
+ Args:
88
+ key: The key of the resource to enable
89
+
90
+ Returns:
91
+ The resource that was enabled
92
+ """
93
+ logger.debug("Enabling resource: %s", key)
94
+
95
+ # 1. Check local resources first. The server will have already applied its filter.
96
+ if key in self._resource_manager._resources:
97
+ resource: Resource = await self._server.get_resource(key)
98
+ resource.enable()
99
+ return resource
100
+ if key in self._resource_manager._templates:
101
+ template: ResourceTemplate = await self._server.get_resource_template(key)
102
+ template.enable()
103
+ return template
104
+
105
+ # 2. Check mounted servers using the filtered protocol path.
106
+ for mounted in reversed(self._resource_manager._mounted_servers):
107
+ if mounted.prefix:
108
+ if has_resource_prefix(
109
+ key,
110
+ mounted.prefix,
111
+ mounted.resource_prefix_format,
112
+ ):
113
+ key = remove_resource_prefix(
114
+ key,
115
+ mounted.prefix,
116
+ mounted.resource_prefix_format,
117
+ )
118
+ mounted_service = ComponentService(mounted.server)
119
+ mounted_resource: (
120
+ Resource | ResourceTemplate
121
+ ) = await mounted_service._enable_resource(key)
122
+ return mounted_resource
123
+ else:
124
+ continue
125
+ raise NotFoundError(f"Unknown resource: {key}")
126
+
127
+ async def _disable_resource(self, key: str) -> Resource | ResourceTemplate:
128
+ """Handle 'disableResource' requests.
129
+
130
+ Args:
131
+ key: The key of the resource to disable
132
+
133
+ Returns:
134
+ The resource that was disabled
135
+ """
136
+ logger.debug("Disable resource: %s", key)
137
+
138
+ # 1. Check local resources first. The server will have already applied its filter.
139
+ if key in self._resource_manager._resources:
140
+ resource: Resource = await self._server.get_resource(key)
141
+ resource.disable()
142
+ return resource
143
+ if key in self._resource_manager._templates:
144
+ template: ResourceTemplate = await self._server.get_resource_template(key)
145
+ template.disable()
146
+ return template
147
+
148
+ # 2. Check mounted servers using the filtered protocol path.
149
+ for mounted in reversed(self._resource_manager._mounted_servers):
150
+ if mounted.prefix:
151
+ if has_resource_prefix(
152
+ key,
153
+ mounted.prefix,
154
+ mounted.resource_prefix_format,
155
+ ):
156
+ key = remove_resource_prefix(
157
+ key,
158
+ mounted.prefix,
159
+ mounted.resource_prefix_format,
160
+ )
161
+ mounted_service = ComponentService(mounted.server)
162
+ mounted_resource: (
163
+ Resource | ResourceTemplate
164
+ ) = await mounted_service._disable_resource(key)
165
+ return mounted_resource
166
+ else:
167
+ continue
168
+ raise NotFoundError(f"Unknown resource: {key}")
169
+
170
+ async def _enable_prompt(self, key: str) -> Prompt:
171
+ """Handle 'enablePrompt' requests.
172
+
173
+ Args:
174
+ key: The key of the prompt to enable
175
+
176
+ Returns:
177
+ The prompt that was enable
178
+ """
179
+ logger.debug("Enabling prompt: %s", key)
180
+
181
+ # 1. Check local prompts first. The server will have already applied its filter.
182
+ if key in self._server._prompt_manager._prompts:
183
+ prompt: Prompt = await self._server.get_prompt(key)
184
+ prompt.enable()
185
+ return prompt
186
+
187
+ # 2. Check mounted servers using the filtered protocol path.
188
+ for mounted in reversed(self._prompt_manager._mounted_servers):
189
+ if mounted.prefix:
190
+ if key.startswith(f"{mounted.prefix}_"):
191
+ prompt_key = key.removeprefix(f"{mounted.prefix}_")
192
+ mounted_service = ComponentService(mounted.server)
193
+ prompt = await mounted_service._enable_prompt(prompt_key)
194
+ return prompt
195
+ else:
196
+ continue
197
+ raise NotFoundError(f"Unknown prompt: {key}")
198
+
199
+ async def _disable_prompt(self, key: str) -> Prompt:
200
+ """Handle 'disablePrompt' requests.
201
+
202
+ Args:
203
+ key: The key of the prompt to disable
204
+
205
+ Returns:
206
+ The prompt that was disabled
207
+ """
208
+
209
+ # 1. Check local prompts first. The server will have already applied its filter.
210
+ if key in self._server._prompt_manager._prompts:
211
+ prompt: Prompt = await self._server.get_prompt(key)
212
+ prompt.disable()
213
+ return prompt
214
+
215
+ # 2. Check mounted servers using the filtered protocol path.
216
+ for mounted in reversed(self._prompt_manager._mounted_servers):
217
+ if mounted.prefix:
218
+ if key.startswith(f"{mounted.prefix}_"):
219
+ prompt_key = key.removeprefix(f"{mounted.prefix}_")
220
+ mounted_service = ComponentService(mounted.server)
221
+ prompt = await mounted_service._disable_prompt(prompt_key)
222
+ return prompt
223
+ else:
224
+ continue
225
+ raise NotFoundError(f"Unknown prompt: {key}")
src/fastmcp/contrib/component_manager/example.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastmcp import FastMCP
2
+ from fastmcp.contrib.component_manager import set_up_component_manager
3
+ from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair
4
+
5
+ key_pair = RSAKeyPair.generate()
6
+
7
+ auth = BearerAuthProvider(
8
+ public_key=key_pair.public_key,
9
+ issuer="https://dev.example.com",
10
+ audience="my-dev-server",
11
+ required_scopes=["mcp:read"],
12
+ )
13
+
14
+ # Build main server
15
+ mcp_token = key_pair.create_token(
16
+ subject="dev-user",
17
+ issuer="https://dev.example.com",
18
+ audience="my-dev-server",
19
+ scopes=["mcp:write", "mcp:read"],
20
+ )
21
+ mcp = FastMCP(
22
+ name="Component Manager",
23
+ instructions="This is a test server with component manager.",
24
+ auth=auth,
25
+ )
26
+
27
+ # Set up main server component manager
28
+ set_up_component_manager(server=mcp, required_scopes=["mcp:write"])
29
+
30
+ # Build mounted server
31
+ mounted_token = key_pair.create_token(
32
+ subject="dev-user",
33
+ issuer="https://dev.example.com",
34
+ audience="my-dev-server",
35
+ scopes=["mounted:write", "mcp:read"],
36
+ )
37
+ mounted = FastMCP(
38
+ name="Component Manager",
39
+ instructions="This is a test server with component manager.",
40
+ auth=auth,
41
+ )
42
+
43
+ # Set up mounted server component manager
44
+ set_up_component_manager(server=mounted, required_scopes=["mounted:write"])
45
+
46
+ # Mount
47
+ mcp.mount(server=mounted, prefix="mo")
48
+
49
+
50
+ @mcp.resource("resource://greeting")
51
+ def get_greeting() -> str:
52
+ """Provides a simple greeting message."""
53
+ return "Hello from FastMCP Resources!"
54
+
55
+
56
+ @mounted.tool("greeting")
57
+ def get_info() -> str:
58
+ """Provides a simple info."""
59
+ return "You are using component manager contrib module!"
src/fastmcp/server/low_level.py CHANGED
@@ -4,12 +4,14 @@ from mcp.server.lowlevel.server import (
4
  LifespanResultT,
5
  NotificationOptions,
6
  RequestT,
7
- Server,
 
 
8
  )
9
  from mcp.server.models import InitializationOptions
10
 
11
 
12
- class LowLevelServer(Server[LifespanResultT, RequestT]):
13
  def __init__(self, *args, **kwargs):
14
  super().__init__(*args, **kwargs)
15
  # FastMCP servers support notifications for all components
 
4
  LifespanResultT,
5
  NotificationOptions,
6
  RequestT,
7
+ )
8
+ from mcp.server.lowlevel.server import (
9
+ Server as _Server,
10
  )
11
  from mcp.server.models import InitializationOptions
12
 
13
 
14
+ class LowLevelServer(_Server[LifespanResultT, RequestT]):
15
  def __init__(self, *args, **kwargs):
16
  super().__init__(*args, **kwargs)
17
  # FastMCP servers support notifications for all components
src/fastmcp/server/openapi.py CHANGED
@@ -13,7 +13,7 @@ from re import Pattern
13
  from typing import TYPE_CHECKING, Any, Literal
14
 
15
  import httpx
16
- from mcp.types import ContentBlock, ToolAnnotations
17
  from pydantic.networks import AnyUrl
18
 
19
  import fastmcp
@@ -21,7 +21,7 @@ from fastmcp.exceptions import ToolError
21
  from fastmcp.resources import Resource, ResourceTemplate
22
  from fastmcp.server.dependencies import get_http_headers
23
  from fastmcp.server.server import FastMCP
24
- from fastmcp.tools.tool import Tool, _convert_to_content
25
  from fastmcp.utilities import openapi
26
  from fastmcp.utilities.logging import get_logger
27
  from fastmcp.utilities.openapi import (
@@ -254,7 +254,7 @@ class OpenAPITool(Tool):
254
  """Custom representation to prevent recursion errors when printing."""
255
  return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
256
 
257
- async def run(self, arguments: dict[str, Any]) -> list[ContentBlock]:
258
  """Execute the HTTP request based on the route configuration."""
259
 
260
  # Prepare URL
@@ -450,10 +450,11 @@ class OpenAPITool(Tool):
450
  # Try to parse as JSON first
451
  try:
452
  result = response.json()
453
- except (json.JSONDecodeError, ValueError):
454
- # Return text content if not JSON
455
- result = response.text
456
- return _convert_to_content(result)
 
457
 
458
  except httpx.HTTPStatusError as e:
459
  # Handle HTTP errors (4xx, 5xx)
 
13
  from typing import TYPE_CHECKING, Any, Literal
14
 
15
  import httpx
16
+ from mcp.types import ToolAnnotations
17
  from pydantic.networks import AnyUrl
18
 
19
  import fastmcp
 
21
  from fastmcp.resources import Resource, ResourceTemplate
22
  from fastmcp.server.dependencies import get_http_headers
23
  from fastmcp.server.server import FastMCP
24
+ from fastmcp.tools.tool import Tool, ToolResult
25
  from fastmcp.utilities import openapi
26
  from fastmcp.utilities.logging import get_logger
27
  from fastmcp.utilities.openapi import (
 
254
  """Custom representation to prevent recursion errors when printing."""
255
  return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
256
 
257
+ async def run(self, arguments: dict[str, Any]) -> ToolResult:
258
  """Execute the HTTP request based on the route configuration."""
259
 
260
  # Prepare URL
 
450
  # Try to parse as JSON first
451
  try:
452
  result = response.json()
453
+ if not isinstance(result, dict):
454
+ result = {"result": result}
455
+ return ToolResult(structured_content=result)
456
+ except json.JSONDecodeError:
457
+ return ToolResult(content=response.text)
458
 
459
  except httpx.HTTPStatusError as e:
460
  # Handle HTTP errors (4xx, 5xx)
src/fastmcp/server/proxy.py CHANGED
@@ -8,7 +8,6 @@ from mcp.shared.exceptions import McpError
8
  from mcp.types import (
9
  METHOD_NOT_FOUND,
10
  BlobResourceContents,
11
- ContentBlock,
12
  GetPromptResult,
13
  TextResourceContents,
14
  )
@@ -23,7 +22,7 @@ from fastmcp.resources import Resource, ResourceTemplate
23
  from fastmcp.resources.resource_manager import ResourceManager
24
  from fastmcp.server.context import Context
25
  from fastmcp.server.server import FastMCP
26
- from fastmcp.tools.tool import Tool
27
  from fastmcp.tools.tool_manager import ToolManager
28
  from fastmcp.utilities.logging import get_logger
29
 
@@ -67,9 +66,7 @@ class ProxyToolManager(ToolManager):
67
  tools_dict = await self.get_tools()
68
  return list(tools_dict.values())
69
 
70
- async def call_tool(
71
- self, key: str, arguments: dict[str, Any]
72
- ) -> list[ContentBlock]:
73
  """Calls a tool, trying local/mounted first, then proxy if not found."""
74
  try:
75
  # First try local and mounted tools
@@ -77,7 +74,11 @@ class ProxyToolManager(ToolManager):
77
  except NotFoundError:
78
  # If not found locally, try proxy
79
  async with self.client:
80
- return await self.client.call_tool(key, arguments)
 
 
 
 
81
 
82
 
83
  class ProxyResourceManager(ResourceManager):
@@ -226,13 +227,14 @@ class ProxyTool(Tool):
226
  description=mcp_tool.description,
227
  parameters=mcp_tool.inputSchema,
228
  annotations=mcp_tool.annotations,
 
229
  )
230
 
231
  async def run(
232
  self,
233
  arguments: dict[str, Any],
234
  context: Context | None = None,
235
- ) -> list[ContentBlock]:
236
  """Executes the tool by making a call through the client."""
237
  # This is where the remote execution logic lives.
238
  async with self._client:
@@ -242,7 +244,10 @@ class ProxyTool(Tool):
242
  )
243
  if result.isError:
244
  raise ToolError(cast(mcp.types.TextContent, result.content[0]).text)
245
- return result.content
 
 
 
246
 
247
 
248
  class ProxyResource(Resource):
 
8
  from mcp.types import (
9
  METHOD_NOT_FOUND,
10
  BlobResourceContents,
 
11
  GetPromptResult,
12
  TextResourceContents,
13
  )
 
22
  from fastmcp.resources.resource_manager import ResourceManager
23
  from fastmcp.server.context import Context
24
  from fastmcp.server.server import FastMCP
25
+ from fastmcp.tools.tool import Tool, ToolResult
26
  from fastmcp.tools.tool_manager import ToolManager
27
  from fastmcp.utilities.logging import get_logger
28
 
 
66
  tools_dict = await self.get_tools()
67
  return list(tools_dict.values())
68
 
69
+ async def call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
 
 
70
  """Calls a tool, trying local/mounted first, then proxy if not found."""
71
  try:
72
  # First try local and mounted tools
 
74
  except NotFoundError:
75
  # If not found locally, try proxy
76
  async with self.client:
77
+ result = await self.client.call_tool(key, arguments)
78
+ return ToolResult(
79
+ content=result.content,
80
+ structured_content=result.structured_content,
81
+ )
82
 
83
 
84
  class ProxyResourceManager(ResourceManager):
 
227
  description=mcp_tool.description,
228
  parameters=mcp_tool.inputSchema,
229
  annotations=mcp_tool.annotations,
230
+ output_schema=mcp_tool.outputSchema,
231
  )
232
 
233
  async def run(
234
  self,
235
  arguments: dict[str, Any],
236
  context: Context | None = None,
237
+ ) -> ToolResult:
238
  """Executes the tool by making a call through the client."""
239
  # This is where the remote execution logic lives.
240
  async with self._client:
 
244
  )
245
  if result.isError:
246
  raise ToolError(cast(mcp.types.TextContent, result.content[0]).text)
247
+ return ToolResult(
248
+ content=result.content,
249
+ structured_content=result.structuredContent,
250
+ )
251
 
252
 
253
  class ProxyResource(Resource):
src/fastmcp/server/server.py CHANGED
@@ -58,11 +58,12 @@ from fastmcp.server.low_level import LowLevelServer
58
  from fastmcp.server.middleware import Middleware, MiddlewareContext
59
  from fastmcp.settings import Settings
60
  from fastmcp.tools import ToolManager
61
- from fastmcp.tools.tool import FunctionTool, Tool
62
  from fastmcp.utilities.cache import TimedCache
63
  from fastmcp.utilities.components import FastMCPComponent
64
  from fastmcp.utilities.logging import get_logger
65
  from fastmcp.utilities.mcp_config import MCPConfig
 
66
 
67
  if TYPE_CHECKING:
68
  from fastmcp.client import Client
@@ -592,7 +593,7 @@ class FastMCP(Generic[LifespanResultT]):
592
 
593
  async def _mcp_call_tool(
594
  self, key: str, arguments: dict[str, Any]
595
- ) -> list[ContentBlock]:
596
  """
597
  Handle MCP 'callTool' requests.
598
 
@@ -609,22 +610,21 @@ class FastMCP(Generic[LifespanResultT]):
609
 
610
  async with fastmcp.server.context.Context(fastmcp=self):
611
  try:
612
- return await self._call_tool(key, arguments)
 
613
  except DisabledError:
614
  raise NotFoundError(f"Unknown tool: {key}")
615
  except NotFoundError:
616
  raise NotFoundError(f"Unknown tool: {key}")
617
 
618
- async def _call_tool(
619
- self, key: str, arguments: dict[str, Any]
620
- ) -> list[ContentBlock]:
621
  """
622
  Applies this server's middleware and delegates the filtered call to the manager.
623
  """
624
 
625
  async def _handler(
626
  context: MiddlewareContext[mcp.types.CallToolRequestParams],
627
- ) -> list[ContentBlock]:
628
  tool = await self._tool_manager.get_tool(context.message.name)
629
  if not self._should_enable_component(tool):
630
  raise NotFoundError(f"Unknown tool: {context.message.name!r}")
@@ -792,6 +792,7 @@ class FastMCP(Generic[LifespanResultT]):
792
  name: str | None = None,
793
  description: str | None = None,
794
  tags: set[str] | None = None,
 
795
  annotations: ToolAnnotations | dict[str, Any] | None = None,
796
  exclude_args: list[str] | None = None,
797
  enabled: bool | None = None,
@@ -805,6 +806,7 @@ class FastMCP(Generic[LifespanResultT]):
805
  name: str | None = None,
806
  description: str | None = None,
807
  tags: set[str] | None = None,
 
808
  annotations: ToolAnnotations | dict[str, Any] | None = None,
809
  exclude_args: list[str] | None = None,
810
  enabled: bool | None = None,
@@ -817,6 +819,7 @@ class FastMCP(Generic[LifespanResultT]):
817
  name: str | None = None,
818
  description: str | None = None,
819
  tags: set[str] | None = None,
 
820
  annotations: ToolAnnotations | dict[str, Any] | None = None,
821
  exclude_args: list[str] | None = None,
822
  enabled: bool | None = None,
@@ -839,6 +842,7 @@ class FastMCP(Generic[LifespanResultT]):
839
  name: Optional name for the tool (keyword-only, alternative to name_or_fn)
840
  description: Optional description of what the tool does
841
  tags: Optional set of tags for categorizing the tool
 
842
  annotations: Optional annotations about the tool's behavior
843
  exclude_args: Optional list of argument names to exclude from the tool schema
844
  enabled: Optional boolean to enable or disable the tool
@@ -895,6 +899,7 @@ class FastMCP(Generic[LifespanResultT]):
895
  name=tool_name,
896
  description=description,
897
  tags=tags,
 
898
  annotations=annotations,
899
  exclude_args=exclude_args,
900
  serializer=self._tool_serializer,
@@ -925,6 +930,7 @@ class FastMCP(Generic[LifespanResultT]):
925
  name=tool_name,
926
  description=description,
927
  tags=tags,
 
928
  annotations=annotations,
929
  exclude_args=exclude_args,
930
  enabled=enabled,
 
58
  from fastmcp.server.middleware import Middleware, MiddlewareContext
59
  from fastmcp.settings import Settings
60
  from fastmcp.tools import ToolManager
61
+ from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
62
  from fastmcp.utilities.cache import TimedCache
63
  from fastmcp.utilities.components import FastMCPComponent
64
  from fastmcp.utilities.logging import get_logger
65
  from fastmcp.utilities.mcp_config import MCPConfig
66
+ from fastmcp.utilities.types import NotSet, NotSetT
67
 
68
  if TYPE_CHECKING:
69
  from fastmcp.client import Client
 
593
 
594
  async def _mcp_call_tool(
595
  self, key: str, arguments: dict[str, Any]
596
+ ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
597
  """
598
  Handle MCP 'callTool' requests.
599
 
 
610
 
611
  async with fastmcp.server.context.Context(fastmcp=self):
612
  try:
613
+ result = await self._call_tool(key, arguments)
614
+ return result.to_mcp_result()
615
  except DisabledError:
616
  raise NotFoundError(f"Unknown tool: {key}")
617
  except NotFoundError:
618
  raise NotFoundError(f"Unknown tool: {key}")
619
 
620
+ async def _call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
 
 
621
  """
622
  Applies this server's middleware and delegates the filtered call to the manager.
623
  """
624
 
625
  async def _handler(
626
  context: MiddlewareContext[mcp.types.CallToolRequestParams],
627
+ ) -> ToolResult:
628
  tool = await self._tool_manager.get_tool(context.message.name)
629
  if not self._should_enable_component(tool):
630
  raise NotFoundError(f"Unknown tool: {context.message.name!r}")
 
792
  name: str | None = None,
793
  description: str | None = None,
794
  tags: set[str] | None = None,
795
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
796
  annotations: ToolAnnotations | dict[str, Any] | None = None,
797
  exclude_args: list[str] | None = None,
798
  enabled: bool | None = None,
 
806
  name: str | None = None,
807
  description: str | None = None,
808
  tags: set[str] | None = None,
809
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
810
  annotations: ToolAnnotations | dict[str, Any] | None = None,
811
  exclude_args: list[str] | None = None,
812
  enabled: bool | None = None,
 
819
  name: str | None = None,
820
  description: str | None = None,
821
  tags: set[str] | None = None,
822
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
823
  annotations: ToolAnnotations | dict[str, Any] | None = None,
824
  exclude_args: list[str] | None = None,
825
  enabled: bool | None = None,
 
842
  name: Optional name for the tool (keyword-only, alternative to name_or_fn)
843
  description: Optional description of what the tool does
844
  tags: Optional set of tags for categorizing the tool
845
+ output_schema: Optional JSON schema for the tool's output
846
  annotations: Optional annotations about the tool's behavior
847
  exclude_args: Optional list of argument names to exclude from the tool schema
848
  enabled: Optional boolean to enable or disable the tool
 
899
  name=tool_name,
900
  description=description,
901
  tags=tags,
902
+ output_schema=output_schema,
903
  annotations=annotations,
904
  exclude_args=exclude_args,
905
  serializer=self._tool_serializer,
 
930
  name=tool_name,
931
  description=description,
932
  tags=tags,
933
+ output_schema=output_schema,
934
  annotations=annotations,
935
  exclude_args=exclude_args,
936
  enabled=enabled,
src/fastmcp/tools/tool.py CHANGED
@@ -3,12 +3,13 @@ from __future__ import annotations
3
  import inspect
4
  from collections.abc import Callable
5
  from dataclasses import dataclass
6
- from typing import TYPE_CHECKING, Any
7
 
 
8
  import pydantic_core
9
  from mcp.types import ContentBlock, TextContent, ToolAnnotations
10
  from mcp.types import Tool as MCPTool
11
- from pydantic import Field
12
 
13
  from fastmcp.server.dependencies import get_context
14
  from fastmcp.utilities.components import FastMCPComponent
@@ -18,8 +19,11 @@ from fastmcp.utilities.types import (
18
  Audio,
19
  File,
20
  Image,
 
 
21
  find_kwarg_by_type,
22
  get_cached_typeadapter,
 
23
  )
24
 
25
  if TYPE_CHECKING:
@@ -28,20 +32,91 @@ if TYPE_CHECKING:
28
  logger = get_logger(__name__)
29
 
30
 
 
 
 
 
31
  def default_serializer(data: Any) -> str:
32
  return pydantic_core.to_json(data, fallback=str, indent=2).decode()
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  class Tool(FastMCPComponent):
36
  """Internal tool registration info."""
37
 
38
- parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
39
- annotations: ToolAnnotations | None = Field(
40
- default=None, description="Additional annotations about the tool"
41
- )
42
- serializer: Callable[[Any], str] | None = Field(
43
- default=None, description="Optional custom serializer for tool results"
44
- )
 
 
 
 
 
 
 
45
 
46
  def enable(self) -> None:
47
  super().enable()
@@ -64,6 +139,7 @@ class Tool(FastMCPComponent):
64
  "name": self.name,
65
  "description": self.description,
66
  "inputSchema": self.parameters,
 
67
  "annotations": self.annotations,
68
  }
69
  return MCPTool(**kwargs | overrides)
@@ -76,6 +152,7 @@ class Tool(FastMCPComponent):
76
  tags: set[str] | None = None,
77
  annotations: ToolAnnotations | None = None,
78
  exclude_args: list[str] | None = None,
 
79
  serializer: Callable[[Any], str] | None = None,
80
  enabled: bool | None = None,
81
  ) -> FunctionTool:
@@ -87,12 +164,21 @@ class Tool(FastMCPComponent):
87
  tags=tags,
88
  annotations=annotations,
89
  exclude_args=exclude_args,
 
90
  serializer=serializer,
91
  enabled=enabled,
92
  )
93
 
94
- async def run(self, arguments: dict[str, Any]) -> list[ContentBlock]:
95
- """Run the tool with arguments."""
 
 
 
 
 
 
 
 
96
  raise NotImplementedError("Subclasses must implement run()")
97
 
98
  @classmethod
@@ -105,6 +191,7 @@ class Tool(FastMCPComponent):
105
  description: str | None = None,
106
  tags: set[str] | None = None,
107
  annotations: ToolAnnotations | None = None,
 
108
  serializer: Callable[[Any], str] | None = None,
109
  enabled: bool | None = None,
110
  ) -> TransformedTool:
@@ -118,6 +205,7 @@ class Tool(FastMCPComponent):
118
  description=description,
119
  tags=tags,
120
  annotations=annotations,
 
121
  serializer=serializer,
122
  enabled=enabled,
123
  )
@@ -135,6 +223,7 @@ class FunctionTool(Tool):
135
  tags: set[str] | None = None,
136
  annotations: ToolAnnotations | None = None,
137
  exclude_args: list[str] | None = None,
 
138
  serializer: Callable[[Any], str] | None = None,
139
  enabled: bool | None = None,
140
  ) -> FunctionTool:
@@ -145,18 +234,32 @@ class FunctionTool(Tool):
145
  if name is None and parsed_fn.name == "<lambda>":
146
  raise ValueError("You must provide a name for lambda functions")
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  return cls(
149
  fn=parsed_fn.fn,
150
  name=name or parsed_fn.name,
151
  description=description or parsed_fn.description,
152
- parameters=parsed_fn.parameters,
153
- tags=tags or set(),
154
  annotations=annotations,
 
155
  serializer=serializer,
156
  enabled=enabled if enabled is not None else True,
157
  )
158
 
159
- async def run(self, arguments: dict[str, Any]) -> list[ContentBlock]:
160
  """Run the tool with arguments."""
161
  from fastmcp.server.context import Context
162
 
@@ -168,10 +271,37 @@ class FunctionTool(Tool):
168
 
169
  type_adapter = get_cached_typeadapter(self.fn)
170
  result = type_adapter.validate_python(arguments)
 
171
  if inspect.isawaitable(result):
172
  result = await result
173
 
174
- return _convert_to_content(result, serializer=self.serializer)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
 
176
 
177
  @dataclass
@@ -179,13 +309,15 @@ class ParsedFunction:
179
  fn: Callable[..., Any]
180
  name: str
181
  description: str | None
182
- parameters: dict[str, Any]
 
183
 
184
  @classmethod
185
  def from_function(
186
  cls,
187
  fn: Callable[..., Any],
188
  exclude_args: list[str] | None = None,
 
189
  validate: bool = True,
190
  ) -> ParsedFunction:
191
  from fastmcp.server.context import Context
@@ -225,9 +357,6 @@ class ParsedFunction:
225
  if isinstance(fn, staticmethod):
226
  fn = fn.__func__
227
 
228
- type_adapter = get_cached_typeadapter(fn)
229
- schema = type_adapter.json_schema()
230
-
231
  prune_params: list[str] = []
232
  context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
233
  if context_kwarg:
@@ -235,12 +364,65 @@ class ParsedFunction:
235
  if exclude_args:
236
  prune_params.extend(exclude_args)
237
 
238
- schema = compress_schema(schema, prune_params=prune_params)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  return cls(
240
  fn=fn,
241
  name=fn_name,
242
  description=fn_doc,
243
- parameters=schema,
 
244
  )
245
 
246
 
 
3
  import inspect
4
  from collections.abc import Callable
5
  from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Annotated, Any, Literal
7
 
8
+ import mcp.types
9
  import pydantic_core
10
  from mcp.types import ContentBlock, TextContent, ToolAnnotations
11
  from mcp.types import Tool as MCPTool
12
+ from pydantic import Field, PydanticSchemaGenerationError
13
 
14
  from fastmcp.server.dependencies import get_context
15
  from fastmcp.utilities.components import FastMCPComponent
 
19
  Audio,
20
  File,
21
  Image,
22
+ NotSet,
23
+ NotSetT,
24
  find_kwarg_by_type,
25
  get_cached_typeadapter,
26
+ replace_type,
27
  )
28
 
29
  if TYPE_CHECKING:
 
32
  logger = get_logger(__name__)
33
 
34
 
35
+ class _UnserializableType:
36
+ pass
37
+
38
+
39
  def default_serializer(data: Any) -> str:
40
  return pydantic_core.to_json(data, fallback=str, indent=2).decode()
41
 
42
 
43
+ def _wrap_schema_if_needed(schema: dict[str, Any] | None) -> dict[str, Any] | None:
44
+ """Wrap non-object schemas with result property for structured output.
45
+
46
+ This wrapping allows primitive types (int, str, etc.) to be returned as
47
+ structured content by placing them under a "result" key.
48
+
49
+ Args:
50
+ schema: The JSON schema to potentially wrap
51
+
52
+ Returns:
53
+ Wrapped schema if needed, or original schema if already an object type
54
+ """
55
+ if schema and schema.get("type") != "object":
56
+ return {
57
+ "type": "object",
58
+ "properties": {"result": schema},
59
+ "x-fastmcp-wrap-result": True,
60
+ }
61
+ return schema
62
+
63
+
64
+ class ToolResult:
65
+ def __init__(
66
+ self,
67
+ content: list[ContentBlock] | Any | None = None,
68
+ structured_content: dict[str, Any] | Any | None = None,
69
+ ):
70
+ if content is None and structured_content is None:
71
+ raise ValueError("Either content or structured_content must be provided")
72
+ elif content is None:
73
+ content = structured_content
74
+
75
+ self.content = _convert_to_content(content)
76
+
77
+ if structured_content is not None:
78
+ try:
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):
88
+ raise ValueError(
89
+ "structured_content must be a dict or None. "
90
+ f"Got {type(structured_content).__name__}: {structured_content!r}. "
91
+ "Tools should wrap non-dict values based on their output_schema."
92
+ )
93
+ self.structured_content: dict[str, Any] | None = structured_content
94
+
95
+ def to_mcp_result(
96
+ self,
97
+ ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
98
+ if self.structured_content is None:
99
+ return self.content
100
+ return self.content, self.structured_content
101
+
102
+
103
  class Tool(FastMCPComponent):
104
  """Internal tool registration info."""
105
 
106
+ parameters: Annotated[
107
+ dict[str, Any], Field(description="JSON schema for tool parameters")
108
+ ]
109
+ output_schema: Annotated[
110
+ dict[str, Any] | None, Field(description="JSON schema for tool output")
111
+ ] = None
112
+ annotations: Annotated[
113
+ ToolAnnotations | None,
114
+ Field(description="Additional annotations about the tool"),
115
+ ] = None
116
+ serializer: Annotated[
117
+ Callable[[Any], str] | None,
118
+ Field(description="Optional custom serializer for tool results"),
119
+ ] = None
120
 
121
  def enable(self) -> None:
122
  super().enable()
 
139
  "name": self.name,
140
  "description": self.description,
141
  "inputSchema": self.parameters,
142
+ "outputSchema": self.output_schema,
143
  "annotations": self.annotations,
144
  }
145
  return MCPTool(**kwargs | overrides)
 
152
  tags: set[str] | None = None,
153
  annotations: ToolAnnotations | None = None,
154
  exclude_args: list[str] | None = None,
155
+ output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
156
  serializer: Callable[[Any], str] | None = None,
157
  enabled: bool | None = None,
158
  ) -> FunctionTool:
 
164
  tags=tags,
165
  annotations=annotations,
166
  exclude_args=exclude_args,
167
+ output_schema=output_schema,
168
  serializer=serializer,
169
  enabled=enabled,
170
  )
171
 
172
+ async def run(self, arguments: dict[str, Any]) -> ToolResult:
173
+ """
174
+ Run the tool with arguments.
175
+
176
+ This method is not implemented in the base Tool class and must be
177
+ implemented by subclasses.
178
+
179
+ `run()` can EITHER return a list of ContentBlocks, or a tuple of
180
+ (list of ContentBlocks, dict of structured output).
181
+ """
182
  raise NotImplementedError("Subclasses must implement run()")
183
 
184
  @classmethod
 
191
  description: str | None = None,
192
  tags: set[str] | None = None,
193
  annotations: ToolAnnotations | None = None,
194
+ output_schema: dict[str, Any] | None | Literal[False] = None,
195
  serializer: Callable[[Any], str] | None = None,
196
  enabled: bool | None = None,
197
  ) -> TransformedTool:
 
205
  description=description,
206
  tags=tags,
207
  annotations=annotations,
208
+ output_schema=output_schema,
209
  serializer=serializer,
210
  enabled=enabled,
211
  )
 
223
  tags: set[str] | None = None,
224
  annotations: ToolAnnotations | None = None,
225
  exclude_args: list[str] | None = None,
226
+ output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
227
  serializer: Callable[[Any], str] | None = None,
228
  enabled: bool | None = None,
229
  ) -> FunctionTool:
 
234
  if name is None and parsed_fn.name == "<lambda>":
235
  raise ValueError("You must provide a name for lambda functions")
236
 
237
+ if isinstance(output_schema, NotSetT):
238
+ output_schema = _wrap_schema_if_needed(parsed_fn.output_schema)
239
+ elif output_schema is False:
240
+ output_schema = None
241
+ # Note: explicit schemas (dict) are used as-is without auto-wrapping
242
+
243
+ # Validate that explicit schemas are object type for structured content
244
+ if output_schema is not None and isinstance(output_schema, dict):
245
+ if output_schema.get("type") != "object":
246
+ raise ValueError(
247
+ f'Output schemas must have "type" set to "object" due to MCP spec limitations. Received: {output_schema!r}'
248
+ )
249
+
250
  return cls(
251
  fn=parsed_fn.fn,
252
  name=name or parsed_fn.name,
253
  description=description or parsed_fn.description,
254
+ parameters=parsed_fn.input_schema,
255
+ output_schema=output_schema,
256
  annotations=annotations,
257
+ tags=tags or set(),
258
  serializer=serializer,
259
  enabled=enabled if enabled is not None else True,
260
  )
261
 
262
+ async def run(self, arguments: dict[str, Any]) -> ToolResult:
263
  """Run the tool with arguments."""
264
  from fastmcp.server.context import Context
265
 
 
271
 
272
  type_adapter = get_cached_typeadapter(self.fn)
273
  result = type_adapter.validate_python(arguments)
274
+
275
  if inspect.isawaitable(result):
276
  result = await result
277
 
278
+ if isinstance(result, ToolResult):
279
+ return result
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,
303
+ structured_content=structured_output,
304
+ )
305
 
306
 
307
  @dataclass
 
309
  fn: Callable[..., Any]
310
  name: str
311
  description: str | None
312
+ input_schema: dict[str, Any]
313
+ output_schema: dict[str, Any] | None
314
 
315
  @classmethod
316
  def from_function(
317
  cls,
318
  fn: Callable[..., Any],
319
  exclude_args: list[str] | None = None,
320
+ ignore_response_types: list[type] | None = None,
321
  validate: bool = True,
322
  ) -> ParsedFunction:
323
  from fastmcp.server.context import Context
 
357
  if isinstance(fn, staticmethod):
358
  fn = fn.__func__
359
 
 
 
 
360
  prune_params: list[str] = []
361
  context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
362
  if context_kwarg:
 
364
  if exclude_args:
365
  prune_params.extend(exclude_args)
366
 
367
+ input_type_adapter = get_cached_typeadapter(fn)
368
+ input_schema = input_type_adapter.json_schema()
369
+ input_schema = compress_schema(input_schema, prune_params=prune_params)
370
+
371
+ output_schema = None
372
+ output_type = inspect.signature(fn).return_annotation
373
+
374
+ if output_type not in (inspect._empty, None, Any, ...):
375
+ # there are a variety of types that we don't want to attempt to
376
+ # serialize because they are either used by FastMCP internally,
377
+ # or are MCP content types that explicitly don't form structured
378
+ # content. By replacing them with an explicitly unserializable type,
379
+ # we ensure that no output schema is automatically generated.
380
+ output_type = replace_type(
381
+ output_type,
382
+ {
383
+ t: _UnserializableType
384
+ for t in (
385
+ Image,
386
+ Audio,
387
+ File,
388
+ ToolResult,
389
+ mcp.types.TextContent,
390
+ mcp.types.ImageContent,
391
+ mcp.types.AudioContent,
392
+ mcp.types.ResourceLink,
393
+ mcp.types.EmbeddedResource,
394
+ )
395
+ },
396
+ )
397
+
398
+ try:
399
+ output_type_adapter = get_cached_typeadapter(output_type)
400
+ output_schema = output_type_adapter.json_schema()
401
+ except PydanticSchemaGenerationError as e:
402
+ if "_UnserializableType" not in str(e):
403
+ logger.debug(f"Unable to generate schema for type {output_type!r}")
404
+
405
+ return cls(
406
+ fn=fn,
407
+ name=fn_name,
408
+ description=fn_doc,
409
+ input_schema=input_schema,
410
+ output_schema=output_schema or None,
411
+ )
412
+
413
+ try:
414
+ output_type_adapter = get_cached_typeadapter(output_type)
415
+ output_schema = output_type_adapter.json_schema()
416
+ except PydanticSchemaGenerationError as e:
417
+ if "_UnserializableType" not in str(e):
418
+ logger.debug(f"Unable to generate schema for type {output_type!r}")
419
+
420
  return cls(
421
  fn=fn,
422
  name=fn_name,
423
  description=fn_doc,
424
+ input_schema=input_schema,
425
+ output_schema=output_schema or None,
426
  )
427
 
428
 
src/fastmcp/tools/tool_manager.py CHANGED
@@ -4,12 +4,12 @@ import warnings
4
  from collections.abc import Callable
5
  from typing import TYPE_CHECKING, Any
6
 
7
- from mcp.types import ContentBlock, ToolAnnotations
8
 
9
  from fastmcp import settings
10
  from fastmcp.exceptions import NotFoundError, ToolError
11
  from fastmcp.settings import DuplicateBehavior
12
- from fastmcp.tools.tool import Tool
13
  from fastmcp.utilities.logging import get_logger
14
 
15
  if TYPE_CHECKING:
@@ -169,9 +169,7 @@ class ToolManager:
169
  else:
170
  raise NotFoundError(f"Tool {key!r} not found")
171
 
172
- async def call_tool(
173
- self, key: str, arguments: dict[str, Any]
174
- ) -> list[ContentBlock]:
175
  """
176
  Internal API for servers: Finds and calls a tool, respecting the
177
  filtered protocol path.
 
4
  from collections.abc import Callable
5
  from typing import TYPE_CHECKING, Any
6
 
7
+ from mcp.types import ToolAnnotations
8
 
9
  from fastmcp import settings
10
  from fastmcp.exceptions import NotFoundError, ToolError
11
  from fastmcp.settings import DuplicateBehavior
12
+ from fastmcp.tools.tool import Tool, ToolResult
13
  from fastmcp.utilities.logging import get_logger
14
 
15
  if TYPE_CHECKING:
 
169
  else:
170
  raise NotFoundError(f"Tool {key!r} not found")
171
 
172
+ async def call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
 
 
173
  """
174
  Internal API for servers: Finds and calls a tool, respecting the
175
  filtered protocol path.
src/fastmcp/tools/tool_transform.py CHANGED
@@ -4,20 +4,17 @@ import inspect
4
  from collections.abc import Callable
5
  from contextvars import ContextVar
6
  from dataclasses import dataclass
7
- from types import EllipsisType
8
  from typing import Any, Literal
9
 
10
- from mcp.types import ContentBlock, ToolAnnotations
11
  from pydantic import ConfigDict
12
 
13
- from fastmcp.tools.tool import ParsedFunction, Tool
14
  from fastmcp.utilities.logging import get_logger
15
- from fastmcp.utilities.types import get_cached_typeadapter
16
 
17
  logger = get_logger(__name__)
18
 
19
- NotSet = ...
20
-
21
 
22
  # Context variable to store current transformed tool
23
  _current_tool: ContextVar[TransformedTool | None] = ContextVar(
@@ -25,7 +22,7 @@ _current_tool: ContextVar[TransformedTool | None] = ContextVar(
25
  )
26
 
27
 
28
- async def forward(**kwargs) -> Any:
29
  """Forward to parent tool with argument transformation applied.
30
 
31
  This function can only be called from within a transformed tool's custom
@@ -41,7 +38,7 @@ async def forward(**kwargs) -> Any:
41
  **kwargs: Arguments to forward to the parent tool (using transformed names).
42
 
43
  Returns:
44
- The result from the parent tool execution.
45
 
46
  Raises:
47
  RuntimeError: If called outside a transformed tool context.
@@ -55,7 +52,7 @@ async def forward(**kwargs) -> Any:
55
  return await tool.forwarding_fn(**kwargs)
56
 
57
 
58
- async def forward_raw(**kwargs) -> Any:
59
  """Forward directly to parent tool without transformation.
60
 
61
  This function bypasses all argument transformation and validation, calling the parent
@@ -69,7 +66,7 @@ async def forward_raw(**kwargs) -> Any:
69
  **kwargs: Arguments to pass directly to the parent tool (using original names).
70
 
71
  Returns:
72
- The result from the parent tool execution.
73
 
74
  Raises:
75
  RuntimeError: If called outside a transformed tool context.
@@ -151,14 +148,14 @@ class ArgTransform:
151
  ```
152
  """
153
 
154
- name: str | EllipsisType = NotSet
155
- description: str | EllipsisType = NotSet
156
- default: Any | EllipsisType = NotSet
157
- default_factory: Callable[[], Any] | EllipsisType = NotSet
158
- type: Any | EllipsisType = NotSet
159
  hide: bool = False
160
- required: Literal[True] | EllipsisType = NotSet
161
- examples: Any | EllipsisType = NotSet
162
 
163
  def __post_init__(self):
164
  """Validate that only one of default or default_factory is provided."""
@@ -201,11 +198,12 @@ class TransformedTool(Tool):
201
 
202
  This class represents a tool that has been created by transforming another tool.
203
  It supports argument renaming, schema modification, custom function injection,
204
- and provides context for the forward() and forward_raw() functions.
205
 
206
  The transformation can be purely schema-based (argument renaming, dropping, etc.)
207
  or can include a custom function that uses forward() to call the parent tool
208
- with transformed arguments.
 
209
 
210
  Attributes:
211
  parent_tool: The original tool that this tool was transformed from.
@@ -222,7 +220,7 @@ class TransformedTool(Tool):
222
  forwarding_fn: Callable[..., Any] # Always present, handles arg transformation
223
  transform_args: dict[str, ArgTransform]
224
 
225
- async def run(self, arguments: dict[str, Any]) -> list[ContentBlock]:
226
  """Run the tool with context set for forward() functions.
227
 
228
  This method executes the tool's function while setting up the context
@@ -233,8 +231,7 @@ class TransformedTool(Tool):
233
  arguments: Dictionary of arguments to pass to the tool's function.
234
 
235
  Returns:
236
- List of content objects (text, image, or embedded resources) representing
237
- the tool's output.
238
  """
239
  from fastmcp.tools.tool import _convert_to_content
240
 
@@ -272,7 +269,57 @@ class TransformedTool(Tool):
272
  token = _current_tool.set(self)
273
  try:
274
  result = await self.fn(**arguments)
275
- return _convert_to_content(result, serializer=self.serializer)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  finally:
277
  _current_tool.reset(token)
278
 
@@ -286,6 +333,7 @@ class TransformedTool(Tool):
286
  transform_fn: Callable[..., Any] | None = None,
287
  transform_args: dict[str, ArgTransform] | None = None,
288
  annotations: ToolAnnotations | None = None,
 
289
  serializer: Callable[[Any], str] | None = None,
290
  enabled: bool | None = None,
291
  ) -> TransformedTool:
@@ -305,6 +353,10 @@ class TransformedTool(Tool):
305
  description: New description. Defaults to parent's description.
306
  tags: New tags. Defaults to parent's tags.
307
  annotations: New annotations. Defaults to parent's annotations.
 
 
 
 
308
  serializer: New serializer. Defaults to parent's serializer.
309
 
310
  Returns:
@@ -333,6 +385,26 @@ class TransformedTool(Tool):
333
 
334
  Tool.from_tool(parent, transform_fn=flexible, transform_args={"a": "x"})
335
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  """
337
  transform_args = transform_args or {}
338
 
@@ -348,19 +420,45 @@ class TransformedTool(Tool):
348
  # Always create the forwarding transform
349
  schema, forwarding_fn = cls._create_forwarding_transform(tool, transform_args)
350
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  if transform_fn is None:
352
  # User wants pure transformation - use forwarding_fn as the main function
353
  final_fn = forwarding_fn
354
  final_schema = schema
355
  else:
356
  # User provided custom function - merge schemas
357
- parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
 
358
  final_fn = transform_fn
359
 
360
  has_kwargs = cls._function_has_kwargs(transform_fn)
361
 
362
  # Validate function parameters against transformed schema
363
- fn_params = set(parsed_fn.parameters.get("properties", {}).keys())
364
  transformed_params = set(schema.get("properties", {}).keys())
365
 
366
  if not has_kwargs:
@@ -377,7 +475,7 @@ class TransformedTool(Tool):
377
  # ArgTransform takes precedence over function signature
378
  # Start with function schema as base, then override with transformed schema
379
  final_schema = cls._merge_schema_with_precedence(
380
- parsed_fn.parameters, schema
381
  )
382
  else:
383
  # With **kwargs, function can access all transformed params
@@ -386,7 +484,7 @@ class TransformedTool(Tool):
386
 
387
  # Start with function schema as base, then override with transformed schema
388
  final_schema = cls._merge_schema_with_precedence(
389
- parsed_fn.parameters, schema
390
  )
391
 
392
  # Additional validation: check for naming conflicts after transformation
@@ -422,6 +520,7 @@ class TransformedTool(Tool):
422
  name=name or tool.name,
423
  description=final_description,
424
  parameters=final_schema,
 
425
  tags=tags or tool.tags,
426
  annotations=annotations or tool.annotations,
427
  serializer=serializer or tool.serializer,
 
4
  from collections.abc import Callable
5
  from contextvars import ContextVar
6
  from dataclasses import dataclass
 
7
  from typing import Any, Literal
8
 
9
+ from mcp.types import ToolAnnotations
10
  from pydantic import ConfigDict
11
 
12
+ from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult, _wrap_schema_if_needed
13
  from fastmcp.utilities.logging import get_logger
14
+ from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter
15
 
16
  logger = get_logger(__name__)
17
 
 
 
18
 
19
  # Context variable to store current transformed tool
20
  _current_tool: ContextVar[TransformedTool | None] = ContextVar(
 
22
  )
23
 
24
 
25
+ async def forward(**kwargs) -> ToolResult:
26
  """Forward to parent tool with argument transformation applied.
27
 
28
  This function can only be called from within a transformed tool's custom
 
38
  **kwargs: Arguments to forward to the parent tool (using transformed names).
39
 
40
  Returns:
41
+ The ToolResult from the parent tool execution.
42
 
43
  Raises:
44
  RuntimeError: If called outside a transformed tool context.
 
52
  return await tool.forwarding_fn(**kwargs)
53
 
54
 
55
+ async def forward_raw(**kwargs) -> ToolResult:
56
  """Forward directly to parent tool without transformation.
57
 
58
  This function bypasses all argument transformation and validation, calling the parent
 
66
  **kwargs: Arguments to pass directly to the parent tool (using original names).
67
 
68
  Returns:
69
+ The ToolResult from the parent tool execution.
70
 
71
  Raises:
72
  RuntimeError: If called outside a transformed tool context.
 
148
  ```
149
  """
150
 
151
+ name: str | NotSetT = NotSet
152
+ description: str | NotSetT = NotSet
153
+ default: Any | NotSetT = NotSet
154
+ default_factory: Callable[[], Any] | NotSetT = NotSet
155
+ type: Any | NotSetT = NotSet
156
  hide: bool = False
157
+ required: Literal[True] | NotSetT = NotSet
158
+ examples: Any | NotSetT = NotSet
159
 
160
  def __post_init__(self):
161
  """Validate that only one of default or default_factory is provided."""
 
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.
 
220
  forwarding_fn: Callable[..., Any] # Always present, handles arg transformation
221
  transform_args: dict[str, ArgTransform]
222
 
223
+ async def run(self, arguments: dict[str, Any]) -> ToolResult:
224
  """Run the tool with context set for forward() functions.
225
 
226
  This method executes the tool's function while setting up the context
 
231
  arguments: Dictionary of arguments to pass to the tool's function.
232
 
233
  Returns:
234
+ ToolResult object containing content and optional structured output.
 
235
  """
236
  from fastmcp.tools.tool import _convert_to_content
237
 
 
269
  token = _current_tool.set(self)
270
  try:
271
  result = await self.fn(**arguments)
272
+
273
+ # If transform function returns ToolResult, respect our output_schema setting
274
+ if isinstance(result, ToolResult):
275
+ if self.output_schema is None:
276
+ # Check if this is from a custom function that returns ToolResult
277
+ import inspect
278
+
279
+ return_annotation = inspect.signature(self.fn).return_annotation
280
+ if return_annotation is ToolResult:
281
+ # Custom function returns ToolResult - preserve its content
282
+ return result
283
+ else:
284
+ # Forwarded call with disabled schema - strip structured content
285
+ return ToolResult(
286
+ content=result.content,
287
+ structured_content=None,
288
+ )
289
+ elif self.output_schema.get(
290
+ "type"
291
+ ) != "object" and not self.output_schema.get("x-fastmcp-wrap-result"):
292
+ # Non-object explicit schemas disable structured content
293
+ return ToolResult(
294
+ content=result.content,
295
+ structured_content=None,
296
+ )
297
+ else:
298
+ return result
299
+
300
+ # Otherwise convert to content and create ToolResult with proper structured content
301
+ from fastmcp.tools.tool import _convert_to_content
302
+
303
+ unstructured_result = _convert_to_content(
304
+ result, serializer=self.serializer
305
+ )
306
+
307
+ # Handle structured content based on output schema
308
+ if self.output_schema is not None:
309
+ if self.output_schema.get("x-fastmcp-wrap-result"):
310
+ # Schema says wrap - always wrap in result key
311
+ structured_output = {"result": result}
312
+ else:
313
+ # Object schemas - use result directly
314
+ # User is responsible for returning dict-compatible data
315
+ structured_output = result
316
+ else:
317
+ structured_output = None
318
+
319
+ return ToolResult(
320
+ content=unstructured_result,
321
+ structured_content=structured_output,
322
+ )
323
  finally:
324
  _current_tool.reset(token)
325
 
 
333
  transform_fn: Callable[..., Any] | None = None,
334
  transform_args: dict[str, ArgTransform] | None = None,
335
  annotations: ToolAnnotations | None = None,
336
+ output_schema: dict[str, Any] | None | Literal[False] = None,
337
  serializer: Callable[[Any], str] | None = None,
338
  enabled: bool | None = None,
339
  ) -> TransformedTool:
 
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
 
 
420
  # Always create the forwarding transform
421
  schema, forwarding_fn = cls._create_forwarding_transform(tool, transform_args)
422
 
423
+ # Handle output schema with smart fallback
424
+ if output_schema is False:
425
+ final_output_schema = None
426
+ elif output_schema is not None:
427
+ # Explicit schema provided - use as-is
428
+ final_output_schema = output_schema
429
+ else:
430
+ # Smart fallback: try custom function, then parent, then None
431
+ if transform_fn is not None:
432
+ parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
433
+ final_output_schema = _wrap_schema_if_needed(parsed_fn.output_schema)
434
+ if final_output_schema is None:
435
+ # Check if function returns ToolResult - if so, don't fall back to parent
436
+ import inspect
437
+
438
+ return_annotation = inspect.signature(
439
+ transform_fn
440
+ ).return_annotation
441
+ if return_annotation is ToolResult:
442
+ final_output_schema = None
443
+ else:
444
+ final_output_schema = tool.output_schema
445
+ else:
446
+ final_output_schema = tool.output_schema
447
+
448
  if transform_fn is None:
449
  # User wants pure transformation - use forwarding_fn as the main function
450
  final_fn = forwarding_fn
451
  final_schema = schema
452
  else:
453
  # User provided custom function - merge schemas
454
+ if "parsed_fn" not in locals():
455
+ parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
456
  final_fn = transform_fn
457
 
458
  has_kwargs = cls._function_has_kwargs(transform_fn)
459
 
460
  # Validate function parameters against transformed schema
461
+ fn_params = set(parsed_fn.input_schema.get("properties", {}).keys())
462
  transformed_params = set(schema.get("properties", {}).keys())
463
 
464
  if not has_kwargs:
 
475
  # ArgTransform takes precedence over function signature
476
  # Start with function schema as base, then override with transformed schema
477
  final_schema = cls._merge_schema_with_precedence(
478
+ parsed_fn.input_schema, schema
479
  )
480
  else:
481
  # With **kwargs, function can access all transformed params
 
484
 
485
  # Start with function schema as base, then override with transformed schema
486
  final_schema = cls._merge_schema_with_precedence(
487
+ parsed_fn.input_schema, schema
488
  )
489
 
490
  # Additional validation: check for naming conflicts after transformation
 
520
  name=name or tool.name,
521
  description=final_description,
522
  parameters=final_schema,
523
+ output_schema=final_output_schema,
524
  tags=tags or tool.tags,
525
  annotations=annotations or tool.annotations,
526
  serializer=serializer or tool.serializer,
src/fastmcp/utilities/json_schema_type.py ADDED
@@ -0,0 +1,646 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert JSON Schema to Python types with validation.
2
+
3
+ The json_schema_to_type function converts a JSON Schema into a Python type that can be used
4
+ for validation with Pydantic. It supports:
5
+
6
+ - Basic types (string, number, integer, boolean, null)
7
+ - Complex types (arrays, objects)
8
+ - Format constraints (date-time, email, uri)
9
+ - Numeric constraints (minimum, maximum, multipleOf)
10
+ - String constraints (minLength, maxLength, pattern)
11
+ - Array constraints (minItems, maxItems, uniqueItems)
12
+ - Object properties with defaults
13
+ - References and recursive schemas
14
+ - Enums and constants
15
+ - Union types
16
+
17
+ Example:
18
+ ```python
19
+ schema = {
20
+ "type": "object",
21
+ "properties": {
22
+ "name": {"type": "string", "minLength": 1},
23
+ "age": {"type": "integer", "minimum": 0},
24
+ "email": {"type": "string", "format": "email"}
25
+ },
26
+ "required": ["name", "age"]
27
+ }
28
+
29
+ # Name is optional and will be inferred from schema's "title" property if not provided
30
+ Person = json_schema_to_type(schema)
31
+ # Creates a validated dataclass with name, age, and optional email fields
32
+ ```
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import hashlib
38
+ import json
39
+ import re
40
+ from collections.abc import Callable, Mapping
41
+ from copy import deepcopy
42
+ from dataclasses import MISSING, field, make_dataclass
43
+ from datetime import datetime
44
+ from enum import Enum
45
+ from typing import (
46
+ Annotated,
47
+ Any,
48
+ ForwardRef,
49
+ Literal,
50
+ Union,
51
+ )
52
+
53
+ from pydantic import (
54
+ AnyUrl,
55
+ BaseModel,
56
+ ConfigDict,
57
+ EmailStr,
58
+ Field,
59
+ Json,
60
+ StringConstraints,
61
+ model_validator,
62
+ )
63
+ from typing_extensions import NotRequired, TypedDict
64
+
65
+ __all__ = ["json_schema_to_type", "JSONSchema"]
66
+
67
+
68
+ FORMAT_TYPES: dict[str, Any] = {
69
+ "date-time": datetime,
70
+ "email": EmailStr,
71
+ "uri": AnyUrl,
72
+ "json": Json,
73
+ }
74
+
75
+ _classes: dict[tuple[str, Any], type | None] = {}
76
+
77
+
78
+ class JSONSchema(TypedDict):
79
+ type: NotRequired[str | list[str]]
80
+ properties: NotRequired[dict[str, JSONSchema]]
81
+ required: NotRequired[list[str]]
82
+ additionalProperties: NotRequired[bool | JSONSchema]
83
+ items: NotRequired[JSONSchema | list[JSONSchema]]
84
+ enum: NotRequired[list[Any]]
85
+ const: NotRequired[Any]
86
+ default: NotRequired[Any]
87
+ description: NotRequired[str]
88
+ title: NotRequired[str]
89
+ examples: NotRequired[list[Any]]
90
+ format: NotRequired[str]
91
+ allOf: NotRequired[list[JSONSchema]]
92
+ anyOf: NotRequired[list[JSONSchema]]
93
+ oneOf: NotRequired[list[JSONSchema]]
94
+ not_: NotRequired[JSONSchema]
95
+ definitions: NotRequired[dict[str, JSONSchema]]
96
+ dependencies: NotRequired[dict[str, JSONSchema | list[str]]]
97
+ pattern: NotRequired[str]
98
+ minLength: NotRequired[int]
99
+ maxLength: NotRequired[int]
100
+ minimum: NotRequired[int | float]
101
+ maximum: NotRequired[int | float]
102
+ exclusiveMinimum: NotRequired[int | float]
103
+ exclusiveMaximum: NotRequired[int | float]
104
+ multipleOf: NotRequired[int | float]
105
+ uniqueItems: NotRequired[bool]
106
+ minItems: NotRequired[int]
107
+ maxItems: NotRequired[int]
108
+ additionalItems: NotRequired[bool | JSONSchema]
109
+
110
+
111
+ def json_schema_to_type(
112
+ schema: Mapping[str, Any],
113
+ name: str | None = None,
114
+ ) -> type:
115
+ """Convert JSON schema to appropriate Python type with validation.
116
+
117
+ Args:
118
+ schema: A JSON Schema dictionary defining the type structure and validation rules
119
+ name: Optional name for object schemas. Only allowed when schema type is "object".
120
+ If not provided for objects, name will be inferred from schema's "title"
121
+ property or default to "Root".
122
+
123
+ Returns:
124
+ A Python type (typically a dataclass for objects) with Pydantic validation
125
+
126
+ Raises:
127
+ ValueError: If a name is provided for a non-object schema
128
+
129
+ Examples:
130
+ Create a dataclass from an object schema:
131
+ ```python
132
+ schema = {
133
+ "type": "object",
134
+ "title": "Person",
135
+ "properties": {
136
+ "name": {"type": "string", "minLength": 1},
137
+ "age": {"type": "integer", "minimum": 0},
138
+ "email": {"type": "string", "format": "email"}
139
+ },
140
+ "required": ["name", "age"]
141
+ }
142
+
143
+ Person = json_schema_to_type(schema)
144
+ # Creates a dataclass with name, age, and optional email fields:
145
+ # @dataclass
146
+ # class Person:
147
+ # name: str
148
+ # age: int
149
+ # email: str | None = None
150
+ ```
151
+ Person(name="John", age=30)
152
+
153
+ Create a scalar type with constraints:
154
+ ```python
155
+ schema = {
156
+ "type": "string",
157
+ "minLength": 3,
158
+ "pattern": "^[A-Z][a-z]+$"
159
+ }
160
+
161
+ NameType = json_schema_to_type(schema)
162
+ # Creates Annotated[str, StringConstraints(min_length=3, pattern="^[A-Z][a-z]+$")]
163
+
164
+ @dataclass
165
+ class Name:
166
+ name: NameType
167
+ ```
168
+ """
169
+ # Always use the top-level schema for references
170
+ if schema.get("type") == "object":
171
+ # If no properties defined but has additionalProperties, return typed dict
172
+ if not schema.get("properties") and schema.get("additionalProperties"):
173
+ additional_props = schema["additionalProperties"]
174
+ if additional_props is True:
175
+ return dict[str, Any] # type: ignore - additionalProperties: true means dict[str, Any]
176
+ else:
177
+ # Handle typed dictionaries like dict[str, str]
178
+ value_type = _schema_to_type(additional_props, schemas=schema)
179
+ return dict[str, value_type] # type: ignore
180
+ # If no properties and no additionalProperties, default to dict[str, Any] for safety
181
+ elif not schema.get("properties") and not schema.get("additionalProperties"):
182
+ return dict[str, Any] # type: ignore
183
+ # If has properties AND additionalProperties is True, use Pydantic BaseModel
184
+ elif schema.get("properties") and schema.get("additionalProperties") is True:
185
+ return _create_pydantic_model(schema, name, schemas=schema)
186
+ # Otherwise use fast dataclass
187
+ return _create_dataclass(schema, name, schemas=schema)
188
+ elif name:
189
+ raise ValueError(f"Can not apply name to non-object schema: {name}")
190
+ result = _schema_to_type(schema, schemas=schema)
191
+ return result # type: ignore[return-value]
192
+
193
+
194
+ def _hash_schema(schema: Mapping[str, Any]) -> str:
195
+ """Generate a deterministic hash for schema caching."""
196
+ return hashlib.sha256(json.dumps(schema, sort_keys=True).encode()).hexdigest()
197
+
198
+
199
+ def _resolve_ref(ref: str, schemas: Mapping[str, Any]) -> Mapping[str, Any]:
200
+ """Resolve JSON Schema reference to target schema."""
201
+ path = ref.replace("#/", "").split("/")
202
+ current = schemas
203
+ for part in path:
204
+ current = current.get(part, {})
205
+ return current
206
+
207
+
208
+ def _create_string_type(schema: Mapping[str, Any]) -> type | Annotated[Any, ...]:
209
+ """Create string type with optional constraints."""
210
+ if "const" in schema:
211
+ return Literal[schema["const"]] # type: ignore
212
+
213
+ if fmt := schema.get("format"):
214
+ if fmt == "uri":
215
+ return AnyUrl
216
+ elif fmt == "uri-reference":
217
+ return str
218
+ return FORMAT_TYPES.get(fmt, str)
219
+
220
+ constraints = {
221
+ k: v
222
+ for k, v in {
223
+ "min_length": schema.get("minLength"),
224
+ "max_length": schema.get("maxLength"),
225
+ "pattern": schema.get("pattern"),
226
+ }.items()
227
+ if v is not None
228
+ }
229
+
230
+ return Annotated[str, StringConstraints(**constraints)] if constraints else str
231
+
232
+
233
+ def _create_numeric_type(
234
+ base: type[int | float], schema: Mapping[str, Any]
235
+ ) -> type | Annotated[Any, ...]:
236
+ """Create numeric type with optional constraints."""
237
+ if "const" in schema:
238
+ return Literal[schema["const"]] # type: ignore
239
+
240
+ constraints = {
241
+ k: v
242
+ for k, v in {
243
+ "gt": schema.get("exclusiveMinimum"),
244
+ "ge": schema.get("minimum"),
245
+ "lt": schema.get("exclusiveMaximum"),
246
+ "le": schema.get("maximum"),
247
+ "multiple_of": schema.get("multipleOf"),
248
+ }.items()
249
+ if v is not None
250
+ }
251
+
252
+ return Annotated[base, Field(**constraints)] if constraints else base
253
+
254
+
255
+ def _create_enum(name: str, values: list[Any]) -> type:
256
+ """Create enum type from list of values."""
257
+ if all(isinstance(v, str) for v in values):
258
+ return Enum(name, {v.upper(): v for v in values}) # type: ignore[return-value]
259
+ return Literal[tuple(values)] # type: ignore[return-value]
260
+
261
+
262
+ def _create_array_type(
263
+ schema: Mapping[str, Any], schemas: Mapping[str, Any]
264
+ ) -> type | Annotated[Any, ...]:
265
+ """Create list/set type with optional constraints."""
266
+ items = schema.get("items", {})
267
+ if isinstance(items, list):
268
+ # Handle positional item schemas
269
+ item_types = [_schema_to_type(s, schemas) for s in items]
270
+ combined = Union[tuple(item_types)] # type: ignore # noqa: UP007
271
+ base = list[combined]
272
+ else:
273
+ # Handle single item schema
274
+ item_type = _schema_to_type(items, schemas)
275
+ base_class = set if schema.get("uniqueItems") else list
276
+ base = base_class[item_type] # type: ignore[misc]
277
+
278
+ constraints = {
279
+ k: v
280
+ for k, v in {
281
+ "min_length": schema.get("minItems"),
282
+ "max_length": schema.get("maxItems"),
283
+ }.items()
284
+ if v is not None
285
+ }
286
+
287
+ return Annotated[base, Field(**constraints)] if constraints else base
288
+
289
+
290
+ def _return_Any() -> Any:
291
+ return Any
292
+
293
+
294
+ def _get_from_type_handler(
295
+ schema: Mapping[str, Any], schemas: Mapping[str, Any]
296
+ ) -> Callable[..., Any]:
297
+ """Get the appropriate type handler for the schema."""
298
+
299
+ type_handlers: dict[str, Callable[..., Any]] = { # TODO
300
+ "string": lambda s: _create_string_type(s), # type: ignore
301
+ "integer": lambda s: _create_numeric_type(int, s), # type: ignore
302
+ "number": lambda s: _create_numeric_type(float, s), # type: ignore
303
+ "boolean": lambda _: bool, # type: ignore
304
+ "null": lambda _: type(None), # type: ignore
305
+ "array": lambda s: _create_array_type(s, schemas), # type: ignore
306
+ "object": lambda s: (
307
+ _create_pydantic_model(s, s.get("title"), schemas)
308
+ if s.get("properties") and s.get("additionalProperties") is True
309
+ else _create_dataclass(s, s.get("title"), schemas)
310
+ ), # type: ignore
311
+ }
312
+ return type_handlers.get(schema.get("type", None), _return_Any)
313
+
314
+
315
+ def _schema_to_type(
316
+ schema: Mapping[str, Any],
317
+ schemas: Mapping[str, Any],
318
+ ) -> type | ForwardRef:
319
+ """Convert schema to appropriate Python type."""
320
+ if not schema:
321
+ return object
322
+
323
+ if "type" not in schema and "properties" in schema:
324
+ return _create_dataclass(schema, schema.get("title", "<unknown>"), schemas)
325
+
326
+ # Handle references first
327
+ if "$ref" in schema:
328
+ ref = schema["$ref"]
329
+ # Handle self-reference
330
+ if ref == "#":
331
+ return ForwardRef(schema.get("title", "Root")) # type: ignore[return-value]
332
+ return _schema_to_type(_resolve_ref(ref, schemas), schemas)
333
+
334
+ if "const" in schema:
335
+ return Literal[schema["const"]] # type: ignore
336
+
337
+ if "enum" in schema:
338
+ return _create_enum(f"Enum_{len(_classes)}", schema["enum"])
339
+
340
+ # Handle anyOf unions
341
+ if "anyOf" in schema:
342
+ types: list[type | Any] = []
343
+ for subschema in schema["anyOf"]:
344
+ # Special handling for dict-like objects in unions
345
+ if (
346
+ subschema.get("type") == "object"
347
+ and not subschema.get("properties")
348
+ and subschema.get("additionalProperties")
349
+ ):
350
+ # This is a dict type, handle it directly
351
+ additional_props = subschema["additionalProperties"]
352
+ if additional_props is True:
353
+ types.append(dict[str, Any]) # type: ignore
354
+ else:
355
+ value_type = _schema_to_type(additional_props, schemas)
356
+ types.append(dict[str, value_type]) # type: ignore
357
+ else:
358
+ types.append(_schema_to_type(subschema, schemas))
359
+
360
+ # Check if one of the types is None (null)
361
+ has_null = type(None) in types
362
+ types = [t for t in types if t is not type(None)]
363
+
364
+ if len(types) == 0:
365
+ return type(None)
366
+ elif len(types) == 1:
367
+ if has_null:
368
+ return types[0] | None # type: ignore
369
+ else:
370
+ return types[0]
371
+ else:
372
+ if has_null:
373
+ return Union[tuple(types + [type(None)])] # type: ignore # noqa: UP007
374
+ else:
375
+ return Union[tuple(types)] # type: ignore # noqa: UP007
376
+
377
+ schema_type = schema.get("type")
378
+ if not schema_type:
379
+ return Any # type: ignore[return-value]
380
+
381
+ if isinstance(schema_type, list):
382
+ # Create a copy of the schema for each type, but keep all constraints
383
+ types: list[type | Any] = []
384
+ for t in schema_type:
385
+ type_schema = dict(schema)
386
+ type_schema["type"] = t
387
+ types.append(_schema_to_type(type_schema, schemas))
388
+ has_null = type(None) in types
389
+ types = [t for t in types if t is not type(None)]
390
+ if has_null:
391
+ if len(types) == 1:
392
+ return types[0] | None # type: ignore
393
+ else:
394
+ return Union[tuple(types + [type(None)])] # type: ignore # noqa: UP007
395
+ return Union[tuple(types)] # type: ignore # noqa: UP007
396
+
397
+ return _get_from_type_handler(schema, schemas)(schema)
398
+
399
+
400
+ def _sanitize_name(name: str) -> str:
401
+ """Convert string to valid Python identifier."""
402
+ # Step 1: replace everything except [0-9a-zA-Z_] with underscores
403
+ cleaned = re.sub(r"[^0-9a-zA-Z_]", "_", name)
404
+ # Step 2: deduplicate underscores
405
+ cleaned = re.sub(r"__+", "_", cleaned)
406
+ # Step 3: if the first char of original name isn't a letter, prepend field_
407
+ if not name or not re.match(r"[a-zA-Z]", name[0]):
408
+ cleaned = f"field_{cleaned}"
409
+ # Step 4: deduplicate again and strip trailing underscores
410
+ cleaned = re.sub(r"__+", "_", cleaned).strip("_")
411
+ return cleaned
412
+
413
+
414
+ def _get_default_value(
415
+ schema: dict[str, Any],
416
+ prop_name: str,
417
+ parent_default: dict[str, Any] | None = None,
418
+ ) -> Any:
419
+ """Get default value with proper priority ordering.
420
+ 1. Value from parent's default if it exists
421
+ 2. Property's own default if it exists
422
+ 3. None
423
+ """
424
+ if parent_default is not None and prop_name in parent_default:
425
+ return parent_default[prop_name]
426
+ return schema.get("default")
427
+
428
+
429
+ def _create_field_with_default(
430
+ field_type: type,
431
+ default_value: Any,
432
+ schema: dict[str, Any],
433
+ ) -> Any:
434
+ """Create a field with simplified default handling."""
435
+ # Always use None as default for complex types
436
+ if isinstance(default_value, dict | list) or default_value is None:
437
+ return field(default=None)
438
+
439
+ # For simple types, use the value directly
440
+ return field(default=default_value)
441
+
442
+
443
+ def _create_pydantic_model(
444
+ schema: Mapping[str, Any],
445
+ name: str | None = None,
446
+ schemas: Mapping[str, Any] | None = None,
447
+ ) -> type:
448
+ """Create Pydantic BaseModel from object schema with additionalProperties."""
449
+ name = name or schema.get("title", "Root")
450
+ assert name is not None # Should not be None after the or operation
451
+ sanitized_name = _sanitize_name(name)
452
+ schema_hash = _hash_schema(schema)
453
+ cache_key = (schema_hash, sanitized_name)
454
+
455
+ # Return existing class if already built
456
+ if cache_key in _classes:
457
+ existing = _classes[cache_key]
458
+ if existing is None:
459
+ return ForwardRef(sanitized_name) # type: ignore[return-value]
460
+ return existing
461
+
462
+ # Place placeholder for recursive references
463
+ _classes[cache_key] = None
464
+
465
+ properties = schema.get("properties", {})
466
+ required = schema.get("required", [])
467
+
468
+ # Build field annotations and defaults
469
+ annotations = {}
470
+ defaults = {}
471
+
472
+ for prop_name, prop_schema in properties.items():
473
+ field_type = _schema_to_type(prop_schema, schemas or {})
474
+
475
+ # Handle defaults
476
+ default_value = prop_schema.get("default", MISSING)
477
+ if default_value is not MISSING:
478
+ defaults[prop_name] = default_value
479
+ annotations[prop_name] = field_type
480
+ elif prop_name in required:
481
+ annotations[prop_name] = field_type
482
+ else:
483
+ annotations[prop_name] = Union[field_type, type(None)] # type: ignore[misc] # noqa: UP007
484
+ defaults[prop_name] = None
485
+
486
+ # Create Pydantic model class
487
+ cls_dict = {
488
+ "__annotations__": annotations,
489
+ "model_config": ConfigDict(extra="allow"),
490
+ **defaults,
491
+ }
492
+
493
+ cls = type(sanitized_name, (BaseModel,), cls_dict)
494
+
495
+ # Store completed class
496
+ _classes[cache_key] = cls
497
+ return cls
498
+
499
+
500
+ def _create_dataclass(
501
+ schema: Mapping[str, Any],
502
+ name: str | None = None,
503
+ schemas: Mapping[str, Any] | None = None,
504
+ ) -> type:
505
+ """Create dataclass from object schema."""
506
+ name = name or schema.get("title", "Root")
507
+ # Sanitize name for class creation
508
+ assert name is not None # Should not be None after the or operation
509
+ sanitized_name = _sanitize_name(name)
510
+ schema_hash = _hash_schema(schema)
511
+ cache_key = (schema_hash, sanitized_name)
512
+ original_schema = dict(schema) # Store copy for validator
513
+
514
+ # Return existing class if already built
515
+ if cache_key in _classes:
516
+ existing = _classes[cache_key]
517
+ if existing is None:
518
+ return ForwardRef(sanitized_name) # type: ignore[return-value]
519
+ return existing
520
+
521
+ # Place placeholder for recursive references
522
+ _classes[cache_key] = None
523
+
524
+ if "$ref" in schema:
525
+ ref = schema["$ref"]
526
+ if ref == "#":
527
+ return ForwardRef(sanitized_name) # type: ignore[return-value]
528
+ schema = _resolve_ref(ref, schemas or {})
529
+
530
+ properties = schema.get("properties", {})
531
+ required = schema.get("required", [])
532
+
533
+ fields: list[tuple[Any, ...]] = []
534
+ for prop_name, prop_schema in properties.items():
535
+ field_name = _sanitize_name(prop_name)
536
+
537
+ # Check for self-reference in property
538
+ if prop_schema.get("$ref") == "#":
539
+ field_type = ForwardRef(sanitized_name)
540
+ else:
541
+ field_type = _schema_to_type(prop_schema, schemas or {})
542
+
543
+ default_val = prop_schema.get("default", MISSING)
544
+ is_required = prop_name in required
545
+
546
+ # Include alias in field metadata
547
+ meta = {"alias": prop_name}
548
+
549
+ if default_val is not MISSING:
550
+ if isinstance(default_val, dict | list):
551
+ field_def = field(
552
+ default_factory=lambda d=default_val: deepcopy(d), metadata=meta
553
+ )
554
+ else:
555
+ field_def = field(default=default_val, metadata=meta)
556
+ else:
557
+ if is_required:
558
+ field_def = field(metadata=meta)
559
+ else:
560
+ field_def = field(default=None, metadata=meta)
561
+
562
+ if is_required and default_val is not MISSING:
563
+ fields.append((field_name, field_type, field_def))
564
+ elif is_required:
565
+ fields.append((field_name, field_type, field_def))
566
+ else:
567
+ fields.append((field_name, Union[field_type, type(None)], field_def)) # type: ignore[misc] # noqa: UP007
568
+
569
+ cls = make_dataclass(sanitized_name, fields, kw_only=True)
570
+
571
+ # Add model validator for defaults
572
+ @model_validator(mode="before")
573
+ @classmethod
574
+ def _apply_defaults(cls, data: Mapping[str, Any]):
575
+ if isinstance(data, dict):
576
+ return _merge_defaults(data, original_schema)
577
+ return data
578
+
579
+ setattr(cls, "_apply_defaults", _apply_defaults)
580
+
581
+ # Store completed class
582
+ _classes[cache_key] = cls
583
+ return cls
584
+
585
+
586
+ def _merge_defaults(
587
+ data: Mapping[str, Any],
588
+ schema: Mapping[str, Any],
589
+ parent_default: Mapping[str, Any] | None = None,
590
+ ) -> dict[str, Any]:
591
+ """Merge defaults with provided data at all levels."""
592
+ # If we have no data
593
+ if not data:
594
+ # Start with parent default if available
595
+ if parent_default:
596
+ result = dict(parent_default)
597
+ # Otherwise use schema default if available
598
+ elif "default" in schema:
599
+ result = dict(schema["default"])
600
+ # Otherwise start empty
601
+ else:
602
+ result = {}
603
+ # If we have data and a parent default, merge them
604
+ elif parent_default:
605
+ result = dict(parent_default)
606
+ for key, value in data.items():
607
+ if (
608
+ isinstance(value, dict)
609
+ and key in result
610
+ and isinstance(result[key], dict)
611
+ ):
612
+ # recursively merge nested dicts
613
+ result[key] = _merge_defaults(value, {"properties": {}}, result[key])
614
+ else:
615
+ result[key] = value
616
+ # Otherwise just use the data
617
+ else:
618
+ result = dict(data)
619
+
620
+ # For each property in the schema
621
+ for prop_name, prop_schema in schema.get("properties", {}).items():
622
+ # If property is missing, apply defaults in priority order
623
+ if prop_name not in result:
624
+ if parent_default and prop_name in parent_default:
625
+ result[prop_name] = parent_default[prop_name]
626
+ elif "default" in prop_schema:
627
+ result[prop_name] = prop_schema["default"]
628
+
629
+ # If property exists and is an object, recursively merge
630
+ if (
631
+ prop_name in result
632
+ and isinstance(result[prop_name], dict)
633
+ and prop_schema.get("type") == "object"
634
+ ):
635
+ # Get the appropriate default for this nested object
636
+ nested_default = None
637
+ if parent_default and prop_name in parent_default:
638
+ nested_default = parent_default[prop_name]
639
+ elif "default" in prop_schema:
640
+ nested_default = prop_schema["default"]
641
+
642
+ result[prop_name] = _merge_defaults(
643
+ result[prop_name], prop_schema, nested_default
644
+ )
645
+
646
+ return result
src/fastmcp/utilities/openapi.py CHANGED
@@ -84,6 +84,7 @@ class HTTPRoute(FastMCPBaseModel):
84
  schema_definitions: dict[str, JsonSchema] = Field(
85
  default_factory=dict
86
  ) # Store component schemas
 
87
 
88
 
89
  # Export public symbols
@@ -591,6 +592,14 @@ class OpenAPIParser(
591
  getattr(operation, "responses", None)
592
  )
593
 
 
 
 
 
 
 
 
 
594
  route = HTTPRoute(
595
  path=path_str,
596
  method=method_upper, # type: ignore[arg-type] # Known valid HTTP method
@@ -602,6 +611,7 @@ class OpenAPIParser(
602
  request_body=request_body_info,
603
  responses=responses,
604
  schema_definitions=schema_definitions,
 
605
  )
606
  routes.append(route)
607
  logger.info(
 
84
  schema_definitions: dict[str, JsonSchema] = Field(
85
  default_factory=dict
86
  ) # Store component schemas
87
+ extensions: dict[str, Any] = Field(default_factory=dict)
88
 
89
 
90
  # Export public symbols
 
592
  getattr(operation, "responses", None)
593
  )
594
 
595
+ extensions = {}
596
+ if hasattr(operation, "model_extra") and operation.model_extra:
597
+ extensions = {
598
+ k: v
599
+ for k, v in operation.model_extra.items()
600
+ if k.startswith("x-")
601
+ }
602
+
603
  route = HTTPRoute(
604
  path=path_str,
605
  method=method_upper, # type: ignore[arg-type] # Known valid HTTP method
 
611
  request_body=request_body_info,
612
  responses=responses,
613
  schema_definitions=schema_definitions,
614
+ extensions=extensions,
615
  )
616
  routes.append(route)
617
  logger.info(
src/fastmcp/utilities/types.py CHANGED
@@ -6,21 +6,19 @@ import mimetypes
6
  from collections.abc import Callable
7
  from functools import lru_cache
8
  from pathlib import Path
9
- from types import UnionType
10
- from typing import Annotated, TypeVar, Union, get_args, get_origin
11
-
12
- from mcp.types import (
13
- Annotations,
14
- AudioContent,
15
- BlobResourceContents,
16
- EmbeddedResource,
17
- ImageContent,
18
- TextResourceContents,
19
- )
20
  from pydantic import AnyUrl, BaseModel, ConfigDict, TypeAdapter, UrlConstraints
21
 
22
  T = TypeVar("T")
23
 
 
 
 
 
24
 
25
  class FastMCPBaseModel(BaseModel):
26
  """Base model for FastMCP models."""
@@ -129,7 +127,7 @@ class Image:
129
  self,
130
  mime_type: str | None = None,
131
  annotations: Annotations | None = None,
132
- ) -> ImageContent:
133
  """Convert to MCP ImageContent."""
134
  if self.path:
135
  with open(self.path, "rb") as f:
@@ -139,7 +137,7 @@ class Image:
139
  else:
140
  raise ValueError("No image data available")
141
 
142
- return ImageContent(
143
  type="image",
144
  data=data,
145
  mimeType=mime_type or self._mime_type,
@@ -188,7 +186,7 @@ class Audio:
188
  self,
189
  mime_type: str | None = None,
190
  annotations: Annotations | None = None,
191
- ) -> AudioContent:
192
  if self.path:
193
  with open(self.path, "rb") as f:
194
  data = base64.b64encode(f.read()).decode()
@@ -197,7 +195,7 @@ class Audio:
197
  else:
198
  raise ValueError("No audio data available")
199
 
200
- return AudioContent(
201
  type="audio",
202
  data=data,
203
  mimeType=mime_type or self._mime_type,
@@ -248,7 +246,7 @@ class File:
248
  self,
249
  mime_type: str | None = None,
250
  annotations: Annotations | None = None,
251
- ) -> EmbeddedResource:
252
  if self.path:
253
  with open(self.path, "rb") as f:
254
  raw_data = f.read()
@@ -271,21 +269,57 @@ class File:
271
  text = raw_data.decode("utf-8")
272
  except UnicodeDecodeError:
273
  text = raw_data.decode("latin-1")
274
- resource = TextResourceContents(
275
  text=text,
276
  mimeType=mime,
277
  uri=uri,
278
  )
279
  else:
280
  data = base64.b64encode(raw_data).decode()
281
- resource = BlobResourceContents(
282
  blob=data,
283
  mimeType=mime,
284
  uri=uri,
285
  )
286
 
287
- return EmbeddedResource(
288
  type="resource",
289
  resource=resource,
290
  annotations=annotations or self.annotations,
291
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  from collections.abc import Callable
7
  from functools import lru_cache
8
  from pathlib import Path
9
+ from types import EllipsisType, UnionType
10
+ from typing import Annotated, TypeAlias, TypeVar, Union, get_args, get_origin
11
+
12
+ import mcp.types
13
+ from mcp.types import Annotations
 
 
 
 
 
 
14
  from pydantic import AnyUrl, BaseModel, ConfigDict, TypeAdapter, UrlConstraints
15
 
16
  T = TypeVar("T")
17
 
18
+ # sentinel values for optional arguments
19
+ NotSet = ...
20
+ NotSetT: TypeAlias = EllipsisType
21
+
22
 
23
  class FastMCPBaseModel(BaseModel):
24
  """Base model for FastMCP models."""
 
127
  self,
128
  mime_type: str | None = None,
129
  annotations: Annotations | None = None,
130
+ ) -> mcp.types.ImageContent:
131
  """Convert to MCP ImageContent."""
132
  if self.path:
133
  with open(self.path, "rb") as f:
 
137
  else:
138
  raise ValueError("No image data available")
139
 
140
+ return mcp.types.ImageContent(
141
  type="image",
142
  data=data,
143
  mimeType=mime_type or self._mime_type,
 
186
  self,
187
  mime_type: str | None = None,
188
  annotations: Annotations | None = None,
189
+ ) -> mcp.types.AudioContent:
190
  if self.path:
191
  with open(self.path, "rb") as f:
192
  data = base64.b64encode(f.read()).decode()
 
195
  else:
196
  raise ValueError("No audio data available")
197
 
198
+ return mcp.types.AudioContent(
199
  type="audio",
200
  data=data,
201
  mimeType=mime_type or self._mime_type,
 
246
  self,
247
  mime_type: str | None = None,
248
  annotations: Annotations | None = None,
249
+ ) -> mcp.types.EmbeddedResource:
250
  if self.path:
251
  with open(self.path, "rb") as f:
252
  raw_data = f.read()
 
269
  text = raw_data.decode("utf-8")
270
  except UnicodeDecodeError:
271
  text = raw_data.decode("latin-1")
272
+ resource = mcp.types.TextResourceContents(
273
  text=text,
274
  mimeType=mime,
275
  uri=uri,
276
  )
277
  else:
278
  data = base64.b64encode(raw_data).decode()
279
+ resource = mcp.types.BlobResourceContents(
280
  blob=data,
281
  mimeType=mime,
282
  uri=uri,
283
  )
284
 
285
+ return mcp.types.EmbeddedResource(
286
  type="resource",
287
  resource=resource,
288
  annotations=annotations or self.annotations,
289
  )
290
+
291
+
292
+ def replace_type(type_, type_map: dict[type, type]):
293
+ """
294
+ Given a (possibly generic, nested, or otherwise complex) type, replaces all
295
+ instances of old_type with new_type.
296
+
297
+ This is useful for transforming types when creating tools.
298
+
299
+ Args:
300
+ type_: The type to replace instances of old_type with new_type.
301
+ old_type: The type to replace.
302
+ new_type: The type to replace old_type with.
303
+
304
+ Examples:
305
+ >>> replace_type(list[int | bool], {int: str})
306
+ list[str | bool]
307
+
308
+ >>> replace_type(list[list[int]], {int: str})
309
+ list[list[str]]
310
+
311
+ """
312
+ if type_ in type_map:
313
+ return type_map[type_]
314
+
315
+ origin = get_origin(type_)
316
+ if not origin:
317
+ return type_
318
+
319
+ args = get_args(type_)
320
+ new_args = tuple(replace_type(arg, type_map) for arg in args)
321
+
322
+ if origin is UnionType:
323
+ return Union[new_args] # type: ignore # noqa: UP007
324
+ else:
325
+ return origin[new_args]
tests/auth/test_oauth_client.py CHANGED
@@ -226,7 +226,9 @@ async def test_call_tool(client_with_headless_oauth: Client):
226
  """Test that we can call a tool."""
227
  async with client_with_headless_oauth:
228
  result = await client_with_headless_oauth.call_tool("add", {"a": 5, "b": 3})
229
- assert result[0].text == "8" # type: ignore[attr-defined]
 
 
230
 
231
 
232
  async def test_list_resources(client_with_headless_oauth: Client):
 
226
  """Test that we can call a tool."""
227
  async with client_with_headless_oauth:
228
  result = await client_with_headless_oauth.call_tool("add", {"a": 5, "b": 3})
229
+ # The add tool returns int which gets wrapped as structured output
230
+ # Client unwraps it and puts the actual int in the data field
231
+ assert result.data == 8
232
 
233
 
234
  async def test_list_resources(client_with_headless_oauth: Client):
tests/client/test_client.py CHANGED
@@ -121,9 +121,10 @@ async def test_call_tool(fastmcp_server):
121
  async with client:
122
  result = await client.call_tool("greet", {"name": "World"})
123
 
124
- # The result content should contain our greeting
125
- content_str = str(result[0])
126
- assert "Hello, World!" in content_str
 
127
 
128
 
129
  async def test_call_tool_mcp(fastmcp_server):
 
121
  async with client:
122
  result = await client.call_tool("greet", {"name": "World"})
123
 
124
+ assert result.content[0].text == "Hello, World!" # type: ignore[attr-defined]
125
+ assert result.structured_content == {"result": "Hello, World!"}
126
+ assert result.data == "Hello, World!"
127
+ assert result.is_error is False
128
 
129
 
130
  async def test_call_tool_mcp(fastmcp_server):
tests/client/test_notifications.py CHANGED
@@ -126,7 +126,7 @@ class TestToolNotifications:
126
 
127
  # Enable the target tool
128
  result = await client.call_tool("enable_target_tool", {})
129
- assert result[0].text == "Target tool enabled" # type: ignore[attr-defined]
130
 
131
  # Check that notification was sent
132
  recording_message_handler.assert_notification_sent(
@@ -147,7 +147,7 @@ class TestToolNotifications:
147
 
148
  # Disable the target tool
149
  result = await client.call_tool("disable_target_tool", {})
150
- assert result[0].text == "Target tool disabled" # type: ignore[attr-defined]
151
 
152
  # Check that notification was sent
153
  recording_message_handler.assert_notification_sent(
@@ -231,7 +231,7 @@ class TestResourceNotifications:
231
 
232
  # Enable the target resource
233
  result = await client.call_tool("enable_target_resource", {})
234
- assert result[0].text == "Target resource enabled" # type: ignore[attr-defined]
235
 
236
  # Check that notification was sent
237
  recording_message_handler.assert_notification_sent(
@@ -252,7 +252,7 @@ class TestResourceNotifications:
252
 
253
  # Disable the target resource
254
  result = await client.call_tool("disable_target_resource", {})
255
- assert result[0].text == "Target resource disabled" # type: ignore[attr-defined]
256
 
257
  # Check that notification was sent
258
  recording_message_handler.assert_notification_sent(
@@ -313,7 +313,7 @@ class TestPromptNotifications:
313
 
314
  # Enable the target prompt
315
  result = await client.call_tool("enable_target_prompt", {})
316
- assert result[0].text == "Target prompt enabled" # type: ignore[attr-defined]
317
 
318
  # Check that notification was sent
319
  recording_message_handler.assert_notification_sent(
@@ -334,7 +334,7 @@ class TestPromptNotifications:
334
 
335
  # Disable the target prompt
336
  result = await client.call_tool("disable_target_prompt", {})
337
- assert result[0].text == "Target prompt disabled" # type: ignore[attr-defined]
338
 
339
  # Check that notification was sent
340
  recording_message_handler.assert_notification_sent(
 
126
 
127
  # Enable the target tool
128
  result = await client.call_tool("enable_target_tool", {})
129
+ assert result.data == "Target tool enabled"
130
 
131
  # Check that notification was sent
132
  recording_message_handler.assert_notification_sent(
 
147
 
148
  # Disable the target tool
149
  result = await client.call_tool("disable_target_tool", {})
150
+ assert result.data == "Target tool disabled"
151
 
152
  # Check that notification was sent
153
  recording_message_handler.assert_notification_sent(
 
231
 
232
  # Enable the target resource
233
  result = await client.call_tool("enable_target_resource", {})
234
+ assert result.data == "Target resource enabled"
235
 
236
  # Check that notification was sent
237
  recording_message_handler.assert_notification_sent(
 
252
 
253
  # Disable the target resource
254
  result = await client.call_tool("disable_target_resource", {})
255
+ assert result.data == "Target resource disabled"
256
 
257
  # Check that notification was sent
258
  recording_message_handler.assert_notification_sent(
 
313
 
314
  # Enable the target prompt
315
  result = await client.call_tool("enable_target_prompt", {})
316
+ assert result.data == "Target prompt enabled"
317
 
318
  # Check that notification was sent
319
  recording_message_handler.assert_notification_sent(
 
334
 
335
  # Disable the target prompt
336
  result = await client.call_tool("disable_target_prompt", {})
337
+ assert result.data == "Target prompt disabled"
338
 
339
  # Check that notification was sent
340
  recording_message_handler.assert_notification_sent(
tests/client/test_openapi.py CHANGED
@@ -118,7 +118,7 @@ class TestClientHeaders:
118
  transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
119
  ) as client:
120
  result = await client.call_tool("post_headers_headers_post")
121
- headers = json.loads(result[0].text) # type: ignore[attr-defined]
122
  assert headers["x-test"] == "test-123"
123
 
124
  async def test_client_headers_shttp_tool(self, shttp_server: str):
@@ -128,7 +128,7 @@ class TestClientHeaders:
128
  )
129
  ) as client:
130
  result = await client.call_tool("post_headers_headers_post")
131
- headers = json.loads(result[0].text) # type: ignore[attr-defined]
132
  assert headers["x-test"] == "test-123"
133
 
134
  async def test_client_overrides_server_headers(self, shttp_server: str):
 
118
  transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
119
  ) as client:
120
  result = await client.call_tool("post_headers_headers_post")
121
+ headers: dict[str, str] = result.data
122
  assert headers["x-test"] == "test-123"
123
 
124
  async def test_client_headers_shttp_tool(self, shttp_server: str):
 
128
  )
129
  ) as client:
130
  result = await client.call_tool("post_headers_headers_post")
131
+ headers: dict[str, str] = result.data
132
  assert headers["x-test"] == "test-123"
133
 
134
  async def test_client_overrides_server_headers(self, shttp_server: str):
tests/client/test_roots.py CHANGED
@@ -1,5 +1,3 @@
1
- import json
2
-
3
  import pytest
4
 
5
  from fastmcp import Client, Context, FastMCP
@@ -40,7 +38,7 @@ class TestClientRoots:
40
  async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
41
  async with Client(fastmcp_server, roots=roots) as client:
42
  result = await client.call_tool("list_roots", {})
43
- assert json.loads(result[0].text) == [ # type: ignore[attr-defined]
44
  "file://x/y/z",
45
  "file://x/y/z",
46
  ]
 
 
 
1
  import pytest
2
 
3
  from fastmcp import Client, Context, FastMCP
 
38
  async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
39
  async with Client(fastmcp_server, roots=roots) as client:
40
  result = await client.call_tool("list_roots", {})
41
+ assert result.data == [
42
  "file://x/y/z",
43
  "file://x/y/z",
44
  ]
tests/client/test_sampling.py CHANGED
@@ -47,8 +47,7 @@ async def test_simple_sampling(fastmcp_server: FastMCP):
47
 
48
  async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
49
  result = await client.call_tool("simple_sample", {"message": "Hello, world!"})
50
- reply = cast(TextContent, result[0])
51
- assert reply.text == "This is the sample message!"
52
 
53
 
54
  async def test_sampling_with_system_prompt(fastmcp_server: FastMCP):
@@ -62,8 +61,7 @@ async def test_sampling_with_system_prompt(fastmcp_server: FastMCP):
62
  result = await client.call_tool(
63
  "sample_with_system_prompt", {"message": "Hello, world!"}
64
  )
65
- reply = cast(TextContent, result[0])
66
- assert reply.text == "You love FastMCP"
67
 
68
 
69
  async def test_sampling_with_messages(fastmcp_server: FastMCP):
@@ -81,5 +79,4 @@ async def test_sampling_with_messages(fastmcp_server: FastMCP):
81
  result = await client.call_tool(
82
  "sample_with_messages", {"message": "Hello, world!"}
83
  )
84
- reply = cast(TextContent, result[0])
85
- assert reply.text == "I need to think."
 
47
 
48
  async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
49
  result = await client.call_tool("simple_sample", {"message": "Hello, world!"})
50
+ assert result.data == "This is the sample message!"
 
51
 
52
 
53
  async def test_sampling_with_system_prompt(fastmcp_server: FastMCP):
 
61
  result = await client.call_tool(
62
  "sample_with_system_prompt", {"message": "Hello, world!"}
63
  )
64
+ assert result.data == "You love FastMCP"
 
65
 
66
 
67
  async def test_sampling_with_messages(fastmcp_server: FastMCP):
 
79
  result = await client.call_tool(
80
  "sample_with_messages", {"message": "Hello, world!"}
81
  )
82
+ assert result.data == "I need to think."
 
tests/client/test_stdio.py CHANGED
@@ -48,11 +48,11 @@ class TestKeepAlive:
48
 
49
  async with client:
50
  result1 = await client.call_tool("pid")
51
- pid1 = int(result1[0].text) # type: ignore[attr-defined]
52
 
53
  async with client:
54
  result2 = await client.call_tool("pid")
55
- pid2 = int(result2[0].text) # type: ignore[attr-defined]
56
 
57
  assert pid1 == pid2
58
 
@@ -66,11 +66,11 @@ class TestKeepAlive:
66
 
67
  async with client:
68
  result1 = await client.call_tool("pid")
69
- pid1 = int(result1[0].text) # type: ignore[attr-defined]
70
 
71
  async with client:
72
  result2 = await client.call_tool("pid")
73
- pid2 = int(result2[0].text) # type: ignore[attr-defined]
74
 
75
  assert pid1 != pid2
76
 
@@ -80,13 +80,13 @@ class TestKeepAlive:
80
 
81
  async with client:
82
  result1 = await client.call_tool("pid")
83
- pid1 = int(result1[0].text) # type: ignore[attr-defined]
84
 
85
  await client.close()
86
 
87
  async with client:
88
  result2 = await client.call_tool("pid")
89
- pid2 = int(result2[0].text) # type: ignore[attr-defined]
90
 
91
  assert pid1 != pid2
92
 
@@ -96,14 +96,14 @@ class TestKeepAlive:
96
 
97
  async with client:
98
  result1 = await client.call_tool("pid")
99
- pid1 = int(result1[0].text) # type: ignore[attr-defined]
100
 
101
  async with client:
102
  result2 = await client.call_tool("pid")
103
- pid2 = int(result2[0].text) # type: ignore[attr-defined]
104
 
105
  result3 = await client.call_tool("pid")
106
- pid3 = int(result3[0].text) # type: ignore[attr-defined]
107
 
108
  assert pid1 == pid2 == pid3
109
 
 
48
 
49
  async with client:
50
  result1 = await client.call_tool("pid")
51
+ pid1: int = result1.data
52
 
53
  async with client:
54
  result2 = await client.call_tool("pid")
55
+ pid2: int = result2.data
56
 
57
  assert pid1 == pid2
58
 
 
66
 
67
  async with client:
68
  result1 = await client.call_tool("pid")
69
+ pid1: int = result1.data
70
 
71
  async with client:
72
  result2 = await client.call_tool("pid")
73
+ pid2: int = result2.data
74
 
75
  assert pid1 != pid2
76
 
 
80
 
81
  async with client:
82
  result1 = await client.call_tool("pid")
83
+ pid1: int = result1.data
84
 
85
  await client.close()
86
 
87
  async with client:
88
  result2 = await client.call_tool("pid")
89
+ pid2: int = result2.data
90
 
91
  assert pid1 != pid2
92
 
 
96
 
97
  async with client:
98
  result1 = await client.call_tool("pid")
99
+ pid1: int = result1.data
100
 
101
  async with client:
102
  result2 = await client.call_tool("pid")
103
+ pid2: int = result2.data
104
 
105
  result3 = await client.call_tool("pid")
106
+ pid3: int = result3.data
107
 
108
  assert pid1 == pid2 == pid3
109
 
tests/client/test_streamable_http.py CHANGED
@@ -7,7 +7,6 @@ from unittest.mock import AsyncMock
7
  import pytest
8
  import uvicorn
9
  from mcp import McpError
10
- from mcp.types import TextContent
11
  from starlette.applications import Starlette
12
  from starlette.routing import Mount
13
 
@@ -166,10 +165,7 @@ async def test_greet_with_progress_tool(streamable_http_server: str):
166
  progress_handler=progress_handler,
167
  ) as client:
168
  result = await client.call_tool("greet_with_progress", {"name": "Alice"})
169
-
170
- assert isinstance(result, list)
171
- assert isinstance(result[0], TextContent)
172
- assert result[0].text == "Hello, Alice!"
173
 
174
  progress_handler.assert_called_once_with(0.5, 1.0, "Greeting in progress")
175
 
 
7
  import pytest
8
  import uvicorn
9
  from mcp import McpError
 
10
  from starlette.applications import Starlette
11
  from starlette.routing import Mount
12
 
 
165
  progress_handler=progress_handler,
166
  ) as client:
167
  result = await client.call_tool("greet_with_progress", {"name": "Alice"})
168
+ assert result.data == "Hello, Alice!"
 
 
 
169
 
170
  progress_handler.assert_called_once_with(0.5, 1.0, "Greeting in progress")
171
 
tests/contrib/test_bulk_tool_caller.py CHANGED
@@ -59,7 +59,10 @@ async def no_return_tool(arg1: str) -> None:
59
  def no_return_tool_result_factory(arg1: str) -> CallToolRequestResult:
60
  """A tool that returns a result based on the input arguments."""
61
  return CallToolRequestResult(
62
- isError=False, content=[], tool="no_return_tool", arguments={"arg1": arg1}
 
 
 
63
  )
64
 
65
 
 
59
  def no_return_tool_result_factory(arg1: str) -> CallToolRequestResult:
60
  """A tool that returns a result based on the input arguments."""
61
  return CallToolRequestResult(
62
+ isError=False,
63
+ content=[],
64
+ tool="no_return_tool",
65
+ arguments={"arg1": arg1},
66
  )
67
 
68
 
tests/contrib/test_component_manager.py ADDED
@@ -0,0 +1,743 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from starlette import status
3
+ from starlette.testclient import TestClient
4
+
5
+ from fastmcp import FastMCP
6
+ from fastmcp.contrib.component_manager import set_up_component_manager
7
+ from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair
8
+
9
+
10
+ class TestComponentManagementRoutes:
11
+ """Test the component management routes for tools, resources, and prompts."""
12
+
13
+ @pytest.fixture
14
+ def mounted_mcp(self):
15
+ """Create a FastMCP server with a mounted sub-server and a tool, resource, and prompt on the sub-server."""
16
+ mounted_mcp = FastMCP("SubServer")
17
+
18
+ @mounted_mcp.tool()
19
+ def mounted_tool() -> str:
20
+ """Test tool for tool management routes."""
21
+ return "mounted_tool_result"
22
+
23
+ @mounted_mcp.resource("data://mounted_resource")
24
+ def mounted_resource() -> str:
25
+ """Test resource for tool management routes."""
26
+ return "mounted_resource_result"
27
+
28
+ # Add a test resource
29
+ @mounted_mcp.resource("data://mounted_resource/{id}")
30
+ def test_template(id: str) -> dict:
31
+ """Test template for tool management routes."""
32
+ return {"id": id, "value": "data"}
33
+
34
+ @mounted_mcp.prompt()
35
+ def mounted_prompt() -> str:
36
+ """Test prompt for tool management routes."""
37
+ return "mounted_prompt_result"
38
+
39
+ return mounted_mcp
40
+
41
+ @pytest.fixture
42
+ def mcp(self, mounted_mcp):
43
+ """Create a FastMCP server with test tools, resources, and prompts."""
44
+ mcp = FastMCP("TestServer")
45
+ mcp.mount(mounted_mcp, prefix="sub")
46
+ set_up_component_manager(server=mcp)
47
+
48
+ # Add a test tool
49
+ @mcp.tool
50
+ def test_tool() -> str:
51
+ """Test tool for tool management routes."""
52
+ return "test_tool_result"
53
+
54
+ # Add a test resource
55
+ @mcp.resource("data://test_resource")
56
+ def test_resource() -> str:
57
+ """Test resource for tool management routes."""
58
+ return "test_resource_result"
59
+
60
+ # Add a test resource
61
+ @mcp.resource("data://test_resource/{id}")
62
+ def test_template(id: str) -> dict:
63
+ """Test template for tool management routes."""
64
+ return {"id": id, "value": "data"}
65
+
66
+ # Add a test prompt
67
+ @mcp.prompt
68
+ def test_prompt() -> str:
69
+ """Test prompt for tool management routes."""
70
+ return "test_prompt_result"
71
+
72
+ return mcp
73
+
74
+ @pytest.fixture
75
+ def client(self, mcp):
76
+ """Create a test client for the FastMCP server."""
77
+ return TestClient(mcp.http_app())
78
+
79
+ async def test_enable_tool_route(self, client, mcp):
80
+ """Test enabling a tool via the HTTP route."""
81
+ # First disable the tool
82
+ tool = await mcp._tool_manager.get_tool("test_tool")
83
+ tool.enabled = False
84
+
85
+ # Enable the tool via the HTTP route
86
+ response = client.post("/tools/test_tool/enable")
87
+
88
+ assert response.status_code == status.HTTP_200_OK
89
+ assert response.json() == {"message": "Enabled tool: test_tool"}
90
+
91
+ # Verify the tool is enabled
92
+ tool = await mcp._tool_manager.get_tool("test_tool")
93
+ assert tool.enabled is True
94
+
95
+ async def test_disable_tool_route(self, client, mcp):
96
+ """Test disabling a tool via the HTTP route."""
97
+ # First ensure the tool is enabled
98
+ tool = await mcp._tool_manager.get_tool("test_tool")
99
+ tool.enabled = True
100
+
101
+ # Disable the tool via the HTTP route
102
+ response = client.post("/tools/test_tool/disable")
103
+
104
+ assert response.status_code == status.HTTP_200_OK
105
+ assert response.json() == {"message": "Disabled tool: test_tool"}
106
+
107
+ # Verify the tool is disabled
108
+ tool = await mcp._tool_manager.get_tool("test_tool")
109
+ assert tool.enabled is False
110
+
111
+ async def test_enable_resource_route(self, client, mcp):
112
+ """Test enabling a resource via the HTTP route."""
113
+ # First disable the resource
114
+ resource = await mcp._resource_manager.get_resource("data://test_resource")
115
+ resource.enabled = False
116
+
117
+ # Enable the resource via the HTTP route
118
+ response = client.post("/resources/data://test_resource/enable")
119
+
120
+ assert response.status_code == status.HTTP_200_OK
121
+ assert response.json() == {"message": "Enabled resource: data://test_resource"}
122
+
123
+ # Verify the resource is enabled
124
+ resource = await mcp._resource_manager.get_resource("data://test_resource")
125
+ assert resource.enabled is True
126
+
127
+ async def test_disable_resource_route(self, client, mcp):
128
+ """Test disabling a resource via the HTTP route."""
129
+ # First ensure the resource is enabled
130
+ resource = await mcp._resource_manager.get_resource("data://test_resource")
131
+ resource.enabled = True
132
+
133
+ # Disable the resource via the HTTP route
134
+ response = client.post("/resources/data://test_resource/disable")
135
+
136
+ assert response.status_code == status.HTTP_200_OK
137
+ assert response.json() == {"message": "Disabled resource: data://test_resource"}
138
+
139
+ # Verify the resource is disabled
140
+ resource = await mcp._resource_manager.get_resource("data://test_resource")
141
+ assert resource.enabled is False
142
+
143
+ async def test_enable_template_route(self, client, mcp):
144
+ """Test enabling a resource on a mounted server via the parent server's HTTP route."""
145
+ key = "data://test_resource/{id}"
146
+ resource = mcp._resource_manager._templates[key]
147
+ resource.enabled = False
148
+ response = client.post("/resources/data://test_resource/{id}/enable")
149
+ assert response.status_code == status.HTTP_200_OK
150
+ assert response.json() == {
151
+ "message": "Enabled resource: data://test_resource/{id}"
152
+ }
153
+ assert resource.enabled is True
154
+
155
+ async def test_disable_template_route(self, client, mcp):
156
+ """Test disabling a resource on a mounted server via the parent server's HTTP route."""
157
+ key = "data://test_resource/{id}"
158
+ resource = mcp._resource_manager._templates[key]
159
+ resource.enabled = True
160
+ response = client.post("/resources/data://test_resource/{id}/disable")
161
+ assert response.status_code == status.HTTP_200_OK
162
+ assert response.json() == {
163
+ "message": "Disabled resource: data://test_resource/{id}"
164
+ }
165
+ assert resource.enabled is False
166
+
167
+ async def test_enable_prompt_route(self, client, mcp):
168
+ """Test enabling a prompt via the HTTP route."""
169
+ # First disable the prompt
170
+ prompt = await mcp._prompt_manager.get_prompt("test_prompt")
171
+ prompt.enabled = False
172
+
173
+ # Enable the prompt via the HTTP route
174
+ response = client.post("/prompts/test_prompt/enable")
175
+
176
+ assert response.status_code == status.HTTP_200_OK
177
+ assert response.json() == {"message": "Enabled prompt: test_prompt"}
178
+
179
+ # Verify the prompt is enabled
180
+ prompt = await mcp._prompt_manager.get_prompt("test_prompt")
181
+ assert prompt.enabled is True
182
+
183
+ async def test_disable_prompt_route(self, client, mcp):
184
+ """Test disabling a prompt via the HTTP route."""
185
+ # First ensure the prompt is enabled
186
+ prompt = await mcp._prompt_manager.get_prompt("test_prompt")
187
+ prompt.enabled = True
188
+
189
+ # Disable the prompt via the HTTP route
190
+ response = client.post("/prompts/test_prompt/disable")
191
+
192
+ assert response.status_code == status.HTTP_200_OK
193
+ assert response.json() == {"message": "Disabled prompt: test_prompt"}
194
+
195
+ # Verify the prompt is disabled
196
+ prompt = await mcp._prompt_manager.get_prompt("test_prompt")
197
+ assert prompt.enabled is False
198
+
199
+ async def test_enable_tool_route_on_mounted_server(self, client, mounted_mcp):
200
+ """Test enabling a tool on a mounted server via the parent server's HTTP route."""
201
+ # Disable the tool on the sub-server
202
+ sub_tool = await mounted_mcp._tool_manager.get_tool("mounted_tool")
203
+ sub_tool.enabled = False
204
+ # Enable via parent
205
+ response = client.post("/tools/sub_mounted_tool/enable")
206
+ assert response.status_code == status.HTTP_200_OK
207
+ assert response.json() == {"message": "Enabled tool: sub_mounted_tool"}
208
+ # Confirm disabled on sub-server
209
+ assert sub_tool.enabled is True
210
+
211
+ async def test_disable_tool_route_on_mounted_server(self, client, mounted_mcp):
212
+ """Test disabling a tool on a mounted server via the parent server's HTTP route."""
213
+ # Enable the tool on the sub-server
214
+ sub_tool = await mounted_mcp._tool_manager.get_tool("mounted_tool")
215
+ sub_tool.enabled = True
216
+ # Disable via parent
217
+ response = client.post("/tools/sub_mounted_tool/disable")
218
+ assert response.status_code == status.HTTP_200_OK
219
+ assert response.json() == {"message": "Disabled tool: sub_mounted_tool"}
220
+ # Confirm disabled on sub-server
221
+ assert sub_tool.enabled is False
222
+
223
+ async def test_enable_resource_route_on_mounted_server(self, client, mounted_mcp):
224
+ """Test enabling a resource on a mounted server via the parent server's HTTP route."""
225
+ resource = await mounted_mcp._resource_manager.get_resource(
226
+ "data://mounted_resource"
227
+ )
228
+ resource.enabled = False
229
+ response = client.post("/resources/data://sub/mounted_resource/enable")
230
+ assert response.status_code == status.HTTP_200_OK
231
+ assert response.json() == {
232
+ "message": "Enabled resource: data://sub/mounted_resource"
233
+ }
234
+ resource = await mounted_mcp._resource_manager.get_resource(
235
+ "data://mounted_resource"
236
+ )
237
+ assert resource.enabled is True
238
+
239
+ async def test_disable_resource_route_on_mounted_server(self, client, mounted_mcp):
240
+ """Test disabling a resource on a mounted server via the parent server's HTTP route."""
241
+ resource = await mounted_mcp._resource_manager.get_resource(
242
+ "data://mounted_resource"
243
+ )
244
+ resource.enabled = True
245
+ response = client.post("/resources/data://sub/mounted_resource/disable")
246
+ assert response.status_code == status.HTTP_200_OK
247
+ assert response.json() == {
248
+ "message": "Disabled resource: data://sub/mounted_resource"
249
+ }
250
+ resource = await mounted_mcp._resource_manager.get_resource(
251
+ "data://mounted_resource"
252
+ )
253
+ assert resource.enabled is False
254
+
255
+ async def test_enable_template_route_on_mounted_server(self, client, mounted_mcp):
256
+ """Test enabling a resource on a mounted server via the parent server's HTTP route."""
257
+ key = "data://mounted_resource/{id}"
258
+ resource = mounted_mcp._resource_manager._templates[key]
259
+ resource.enabled = False
260
+ response = client.post("/resources/data://sub/mounted_resource/{id}/enable")
261
+ assert response.status_code == status.HTTP_200_OK
262
+ assert response.json() == {
263
+ "message": "Enabled resource: data://sub/mounted_resource/{id}"
264
+ }
265
+ assert resource.enabled is True
266
+
267
+ async def test_disable_template_route_on_mounted_server(self, client, mounted_mcp):
268
+ """Test disabling a resource on a mounted server via the parent server's HTTP route."""
269
+ key = "data://mounted_resource/{id}"
270
+ resource = mounted_mcp._resource_manager._templates[key]
271
+ resource.enabled = True
272
+ response = client.post("/resources/data://sub/mounted_resource/{id}/disable")
273
+ assert response.status_code == status.HTTP_200_OK
274
+ assert response.json() == {
275
+ "message": "Disabled resource: data://sub/mounted_resource/{id}"
276
+ }
277
+ assert resource.enabled is False
278
+
279
+ async def test_enable_prompt_route_on_mounted_server(self, client, mounted_mcp):
280
+ """Test enabling a prompt on a mounted server via the parent server's HTTP route."""
281
+ prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt")
282
+ prompt.enabled = False
283
+ response = client.post("/prompts/sub_mounted_prompt/enable")
284
+ assert response.status_code == status.HTTP_200_OK
285
+ assert response.json() == {"message": "Enabled prompt: sub_mounted_prompt"}
286
+ prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt")
287
+ assert prompt.enabled is True
288
+
289
+ async def test_disable_prompt_route_on_mounted_server(self, client, mounted_mcp):
290
+ """Test disabling a prompt on a mounted server via the parent server's HTTP route."""
291
+ prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt")
292
+ prompt.enabled = True
293
+ response = client.post("/prompts/sub_mounted_prompt/disable")
294
+ assert response.status_code == status.HTTP_200_OK
295
+ assert response.json() == {"message": "Disabled prompt: sub_mounted_prompt"}
296
+ prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt")
297
+ assert prompt.enabled is False
298
+
299
+ def test_enable_nonexistent_tool(self, client):
300
+ """Test enabling a non-existent tool returns 404."""
301
+ response = client.post("/tools/nonexistent_tool/enable")
302
+ assert response.status_code == status.HTTP_404_NOT_FOUND
303
+ assert response.text == "Unknown tool: nonexistent_tool"
304
+
305
+ def test_disable_nonexistent_tool(self, client):
306
+ """Test disabling a non-existent tool returns 404."""
307
+ response = client.post("/tools/nonexistent_tool/disable")
308
+ assert response.status_code == status.HTTP_404_NOT_FOUND
309
+ assert response.text == "Unknown tool: nonexistent_tool"
310
+
311
+ def test_enable_nonexistent_resource(self, client):
312
+ """Test enabling a non-existent resource returns 404."""
313
+ response = client.post("/resources/nonexistent://resource/enable")
314
+ assert response.status_code == status.HTTP_404_NOT_FOUND
315
+ assert response.text == "Unknown resource: nonexistent://resource"
316
+
317
+ def test_disable_nonexistent_resource(self, client):
318
+ """Test disabling a non-existent resource returns 404."""
319
+ response = client.post("/resources/nonexistent://resource/disable")
320
+ assert response.status_code == status.HTTP_404_NOT_FOUND
321
+ assert response.text == "Unknown resource: nonexistent://resource"
322
+
323
+ def test_enable_nonexistent_prompt(self, client):
324
+ """Test enabling a non-existent prompt returns 404."""
325
+ response = client.post("/prompts/nonexistent_prompt/enable")
326
+ assert response.status_code == status.HTTP_404_NOT_FOUND
327
+ assert response.text == "Unknown prompt: nonexistent_prompt"
328
+
329
+ def test_disable_nonexistent_prompt(self, client):
330
+ """Test disabling a non-existent prompt returns 404."""
331
+ response = client.post("/prompts/nonexistent_prompt/disable")
332
+ assert response.status_code == status.HTTP_404_NOT_FOUND
333
+ assert response.text == "Unknown prompt: nonexistent_prompt"
334
+
335
+
336
+ class TestAuthComponentManagementRoutes:
337
+ """Test the component management routes with authentication for tools, resources, and prompts."""
338
+
339
+ def setup_method(self):
340
+ """Set up test fixtures."""
341
+ # Generate a key pair and create an auth provider
342
+ key_pair = RSAKeyPair.generate()
343
+ self.auth = BearerAuthProvider(
344
+ public_key=key_pair.public_key,
345
+ issuer="https://dev.example.com",
346
+ audience="my-dev-server",
347
+ )
348
+ self.mcp = FastMCP("TestServerWithAuth", auth=self.auth)
349
+ set_up_component_manager(
350
+ server=self.mcp, required_scopes=["tool:write", "tool:read"]
351
+ )
352
+ self.token = key_pair.create_token(
353
+ subject="dev-user",
354
+ issuer="https://dev.example.com",
355
+ audience="my-dev-server",
356
+ scopes=["tool:write", "tool:read"],
357
+ )
358
+ self.token_without_scopes = key_pair.create_token(
359
+ subject="dev-user",
360
+ issuer="https://dev.example.com",
361
+ audience="my-dev-server",
362
+ scopes=["tool:read"],
363
+ )
364
+
365
+ # Add test components
366
+ @self.mcp.tool
367
+ def test_tool() -> str:
368
+ """Test tool for auth testing."""
369
+ return "test_tool_result"
370
+
371
+ @self.mcp.resource("data://test_resource")
372
+ def test_resource() -> str:
373
+ """Test resource for auth testing."""
374
+ return "test_resource_result"
375
+
376
+ @self.mcp.prompt
377
+ def test_prompt() -> str:
378
+ """Test prompt for auth testing."""
379
+ return "test_prompt_result"
380
+
381
+ # Create test client
382
+ self.client = TestClient(self.mcp.http_app())
383
+
384
+ async def test_unauthorized_enable_tool(self):
385
+ """Test that unauthenticated requests to enable a tool are rejected."""
386
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
387
+ tool.enabled = False
388
+
389
+ response = self.client.post("/tools/test_tool/enable")
390
+ assert response.status_code == 401
391
+ assert tool.enabled is False
392
+
393
+ async def test_authorized_enable_tool(self):
394
+ """Test that authenticated requests to enable a tool are allowed."""
395
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
396
+ tool.enabled = False
397
+
398
+ response = self.client.post(
399
+ "/tools/test_tool/enable", headers={"Authorization": "Bearer " + self.token}
400
+ )
401
+ assert response.status_code == 200
402
+ assert response.json() == {"message": "Enabled tool: test_tool"}
403
+ assert tool.enabled is True
404
+
405
+ async def test_unauthorized_disable_tool(self):
406
+ """Test that unauthenticated requests to disable a tool are rejected."""
407
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
408
+ tool.enabled = True
409
+
410
+ response = self.client.post("/tools/test_tool/disable")
411
+ assert response.status_code == 401
412
+ assert tool.enabled is True
413
+
414
+ async def test_authorized_disable_tool(self):
415
+ """Test that authenticated requests to disable a tool are allowed."""
416
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
417
+ tool.enabled = True
418
+
419
+ response = self.client.post(
420
+ "/tools/test_tool/disable",
421
+ headers={"Authorization": "Bearer " + self.token},
422
+ )
423
+ assert response.status_code == 200
424
+ assert response.json() == {"message": "Disabled tool: test_tool"}
425
+ assert tool.enabled is False
426
+
427
+ async def test_forbidden_enable_tool(self):
428
+ """Test that unauthenticated requests to enable a resource are rejected."""
429
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
430
+ tool.enabled = False
431
+
432
+ response = self.client.post(
433
+ "/tools/test_tool/enable",
434
+ headers={"Authorization": "Bearer " + self.token_without_scopes},
435
+ )
436
+ assert response.status_code == 403
437
+ assert tool.enabled is False
438
+
439
+ async def test_authorized_enable_resource(self):
440
+ """Test that authenticated requests to enable a resource are allowed."""
441
+ resource = await self.mcp._resource_manager.get_resource("data://test_resource")
442
+ resource.enabled = False
443
+
444
+ response = self.client.post(
445
+ "/resources/data://test_resource/enable",
446
+ headers={"Authorization": "Bearer " + self.token},
447
+ )
448
+ assert response.status_code == 200
449
+ assert response.json() == {"message": "Enabled resource: data://test_resource"}
450
+ assert resource.enabled is True
451
+
452
+ async def test_unauthorized_disable_resource(self):
453
+ """Test that unauthenticated requests to disable a resource are rejected."""
454
+ resource = await self.mcp._resource_manager.get_resource("data://test_resource")
455
+ resource.enabled = True
456
+
457
+ response = self.client.post("/resources/data://test_resource/disable")
458
+ assert response.status_code == 401
459
+ assert resource.enabled is True
460
+
461
+ async def test_forbidden_enable_resource(self):
462
+ """Test that unauthenticated requests to enable a resource are rejected."""
463
+ resource = await self.mcp._resource_manager.get_resource("data://test_resource")
464
+ resource.enabled = False
465
+
466
+ response = self.client.post(
467
+ "/resources/data://test_resource/disable",
468
+ headers={"Authorization": "Bearer " + self.token_without_scopes},
469
+ )
470
+ assert response.status_code == 403
471
+ assert resource.enabled is False
472
+
473
+ async def test_authorized_disable_resource(self):
474
+ """Test that authenticated requests to disable a resource are allowed."""
475
+ resource = await self.mcp._resource_manager.get_resource("data://test_resource")
476
+ resource.enabled = True
477
+
478
+ response = self.client.post(
479
+ "/resources/data://test_resource/disable",
480
+ headers={"Authorization": "Bearer " + self.token},
481
+ )
482
+ assert response.status_code == 200
483
+ assert response.json() == {"message": "Disabled resource: data://test_resource"}
484
+ assert resource.enabled is False
485
+
486
+ async def test_unauthorized_enable_prompt(self):
487
+ """Test that unauthenticated requests to enable a prompt are rejected."""
488
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
489
+ prompt.enabled = False
490
+
491
+ response = self.client.post("/prompts/test_prompt/enable")
492
+ assert response.status_code == 401
493
+ assert prompt.enabled is False
494
+
495
+ async def test_authorized_enable_prompt(self):
496
+ """Test that authenticated requests to enable a prompt are allowed."""
497
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
498
+ prompt.enabled = False
499
+
500
+ response = self.client.post(
501
+ "/prompts/test_prompt/enable",
502
+ headers={"Authorization": "Bearer " + self.token},
503
+ )
504
+ assert response.status_code == 200
505
+ assert response.json() == {"message": "Enabled prompt: test_prompt"}
506
+ assert prompt.enabled is True
507
+
508
+ async def test_unauthorized_disable_prompt(self):
509
+ """Test that unauthenticated requests to disable a prompt are rejected."""
510
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
511
+ prompt.enabled = True
512
+
513
+ response = self.client.post("/prompts/test_prompt/disable")
514
+ assert response.status_code == 401
515
+ assert prompt.enabled is True
516
+
517
+ async def test_forbidden_disable_prompt(self):
518
+ """Test that unauthenticated requests to enable a resource are rejected."""
519
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
520
+ prompt.enabled = True
521
+
522
+ response = self.client.post(
523
+ "/prompts/test_prompt/disable",
524
+ headers={"Authorization": "Bearer " + self.token_without_scopes},
525
+ )
526
+ assert response.status_code == 403
527
+ assert prompt.enabled is True
528
+
529
+ async def test_authorized_disable_prompt(self):
530
+ """Test that authenticated requests to disable a prompt are allowed."""
531
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
532
+ prompt.enabled = True
533
+
534
+ response = self.client.post(
535
+ "/prompts/test_prompt/disable",
536
+ headers={"Authorization": "Bearer " + self.token},
537
+ )
538
+ assert response.status_code == 200
539
+ assert response.json() == {"message": "Disabled prompt: test_prompt"}
540
+ assert prompt.enabled is False
541
+
542
+
543
+ class TestComponentManagerWithPath:
544
+ """Test component manager routes when mounted at a custom path."""
545
+
546
+ @pytest.fixture
547
+ def mcp_with_path(self):
548
+ mcp = FastMCP("TestServerWithPath")
549
+ set_up_component_manager(server=mcp, path="/test")
550
+
551
+ @mcp.tool
552
+ def test_tool() -> str:
553
+ return "test_tool_result"
554
+
555
+ @mcp.resource("data://test_resource")
556
+ def test_resource() -> str:
557
+ return "test_resource_result"
558
+
559
+ @mcp.prompt
560
+ def test_prompt() -> str:
561
+ return "test_prompt_result"
562
+
563
+ return mcp
564
+
565
+ @pytest.fixture
566
+ def client_with_path(self, mcp_with_path):
567
+ return TestClient(mcp_with_path.http_app())
568
+
569
+ @pytest.mark.asyncio
570
+ async def test_enable_tool_route_with_path(self, client_with_path, mcp_with_path):
571
+ tool = await mcp_with_path._tool_manager.get_tool("test_tool")
572
+ tool.enabled = False
573
+ response = client_with_path.post("/test/tools/test_tool/enable")
574
+ assert response.status_code == status.HTTP_200_OK
575
+ assert response.json() == {"message": "Enabled tool: test_tool"}
576
+ tool = await mcp_with_path._tool_manager.get_tool("test_tool")
577
+ assert tool.enabled is True
578
+
579
+ @pytest.mark.asyncio
580
+ async def test_disable_resource_route_with_path(
581
+ self, client_with_path, mcp_with_path
582
+ ):
583
+ resource = await mcp_with_path._resource_manager.get_resource(
584
+ "data://test_resource"
585
+ )
586
+ resource.enabled = True
587
+ response = client_with_path.post("/test/resources/data://test_resource/disable")
588
+ assert response.status_code == status.HTTP_200_OK
589
+ assert response.json() == {"message": "Disabled resource: data://test_resource"}
590
+ resource = await mcp_with_path._resource_manager.get_resource(
591
+ "data://test_resource"
592
+ )
593
+ assert resource.enabled is False
594
+
595
+ @pytest.mark.asyncio
596
+ async def test_enable_prompt_route_with_path(self, client_with_path, mcp_with_path):
597
+ prompt = await mcp_with_path._prompt_manager.get_prompt("test_prompt")
598
+ prompt.enabled = False
599
+ response = client_with_path.post("/test/prompts/test_prompt/enable")
600
+ assert response.status_code == status.HTTP_200_OK
601
+ assert response.json() == {"message": "Enabled prompt: test_prompt"}
602
+ prompt = await mcp_with_path._prompt_manager.get_prompt("test_prompt")
603
+ assert prompt.enabled is True
604
+
605
+
606
+ class TestComponentManagerWithPathAuth:
607
+ """Test component manager routes with auth when mounted at a custom path."""
608
+
609
+ def setup_method(self):
610
+ # Generate a key pair and create an auth provider
611
+ key_pair = RSAKeyPair.generate()
612
+ self.auth = BearerAuthProvider(
613
+ public_key=key_pair.public_key,
614
+ issuer="https://dev.example.com",
615
+ audience="my-dev-server",
616
+ required_scopes=["tool:write", "tool:read"],
617
+ )
618
+ self.mcp = FastMCP("TestServerWithPathAuth", auth=self.auth)
619
+ set_up_component_manager(
620
+ server=self.mcp, path="/test", required_scopes=["tool:write", "tool:read"]
621
+ )
622
+ self.token = key_pair.create_token(
623
+ subject="dev-user",
624
+ issuer="https://dev.example.com",
625
+ audience="my-dev-server",
626
+ scopes=["tool:read", "tool:write"],
627
+ )
628
+ self.token_without_scopes = key_pair.create_token(
629
+ subject="dev-user",
630
+ issuer="https://dev.example.com",
631
+ audience="my-dev-server",
632
+ scopes=[],
633
+ )
634
+
635
+ @self.mcp.tool
636
+ def test_tool() -> str:
637
+ return "test_tool_result"
638
+
639
+ @self.mcp.resource("data://test_resource")
640
+ def test_resource() -> str:
641
+ return "test_resource_result"
642
+
643
+ @self.mcp.prompt
644
+ def test_prompt() -> str:
645
+ return "test_prompt_result"
646
+
647
+ self.client = TestClient(self.mcp.http_app())
648
+
649
+ @pytest.mark.asyncio
650
+ async def test_unauthorized_enable_tool(self):
651
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
652
+ tool.enabled = False
653
+ response = self.client.post("/test/tools/test_tool/enable")
654
+ assert response.status_code == 401
655
+ assert tool.enabled is False
656
+
657
+ @pytest.mark.asyncio
658
+ async def test_forbidden_enable_tool(self):
659
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
660
+ tool.enabled = False
661
+ response = self.client.post(
662
+ "/test/tools/test_tool/enable",
663
+ headers={"Authorization": "Bearer " + self.token_without_scopes},
664
+ )
665
+ assert response.status_code == 403
666
+ assert tool.enabled is False
667
+
668
+ @pytest.mark.asyncio
669
+ async def test_authorized_enable_tool(self):
670
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
671
+ tool.enabled = False
672
+ response = self.client.post(
673
+ "/test/tools/test_tool/enable",
674
+ headers={"Authorization": "Bearer " + self.token},
675
+ )
676
+ assert response.status_code == 200
677
+ assert response.json() == {"message": "Enabled tool: test_tool"}
678
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
679
+ assert tool.enabled is True
680
+
681
+ @pytest.mark.asyncio
682
+ async def test_unauthorized_disable_resource(self):
683
+ resource = await self.mcp._resource_manager.get_resource("data://test_resource")
684
+ resource.enabled = True
685
+ response = self.client.post("/test/resources/data://test_resource/disable")
686
+ assert response.status_code == 401
687
+ assert resource.enabled is True
688
+
689
+ @pytest.mark.asyncio
690
+ async def test_forbidden_disable_resource(self):
691
+ resource = await self.mcp._resource_manager.get_resource("data://test_resource")
692
+ resource.enabled = True
693
+ response = self.client.post(
694
+ "/test/resources/data://test_resource/disable",
695
+ headers={"Authorization": "Bearer " + self.token_without_scopes},
696
+ )
697
+ assert response.status_code == 403
698
+ assert resource.enabled is True
699
+
700
+ @pytest.mark.asyncio
701
+ async def test_authorized_disable_resource(self):
702
+ resource = await self.mcp._resource_manager.get_resource("data://test_resource")
703
+ resource.enabled = True
704
+ response = self.client.post(
705
+ "/test/resources/data://test_resource/disable",
706
+ headers={"Authorization": "Bearer " + self.token},
707
+ )
708
+ assert response.status_code == 200
709
+ assert response.json() == {"message": "Disabled resource: data://test_resource"}
710
+ resource = await self.mcp._resource_manager.get_resource("data://test_resource")
711
+ assert resource.enabled is False
712
+
713
+ @pytest.mark.asyncio
714
+ async def test_unauthorized_enable_prompt(self):
715
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
716
+ prompt.enabled = False
717
+ response = self.client.post("/test/prompts/test_prompt/enable")
718
+ assert response.status_code == 401
719
+ assert prompt.enabled is False
720
+
721
+ @pytest.mark.asyncio
722
+ async def test_forbidden_enable_prompt(self):
723
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
724
+ prompt.enabled = False
725
+ response = self.client.post(
726
+ "/test/prompts/test_prompt/enable",
727
+ headers={"Authorization": "Bearer " + self.token_without_scopes},
728
+ )
729
+ assert response.status_code == 403
730
+ assert prompt.enabled is False
731
+
732
+ @pytest.mark.asyncio
733
+ async def test_authorized_enable_prompt(self):
734
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
735
+ prompt.enabled = False
736
+ response = self.client.post(
737
+ "/test/prompts/test_prompt/enable",
738
+ headers={"Authorization": "Bearer " + self.token},
739
+ )
740
+ assert response.status_code == 200
741
+ assert response.json() == {"message": "Enabled prompt: test_prompt"}
742
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
743
+ assert prompt.enabled is True
tests/deprecated/test_mount_import_arg_order.py CHANGED
@@ -36,7 +36,7 @@ class TestDeprecatedMountArgOrder:
36
  # Test functionality
37
  async with Client(main_app) as client:
38
  result = await client.call_tool("sub_sub_tool", {})
39
- assert result[0].text == "Sub tool result" # type: ignore[attr-defined]
40
 
41
  async def test_mount_new_arg_order_no_warning(self):
42
  """Test that mount(server, prefix) works without deprecation warning."""
@@ -122,7 +122,7 @@ class TestDeprecatedImportArgOrder:
122
  # Test functionality
123
  async with Client(main_app) as client:
124
  result = await client.call_tool("sub_sub_tool", {})
125
- assert result[0].text == "Sub tool result" # type: ignore[attr-defined]
126
 
127
  async def test_import_new_arg_order_no_warning(self):
128
  """Test that import_server(server, prefix) works without deprecation warning."""
 
36
  # Test functionality
37
  async with Client(main_app) as client:
38
  result = await client.call_tool("sub_sub_tool", {})
39
+ assert result.data == "Sub tool result"
40
 
41
  async def test_mount_new_arg_order_no_warning(self):
42
  """Test that mount(server, prefix) works without deprecation warning."""
 
122
  # Test functionality
123
  async with Client(main_app) as client:
124
  result = await client.call_tool("sub_sub_tool", {})
125
+ assert result.data == "Sub tool result"
126
 
127
  async def test_import_new_arg_order_no_warning(self):
128
  """Test that import_server(server, prefix) works without deprecation warning."""
tests/server/http/test_http_dependencies.py CHANGED
@@ -86,9 +86,8 @@ async def test_http_headers_tool_shttp(shttp_server: str):
86
  )
87
  ) as client:
88
  result = await client.call_tool("get_headers_tool")
89
- json_result = json.loads(result[0].text) # type: ignore[attr-defined]
90
- assert "x-demo-header" in json_result
91
- assert json_result["x-demo-header"] == "ABC"
92
 
93
 
94
  async def test_http_headers_tool_sse(sse_server: str):
@@ -96,9 +95,8 @@ async def test_http_headers_tool_sse(sse_server: str):
96
  transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
97
  ) as client:
98
  result = await client.call_tool("get_headers_tool")
99
- json_result = json.loads(result[0].text) # type: ignore[attr-defined]
100
- assert "x-demo-header" in json_result
101
- assert json_result["x-demo-header"] == "ABC"
102
 
103
 
104
  async def test_http_headers_prompt_shttp(shttp_server: str):
 
86
  )
87
  ) as client:
88
  result = await client.call_tool("get_headers_tool")
89
+ assert "x-demo-header" in result.data
90
+ assert result.data["x-demo-header"] == "ABC"
 
91
 
92
 
93
  async def test_http_headers_tool_sse(sse_server: str):
 
95
  transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
96
  ) as client:
97
  result = await client.call_tool("get_headers_tool")
98
+ assert "x-demo-header" in result.data
99
+ assert result.data["x-demo-header"] == "ABC"
 
100
 
101
 
102
  async def test_http_headers_prompt_shttp(shttp_server: str):
tests/server/openapi/test_openapi.py CHANGED
@@ -269,9 +269,8 @@ class TestTools:
269
  "create_user_users_post", {"name": "David", "active": False}
270
  )
271
 
272
- response_data = json.loads(tool_response[0].text) # type: ignore[attr-defined]
273
  expected_user = User(id=4, name="David", active=False).model_dump()
274
- assert response_data == expected_user
275
 
276
  # Check that the user was created via API
277
  response = await api_client.get("/users")
@@ -298,9 +297,8 @@ class TestTools:
298
  {"user_id": 1, "name": "XYZ"},
299
  )
300
 
301
- response_data = json.loads(tool_response[0].text) # type: ignore[attr-defined]
302
  expected_data = dict(id=1, name="XYZ", active=True)
303
- assert response_data == expected_data
304
 
305
  # Check that the user was updated via API
306
  response = await api_client.get("/users")
@@ -332,10 +330,12 @@ class TestTools:
332
  )
333
  async with Client(mcp_server) as client:
334
  tool_response = await client.call_tool("get_users_users_get", {})
335
- assert json.loads(tool_response[0].text) == [ # type: ignore[attr-defined]
336
- user.model_dump()
337
- for user in sorted(users_db.values(), key=lambda x: x.id)
338
- ]
 
 
339
 
340
 
341
  class TestResources:
@@ -729,12 +729,22 @@ class TestOpenAPI30Compatibility:
729
  "createProduct", {"name": "New Product", "price": 39.99}
730
  )
731
  # Result should be a text content
732
- assert len(result) == 1
733
- product = json.loads(result[0].text) # type: ignore[attr-defined]
734
  assert product["id"] == "p3"
735
  assert product["name"] == "New Product"
736
  assert product["price"] == 39.99
737
 
 
 
 
 
 
 
 
 
 
 
738
 
739
  class TestOpenAPI31Compatibility:
740
  """Tests for compatibility with OpenAPI 3.1 specifications."""
@@ -905,12 +915,22 @@ class TestOpenAPI31Compatibility:
905
  "createOrder", {"customer": "Charlie", "items": ["item4", "item5"]}
906
  )
907
  # Result should be a text content
908
- assert len(result) == 1
909
- order = json.loads(result[0].text) # type: ignore[attr-dict]
910
  assert order["id"] == "o3"
911
  assert order["customer"] == "Charlie"
912
  assert order["items"] == ["item4", "item5"]
913
 
 
 
 
 
 
 
 
 
 
 
914
 
915
  async def test_empty_query_parameters_not_sent(
916
  fastapi_app: FastAPI, api_client: httpx.AsyncClient
 
269
  "create_user_users_post", {"name": "David", "active": False}
270
  )
271
 
 
272
  expected_user = User(id=4, name="David", active=False).model_dump()
273
+ assert tool_response.data == expected_user
274
 
275
  # Check that the user was created via API
276
  response = await api_client.get("/users")
 
297
  {"user_id": 1, "name": "XYZ"},
298
  )
299
 
 
300
  expected_data = dict(id=1, name="XYZ", active=True)
301
+ assert tool_response.data == expected_data
302
 
303
  # Check that the user was updated via API
304
  response = await api_client.get("/users")
 
330
  )
331
  async with Client(mcp_server) as client:
332
  tool_response = await client.call_tool("get_users_users_get", {})
333
+ assert tool_response.data == {
334
+ "result": [
335
+ user.model_dump()
336
+ for user in sorted(users_db.values(), key=lambda x: x.id)
337
+ ]
338
+ }
339
 
340
 
341
  class TestResources:
 
729
  "createProduct", {"name": "New Product", "price": 39.99}
730
  )
731
  # Result should be a text content
732
+ assert len(result.content) == 1
733
+ product = json.loads(result.content[0].text) # type: ignore[attr-defined]
734
  assert product["id"] == "p3"
735
  assert product["name"] == "New Product"
736
  assert product["price"] == 39.99
737
 
738
+ assert result.structured_content is not None
739
+ assert result.structured_content["id"] == "p3"
740
+ assert result.structured_content["name"] == "New Product"
741
+ assert result.structured_content["price"] == 39.99
742
+
743
+ assert result.data is not None
744
+ assert result.data["id"] == "p3"
745
+ assert result.data["name"] == "New Product"
746
+ assert result.data["price"] == 39.99
747
+
748
 
749
  class TestOpenAPI31Compatibility:
750
  """Tests for compatibility with OpenAPI 3.1 specifications."""
 
915
  "createOrder", {"customer": "Charlie", "items": ["item4", "item5"]}
916
  )
917
  # Result should be a text content
918
+ assert len(result.content) == 1
919
+ order = json.loads(result.content[0].text) # type: ignore[attr-dict]
920
  assert order["id"] == "o3"
921
  assert order["customer"] == "Charlie"
922
  assert order["items"] == ["item4", "item5"]
923
 
924
+ assert result.structured_content is not None
925
+ assert result.structured_content["id"] == "o3"
926
+ assert result.structured_content["customer"] == "Charlie"
927
+ assert result.structured_content["items"] == ["item4", "item5"]
928
+
929
+ assert result.data is not None
930
+ assert result.data["id"] == "o3"
931
+ assert result.data["customer"] == "Charlie"
932
+ assert result.data["items"] == ["item4", "item5"]
933
+
934
 
935
  async def test_empty_query_parameters_not_sent(
936
  fastapi_app: FastAPI, api_client: httpx.AsyncClient
tests/server/openapi/test_openapi_path_parameters.py CHANGED
@@ -301,20 +301,11 @@ async def test_array_query_param_with_fastapi():
301
 
302
  # Single day
303
  result = await client.call_tool(tool_name, {"days": ["monday"]})
304
- # Client returns TextContent objects, so parse the JSON
305
- assert len(result) == 1
306
- assert result[0].type == "text"
307
- import json
308
-
309
- result_data = json.loads(result[0].text)
310
- assert result_data == {"selected": ["monday"]}
311
 
312
  # Multiple days
313
  result = await client.call_tool(tool_name, {"days": ["monday", "tuesday"]})
314
- assert len(result) == 1
315
- assert result[0].type == "text"
316
- result_data = json.loads(result[0].text)
317
- assert result_data == {"selected": ["monday", "tuesday"]}
318
 
319
 
320
  async def test_array_query_parameter_format(mock_client):
 
301
 
302
  # Single day
303
  result = await client.call_tool(tool_name, {"days": ["monday"]})
304
+ assert result.data == {"selected": ["monday"]}
 
 
 
 
 
 
305
 
306
  # Multiple days
307
  result = await client.call_tool(tool_name, {"days": ["monday", "tuesday"]})
308
+ assert result.data == {"selected": ["monday", "tuesday"]}
 
 
 
309
 
310
 
311
  async def test_array_query_parameter_format(mock_client):
tests/server/test_import_server.py CHANGED
@@ -224,7 +224,7 @@ async def test_call_imported_custom_named_tool():
224
 
225
  async with Client(main_app) as client:
226
  result = await client.call_tool("api_get_data", {"query": "test"})
227
- assert result[0].text == "Data for query: test" # type: ignore[attr-defined]
228
 
229
 
230
  async def test_first_level_importing_with_custom_name():
@@ -278,7 +278,7 @@ async def test_call_nested_imported_tool():
278
 
279
  async with Client(main_app) as client:
280
  result = await client.call_tool("service_provider_compute", {"input": 21})
281
- assert result[0].text == "42" # type: ignore[attr-defined]
282
 
283
 
284
  async def test_import_with_proxy_tools():
@@ -302,7 +302,7 @@ async def test_import_with_proxy_tools():
302
 
303
  async with Client(main_app) as client:
304
  result = await client.call_tool("api_get_data", {"query": "test"})
305
- assert result[0].text == "Data for query: test" # type: ignore[attr-defined]
306
 
307
 
308
  async def test_import_with_proxy_prompts():
@@ -443,7 +443,7 @@ async def test_import_with_no_prefix():
443
  async with Client(main_app) as client:
444
  # Test tool
445
  tool_result = await client.call_tool("sub_tool", {})
446
- assert tool_result[0].text == "Sub tool result" # type: ignore[attr-defined]
447
 
448
  # Test resource
449
  resource_result = await client.read_resource("data://config")
@@ -485,7 +485,7 @@ async def test_import_conflict_resolution_tools():
485
  assert tool_names.count("shared_tool") == 1 # Should only appear once
486
 
487
  result = await client.call_tool("shared_tool", {})
488
- assert result[0].text == "Second app tool" # type: ignore[attr-defined]
489
 
490
 
491
  async def test_import_conflict_resolution_resources():
@@ -604,4 +604,4 @@ async def test_import_conflict_resolution_with_prefix():
604
  assert tool_names.count("api_shared_tool") == 1 # Should only appear once
605
 
606
  result = await client.call_tool("api_shared_tool", {})
607
- assert result[0].text == "Second app tool" # type: ignore[attr-defined]
 
224
 
225
  async with Client(main_app) as client:
226
  result = await client.call_tool("api_get_data", {"query": "test"})
227
+ assert result.data == "Data for query: test"
228
 
229
 
230
  async def test_first_level_importing_with_custom_name():
 
278
 
279
  async with Client(main_app) as client:
280
  result = await client.call_tool("service_provider_compute", {"input": 21})
281
+ assert result.data == 42
282
 
283
 
284
  async def test_import_with_proxy_tools():
 
302
 
303
  async with Client(main_app) as client:
304
  result = await client.call_tool("api_get_data", {"query": "test"})
305
+ assert result.data == "Data for query: test"
306
 
307
 
308
  async def test_import_with_proxy_prompts():
 
443
  async with Client(main_app) as client:
444
  # Test tool
445
  tool_result = await client.call_tool("sub_tool", {})
446
+ assert tool_result.data == "Sub tool result"
447
 
448
  # Test resource
449
  resource_result = await client.read_resource("data://config")
 
485
  assert tool_names.count("shared_tool") == 1 # Should only appear once
486
 
487
  result = await client.call_tool("shared_tool", {})
488
+ assert result.data == "Second app tool"
489
 
490
 
491
  async def test_import_conflict_resolution_resources():
 
604
  assert tool_names.count("api_shared_tool") == 1 # Should only appear once
605
 
606
  result = await client.call_tool("api_shared_tool", {})
607
+ assert result.data == "Second app tool"
tests/server/test_mount.py CHANGED
@@ -33,7 +33,7 @@ class TestBasicMount:
33
 
34
  async with Client(main_app) as client:
35
  result = await client.call_tool("sub_sub_tool", {})
36
- assert result[0].text == "This is from the sub app" # type: ignore[attr-defined]
37
 
38
  async def test_mount_with_custom_separator(self):
39
  """Test mounting with a custom tool separator (deprecated but still supported)."""
@@ -52,8 +52,9 @@ class TestBasicMount:
52
  assert "sub_greet" in tools
53
 
54
  # Call the tool
55
- result = await main_app._mcp_call_tool("sub_greet", {"name": "World"})
56
- assert result[0].text == "Hello, World!" # type: ignore[attr-defined]
 
57
 
58
  async def test_mount_invalid_resource_prefix(self):
59
  main_app = FastMCP("MainApp")
@@ -104,8 +105,9 @@ class TestBasicMount:
104
  assert "sub_tool" in tools
105
 
106
  # Call the tool to verify it works
107
- result = await main_app._mcp_call_tool("sub_tool", {})
108
- assert result[0].text == "This is from the sub app" # type: ignore[attr-defined]
 
109
 
110
  async def test_mount_tools_no_prefix(self):
111
  """Test mounting a server with tools without prefix."""
@@ -124,8 +126,9 @@ class TestBasicMount:
124
  assert "sub_tool" in tools
125
 
126
  # Test actual functionality
127
- tool_result = await main_app._mcp_call_tool("sub_tool", {})
128
- assert tool_result[0].text == "Sub tool result" # type: ignore[attr-defined]
 
129
 
130
  async def test_mount_resources_no_prefix(self):
131
  """Test mounting a server with resources without prefix."""
@@ -144,8 +147,9 @@ class TestBasicMount:
144
  assert "data://config" in resources
145
 
146
  # Test actual functionality
147
- resource_result = await main_app._mcp_read_resource("data://config")
148
- assert resource_result[0].content == "Sub resource data" # type: ignore[attr-defined]
 
149
 
150
  async def test_mount_resource_templates_no_prefix(self):
151
  """Test mounting a server with resource templates without prefix."""
@@ -164,8 +168,9 @@ class TestBasicMount:
164
  assert "users://{user_id}/info" in templates
165
 
166
  # Test actual functionality
167
- template_result = await main_app._mcp_read_resource("users://123/info")
168
- assert template_result[0].content == "Sub template for user 123" # type: ignore[attr-defined]
 
169
 
170
  async def test_mount_prompts_no_prefix(self):
171
  """Test mounting a server with prompts without prefix."""
@@ -184,8 +189,9 @@ class TestBasicMount:
184
  assert "sub_prompt" in prompts
185
 
186
  # Test actual functionality
187
- prompt_result = await main_app._mcp_get_prompt("sub_prompt", {})
188
- assert prompt_result.messages is not None
 
189
 
190
 
191
  class TestMultipleServerMount:
@@ -215,11 +221,11 @@ class TestMultipleServerMount:
215
  assert "news_get_headlines" in tools
216
 
217
  # Call tools from both mounted servers
218
- result1 = await main_app._mcp_call_tool("weather_get_forecast", {})
219
- assert result1[0].text == "Weather forecast" # type: ignore[attr-defined]
220
-
221
- result2 = await main_app._mcp_call_tool("news_get_headlines", {})
222
- assert result2[0].text == "News headlines" # type: ignore[attr-defined]
223
 
224
  async def test_mount_same_prefix(self):
225
  """Test that mounting with the same prefix replaces the previous mount."""
@@ -292,7 +298,7 @@ class TestMultipleServerMount:
292
 
293
  # Test calling a tool
294
  result = await client.call_tool("working_working_tool", {})
295
- assert result[0].text == "Working tool" # type: ignore[attr-defined]
296
 
297
  # Test resources
298
  resources = await client.list_resources()
@@ -352,7 +358,7 @@ class TestPrefixConflictResolution:
352
 
353
  # Test that calling the tool uses the later server's implementation
354
  result = await client.call_tool("shared_tool", {})
355
- assert result[0].text == "Second app tool" # type: ignore[attr-defined]
356
 
357
  async def test_later_server_wins_tools_same_prefix(self):
358
  """Test that later mounted server wins for tools when same prefix is used."""
@@ -381,7 +387,7 @@ class TestPrefixConflictResolution:
381
 
382
  # Test that calling the tool uses the later server's implementation
383
  result = await client.call_tool("api_shared_tool", {})
384
- assert result[0].text == "Second app tool" # type: ignore[attr-defined]
385
 
386
  async def test_later_server_wins_resources_no_prefix(self):
387
  """Test that later mounted server wins for resources when no prefix is used."""
@@ -593,8 +599,9 @@ class TestDynamicChanges:
593
  assert "sub_dynamic_tool" in tools
594
 
595
  # Call the dynamically added tool
596
- result = await main_app._mcp_call_tool("sub_dynamic_tool", {})
597
- assert result[0].text == "Added after mounting" # type: ignore[attr-defined]
 
598
 
599
  async def test_removing_tool_after_mounting(self):
600
  """Test that tools removed from mounted servers are no longer accessible."""
@@ -726,8 +733,9 @@ class TestPrompts:
726
  assert "assistant_greeting" in prompts
727
 
728
  # Render the prompt
729
- result = await main_app._mcp_get_prompt("assistant_greeting", {"name": "World"})
730
- assert result.messages is not None
 
731
  # The message should contain our greeting text
732
 
733
  async def test_adding_prompt_after_mounting(self):
@@ -748,8 +756,9 @@ class TestPrompts:
748
  assert "assistant_farewell" in prompts
749
 
750
  # Render the prompt
751
- result = await main_app._mcp_get_prompt("assistant_farewell", {"name": "World"})
752
- assert result.messages is not None
 
753
  # The message should contain our farewell text
754
 
755
 
@@ -779,8 +788,9 @@ class TestProxyServer:
779
  assert "proxy_get_data" in tools
780
 
781
  # Call the tool
782
- result = await main_app._mcp_call_tool("proxy_get_data", {"query": "test"})
783
- assert result[0].text == "Data for test" # type: ignore[attr-defined]
 
784
 
785
  async def test_dynamically_adding_to_proxied_server(self):
786
  """Test that changes to the original server are reflected in the mounted proxy."""
@@ -806,8 +816,9 @@ class TestProxyServer:
806
  assert "proxy_dynamic_data" in tools
807
 
808
  # Call the tool
809
- result = await main_app._mcp_call_tool("proxy_dynamic_data", {})
810
- assert result[0].text == "Dynamic data" # type: ignore[attr-defined]
 
811
 
812
  async def test_proxy_server_with_resources(self):
813
  """Test mounting a proxy server with resources."""
@@ -828,9 +839,10 @@ class TestProxyServer:
828
  main_app.mount(proxy_server, "proxy")
829
 
830
  # Resource should be accessible through main app
831
- result = await main_app._mcp_read_resource("config://proxy/settings")
832
- config = json.loads(result[0].content) # type: ignore[attr-defined]
833
- assert config["api_key"] == "12345"
 
834
 
835
  async def test_proxy_server_with_prompts(self):
836
  """Test mounting a proxy server with prompts."""
@@ -851,8 +863,9 @@ class TestProxyServer:
851
  main_app.mount(proxy_server, "proxy")
852
 
853
  # Prompt should be accessible through main app
854
- result = await main_app._mcp_get_prompt("proxy_welcome", {"name": "World"})
855
- assert result.messages is not None
 
856
  # The message should contain our welcome text
857
 
858
 
 
33
 
34
  async with Client(main_app) as client:
35
  result = await client.call_tool("sub_sub_tool", {})
36
+ assert result.data == "This is from the sub app"
37
 
38
  async def test_mount_with_custom_separator(self):
39
  """Test mounting with a custom tool separator (deprecated but still supported)."""
 
52
  assert "sub_greet" in tools
53
 
54
  # Call the tool
55
+ async with Client(main_app) as client:
56
+ result = await client.call_tool("sub_greet", {"name": "World"})
57
+ assert result.data == "Hello, World!"
58
 
59
  async def test_mount_invalid_resource_prefix(self):
60
  main_app = FastMCP("MainApp")
 
105
  assert "sub_tool" in tools
106
 
107
  # Call the tool to verify it works
108
+ async with Client(main_app) as client:
109
+ result = await client.call_tool("sub_tool", {})
110
+ assert result.data == "This is from the sub app"
111
 
112
  async def test_mount_tools_no_prefix(self):
113
  """Test mounting a server with tools without prefix."""
 
126
  assert "sub_tool" in tools
127
 
128
  # Test actual functionality
129
+ async with Client(main_app) as client:
130
+ tool_result = await client.call_tool("sub_tool", {})
131
+ assert tool_result.data == "Sub tool result"
132
 
133
  async def test_mount_resources_no_prefix(self):
134
  """Test mounting a server with resources without prefix."""
 
147
  assert "data://config" in resources
148
 
149
  # Test actual functionality
150
+ async with Client(main_app) as client:
151
+ resource_result = await client.read_resource("data://config")
152
+ assert resource_result[0].text == "Sub resource data" # type: ignore[attr-defined]
153
 
154
  async def test_mount_resource_templates_no_prefix(self):
155
  """Test mounting a server with resource templates without prefix."""
 
168
  assert "users://{user_id}/info" in templates
169
 
170
  # Test actual functionality
171
+ async with Client(main_app) as client:
172
+ template_result = await client.read_resource("users://123/info")
173
+ assert template_result[0].text == "Sub template for user 123" # type: ignore[attr-defined]
174
 
175
  async def test_mount_prompts_no_prefix(self):
176
  """Test mounting a server with prompts without prefix."""
 
189
  assert "sub_prompt" in prompts
190
 
191
  # Test actual functionality
192
+ async with Client(main_app) as client:
193
+ prompt_result = await client.get_prompt("sub_prompt", {})
194
+ assert prompt_result.messages is not None
195
 
196
 
197
  class TestMultipleServerMount:
 
221
  assert "news_get_headlines" in tools
222
 
223
  # Call tools from both mounted servers
224
+ async with Client(main_app) as client:
225
+ result1 = await client.call_tool("weather_get_forecast", {})
226
+ assert result1.data == "Weather forecast"
227
+ result2 = await client.call_tool("news_get_headlines", {})
228
+ assert result2.data == "News headlines"
229
 
230
  async def test_mount_same_prefix(self):
231
  """Test that mounting with the same prefix replaces the previous mount."""
 
298
 
299
  # Test calling a tool
300
  result = await client.call_tool("working_working_tool", {})
301
+ assert result.data == "Working tool"
302
 
303
  # Test resources
304
  resources = await client.list_resources()
 
358
 
359
  # Test that calling the tool uses the later server's implementation
360
  result = await client.call_tool("shared_tool", {})
361
+ assert result.data == "Second app tool"
362
 
363
  async def test_later_server_wins_tools_same_prefix(self):
364
  """Test that later mounted server wins for tools when same prefix is used."""
 
387
 
388
  # Test that calling the tool uses the later server's implementation
389
  result = await client.call_tool("api_shared_tool", {})
390
+ assert result.data == "Second app tool"
391
 
392
  async def test_later_server_wins_resources_no_prefix(self):
393
  """Test that later mounted server wins for resources when no prefix is used."""
 
599
  assert "sub_dynamic_tool" in tools
600
 
601
  # Call the dynamically added tool
602
+ async with Client(main_app) as client:
603
+ result = await client.call_tool("sub_dynamic_tool", {})
604
+ assert result.data == "Added after mounting"
605
 
606
  async def test_removing_tool_after_mounting(self):
607
  """Test that tools removed from mounted servers are no longer accessible."""
 
733
  assert "assistant_greeting" in prompts
734
 
735
  # Render the prompt
736
+ async with Client(main_app) as client:
737
+ result = await client.get_prompt("assistant_greeting", {"name": "World"})
738
+ assert result.messages is not None
739
  # The message should contain our greeting text
740
 
741
  async def test_adding_prompt_after_mounting(self):
 
756
  assert "assistant_farewell" in prompts
757
 
758
  # Render the prompt
759
+ async with Client(main_app) as client:
760
+ result = await client.get_prompt("assistant_farewell", {"name": "World"})
761
+ assert result.messages is not None
762
  # The message should contain our farewell text
763
 
764
 
 
788
  assert "proxy_get_data" in tools
789
 
790
  # Call the tool
791
+ async with Client(main_app) as client:
792
+ result = await client.call_tool("proxy_get_data", {"query": "test"})
793
+ assert result.data == "Data for test"
794
 
795
  async def test_dynamically_adding_to_proxied_server(self):
796
  """Test that changes to the original server are reflected in the mounted proxy."""
 
816
  assert "proxy_dynamic_data" in tools
817
 
818
  # Call the tool
819
+ async with Client(main_app) as client:
820
+ result = await client.call_tool("proxy_dynamic_data", {})
821
+ assert result.data == "Dynamic data"
822
 
823
  async def test_proxy_server_with_resources(self):
824
  """Test mounting a proxy server with resources."""
 
839
  main_app.mount(proxy_server, "proxy")
840
 
841
  # Resource should be accessible through main app
842
+ async with Client(main_app) as client:
843
+ result = await client.read_resource("config://proxy/settings")
844
+ config = json.loads(result[0].text) # type: ignore[attr-defined]
845
+ assert config["api_key"] == "12345"
846
 
847
  async def test_proxy_server_with_prompts(self):
848
  """Test mounting a proxy server with prompts."""
 
863
  main_app.mount(proxy_server, "proxy")
864
 
865
  # Prompt should be accessible through main app
866
+ async with Client(main_app) as client:
867
+ result = await client.get_prompt("proxy_welcome", {"name": "World"})
868
+ assert result.messages is not None
869
  # The message should contain our welcome text
870
 
871
 
tests/server/test_proxy.py CHANGED
@@ -89,15 +89,17 @@ async def test_create_proxy(fastmcp_server):
89
  async def test_as_proxy_with_server(fastmcp_server):
90
  """FastMCP.as_proxy should accept a FastMCP instance."""
91
  proxy = FastMCP.as_proxy(fastmcp_server)
92
- result = await proxy._mcp_call_tool("greet", {"name": "Test"})
93
- assert result[0].text == "Hello, Test!" # type: ignore[attr-defined]
 
94
 
95
 
96
  async def test_as_proxy_with_transport(fastmcp_server):
97
  """FastMCP.as_proxy should accept a ClientTransport."""
98
  proxy = FastMCP.as_proxy(FastMCPTransport(fastmcp_server))
99
- result = await proxy._mcp_call_tool("greet", {"name": "Test"})
100
- assert result[0].text == "Hello, Test!" # type: ignore[attr-defined]
 
101
 
102
 
103
  def test_as_proxy_with_url():
@@ -137,7 +139,7 @@ class TestTools:
137
  async def test_call_tool_calls_tool(self, proxy_server):
138
  async with Client(proxy_server) as client:
139
  proxy_result = await client.call_tool("add", {"a": 1, "b": 2})
140
- assert proxy_result[0].text == "3" # type: ignore[attr-defined]
141
 
142
  async def test_error_tool_raises_error(self, proxy_server):
143
  with pytest.raises(ToolError, match="This is a test error"):
@@ -155,7 +157,7 @@ class TestTools:
155
 
156
  async with Client(proxy_server) as client:
157
  result = await client.call_tool("greet", {"name": "Marvin", "extra": "abc"})
158
- assert result[0].text == "Overwritten, Marvin! abc" # type: ignore[attr-defined]
159
 
160
  async def test_proxy_errors_if_overwritten_tool_is_disabled(self, proxy_server):
161
  """
 
89
  async def test_as_proxy_with_server(fastmcp_server):
90
  """FastMCP.as_proxy should accept a FastMCP instance."""
91
  proxy = FastMCP.as_proxy(fastmcp_server)
92
+ async with Client(proxy) as client:
93
+ result = await client.call_tool("greet", {"name": "Test"})
94
+ assert result.data == "Hello, Test!"
95
 
96
 
97
  async def test_as_proxy_with_transport(fastmcp_server):
98
  """FastMCP.as_proxy should accept a ClientTransport."""
99
  proxy = FastMCP.as_proxy(FastMCPTransport(fastmcp_server))
100
+ async with Client(proxy) as client:
101
+ result = await client.call_tool("greet", {"name": "Test"})
102
+ assert result.data == "Hello, Test!"
103
 
104
 
105
  def test_as_proxy_with_url():
 
139
  async def test_call_tool_calls_tool(self, proxy_server):
140
  async with Client(proxy_server) as client:
141
  proxy_result = await client.call_tool("add", {"a": 1, "b": 2})
142
+ assert proxy_result.data == 3
143
 
144
  async def test_error_tool_raises_error(self, proxy_server):
145
  with pytest.raises(ToolError, match="This is a test error"):
 
157
 
158
  async with Client(proxy_server) as client:
159
  result = await client.call_tool("greet", {"name": "Marvin", "extra": "abc"})
160
+ assert result.data == "Overwritten, Marvin! abc"
161
 
162
  async def test_proxy_errors_if_overwritten_tool_is_disabled(self, proxy_server):
163
  """
tests/server/test_server.py CHANGED
@@ -45,9 +45,7 @@ class TestCreateServer:
45
  assert "🎉" in tool.description
46
 
47
  result = await client.call_tool("hello_world", {})
48
- assert len(result) == 1
49
- content = result[0]
50
- assert content.text == "¡Hola, 世界! 👋" # type: ignore[attr-defined]
51
 
52
 
53
  class TestTools:
@@ -129,8 +127,9 @@ class TestToolDecorator:
129
  def add(x: int, y: int) -> int:
130
  return x + y
131
 
132
- result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
133
- assert result[0].text == "3" # type: ignore[attr-defined]
 
134
 
135
  async def test_tool_decorator_without_parentheses(self):
136
  """Test that @tool decorator works without parentheses."""
@@ -146,8 +145,9 @@ class TestToolDecorator:
146
  assert "add" in tools
147
 
148
  # Verify it can be called
149
- result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
150
- assert result[0].text == "3" # type: ignore[attr-defined]
 
151
 
152
  async def test_tool_decorator_with_name(self):
153
  mcp = FastMCP()
@@ -156,8 +156,9 @@ class TestToolDecorator:
156
  def add(x: int, y: int) -> int:
157
  return x + y
158
 
159
- result = await mcp._mcp_call_tool("custom-add", {"x": 1, "y": 2})
160
- assert result[0].text == "3" # type: ignore[attr-defined]
 
161
 
162
  async def test_tool_decorator_with_description(self):
163
  mcp = FastMCP()
@@ -183,8 +184,9 @@ class TestToolDecorator:
183
 
184
  obj = MyClass(10)
185
  mcp.add_tool(Tool.from_function(obj.add))
186
- result = await mcp._mcp_call_tool("add", {"y": 2})
187
- assert result[0].text == "12" # type: ignore[attr-defined]
 
188
 
189
  async def test_tool_decorator_classmethod(self):
190
  mcp = FastMCP()
@@ -197,8 +199,9 @@ class TestToolDecorator:
197
  return cls.x + y
198
 
199
  mcp.add_tool(Tool.from_function(MyClass.add))
200
- result = await mcp._mcp_call_tool("add", {"y": 2})
201
- assert result[0].text == "12" # type: ignore[attr-defined]
 
202
 
203
  async def test_tool_decorator_staticmethod(self):
204
  mcp = FastMCP()
@@ -209,8 +212,9 @@ class TestToolDecorator:
209
  def add(x: int, y: int) -> int:
210
  return x + y
211
 
212
- result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
213
- assert result[0].text == "3" # type: ignore[attr-defined]
 
214
 
215
  async def test_tool_decorator_async_function(self):
216
  mcp = FastMCP()
@@ -219,8 +223,9 @@ class TestToolDecorator:
219
  async def add(x: int, y: int) -> int:
220
  return x + y
221
 
222
- result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
223
- assert result[0].text == "3" # type: ignore[attr-defined]
 
224
 
225
  async def test_tool_decorator_classmethod_error(self):
226
  mcp = FastMCP()
@@ -244,8 +249,9 @@ class TestToolDecorator:
244
  return cls.x + y
245
 
246
  mcp.add_tool(Tool.from_function(MyClass.add))
247
- result = await mcp._mcp_call_tool("add", {"y": 2})
248
- assert result[0].text == "12" # type: ignore[attr-defined]
 
249
 
250
  async def test_tool_decorator_staticmethod_async_function(self):
251
  mcp = FastMCP()
@@ -256,8 +262,9 @@ class TestToolDecorator:
256
  return x + y
257
 
258
  mcp.add_tool(Tool.from_function(MyClass.add))
259
- result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
260
- assert result[0].text == "3" # type: ignore[attr-defined]
 
261
 
262
  async def test_tool_decorator_staticmethod_order(self):
263
  """Test that the recommended decorator order works for static methods"""
@@ -270,8 +277,9 @@ class TestToolDecorator:
270
  return x + y
271
 
272
  # Test that the recommended order works
273
- result = await mcp._mcp_call_tool("add_v1", {"x": 1, "y": 2})
274
- assert result[0].text == "3" # type: ignore[attr-defined]
 
275
 
276
  async def test_tool_decorator_with_tags(self):
277
  """Test that the tool decorator properly sets tags."""
@@ -301,8 +309,9 @@ class TestToolDecorator:
301
  assert "custom_multiply" in tools
302
 
303
  # Call the tool by its custom name
304
- result = await mcp._mcp_call_tool("custom_multiply", {"a": 5, "b": 3})
305
- assert result[0].text == "15" # type: ignore[attr-defined]
 
306
 
307
  # Original name should not be registered
308
  assert "multiply" not in tools
@@ -356,8 +365,9 @@ class TestToolDecorator:
356
  assert tools["direct_call_tool"] is result_fn
357
 
358
  # Verify it can be called
359
- result = await mcp._mcp_call_tool("direct_call_tool", {"x": 5, "y": 3})
360
- assert result[0].text == "8" # type: ignore[attr-defined]
 
361
 
362
  async def test_tool_decorator_with_string_name(self):
363
  """Test that @tool("custom_name") syntax works correctly."""
@@ -374,8 +384,9 @@ class TestToolDecorator:
374
  assert "my_function" not in tools # Original name should not be registered
375
 
376
  # Verify it can be called
377
- result = await mcp._mcp_call_tool("string_named_tool", {"x": 42})
378
- assert result[0].text == "Result: 42" # type: ignore[attr-defined]
 
379
 
380
  async def test_tool_decorator_conflicting_names_error(self):
381
  """Test that providing both positional and keyword name raises an error."""
@@ -390,6 +401,17 @@ class TestToolDecorator:
390
  def my_function(x: int) -> str:
391
  return f"Result: {x}"
392
 
 
 
 
 
 
 
 
 
 
 
 
393
 
394
  class TestResourceDecorator:
395
  async def test_no_resources_before_decorator(self):
 
45
  assert "🎉" in tool.description
46
 
47
  result = await client.call_tool("hello_world", {})
48
+ assert result.data == "¡Hola, 世界! 👋"
 
 
49
 
50
 
51
  class TestTools:
 
127
  def add(x: int, y: int) -> int:
128
  return x + y
129
 
130
+ async with Client(mcp) as client:
131
+ result = await client.call_tool("add", {"x": 1, "y": 2})
132
+ assert result.data == 3
133
 
134
  async def test_tool_decorator_without_parentheses(self):
135
  """Test that @tool decorator works without parentheses."""
 
145
  assert "add" in tools
146
 
147
  # Verify it can be called
148
+ async with Client(mcp) as client:
149
+ result = await client.call_tool("add", {"x": 1, "y": 2})
150
+ assert result.data == 3
151
 
152
  async def test_tool_decorator_with_name(self):
153
  mcp = FastMCP()
 
156
  def add(x: int, y: int) -> int:
157
  return x + y
158
 
159
+ async with Client(mcp) as client:
160
+ result = await client.call_tool("custom-add", {"x": 1, "y": 2})
161
+ assert result.data == 3
162
 
163
  async def test_tool_decorator_with_description(self):
164
  mcp = FastMCP()
 
184
 
185
  obj = MyClass(10)
186
  mcp.add_tool(Tool.from_function(obj.add))
187
+ async with Client(mcp) as client:
188
+ result = await client.call_tool("add", {"y": 2})
189
+ assert result.data == 12
190
 
191
  async def test_tool_decorator_classmethod(self):
192
  mcp = FastMCP()
 
199
  return cls.x + y
200
 
201
  mcp.add_tool(Tool.from_function(MyClass.add))
202
+ async with Client(mcp) as client:
203
+ result = await client.call_tool("add", {"y": 2})
204
+ assert result.data == 12
205
 
206
  async def test_tool_decorator_staticmethod(self):
207
  mcp = FastMCP()
 
212
  def add(x: int, y: int) -> int:
213
  return x + y
214
 
215
+ async with Client(mcp) as client:
216
+ result = await client.call_tool("add", {"x": 1, "y": 2})
217
+ assert result.data == 3
218
 
219
  async def test_tool_decorator_async_function(self):
220
  mcp = FastMCP()
 
223
  async def add(x: int, y: int) -> int:
224
  return x + y
225
 
226
+ async with Client(mcp) as client:
227
+ result = await client.call_tool("add", {"x": 1, "y": 2})
228
+ assert result.data == 3
229
 
230
  async def test_tool_decorator_classmethod_error(self):
231
  mcp = FastMCP()
 
249
  return cls.x + y
250
 
251
  mcp.add_tool(Tool.from_function(MyClass.add))
252
+ async with Client(mcp) as client:
253
+ result = await client.call_tool("add", {"y": 2})
254
+ assert result.data == 12
255
 
256
  async def test_tool_decorator_staticmethod_async_function(self):
257
  mcp = FastMCP()
 
262
  return x + y
263
 
264
  mcp.add_tool(Tool.from_function(MyClass.add))
265
+ async with Client(mcp) as client:
266
+ result = await client.call_tool("add", {"x": 1, "y": 2})
267
+ assert result.data == 3
268
 
269
  async def test_tool_decorator_staticmethod_order(self):
270
  """Test that the recommended decorator order works for static methods"""
 
277
  return x + y
278
 
279
  # Test that the recommended order works
280
+ async with Client(mcp) as client:
281
+ result = await client.call_tool("add_v1", {"x": 1, "y": 2})
282
+ assert result.data == 3
283
 
284
  async def test_tool_decorator_with_tags(self):
285
  """Test that the tool decorator properly sets tags."""
 
309
  assert "custom_multiply" in tools
310
 
311
  # Call the tool by its custom name
312
+ async with Client(mcp) as client:
313
+ result = await client.call_tool("custom_multiply", {"a": 5, "b": 3})
314
+ assert result.data == 15
315
 
316
  # Original name should not be registered
317
  assert "multiply" not in tools
 
365
  assert tools["direct_call_tool"] is result_fn
366
 
367
  # Verify it can be called
368
+ async with Client(mcp) as client:
369
+ result = await client.call_tool("direct_call_tool", {"x": 5, "y": 3})
370
+ assert result.data == 8
371
 
372
  async def test_tool_decorator_with_string_name(self):
373
  """Test that @tool("custom_name") syntax works correctly."""
 
384
  assert "my_function" not in tools # Original name should not be registered
385
 
386
  # Verify it can be called
387
+ async with Client(mcp) as client:
388
+ result = await client.call_tool("string_named_tool", {"x": 42})
389
+ assert result.data == "Result: 42"
390
 
391
  async def test_tool_decorator_conflicting_names_error(self):
392
  """Test that providing both positional and keyword name raises an error."""
 
401
  def my_function(x: int) -> str:
402
  return f"Result: {x}"
403
 
404
+ async def test_tool_decorator_with_output_schema(self):
405
+ mcp = FastMCP()
406
+
407
+ with pytest.raises(
408
+ ValueError, match='Output schemas must have "type" set to "object"'
409
+ ):
410
+
411
+ @mcp.tool(output_schema={"type": "integer"})
412
+ def my_function(x: int) -> str:
413
+ return f"Result: {x}"
414
+
415
 
416
  class TestResourceDecorator:
417
  async def test_no_resources_before_decorator(self):
tests/server/test_server_interactions.py CHANGED
@@ -2,11 +2,11 @@ import base64
2
  import datetime
3
  import json
4
  import uuid
 
5
  from enum import Enum
6
  from pathlib import Path
7
- from typing import Annotated, Literal
8
 
9
- import pydantic_core
10
  import pytest
11
  from mcp import McpError
12
  from mcp.types import (
@@ -17,7 +17,8 @@ from mcp.types import (
17
  TextContent,
18
  TextResourceContents,
19
  )
20
- from pydantic import AnyUrl, Field
 
21
 
22
  from fastmcp import Client, Context, FastMCP
23
  from fastmcp.client.transports import FastMCPTransport
@@ -25,10 +26,26 @@ from fastmcp.exceptions import ToolError
25
  from fastmcp.prompts.prompt import Prompt, PromptMessage
26
  from fastmcp.resources import FileResource, ResourceTemplate
27
  from fastmcp.resources.resource import FunctionResource
28
- from fastmcp.tools.tool import Tool
29
  from fastmcp.utilities.types import Audio, File, Image
30
 
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  @pytest.fixture
33
  def tool_server():
34
  mcp = FastMCP()
@@ -72,7 +89,7 @@ def tool_server():
72
  ),
73
  ]
74
 
75
- @mcp.tool
76
  def mixed_list_fn(image_path: str) -> list:
77
  return [
78
  "text message",
@@ -81,7 +98,7 @@ def tool_server():
81
  TextContent(type="text", text="direct content"),
82
  ]
83
 
84
- @mcp.tool
85
  def mixed_audio_list_fn(audio_path: str) -> list:
86
  return [
87
  "text message",
@@ -90,7 +107,7 @@ def tool_server():
90
  TextContent(type="text", text="direct content"),
91
  ]
92
 
93
- @mcp.tool
94
  def mixed_file_list_fn(file_path: str) -> list:
95
  return [
96
  "text message",
@@ -117,26 +134,24 @@ class TestTools:
117
  async with Client(tool_server) as client:
118
  assert len(await client.list_tools()) == 11
119
 
120
- async def test_call_tool(self, tool_server: FastMCP):
121
  async with Client(tool_server) as client:
122
- result = await client.call_tool("add", {"x": 1, "y": 2})
123
- assert result[0].text == "3" # type: ignore[attr-defined]
 
124
 
125
- async def test_call_tool_as_client(self, tool_server: FastMCP):
126
  async with Client(tool_server) as client:
127
  result = await client.call_tool("add", {"x": 1, "y": 2})
128
- assert result[0].text == "3" # type: ignore[attr-defined]
 
 
129
 
130
  async def test_call_tool_error(self, tool_server: FastMCP):
131
  async with Client(tool_server) as client:
132
  with pytest.raises(Exception):
133
  await client.call_tool("error_tool", {})
134
 
135
- async def test_call_tool_error_as_client(self, tool_server: FastMCP):
136
- async with Client(tool_server) as client:
137
- with pytest.raises(Exception):
138
- await client.call_tool("error_tool", {})
139
-
140
  async def test_call_tool_error_as_client_raw(self):
141
  """Test raising and catching errors from a tool."""
142
  mcp = FastMCP()
@@ -154,13 +169,14 @@ class TestTools:
154
  async def test_tool_returns_list(self, tool_server: FastMCP):
155
  async with Client(tool_server) as client:
156
  result = await client.call_tool("list_tool", {})
157
- assert result[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined]
 
158
 
159
  async def test_file_text_tool(self, tool_server: FastMCP):
160
  async with Client(tool_server) as client:
161
  result = await client.call_tool("file_text_tool", {})
162
- assert len(result) == 1
163
- embedded = result[0]
164
  assert isinstance(embedded, EmbeddedResource)
165
  resource = embedded.resource
166
  assert isinstance(resource, TextResourceContents)
@@ -222,7 +238,7 @@ class TestToolTags:
222
 
223
  async with Client(mcp) as client:
224
  result_1 = await client.call_tool("tool_1", {})
225
- assert result_1[0].text == "1" # type: ignore[attr-defined]
226
 
227
  with pytest.raises(ToolError, match="Unknown tool"):
228
  await client.call_tool("tool_2", {})
@@ -235,7 +251,7 @@ class TestToolTags:
235
  await client.call_tool("tool_1", {})
236
 
237
  result_2 = await client.call_tool("tool_2", {})
238
- assert result_2[0].text == "2" # type: ignore[attr-defined]
239
 
240
 
241
  class TestToolReturnTypes:
@@ -248,7 +264,7 @@ class TestToolReturnTypes:
248
 
249
  async with Client(mcp) as client:
250
  result = await client.call_tool("string_tool", {})
251
- assert result[0].text == "Hello, world!" # type: ignore[attr-defined]
252
 
253
  async def test_bytes(self, tmp_path: Path):
254
  mcp = FastMCP()
@@ -259,7 +275,7 @@ class TestToolReturnTypes:
259
 
260
  async with Client(mcp) as client:
261
  result = await client.call_tool("bytes_tool", {})
262
- assert result[0].text == '"Hello, world!"' # type: ignore[attr-defined]
263
 
264
  async def test_uuid(self):
265
  mcp = FastMCP()
@@ -272,7 +288,7 @@ class TestToolReturnTypes:
272
 
273
  async with Client(mcp) as client:
274
  result = await client.call_tool("uuid_tool", {})
275
- assert result[0].text == pydantic_core.to_json(test_uuid).decode() # type: ignore[attr-defined]
276
 
277
  async def test_path(self):
278
  mcp = FastMCP()
@@ -285,7 +301,7 @@ class TestToolReturnTypes:
285
 
286
  async with Client(mcp) as client:
287
  result = await client.call_tool("path_tool", {})
288
- assert result[0].text == pydantic_core.to_json(test_path).decode() # type: ignore[attr-defined]
289
 
290
  async def test_datetime(self):
291
  mcp = FastMCP()
@@ -298,7 +314,7 @@ class TestToolReturnTypes:
298
 
299
  async with Client(mcp) as client:
300
  result = await client.call_tool("datetime_tool", {})
301
- assert result[0].text == pydantic_core.to_json(dt).decode() # type: ignore[attr-defined]
302
 
303
  async def test_image(self, tmp_path: Path):
304
  mcp = FastMCP()
@@ -313,7 +329,8 @@ class TestToolReturnTypes:
313
 
314
  async with Client(mcp) as client:
315
  result = await client.call_tool("image_tool", {"path": str(image_path)})
316
- content = result[0]
 
317
  assert isinstance(content, ImageContent)
318
  assert content.type == "image"
319
  assert content.mimeType == "image/png"
@@ -334,7 +351,7 @@ class TestToolReturnTypes:
334
 
335
  async with Client(mcp) as client:
336
  result = await client.call_tool("audio_tool", {"path": str(audio_path)})
337
- content = result[0]
338
  assert isinstance(content, AudioContent)
339
  assert content.type == "audio"
340
  assert content.mimeType == "audio/wav"
@@ -355,7 +372,7 @@ class TestToolReturnTypes:
355
 
356
  async with Client(mcp) as client:
357
  result = await client.call_tool("file_tool", {"path": str(file_path)})
358
- content = result[0]
359
  assert isinstance(content, EmbeddedResource)
360
  assert content.type == "resource"
361
  resource = content.resource
@@ -371,10 +388,10 @@ class TestToolReturnTypes:
371
  async def test_tool_mixed_content(self, tool_server: FastMCP):
372
  async with Client(tool_server) as client:
373
  result = await client.call_tool("mixed_content_tool", {})
374
- assert len(result) == 3
375
- content1 = result[0]
376
- content2 = result[1]
377
- content3 = result[2]
378
  assert isinstance(content1, TextContent)
379
  assert content1.text == "Hello"
380
  assert isinstance(content2, ImageContent)
@@ -402,18 +419,18 @@ class TestToolReturnTypes:
402
  result = await client.call_tool(
403
  "mixed_list_fn", {"image_path": str(image_path)}
404
  )
405
- assert len(result) == 3
406
  # Check text conversion
407
- content1 = result[0]
408
  assert isinstance(content1, TextContent)
409
  assert json.loads(content1.text) == ["text message", {"key": "value"}]
410
  # Check image conversion
411
- content2 = result[1]
412
  assert isinstance(content2, ImageContent)
413
  assert content2.mimeType == "image/png"
414
  assert base64.b64decode(content2.data) == b"test image data"
415
  # Check direct TextContent
416
- content3 = result[2]
417
  assert isinstance(content3, TextContent)
418
  assert content3.text == "direct content"
419
 
@@ -430,18 +447,18 @@ class TestToolReturnTypes:
430
  result = await client.call_tool(
431
  "mixed_audio_list_fn", {"audio_path": str(audio_path)}
432
  )
433
- assert len(result) == 3
434
  # Check text conversion
435
- content1 = result[0]
436
  assert isinstance(content1, TextContent)
437
  assert json.loads(content1.text) == ["text message", {"key": "value"}]
438
  # Check audio conversion
439
- content2 = result[1]
440
  assert isinstance(content2, AudioContent)
441
  assert content2.mimeType == "audio/wav"
442
  assert base64.b64decode(content2.data) == b"test audio data"
443
  # Check direct TextContent
444
- content3 = result[2]
445
  assert isinstance(content3, TextContent)
446
  assert content3.text == "direct content"
447
 
@@ -458,13 +475,13 @@ class TestToolReturnTypes:
458
  result = await client.call_tool(
459
  "mixed_file_list_fn", {"file_path": str(file_path)}
460
  )
461
- assert len(result) == 3
462
  # Check text conversion
463
- content1 = result[0]
464
  assert isinstance(content1, TextContent)
465
  assert json.loads(content1.text) == ["text message", {"key": "value"}]
466
  # Check file conversion
467
- content2 = result[1]
468
  assert isinstance(content2, EmbeddedResource)
469
  assert content2.type == "resource"
470
  resource = content2.resource
@@ -473,7 +490,7 @@ class TestToolReturnTypes:
473
  blob_data = getattr(resource, "blob")
474
  assert base64.b64decode(blob_data) == b"test file data"
475
  # Check direct TextContent
476
- content3 = result[2]
477
  assert isinstance(content3, TextContent)
478
  assert content3.text == "direct content"
479
 
@@ -540,9 +557,10 @@ class TestToolParameters:
540
  result = await client.call_tool(
541
  "process_image", {"image": b"fake png data"}
542
  )
543
- assert isinstance(result[0], ImageContent)
544
- assert result[0].mimeType == "image/png"
545
- assert result[0].data == base64.b64encode(b"fake png data").decode()
 
546
 
547
  async def test_tool_with_invalid_input(self):
548
  mcp = FastMCP()
@@ -660,7 +678,7 @@ class TestToolParameters:
660
 
661
  async with Client(mcp) as client:
662
  result = await client.call_tool("analyze", {"x": "a"})
663
- assert result[0].text == "a" # type: ignore[attr-defined]
664
 
665
  async def test_enum_type_validation_error(self):
666
  mcp = FastMCP()
@@ -695,7 +713,7 @@ class TestToolParameters:
695
 
696
  async with Client(mcp) as client:
697
  result = await client.call_tool("analyze", {"x": "red"})
698
- assert result[0].text == "red" # type: ignore[attr-defined]
699
 
700
  async def test_union_type_validation(self):
701
  mcp = FastMCP()
@@ -706,10 +724,10 @@ class TestToolParameters:
706
 
707
  async with Client(mcp) as client:
708
  result = await client.call_tool("analyze", {"x": 1})
709
- assert result[0].text == "1" # type: ignore[attr-defined]
710
 
711
  result = await client.call_tool("analyze", {"x": 1.0})
712
- assert result[0].text == "1.0" # type: ignore[attr-defined]
713
 
714
  with pytest.raises(
715
  ToolError,
@@ -730,7 +748,7 @@ class TestToolParameters:
730
 
731
  async with Client(mcp) as client:
732
  result = await client.call_tool("send_path", {"path": str(test_path)})
733
- assert result[0].text == str(test_path) # type: ignore[attr-defined]
734
 
735
  async def test_path_type_error(self):
736
  mcp = FastMCP()
@@ -757,7 +775,7 @@ class TestToolParameters:
757
 
758
  async with Client(mcp) as client:
759
  result = await client.call_tool("send_uuid", {"x": test_uuid})
760
- assert result[0].text == str(test_uuid) # type: ignore[attr-defined]
761
 
762
  async def test_uuid_type_error(self):
763
  mcp = FastMCP()
@@ -781,7 +799,7 @@ class TestToolParameters:
781
 
782
  async with Client(mcp) as client:
783
  result = await client.call_tool("send_datetime", {"x": dt})
784
- assert result[0].text == dt.isoformat() # type: ignore[attr-defined]
785
 
786
  async def test_datetime_type_parse_string(self):
787
  mcp = FastMCP()
@@ -794,7 +812,7 @@ class TestToolParameters:
794
  result = await client.call_tool(
795
  "send_datetime", {"x": "2021-01-01T00:00:00"}
796
  )
797
- assert result[0].text == "2021-01-01T00:00:00" # type: ignore[attr-defined]
798
 
799
  async def test_datetime_type_error(self):
800
  mcp = FastMCP()
@@ -816,7 +834,7 @@ class TestToolParameters:
816
 
817
  async with Client(mcp) as client:
818
  result = await client.call_tool("send_date", {"x": datetime.date.today()})
819
- assert result[0].text == datetime.date.today().isoformat() # type: ignore[attr-defined]
820
 
821
  async def test_date_type_parse_string(self):
822
  mcp = FastMCP()
@@ -827,7 +845,7 @@ class TestToolParameters:
827
 
828
  async with Client(mcp) as client:
829
  result = await client.call_tool("send_date", {"x": "2021-01-01"})
830
- assert result[0].text == "2021-01-01" # type: ignore[attr-defined]
831
 
832
  async def test_timedelta_type(self):
833
  mcp = FastMCP()
@@ -840,7 +858,7 @@ class TestToolParameters:
840
  result = await client.call_tool(
841
  "send_timedelta", {"x": datetime.timedelta(days=1)}
842
  )
843
- assert result[0].text == "1 day, 0:00:00" # type: ignore[attr-defined]
844
 
845
  async def test_timedelta_type_parse_int(self):
846
  """Test that invalid timedelta input raises validation error."""
@@ -859,6 +877,270 @@ class TestToolParameters:
859
  await client.call_tool("send_timedelta", {"x": 1000})
860
 
861
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
862
  class TestToolContextInjection:
863
  """Test context injection in tools."""
864
 
@@ -887,9 +1169,7 @@ class TestToolContextInjection:
887
 
888
  async with Client(mcp) as client:
889
  result = await client.call_tool("tool_with_context", {"x": 42})
890
- assert len(result) == 1
891
- content = result[0]
892
- assert content.text == "1" # type: ignore[attr-defined]
893
 
894
  async def test_async_context(self):
895
  """Test that context works in async functions."""
@@ -902,9 +1182,7 @@ class TestToolContextInjection:
902
 
903
  async with Client(mcp) as client:
904
  result = await client.call_tool("async_tool", {"x": 42})
905
- assert len(result) == 1
906
- content = result[0]
907
- assert content.text == "Async request 1: 42" # type: ignore[attr-defined]
908
 
909
  async def test_optional_context(self):
910
  """Test that context is optional."""
@@ -916,9 +1194,7 @@ class TestToolContextInjection:
916
 
917
  async with Client(mcp) as client:
918
  result = await client.call_tool("no_context", {"x": 21})
919
- assert len(result) == 1
920
- content = result[0]
921
- assert content.text == "42" # type: ignore[attr-defined]
922
 
923
  async def test_context_resource_access(self):
924
  """Test that context can access resources."""
@@ -938,9 +1214,9 @@ class TestToolContextInjection:
938
 
939
  async with Client(mcp) as client:
940
  result = await client.call_tool("tool_with_resource", {})
941
- assert len(result) == 1
942
- content = result[0]
943
- assert "Read resource: resource data" in content.text # type: ignore[attr-defined]
944
 
945
  async def test_tool_decorator_with_tags(self):
946
  """Test that the tool decorator properly sets tags."""
@@ -968,7 +1244,7 @@ class TestToolContextInjection:
968
 
969
  async with Client(mcp) as client:
970
  result = await client.call_tool("MyTool", {"x": 2})
971
- assert result[0].text == "3" # type: ignore[attr-defined]
972
 
973
 
974
  class TestToolEnabled:
 
2
  import datetime
3
  import json
4
  import uuid
5
+ from dataclasses import dataclass
6
  from enum import Enum
7
  from pathlib import Path
8
+ from typing import Annotated, Any, Literal
9
 
 
10
  import pytest
11
  from mcp import McpError
12
  from mcp.types import (
 
17
  TextContent,
18
  TextResourceContents,
19
  )
20
+ from pydantic import AnyUrl, BaseModel, Field, TypeAdapter
21
+ from typing_extensions import TypedDict
22
 
23
  from fastmcp import Client, Context, FastMCP
24
  from fastmcp.client.transports import FastMCPTransport
 
26
  from fastmcp.prompts.prompt import Prompt, PromptMessage
27
  from fastmcp.resources import FileResource, ResourceTemplate
28
  from fastmcp.resources.resource import FunctionResource
29
+ from fastmcp.tools.tool import Tool, ToolResult
30
  from fastmcp.utilities.types import Audio, File, Image
31
 
32
 
33
+ class PersonTypedDict(TypedDict):
34
+ name: str
35
+ age: int
36
+
37
+
38
+ class PersonModel(BaseModel):
39
+ name: str
40
+ age: int
41
+
42
+
43
+ @dataclass
44
+ class PersonDataclass:
45
+ name: str
46
+ age: int
47
+
48
+
49
  @pytest.fixture
50
  def tool_server():
51
  mcp = FastMCP()
 
89
  ),
90
  ]
91
 
92
+ @mcp.tool(output_schema=None)
93
  def mixed_list_fn(image_path: str) -> list:
94
  return [
95
  "text message",
 
98
  TextContent(type="text", text="direct content"),
99
  ]
100
 
101
+ @mcp.tool(output_schema=None)
102
  def mixed_audio_list_fn(audio_path: str) -> list:
103
  return [
104
  "text message",
 
107
  TextContent(type="text", text="direct content"),
108
  ]
109
 
110
+ @mcp.tool(output_schema=None)
111
  def mixed_file_list_fn(file_path: str) -> list:
112
  return [
113
  "text message",
 
134
  async with Client(tool_server) as client:
135
  assert len(await client.list_tools()) == 11
136
 
137
+ async def test_call_tool_mcp(self, tool_server: FastMCP):
138
  async with Client(tool_server) as client:
139
+ result = await client.call_tool_mcp("add", {"x": 1, "y": 2})
140
+ assert result.content[0].text == "3" # type: ignore[attr-defined]
141
+ assert result.structuredContent == {"result": 3}
142
 
143
+ async def test_call_tool(self, tool_server: FastMCP):
144
  async with Client(tool_server) as client:
145
  result = await client.call_tool("add", {"x": 1, "y": 2})
146
+ assert result.content[0].text == "3" # type: ignore[attr-defined]
147
+ assert result.structured_content == {"result": 3}
148
+ assert result.data == 3
149
 
150
  async def test_call_tool_error(self, tool_server: FastMCP):
151
  async with Client(tool_server) as client:
152
  with pytest.raises(Exception):
153
  await client.call_tool("error_tool", {})
154
 
 
 
 
 
 
155
  async def test_call_tool_error_as_client_raw(self):
156
  """Test raising and catching errors from a tool."""
157
  mcp = FastMCP()
 
169
  async def test_tool_returns_list(self, tool_server: FastMCP):
170
  async with Client(tool_server) as client:
171
  result = await client.call_tool("list_tool", {})
172
+ assert result.content[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined]
173
+ assert result.data == ["x", 2]
174
 
175
  async def test_file_text_tool(self, tool_server: FastMCP):
176
  async with Client(tool_server) as client:
177
  result = await client.call_tool("file_text_tool", {})
178
+ assert len(result.content) == 1
179
+ embedded = result.content[0]
180
  assert isinstance(embedded, EmbeddedResource)
181
  resource = embedded.resource
182
  assert isinstance(resource, TextResourceContents)
 
238
 
239
  async with Client(mcp) as client:
240
  result_1 = await client.call_tool("tool_1", {})
241
+ assert result_1.data == 1
242
 
243
  with pytest.raises(ToolError, match="Unknown tool"):
244
  await client.call_tool("tool_2", {})
 
251
  await client.call_tool("tool_1", {})
252
 
253
  result_2 = await client.call_tool("tool_2", {})
254
+ assert result_2.data == 2
255
 
256
 
257
  class TestToolReturnTypes:
 
264
 
265
  async with Client(mcp) as client:
266
  result = await client.call_tool("string_tool", {})
267
+ assert result.data == "Hello, world!"
268
 
269
  async def test_bytes(self, tmp_path: Path):
270
  mcp = FastMCP()
 
275
 
276
  async with Client(mcp) as client:
277
  result = await client.call_tool("bytes_tool", {})
278
+ assert result.data == "Hello, world!"
279
 
280
  async def test_uuid(self):
281
  mcp = FastMCP()
 
288
 
289
  async with Client(mcp) as client:
290
  result = await client.call_tool("uuid_tool", {})
291
+ assert result.data == str(test_uuid)
292
 
293
  async def test_path(self):
294
  mcp = FastMCP()
 
301
 
302
  async with Client(mcp) as client:
303
  result = await client.call_tool("path_tool", {})
304
+ assert result.data == str(test_path)
305
 
306
  async def test_datetime(self):
307
  mcp = FastMCP()
 
314
 
315
  async with Client(mcp) as client:
316
  result = await client.call_tool("datetime_tool", {})
317
+ assert result.data == dt
318
 
319
  async def test_image(self, tmp_path: Path):
320
  mcp = FastMCP()
 
329
 
330
  async with Client(mcp) as client:
331
  result = await client.call_tool("image_tool", {"path": str(image_path)})
332
+ assert result.structured_content is None
333
+ content = result.content[0]
334
  assert isinstance(content, ImageContent)
335
  assert content.type == "image"
336
  assert content.mimeType == "image/png"
 
351
 
352
  async with Client(mcp) as client:
353
  result = await client.call_tool("audio_tool", {"path": str(audio_path)})
354
+ content = result.content[0]
355
  assert isinstance(content, AudioContent)
356
  assert content.type == "audio"
357
  assert content.mimeType == "audio/wav"
 
372
 
373
  async with Client(mcp) as client:
374
  result = await client.call_tool("file_tool", {"path": str(file_path)})
375
+ content = result.content[0]
376
  assert isinstance(content, EmbeddedResource)
377
  assert content.type == "resource"
378
  resource = content.resource
 
388
  async def test_tool_mixed_content(self, tool_server: FastMCP):
389
  async with Client(tool_server) as client:
390
  result = await client.call_tool("mixed_content_tool", {})
391
+ assert len(result.content) == 3
392
+ content1 = result.content[0]
393
+ content2 = result.content[1]
394
+ content3 = result.content[2]
395
  assert isinstance(content1, TextContent)
396
  assert content1.text == "Hello"
397
  assert isinstance(content2, ImageContent)
 
419
  result = await client.call_tool(
420
  "mixed_list_fn", {"image_path": str(image_path)}
421
  )
422
+ assert len(result.content) == 3
423
  # Check text conversion
424
+ content1 = result.content[0]
425
  assert isinstance(content1, TextContent)
426
  assert json.loads(content1.text) == ["text message", {"key": "value"}]
427
  # Check image conversion
428
+ content2 = result.content[1]
429
  assert isinstance(content2, ImageContent)
430
  assert content2.mimeType == "image/png"
431
  assert base64.b64decode(content2.data) == b"test image data"
432
  # Check direct TextContent
433
+ content3 = result.content[2]
434
  assert isinstance(content3, TextContent)
435
  assert content3.text == "direct content"
436
 
 
447
  result = await client.call_tool(
448
  "mixed_audio_list_fn", {"audio_path": str(audio_path)}
449
  )
450
+ assert len(result.content) == 3
451
  # Check text conversion
452
+ content1 = result.content[0]
453
  assert isinstance(content1, TextContent)
454
  assert json.loads(content1.text) == ["text message", {"key": "value"}]
455
  # Check audio conversion
456
+ content2 = result.content[1]
457
  assert isinstance(content2, AudioContent)
458
  assert content2.mimeType == "audio/wav"
459
  assert base64.b64decode(content2.data) == b"test audio data"
460
  # Check direct TextContent
461
+ content3 = result.content[2]
462
  assert isinstance(content3, TextContent)
463
  assert content3.text == "direct content"
464
 
 
475
  result = await client.call_tool(
476
  "mixed_file_list_fn", {"file_path": str(file_path)}
477
  )
478
+ assert len(result.content) == 3
479
  # Check text conversion
480
+ content1 = result.content[0]
481
  assert isinstance(content1, TextContent)
482
  assert json.loads(content1.text) == ["text message", {"key": "value"}]
483
  # Check file conversion
484
+ content2 = result.content[1]
485
  assert isinstance(content2, EmbeddedResource)
486
  assert content2.type == "resource"
487
  resource = content2.resource
 
490
  blob_data = getattr(resource, "blob")
491
  assert base64.b64decode(blob_data) == b"test file data"
492
  # Check direct TextContent
493
+ content3 = result.content[2]
494
  assert isinstance(content3, TextContent)
495
  assert content3.text == "direct content"
496
 
 
557
  result = await client.call_tool(
558
  "process_image", {"image": b"fake png data"}
559
  )
560
+ assert result.structured_content is None
561
+ assert isinstance(result.content[0], ImageContent)
562
+ assert result.content[0].mimeType == "image/png"
563
+ assert result.content[0].data == base64.b64encode(b"fake png data").decode()
564
 
565
  async def test_tool_with_invalid_input(self):
566
  mcp = FastMCP()
 
678
 
679
  async with Client(mcp) as client:
680
  result = await client.call_tool("analyze", {"x": "a"})
681
+ assert result.data == "a"
682
 
683
  async def test_enum_type_validation_error(self):
684
  mcp = FastMCP()
 
713
 
714
  async with Client(mcp) as client:
715
  result = await client.call_tool("analyze", {"x": "red"})
716
+ assert result.data == "red"
717
 
718
  async def test_union_type_validation(self):
719
  mcp = FastMCP()
 
724
 
725
  async with Client(mcp) as client:
726
  result = await client.call_tool("analyze", {"x": 1})
727
+ assert result.data == "1"
728
 
729
  result = await client.call_tool("analyze", {"x": 1.0})
730
+ assert result.data == "1.0"
731
 
732
  with pytest.raises(
733
  ToolError,
 
748
 
749
  async with Client(mcp) as client:
750
  result = await client.call_tool("send_path", {"path": str(test_path)})
751
+ assert result.data == str(test_path)
752
 
753
  async def test_path_type_error(self):
754
  mcp = FastMCP()
 
775
 
776
  async with Client(mcp) as client:
777
  result = await client.call_tool("send_uuid", {"x": test_uuid})
778
+ assert result.data == str(test_uuid)
779
 
780
  async def test_uuid_type_error(self):
781
  mcp = FastMCP()
 
799
 
800
  async with Client(mcp) as client:
801
  result = await client.call_tool("send_datetime", {"x": dt})
802
+ assert result.data == dt.isoformat()
803
 
804
  async def test_datetime_type_parse_string(self):
805
  mcp = FastMCP()
 
812
  result = await client.call_tool(
813
  "send_datetime", {"x": "2021-01-01T00:00:00"}
814
  )
815
+ assert result.data == "2021-01-01T00:00:00"
816
 
817
  async def test_datetime_type_error(self):
818
  mcp = FastMCP()
 
834
 
835
  async with Client(mcp) as client:
836
  result = await client.call_tool("send_date", {"x": datetime.date.today()})
837
+ assert result.data == datetime.date.today().isoformat()
838
 
839
  async def test_date_type_parse_string(self):
840
  mcp = FastMCP()
 
845
 
846
  async with Client(mcp) as client:
847
  result = await client.call_tool("send_date", {"x": "2021-01-01"})
848
+ assert result.data == "2021-01-01"
849
 
850
  async def test_timedelta_type(self):
851
  mcp = FastMCP()
 
858
  result = await client.call_tool(
859
  "send_timedelta", {"x": datetime.timedelta(days=1)}
860
  )
861
+ assert result.data == "1 day, 0:00:00"
862
 
863
  async def test_timedelta_type_parse_int(self):
864
  """Test that invalid timedelta input raises validation error."""
 
877
  await client.call_tool("send_timedelta", {"x": 1000})
878
 
879
 
880
+ class TestToolOutputSchema:
881
+ @pytest.mark.parametrize("annotation", [str, int, float, bool, list, AnyUrl])
882
+ async def test_simple_output_schema(self, annotation):
883
+ mcp = FastMCP()
884
+
885
+ @mcp.tool
886
+ def f() -> annotation: # type: ignore
887
+ return "hello"
888
+
889
+ async with Client(mcp) as client:
890
+ tools = await client.list_tools()
891
+ assert len(tools) == 1
892
+
893
+ type_schema = TypeAdapter(annotation).json_schema()
894
+ # this line will fail until MCP adds output schemas!!
895
+ assert tools[0].outputSchema == {
896
+ "type": "object",
897
+ "properties": {"result": type_schema},
898
+ "x-fastmcp-wrap-result": True,
899
+ }
900
+
901
+ @pytest.mark.parametrize(
902
+ "annotation",
903
+ [dict[str, int | str], PersonTypedDict, PersonModel, PersonDataclass],
904
+ )
905
+ async def test_structured_output_schema(self, annotation):
906
+ mcp = FastMCP()
907
+
908
+ @mcp.tool
909
+ def f() -> annotation: # type: ignore[valid-type]
910
+ return {"name": "John", "age": 30}
911
+
912
+ async with Client(mcp) as client:
913
+ tools = await client.list_tools()
914
+
915
+ type_schema = TypeAdapter(annotation).json_schema()
916
+ assert len(tools) == 1
917
+ assert tools[0].outputSchema == type_schema
918
+
919
+ async def test_disabled_output_schema_no_structured_content(self):
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
+
932
+ async def test_manual_structured_content(self):
933
+ mcp = FastMCP()
934
+
935
+ @mcp.tool
936
+ def f() -> ToolResult:
937
+ return ToolResult(
938
+ content="Hello, world!", structured_content={"message": "Hello, world!"}
939
+ )
940
+
941
+ assert f.output_schema is None
942
+
943
+ async with Client(mcp) as client:
944
+ result = await client.call_tool("f", {})
945
+ assert result.content[0].text == "Hello, world!" # type: ignore[attr-defined]
946
+ assert result.structured_content == {"message": "Hello, world!"}
947
+ assert result.data == {"message": "Hello, world!"}
948
+
949
+ async def test_output_schema_false_full_handshake(self):
950
+ """Test that output_schema=False works through full client/server
951
+ handshake. We test this by returning a scalar, which requires an output
952
+ schema to serialize."""
953
+ mcp = FastMCP()
954
+
955
+ @mcp.tool(output_schema=False) # type: ignore[arg-type]
956
+ def simple_tool() -> int:
957
+ return 42
958
+
959
+ async with Client(mcp) as client:
960
+ # List tools and verify output schema is None
961
+ tools = await client.list_tools()
962
+ tool = next(t for t in tools if t.name == "simple_tool")
963
+ assert tool.outputSchema is None
964
+
965
+ # Call tool and verify no structured content
966
+ result = await client.call_tool("simple_tool", {})
967
+ assert result.structured_content is None
968
+ assert result.data is None
969
+ assert result.content[0].text == "42" # type: ignore[attr-defined]
970
+
971
+ async def test_output_schema_explicit_object_full_handshake(self):
972
+ """Test explicit object output schema through full client/server handshake."""
973
+ mcp = FastMCP()
974
+
975
+ @mcp.tool(
976
+ output_schema={
977
+ "type": "object",
978
+ "properties": {
979
+ "greeting": {"type": "string"},
980
+ "count": {"type": "integer"},
981
+ },
982
+ "required": ["greeting"],
983
+ }
984
+ )
985
+ def explicit_tool() -> dict[str, Any]:
986
+ return {"greeting": "Hello", "count": 42}
987
+
988
+ async with Client(mcp) as client:
989
+ # List tools and verify exact schema is preserved
990
+ tools = await client.list_tools()
991
+ tool = next(t for t in tools if t.name == "explicit_tool")
992
+ expected_schema = {
993
+ "type": "object",
994
+ "properties": {
995
+ "greeting": {"type": "string"},
996
+ "count": {"type": "integer"},
997
+ },
998
+ "required": ["greeting"],
999
+ }
1000
+ assert tool.outputSchema == expected_schema
1001
+
1002
+ # Call tool and verify structured content matches return value directly
1003
+ result = await client.call_tool("explicit_tool", {})
1004
+ assert result.structured_content == {"greeting": "Hello", "count": 42}
1005
+ # Client deserializes according to schema, so check fields
1006
+ assert result.data.greeting == "Hello" # type: ignore[attr-defined]
1007
+ assert result.data.count == 42 # type: ignore[attr-defined]
1008
+
1009
+ async def test_output_schema_wrapped_primitive_full_handshake(self):
1010
+ """Test wrapped primitive output schema through full client/server handshake."""
1011
+ mcp = FastMCP()
1012
+
1013
+ @mcp.tool
1014
+ def primitive_tool() -> str:
1015
+ return "Hello, primitives!"
1016
+
1017
+ async with Client(mcp) as client:
1018
+ # List tools and verify schema shows wrapped structure
1019
+ tools = await client.list_tools()
1020
+ tool = next(t for t in tools if t.name == "primitive_tool")
1021
+ expected_schema = {
1022
+ "type": "object",
1023
+ "properties": {"result": {"type": "string"}},
1024
+ "x-fastmcp-wrap-result": True,
1025
+ }
1026
+ assert tool.outputSchema == expected_schema
1027
+
1028
+ # Call tool and verify structured content is wrapped
1029
+ result = await client.call_tool("primitive_tool", {})
1030
+ assert result.structured_content == {"result": "Hello, primitives!"}
1031
+ assert result.data == "Hello, primitives!" # Client unwraps for convenience
1032
+
1033
+ async def test_output_schema_complex_type_full_handshake(self):
1034
+ """Test complex type output schema through full client/server handshake."""
1035
+ mcp = FastMCP()
1036
+
1037
+ @mcp.tool
1038
+ def complex_tool() -> list[dict[str, int]]:
1039
+ return [{"a": 1, "b": 2}, {"c": 3, "d": 4}]
1040
+
1041
+ async with Client(mcp) as client:
1042
+ # List tools and verify schema shows wrapped array
1043
+ tools = await client.list_tools()
1044
+ tool = next(t for t in tools if t.name == "complex_tool")
1045
+ expected_inner_schema = TypeAdapter(list[dict[str, int]]).json_schema()
1046
+ expected_schema = {
1047
+ "type": "object",
1048
+ "properties": {"result": expected_inner_schema},
1049
+ "x-fastmcp-wrap-result": True,
1050
+ }
1051
+ assert tool.outputSchema == expected_schema
1052
+
1053
+ # Call tool and verify structured content is wrapped
1054
+ result = await client.call_tool("complex_tool", {})
1055
+ expected_data = [{"a": 1, "b": 2}, {"c": 3, "d": 4}]
1056
+ assert result.structured_content == {"result": expected_data}
1057
+ # Client deserializes - just verify we got data back
1058
+ assert result.data is not None
1059
+
1060
+ async def test_output_schema_dataclass_full_handshake(self):
1061
+ """Test dataclass output schema through full client/server handshake."""
1062
+ mcp = FastMCP()
1063
+
1064
+ @dataclass
1065
+ class User:
1066
+ name: str
1067
+ age: int
1068
+
1069
+ @mcp.tool
1070
+ def dataclass_tool() -> User:
1071
+ return User(name="Alice", age=30)
1072
+
1073
+ async with Client(mcp) as client:
1074
+ # List tools and verify schema is object type (not wrapped)
1075
+ tools = await client.list_tools()
1076
+ tool = next(t for t in tools if t.name == "dataclass_tool")
1077
+ expected_schema = TypeAdapter(User).json_schema()
1078
+ assert tool.outputSchema == expected_schema
1079
+ assert (
1080
+ tool.outputSchema and "x-fastmcp-wrap-result" not in tool.outputSchema
1081
+ )
1082
+
1083
+ # Call tool and verify structured content is direct
1084
+ result = await client.call_tool("dataclass_tool", {})
1085
+ assert result.structured_content == {"name": "Alice", "age": 30}
1086
+ # Client deserializes according to schema
1087
+ assert result.data.name == "Alice" # type: ignore[attr-defined]
1088
+ assert result.data.age == 30 # type: ignore[attr-defined]
1089
+
1090
+ async def test_output_schema_mixed_content_types(self):
1091
+ """Test tools with mixed content and output schemas."""
1092
+ mcp = FastMCP()
1093
+
1094
+ @mcp.tool
1095
+ def mixed_output() -> list[Any]:
1096
+ # Return mixed content that includes MCP types and regular data
1097
+ return [
1098
+ "text message",
1099
+ {"structured": "data"},
1100
+ TextContent(type="text", text="direct MCP content"),
1101
+ ]
1102
+
1103
+ async with Client(mcp) as client:
1104
+ result = await client.call_tool("mixed_output", {})
1105
+
1106
+ # Should have multiple content blocks
1107
+ assert len(result.content) >= 2
1108
+
1109
+ # Should have structured output with wrapped result
1110
+ expected_data = [
1111
+ "text message",
1112
+ {"structured": "data"},
1113
+ {
1114
+ "type": "text",
1115
+ "text": "direct MCP content",
1116
+ "annotations": None,
1117
+ "_meta": None,
1118
+ },
1119
+ ]
1120
+ assert result.structured_content == {"result": expected_data}
1121
+
1122
+ async def test_output_schema_serialization_edge_cases(self):
1123
+ """Test edge cases in output schema serialization."""
1124
+ mcp = FastMCP()
1125
+
1126
+ @mcp.tool
1127
+ def edge_case_tool() -> tuple[int, str]:
1128
+ return (42, "hello")
1129
+
1130
+ async with Client(mcp) as client:
1131
+ # Verify tuple gets proper schema
1132
+ tools = await client.list_tools()
1133
+ tool = next(t for t in tools if t.name == "edge_case_tool")
1134
+
1135
+ # Tuples should be wrapped since they're not object type
1136
+ assert tool.outputSchema and "x-fastmcp-wrap-result" in tool.outputSchema
1137
+
1138
+ result = await client.call_tool("edge_case_tool", {})
1139
+ # Should be wrapped with result key
1140
+ assert result.structured_content == {"result": [42, "hello"]}
1141
+ assert result.data == [42, "hello"]
1142
+
1143
+
1144
  class TestToolContextInjection:
1145
  """Test context injection in tools."""
1146
 
 
1169
 
1170
  async with Client(mcp) as client:
1171
  result = await client.call_tool("tool_with_context", {"x": 42})
1172
+ assert result.data == "1"
 
 
1173
 
1174
  async def test_async_context(self):
1175
  """Test that context works in async functions."""
 
1182
 
1183
  async with Client(mcp) as client:
1184
  result = await client.call_tool("async_tool", {"x": 42})
1185
+ assert result.data == "Async request 1: 42"
 
 
1186
 
1187
  async def test_optional_context(self):
1188
  """Test that context is optional."""
 
1194
 
1195
  async with Client(mcp) as client:
1196
  result = await client.call_tool("no_context", {"x": 21})
1197
+ assert result.data == 42
 
 
1198
 
1199
  async def test_context_resource_access(self):
1200
  """Test that context can access resources."""
 
1214
 
1215
  async with Client(mcp) as client:
1216
  result = await client.call_tool("tool_with_resource", {})
1217
+ assert (
1218
+ result.data == "Read resource: resource data with mime type text/plain"
1219
+ )
1220
 
1221
  async def test_tool_decorator_with_tags(self):
1222
  """Test that the tool decorator properly sets tags."""
 
1244
 
1245
  async with Client(mcp) as client:
1246
  result = await client.call_tool("MyTool", {"x": 2})
1247
+ assert result.data == 3
1248
 
1249
 
1250
  class TestToolEnabled:
tests/server/test_tool_annotations.py CHANGED
@@ -218,8 +218,4 @@ async def test_tool_functionality_with_annotations():
218
  result = await client.call_tool(
219
  "create_item", {"name": "test_item", "value": 42}
220
  )
221
- assert len(result) == 1
222
-
223
- # The result should contain the expected JSON
224
- assert '"name": "test_item"' in result[0].text # type: ignore[attr-defined]
225
- assert '"value": 42' in result[0].text # type: ignore[attr-defined]
 
218
  result = await client.call_tool(
219
  "create_item", {"name": "test_item", "value": 42}
220
  )
221
+ assert result.data == {"name": "test_item", "value": 42}
 
 
 
 
tests/server/test_tool_exclude_args.py CHANGED
@@ -1,7 +1,6 @@
1
  from typing import Any
2
 
3
  import pytest
4
- from mcp.types import TextContent
5
 
6
  from fastmcp import Client, FastMCP
7
  from fastmcp.tools.tool import Tool
@@ -92,9 +91,4 @@ async def test_tool_functionality_with_exclude_args():
92
  result = await client.call_tool(
93
  "create_item", {"name": "test_item", "value": 42}
94
  )
95
- assert len(result) == 1
96
- assert isinstance(result[0], TextContent)
97
-
98
- # The result should contain the expected JSON
99
- assert '"name": "test_item"' in result[0].text
100
- assert '"value": 42' in result[0].text
 
1
  from typing import Any
2
 
3
  import pytest
 
4
 
5
  from fastmcp import Client, FastMCP
6
  from fastmcp.tools.tool import Tool
 
91
  result = await client.call_tool(
92
  "create_item", {"name": "test_item", "value": 42}
93
  )
94
+ assert result.data == {"name": "test_item", "value": 42}
 
 
 
 
 
tests/test_examples.py CHANGED
@@ -10,9 +10,9 @@ async def test_simple_echo():
10
  from examples.simple_echo import mcp
11
 
12
  async with Client(mcp) as client:
13
- result = await client.call_tool("echo", {"text": "hello"})
14
- assert len(result) == 1
15
- assert result[0].text == "hello" # type: ignore[attr-defined]
16
 
17
 
18
  async def test_complex_inputs():
@@ -21,11 +21,11 @@ async def test_complex_inputs():
21
 
22
  async with Client(mcp) as client:
23
  tank = {"shrimp": [{"name": "bob"}, {"name": "alice"}]}
24
- result = await client.call_tool(
25
  "name_shrimp", {"tank": tank, "extra_names": ["charlie"]}
26
  )
27
- assert len(result) == 1
28
- assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]' # type: ignore[attr-defined]
29
 
30
 
31
  async def test_desktop(monkeypatch):
@@ -34,9 +34,9 @@ async def test_desktop(monkeypatch):
34
 
35
  async with Client(mcp) as client:
36
  # Test the add function
37
- result = await client.call_tool("add", {"a": 1, "b": 2})
38
- assert len(result) == 1
39
- assert result[0].text == "3" # type: ignore[attr-defined]
40
 
41
  async with Client(mcp) as client:
42
  result = await client.read_resource(AnyUrl("greeting://rooter12"))
@@ -49,9 +49,9 @@ async def test_echo():
49
  from examples.echo import mcp
50
 
51
  async with Client(mcp) as client:
52
- result = await client.call_tool("echo_tool", {"text": "hello"})
53
- assert len(result) == 1
54
- assert result[0].text == "hello" # type: ignore[attr-defined]
55
 
56
  async with Client(mcp) as client:
57
  result = await client.read_resource(AnyUrl("echo://static"))
 
10
  from examples.simple_echo import mcp
11
 
12
  async with Client(mcp) as client:
13
+ result = await client.call_tool_mcp("echo", {"text": "hello"})
14
+ assert len(result.content) == 1
15
+ assert result.content[0].text == "hello" # type: ignore[attr-defined]
16
 
17
 
18
  async def test_complex_inputs():
 
21
 
22
  async with Client(mcp) as client:
23
  tank = {"shrimp": [{"name": "bob"}, {"name": "alice"}]}
24
+ result = await client.call_tool_mcp(
25
  "name_shrimp", {"tank": tank, "extra_names": ["charlie"]}
26
  )
27
+ assert len(result.content) == 1
28
+ assert result.content[0].text == '[\n "bob",\n "alice",\n "charlie"\n]' # type: ignore[attr-defined]
29
 
30
 
31
  async def test_desktop(monkeypatch):
 
34
 
35
  async with Client(mcp) as client:
36
  # Test the add function
37
+ result = await client.call_tool_mcp("add", {"a": 1, "b": 2})
38
+ assert len(result.content) == 1
39
+ assert result.content[0].text == "3" # type: ignore[attr-defined]
40
 
41
  async with Client(mcp) as client:
42
  result = await client.read_resource(AnyUrl("greeting://rooter12"))
 
49
  from examples.echo import mcp
50
 
51
  async with Client(mcp) as client:
52
+ result = await client.call_tool_mcp("echo_tool", {"text": "hello"})
53
+ assert len(result.content) == 1
54
+ assert result.content[0].text == "hello" # type: ignore[attr-defined]
55
 
56
  async with Client(mcp) as client:
57
  result = await client.read_resource(AnyUrl("echo://static"))
tests/tools/test_tool.py CHANGED
@@ -1,4 +1,6 @@
1
  import json
 
 
2
 
3
  import pytest
4
  from mcp.types import (
@@ -8,7 +10,8 @@ from mcp.types import (
8
  TextContent,
9
  TextResourceContents,
10
  )
11
- from pydantic import AnyUrl, BaseModel
 
12
 
13
  from fastmcp.tools.tool import Tool, _convert_to_content
14
  from fastmcp.utilities.types import Audio, File, Image
@@ -29,6 +32,13 @@ class TestToolFromFunction:
29
  assert len(tool.parameters["properties"]) == 2
30
  assert tool.parameters["properties"]["a"]["type"] == "integer"
31
  assert tool.parameters["properties"]["b"]["type"] == "integer"
 
 
 
 
 
 
 
32
 
33
  async def test_async_function(self):
34
  """Test registering and running an async function."""
@@ -100,7 +110,7 @@ class TestToolFromFunction:
100
 
101
  result = await tool.run({"data": "test.png"})
102
  assert tool.parameters["properties"]["data"]["type"] == "string"
103
- assert isinstance(result[0], ImageContent)
104
 
105
  async def test_tool_with_audio_return(self):
106
  def audio_tool(data: bytes) -> Audio:
@@ -110,7 +120,7 @@ class TestToolFromFunction:
110
 
111
  result = await tool.run({"data": "test.wav"})
112
  assert tool.parameters["properties"]["data"]["type"] == "string"
113
- assert isinstance(result[0], AudioContent)
114
 
115
  async def test_tool_with_file_return(self):
116
  def file_tool(data: bytes) -> File:
@@ -120,11 +130,11 @@ class TestToolFromFunction:
120
 
121
  result = await tool.run({"data": "test.bin"})
122
  assert tool.parameters["properties"]["data"]["type"] == "string"
123
- assert len(result) == 1
124
- assert isinstance(result[0], EmbeddedResource)
125
- assert result[0].type == "resource"
126
- assert hasattr(result[0], "resource")
127
- resource = result[0].resource
128
  assert resource.mimeType == "application/octet-stream"
129
 
130
  def test_non_callable_fn(self):
@@ -236,8 +246,490 @@ class TestToolFromFunction:
236
  tool = Tool.from_function(process_list, serializer=custom_serializer)
237
 
238
  result = await tool.run(arguments={"items": [1, 2, 3, 4, 5]})
239
- assert isinstance(result[0], TextContent)
240
- assert result[0].text == "Custom serializer: 15"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
 
243
  class TestConvertResultToContent:
@@ -537,3 +1029,199 @@ class TestConvertResultToContent:
537
  1,
538
  {"type": "text", "text": "hello", "annotations": None, "_meta": None},
539
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import json
2
+ from dataclasses import dataclass
3
+ from typing import Annotated, Any
4
 
5
  import pytest
6
  from mcp.types import (
 
10
  TextContent,
11
  TextResourceContents,
12
  )
13
+ from pydantic import AnyUrl, BaseModel, Field, TypeAdapter
14
+ from typing_extensions import TypedDict
15
 
16
  from fastmcp.tools.tool import Tool, _convert_to_content
17
  from fastmcp.utilities.types import Audio, File, Image
 
32
  assert len(tool.parameters["properties"]) == 2
33
  assert tool.parameters["properties"]["a"]["type"] == "integer"
34
  assert tool.parameters["properties"]["b"]["type"] == "integer"
35
+ # With primitive wrapping, int return type becomes object with result property
36
+ expected_schema = {
37
+ "type": "object",
38
+ "properties": {"result": {"type": "integer"}},
39
+ "x-fastmcp-wrap-result": True,
40
+ }
41
+ assert tool.output_schema == expected_schema
42
 
43
  async def test_async_function(self):
44
  """Test registering and running an async function."""
 
110
 
111
  result = await tool.run({"data": "test.png"})
112
  assert tool.parameters["properties"]["data"]["type"] == "string"
113
+ assert isinstance(result.content[0], ImageContent)
114
 
115
  async def test_tool_with_audio_return(self):
116
  def audio_tool(data: bytes) -> Audio:
 
120
 
121
  result = await tool.run({"data": "test.wav"})
122
  assert tool.parameters["properties"]["data"]["type"] == "string"
123
+ assert isinstance(result.content[0], AudioContent)
124
 
125
  async def test_tool_with_file_return(self):
126
  def file_tool(data: bytes) -> File:
 
130
 
131
  result = await tool.run({"data": "test.bin"})
132
  assert tool.parameters["properties"]["data"]["type"] == "string"
133
+ assert len(result.content) == 1
134
+ assert isinstance(result.content[0], EmbeddedResource)
135
+ assert result.content[0].type == "resource"
136
+ assert hasattr(result.content[0], "resource")
137
+ resource = result.content[0].resource
138
  assert resource.mimeType == "application/octet-stream"
139
 
140
  def test_non_callable_fn(self):
 
246
  tool = Tool.from_function(process_list, serializer=custom_serializer)
247
 
248
  result = await tool.run(arguments={"items": [1, 2, 3, 4, 5]})
249
+ # Custom serializer affects unstructured content
250
+ assert isinstance(result.content[0], TextContent)
251
+ assert result.content[0].text == "Custom serializer: 15"
252
+ # Structured output should have the raw value
253
+ assert result.structured_content == {"result": 15}
254
+
255
+
256
+ class TestToolFromFunctionOutputSchema:
257
+ async def test_no_return_annotation(self):
258
+ def func():
259
+ pass
260
+
261
+ tool = Tool.from_function(func)
262
+ assert tool.output_schema is None
263
+
264
+ @pytest.mark.parametrize(
265
+ "annotation",
266
+ [
267
+ int,
268
+ float,
269
+ bool,
270
+ str,
271
+ int | float,
272
+ list,
273
+ list[int],
274
+ list[int | float],
275
+ dict,
276
+ dict[str, Any],
277
+ dict[str, int | None],
278
+ tuple[int, str],
279
+ set[int],
280
+ list[tuple[int, str]],
281
+ ],
282
+ )
283
+ async def test_simple_return_annotation(self, annotation):
284
+ def func() -> annotation: # type: ignore
285
+ return 1
286
+
287
+ tool = Tool.from_function(func)
288
+
289
+ base_schema = TypeAdapter(annotation).json_schema()
290
+
291
+ # Non-object types get wrapped
292
+ schema_type = base_schema.get("type")
293
+ is_object_type = schema_type == "object"
294
+
295
+ if not is_object_type:
296
+ # Non-object types get wrapped
297
+ expected_schema = {
298
+ "type": "object",
299
+ "properties": {"result": base_schema},
300
+ "x-fastmcp-wrap-result": True,
301
+ }
302
+ assert tool.output_schema == expected_schema
303
+ else:
304
+ # Object types remain unwrapped
305
+ assert tool.output_schema == base_schema
306
+
307
+ @pytest.mark.parametrize(
308
+ "annotation",
309
+ [
310
+ AnyUrl,
311
+ Annotated[int, Field(ge=1)],
312
+ Annotated[int, Field(ge=1)],
313
+ ],
314
+ )
315
+ async def test_complex_return_annotation(self, annotation):
316
+ def func() -> annotation: # type: ignore
317
+ return 1
318
+
319
+ tool = Tool.from_function(func)
320
+ base_schema = TypeAdapter(annotation).json_schema()
321
+
322
+ expected_schema = {
323
+ "type": "object",
324
+ "properties": {"result": base_schema},
325
+ "x-fastmcp-wrap-result": True,
326
+ }
327
+ assert tool.output_schema == expected_schema
328
+
329
+ async def test_none_return_annotation(self):
330
+ def func() -> None:
331
+ pass
332
+
333
+ tool = Tool.from_function(func)
334
+ assert tool.output_schema is None
335
+
336
+ async def test_any_return_annotation(self):
337
+ def func() -> Any:
338
+ return 1
339
+
340
+ tool = Tool.from_function(func)
341
+ assert tool.output_schema is None
342
+
343
+ @pytest.mark.parametrize(
344
+ "annotation, expected",
345
+ [
346
+ (Image, ImageContent),
347
+ (Audio, AudioContent),
348
+ (File, EmbeddedResource),
349
+ (Image | int, ImageContent | int),
350
+ (Image | Audio, ImageContent | AudioContent),
351
+ (list[Image | Audio], list[ImageContent | AudioContent]),
352
+ ],
353
+ )
354
+ async def test_converted_return_annotation(self, annotation, expected):
355
+ def func() -> annotation: # type: ignore
356
+ return 1
357
+
358
+ tool = Tool.from_function(func)
359
+ # Image, Audio, File types don't generate output schemas since they're converted to content directly
360
+ assert tool.output_schema is None
361
+
362
+ async def test_dataclass_return_annotation(self):
363
+ @dataclass
364
+ class Person:
365
+ name: str
366
+ age: int
367
+
368
+ def func() -> Person:
369
+ return Person(name="John", age=30)
370
+
371
+ tool = Tool.from_function(func)
372
+ assert tool.output_schema == TypeAdapter(Person).json_schema()
373
+
374
+ async def test_base_model_return_annotation(self):
375
+ class Person(BaseModel):
376
+ name: str
377
+ age: int
378
+
379
+ def func() -> Person:
380
+ return Person(name="John", age=30)
381
+
382
+ tool = Tool.from_function(func)
383
+ assert tool.output_schema == TypeAdapter(Person).json_schema()
384
+
385
+ async def test_typeddict_return_annotation(self):
386
+ class Person(TypedDict):
387
+ name: str
388
+ age: int
389
+
390
+ def func() -> Person:
391
+ return Person(name="John", age=30)
392
+
393
+ tool = Tool.from_function(func)
394
+ assert tool.output_schema == TypeAdapter(Person).json_schema()
395
+
396
+ async def test_unserializable_return_annotation(self):
397
+ class Unserializable:
398
+ def __init__(self, data: Any):
399
+ self.data = data
400
+
401
+ def func() -> Unserializable:
402
+ return Unserializable(data="test")
403
+
404
+ tool = Tool.from_function(func)
405
+ assert tool.output_schema is None
406
+
407
+ async def test_mixed_unserializable_return_annotation(self):
408
+ class Unserializable:
409
+ def __init__(self, data: Any):
410
+ self.data = data
411
+
412
+ def func() -> Unserializable | int:
413
+ return Unserializable(data="test")
414
+
415
+ tool = Tool.from_function(func)
416
+ assert tool.output_schema is None
417
+
418
+ async def test_provided_output_schema_takes_precedence_over_json_compatible_annotation(
419
+ self,
420
+ ):
421
+ """Test that provided output_schema takes precedence over inferred schema from JSON-compatible annotation."""
422
+
423
+ def func() -> dict[str, int]:
424
+ return {"a": 1, "b": 2}
425
+
426
+ # Provide a custom output schema that differs from the inferred one
427
+ custom_schema = {"type": "object", "description": "Custom schema"}
428
+
429
+ tool = Tool.from_function(func, output_schema=custom_schema)
430
+ assert tool.output_schema == custom_schema
431
+
432
+ async def test_provided_output_schema_takes_precedence_over_complex_annotation(
433
+ self,
434
+ ):
435
+ """Test that provided output_schema takes precedence over inferred schema from complex annotation."""
436
+
437
+ def func() -> list[dict[str, int | float]]:
438
+ return [{"a": 1, "b": 2.5}]
439
+
440
+ # Provide a custom output schema that differs from the inferred one
441
+ custom_schema = {"type": "object", "properties": {"custom": {"type": "string"}}}
442
+
443
+ tool = Tool.from_function(func, output_schema=custom_schema)
444
+ assert tool.output_schema == custom_schema
445
+
446
+ async def test_provided_output_schema_takes_precedence_over_unserializable_annotation(
447
+ self,
448
+ ):
449
+ """Test that provided output_schema takes precedence over None schema from unserializable annotation."""
450
+
451
+ class Unserializable:
452
+ def __init__(self, data: Any):
453
+ self.data = data
454
+
455
+ def func() -> Unserializable:
456
+ return Unserializable(data="test")
457
+
458
+ # Provide a custom output schema even though the annotation is unserializable
459
+ custom_schema = {
460
+ "type": "object",
461
+ "properties": {"items": {"type": "array", "items": {"type": "string"}}},
462
+ }
463
+
464
+ tool = Tool.from_function(func, output_schema=custom_schema)
465
+ assert tool.output_schema == custom_schema
466
+
467
+ async def test_provided_output_schema_takes_precedence_over_no_annotation(self):
468
+ """Test that provided output_schema takes precedence over None schema from no annotation."""
469
+
470
+ def func():
471
+ return "hello"
472
+
473
+ # Provide a custom output schema even though there's no return annotation
474
+ custom_schema = {
475
+ "type": "object",
476
+ "properties": {"value": {"type": "number", "minimum": 0}},
477
+ }
478
+
479
+ tool = Tool.from_function(func, output_schema=custom_schema)
480
+ assert tool.output_schema == custom_schema
481
+
482
+ async def test_provided_output_schema_takes_precedence_over_converted_annotation(
483
+ self,
484
+ ):
485
+ """Test that provided output_schema takes precedence over converted schema from Image/Audio/File annotations."""
486
+
487
+ def func() -> Image:
488
+ return Image(data=b"test")
489
+
490
+ # Provide a custom output schema that differs from the converted ImageContent schema
491
+ custom_schema = {
492
+ "type": "object",
493
+ "properties": {"custom_image": {"type": "string"}},
494
+ }
495
+
496
+ tool = Tool.from_function(func, output_schema=custom_schema)
497
+ assert tool.output_schema == custom_schema
498
+
499
+ async def test_provided_output_schema_takes_precedence_over_union_annotation(self):
500
+ """Test that provided output_schema takes precedence over inferred schema from union annotation."""
501
+
502
+ def func() -> str | int | None:
503
+ return "hello"
504
+
505
+ # Provide a custom output schema that differs from the inferred union schema
506
+ custom_schema = {"type": "object", "properties": {"flag": {"type": "boolean"}}}
507
+
508
+ tool = Tool.from_function(func, output_schema=custom_schema)
509
+ assert tool.output_schema == custom_schema
510
+
511
+ async def test_provided_output_schema_takes_precedence_over_pydantic_annotation(
512
+ self,
513
+ ):
514
+ """Test that provided output_schema takes precedence over inferred schema from Pydantic model annotation."""
515
+
516
+ class Person(BaseModel):
517
+ name: str
518
+ age: int
519
+
520
+ def func() -> Person:
521
+ return Person(name="John", age=30)
522
+
523
+ # Provide a custom output schema that differs from the inferred Person schema
524
+ custom_schema = {
525
+ "type": "object",
526
+ "properties": {"numbers": {"type": "array", "items": {"type": "number"}}},
527
+ }
528
+
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!"}
537
+
538
+ tool = Tool.from_function(func, output_schema=False)
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
+
547
+ async def test_output_schema_none_disables_structured_content(self):
548
+ """Test that output_schema=None explicitly disables structured content."""
549
+
550
+ def func() -> int:
551
+ return 42
552
+
553
+ tool = Tool.from_function(func, output_schema=None)
554
+ assert tool.output_schema is None
555
+
556
+ result = await tool.run({})
557
+ assert result.structured_content is None
558
+ assert len(result.content) == 1
559
+ assert result.content[0].text == "42" # type: ignore[attr-defined]
560
+
561
+ async def test_output_schema_inferred_when_not_specified(self):
562
+ """Test that output schema is inferred when not explicitly specified."""
563
+
564
+ def func() -> int:
565
+ return 42
566
+
567
+ # Don't specify output_schema - should infer and wrap
568
+ tool = Tool.from_function(func)
569
+ expected_schema = {
570
+ "type": "object",
571
+ "properties": {"result": {"type": "integer"}},
572
+ "x-fastmcp-wrap-result": True,
573
+ }
574
+ assert tool.output_schema == expected_schema
575
+
576
+ result = await tool.run({})
577
+ assert result.structured_content == {"result": 42}
578
+
579
+ async def test_explicit_object_schema_with_dict_return(self):
580
+ """Test that explicit object schemas work when function returns a dict."""
581
+
582
+ def func() -> dict[str, int]:
583
+ return {"value": 42}
584
+
585
+ # Provide explicit object schema
586
+ explicit_schema = {
587
+ "type": "object",
588
+ "properties": {"value": {"type": "integer", "minimum": 0}},
589
+ }
590
+ tool = Tool.from_function(func, output_schema=explicit_schema)
591
+ assert tool.output_schema == explicit_schema # Schema not wrapped
592
+ assert tool.output_schema and "x-fastmcp-wrap-result" not in tool.output_schema
593
+
594
+ result = await tool.run({})
595
+ # Dict result with object schema is used directly
596
+ assert result.structured_content == {"value": 42}
597
+ assert result.content[0].text == '{\n "value": 42\n}' # type: ignore[attr-defined]
598
+
599
+ async def test_explicit_object_schema_with_non_dict_return_fails(self):
600
+ """Test that explicit object schemas fail when function returns non-dict."""
601
+
602
+ def func() -> int:
603
+ return 42
604
+
605
+ # Provide explicit object schema but return non-dict
606
+ explicit_schema = {
607
+ "type": "object",
608
+ "properties": {"value": {"type": "integer"}},
609
+ }
610
+ tool = Tool.from_function(func, output_schema=explicit_schema)
611
+
612
+ # Should fail because int is not dict-compatible with object schema
613
+ with pytest.raises(ValueError, match="structured_content must be a dict"):
614
+ await tool.run({})
615
+
616
+ async def test_object_output_schema_not_wrapped(self):
617
+ """Test that object-type output schemas are never wrapped."""
618
+
619
+ def func() -> dict[str, int]:
620
+ return {"value": 42}
621
+
622
+ # Object schemas should never be wrapped, even when inferred
623
+ tool = Tool.from_function(func)
624
+ expected_schema = TypeAdapter(dict[str, int]).json_schema()
625
+ assert tool.output_schema == expected_schema # Not wrapped
626
+ assert tool.output_schema and "x-fastmcp-wrap-result" not in tool.output_schema
627
+
628
+ result = await tool.run({})
629
+ assert result.structured_content == {"value": 42} # Direct value
630
+
631
+ async def test_structured_content_interaction_with_wrapping(self):
632
+ """Test that structured content works correctly with schema wrapping."""
633
+
634
+ def func() -> str:
635
+ return "hello"
636
+
637
+ # Inferred schema should wrap string type
638
+ tool = Tool.from_function(func)
639
+ expected_schema = {
640
+ "type": "object",
641
+ "properties": {"result": {"type": "string"}},
642
+ "x-fastmcp-wrap-result": True,
643
+ }
644
+ assert tool.output_schema == expected_schema
645
+
646
+ result = await tool.run({})
647
+ # Unstructured content
648
+ assert len(result.content) == 1
649
+ assert result.content[0].text == "hello" # type: ignore[attr-defined]
650
+ # Structured content should be wrapped
651
+ assert result.structured_content == {"result": "hello"}
652
+
653
+ async def test_structured_content_with_explicit_object_schema(self):
654
+ """Test structured content with explicit object schema."""
655
+
656
+ def func() -> dict[str, str]:
657
+ return {"greeting": "hello"}
658
+
659
+ # Provide explicit object schema
660
+ explicit_schema = {
661
+ "type": "object",
662
+ "properties": {"greeting": {"type": "string"}},
663
+ "required": ["greeting"],
664
+ }
665
+ tool = Tool.from_function(func, output_schema=explicit_schema)
666
+ assert tool.output_schema == explicit_schema
667
+
668
+ result = await tool.run({})
669
+ # Should use direct value since explicit schema doesn't have wrap marker
670
+ assert result.structured_content == {"greeting": "hello"}
671
+
672
+ async def test_structured_content_with_custom_wrapper_schema(self):
673
+ """Test structured content with custom schema that includes wrap marker."""
674
+
675
+ def func() -> str:
676
+ return "world"
677
+
678
+ # Custom schema with wrap marker
679
+ custom_schema = {
680
+ "type": "object",
681
+ "properties": {"message": {"type": "string"}},
682
+ "x-fastmcp-wrap-result": True,
683
+ }
684
+ tool = Tool.from_function(func, output_schema=custom_schema)
685
+ assert tool.output_schema == custom_schema
686
+
687
+ result = await tool.run({})
688
+ # Should wrap with "result" key due to wrap marker
689
+ assert result.structured_content == {"result": "world"}
690
+
691
+ async def test_none_vs_false_output_schema_behavior(self):
692
+ """Test the difference between None and False for output_schema."""
693
+
694
+ def func() -> int:
695
+ return 123
696
+
697
+ # None should disable
698
+ tool_none = Tool.from_function(func, output_schema=None)
699
+ assert tool_none.output_schema is None
700
+
701
+ # False should also disable
702
+ tool_false = Tool.from_function(func, output_schema=False)
703
+ assert tool_false.output_schema is None
704
+
705
+ # Both should have same behavior
706
+ result_none = await tool_none.run({})
707
+ result_false = await tool_false.run({})
708
+
709
+ assert result_none.structured_content is None
710
+ assert result_false.structured_content is None
711
+ assert result_none.content[0].text == result_false.content[0].text == "123" # type: ignore[attr-defined]
712
+
713
+ async def test_non_object_output_schema_raises_error(self):
714
+ """Test that providing a non-object output schema raises a ValueError."""
715
+
716
+ def func() -> int:
717
+ return 42
718
+
719
+ # Test various non-object schemas that should raise errors
720
+ non_object_schemas = [
721
+ {"type": "string"},
722
+ {"type": "integer", "minimum": 0},
723
+ {"type": "number"},
724
+ {"type": "boolean"},
725
+ {"type": "array", "items": {"type": "string"}},
726
+ ]
727
+
728
+ for schema in non_object_schemas:
729
+ with pytest.raises(
730
+ ValueError, match='Output schemas must have "type" set to "object"'
731
+ ):
732
+ Tool.from_function(func, output_schema=schema)
733
 
734
 
735
  class TestConvertResultToContent:
 
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
tests/tools/test_tool_manager.py CHANGED
@@ -125,7 +125,8 @@ class TestAddTools:
125
  tool = await manager.get_tool("image_tool")
126
  result = await tool.run({"data": "test.png"})
127
  assert tool.parameters["properties"]["data"]["type"] == "string"
128
- assert isinstance(result[0], ImageContent)
 
129
 
130
  def test_add_noncallable_tool(self):
131
  manager = ToolManager()
@@ -353,7 +354,8 @@ class TestCallTools:
353
  manager.add_tool(tool)
354
  result = await manager.call_tool("add", {"a": 1, "b": 2})
355
 
356
- assert result[0].text == "3" # type: ignore[attr-defined]
 
357
 
358
  async def test_call_async_tool(self):
359
  async def double(n: int) -> int:
@@ -364,7 +366,8 @@ class TestCallTools:
364
  tool = Tool.from_function(double)
365
  manager.add_tool(tool)
366
  result = await manager.call_tool("double", {"n": 5})
367
- assert result[0].text == "10" # type: ignore[attr-defined]
 
368
 
369
  async def test_call_tool_callable_object(self):
370
  class Adder:
@@ -378,7 +381,8 @@ class TestCallTools:
378
  tool = Tool.from_function(Adder())
379
  manager.add_tool(tool)
380
  result = await manager.call_tool("Adder", {"x": 1, "y": 2})
381
- assert result[0].text == "3" # type: ignore[attr-defined]
 
382
 
383
  async def test_call_tool_callable_object_async(self):
384
  class Adder:
@@ -392,7 +396,8 @@ class TestCallTools:
392
  tool = Tool.from_function(Adder())
393
  manager.add_tool(tool)
394
  result = await manager.call_tool("Adder", {"x": 1, "y": 2})
395
- assert result[0].text == "3" # type: ignore[attr-defined]
 
396
 
397
  async def test_call_tool_with_default_args(self):
398
  def add(a: int, b: int = 1) -> int:
@@ -404,7 +409,8 @@ class TestCallTools:
404
  manager.add_tool(tool)
405
  result = await manager.call_tool("add", {"a": 1})
406
 
407
- assert result[0].text == "2" # type: ignore[attr-defined]
 
408
 
409
  async def test_call_tool_with_missing_args(self):
410
  def add(a: int, b: int) -> int:
@@ -431,7 +437,8 @@ class TestCallTools:
431
  manager.add_tool(tool)
432
 
433
  result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
434
- assert result[0].text == "6" # type: ignore[attr-defined]
 
435
 
436
  async def test_call_tool_with_list_str_or_str_input(self):
437
  def concat_strs(vals: list[str] | str) -> str:
@@ -443,10 +450,12 @@ class TestCallTools:
443
 
444
  # Try both with plain python object and with JSON list
445
  result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
446
- assert result[0].text == "abc" # type: ignore[attr-defined]
 
447
 
448
  result = await manager.call_tool("concat_strs", {"vals": "a"})
449
- assert result[0].text == "a" # type: ignore[attr-defined]
 
450
 
451
  async def test_call_tool_with_complex_model(self):
452
  class MyShrimpTank(BaseModel):
@@ -477,7 +486,8 @@ class TestCallTools:
477
  },
478
  )
479
 
480
- assert result[0].text == '[\n "rex",\n "gertrude"\n]' # type: ignore[attr-defined]
 
481
 
482
  async def test_call_tool_with_custom_serializer(self):
483
  """Test that a custom serializer provided to FastMCP is used by tools."""
@@ -496,7 +506,8 @@ class TestCallTools:
496
  return {"key": "value", "number": 123}
497
 
498
  result = await manager.call_tool("get_data", {})
499
- assert result[0].text == 'CUSTOM:{"key": "value", "number": 123}' # type: ignore[attr-defined]
 
500
 
501
  async def test_call_tool_with_list_result_custom_serializer(self):
502
  """Test that a custom serializer provided to FastMCP is used by tools that return lists."""
@@ -518,9 +529,15 @@ class TestCallTools:
518
 
519
  result = await manager.call_tool("get_data", {})
520
  assert (
521
- result[0].text # type: ignore[attr-defined]
522
  == 'CUSTOM:[{"key": "value", "number": 123}, {"key": "value2", "number": 456}]' # type: ignore[attr-defined]
523
  )
 
 
 
 
 
 
524
 
525
  async def test_custom_serializer_fallback_on_error(self):
526
  """Test that a broken custom serializer gracefully falls back."""
@@ -538,7 +555,11 @@ class TestCallTools:
538
  return uuid_result
539
 
540
  result = await manager.call_tool("get_data", {})
541
- assert result[0].text == pydantic_core.to_json(uuid_result).decode() # type: ignore[attr-defined]
 
 
 
 
542
 
543
 
544
  class TestToolSchema:
@@ -608,7 +629,8 @@ class TestContextHandling:
608
 
609
  async with context:
610
  result = await manager.call_tool("tool_with_context", {"x": 42})
611
- assert result[0].text == "42" # type: ignore[attr-defined]
 
612
 
613
  async def test_context_injection_async(self):
614
  """Test that context is properly injected in async tools."""
@@ -626,7 +648,8 @@ class TestContextHandling:
626
 
627
  async with context:
628
  result = await manager.call_tool("async_tool", {"x": 42})
629
- assert result[0].text == "42" # type: ignore[attr-defined]
 
630
 
631
  async def test_context_optional(self):
632
  """Test that context is optional when calling tools."""
@@ -644,7 +667,8 @@ class TestContextHandling:
644
 
645
  async with context:
646
  result = await manager.call_tool("tool_with_context", {"x": 42})
647
- assert result[0].text == "42" # type: ignore[attr-defined]
 
648
 
649
  def test_parameterized_context_parameter_detection(self):
650
  """Test that context parameters are properly detected in
@@ -752,7 +776,8 @@ class TestCustomToolNames:
752
 
753
  # Tool should be callable by its custom name
754
  result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3})
755
- assert result[0].text == "15" # type: ignore[attr-defined]
 
756
 
757
  # Original name should not be registered
758
  with pytest.raises(NotFoundError, match="Tool 'multiply' not found"):
 
125
  tool = await manager.get_tool("image_tool")
126
  result = await tool.run({"data": "test.png"})
127
  assert tool.parameters["properties"]["data"]["type"] == "string"
128
+ assert isinstance(result.content[0], ImageContent)
129
+ assert result.structured_content is None
130
 
131
  def test_add_noncallable_tool(self):
132
  manager = ToolManager()
 
354
  manager.add_tool(tool)
355
  result = await manager.call_tool("add", {"a": 1, "b": 2})
356
 
357
+ assert result.content[0].text == "3" # type: ignore[attr-defined]
358
+ assert result.structured_content == {"result": 3}
359
 
360
  async def test_call_async_tool(self):
361
  async def double(n: int) -> int:
 
366
  tool = Tool.from_function(double)
367
  manager.add_tool(tool)
368
  result = await manager.call_tool("double", {"n": 5})
369
+ assert result.content[0].text == "10" # type: ignore[attr-defined]
370
+ assert result.structured_content == {"result": 10}
371
 
372
  async def test_call_tool_callable_object(self):
373
  class Adder:
 
381
  tool = Tool.from_function(Adder())
382
  manager.add_tool(tool)
383
  result = await manager.call_tool("Adder", {"x": 1, "y": 2})
384
+ assert result.content[0].text == "3" # type: ignore[attr-defined]
385
+ assert result.structured_content == {"result": 3}
386
 
387
  async def test_call_tool_callable_object_async(self):
388
  class Adder:
 
396
  tool = Tool.from_function(Adder())
397
  manager.add_tool(tool)
398
  result = await manager.call_tool("Adder", {"x": 1, "y": 2})
399
+ assert result.content[0].text == "3" # type: ignore[attr-defined]
400
+ assert result.structured_content == {"result": 3}
401
 
402
  async def test_call_tool_with_default_args(self):
403
  def add(a: int, b: int = 1) -> int:
 
409
  manager.add_tool(tool)
410
  result = await manager.call_tool("add", {"a": 1})
411
 
412
+ assert result.content[0].text == "2" # type: ignore[attr-defined]
413
+ assert result.structured_content == {"result": 2}
414
 
415
  async def test_call_tool_with_missing_args(self):
416
  def add(a: int, b: int) -> int:
 
437
  manager.add_tool(tool)
438
 
439
  result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
440
+ assert result.content[0].text == "6" # type: ignore[attr-defined]
441
+ assert result.structured_content == {"result": 6}
442
 
443
  async def test_call_tool_with_list_str_or_str_input(self):
444
  def concat_strs(vals: list[str] | str) -> str:
 
450
 
451
  # Try both with plain python object and with JSON list
452
  result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
453
+ assert result.content[0].text == "abc" # type: ignore[attr-defined]
454
+ assert result.structured_content == {"result": "abc"}
455
 
456
  result = await manager.call_tool("concat_strs", {"vals": "a"})
457
+ assert result.content[0].text == "a" # type: ignore[attr-defined]
458
+ assert result.structured_content == {"result": "a"}
459
 
460
  async def test_call_tool_with_complex_model(self):
461
  class MyShrimpTank(BaseModel):
 
486
  },
487
  )
488
 
489
+ assert result.content[0].text == '[\n "rex",\n "gertrude"\n]' # type: ignore[attr-defined]
490
+ assert result.structured_content == {"result": ["rex", "gertrude"]}
491
 
492
  async def test_call_tool_with_custom_serializer(self):
493
  """Test that a custom serializer provided to FastMCP is used by tools."""
 
506
  return {"key": "value", "number": 123}
507
 
508
  result = await manager.call_tool("get_data", {})
509
+ assert result.content[0].text == 'CUSTOM:{"key": "value", "number": 123}' # type: ignore[attr-defined]
510
+ assert result.structured_content == {"key": "value", "number": 123}
511
 
512
  async def test_call_tool_with_list_result_custom_serializer(self):
513
  """Test that a custom serializer provided to FastMCP is used by tools that return lists."""
 
529
 
530
  result = await manager.call_tool("get_data", {})
531
  assert (
532
+ result.content[0].text # type: ignore[attr-defined]
533
  == 'CUSTOM:[{"key": "value", "number": 123}, {"key": "value2", "number": 456}]' # type: ignore[attr-defined]
534
  )
535
+ assert result.structured_content == {
536
+ "result": [
537
+ {"key": "value", "number": 123},
538
+ {"key": "value2", "number": 456},
539
+ ]
540
+ }
541
 
542
  async def test_custom_serializer_fallback_on_error(self):
543
  """Test that a broken custom serializer gracefully falls back."""
 
555
  return uuid_result
556
 
557
  result = await manager.call_tool("get_data", {})
558
+ assert (
559
+ result.content[0].text # type: ignore[attr-defined]
560
+ == pydantic_core.to_json(uuid_result).decode()
561
+ )
562
+ assert result.structured_content == {"result": str(uuid_result)}
563
 
564
 
565
  class TestToolSchema:
 
629
 
630
  async with context:
631
  result = await manager.call_tool("tool_with_context", {"x": 42})
632
+ assert result.content[0].text == "42" # type: ignore[attr-defined]
633
+ assert result.structured_content == {"result": "42"}
634
 
635
  async def test_context_injection_async(self):
636
  """Test that context is properly injected in async tools."""
 
648
 
649
  async with context:
650
  result = await manager.call_tool("async_tool", {"x": 42})
651
+ assert result.content[0].text == "42" # type: ignore[attr-defined]
652
+ assert result.structured_content == {"result": "42"}
653
 
654
  async def test_context_optional(self):
655
  """Test that context is optional when calling tools."""
 
667
 
668
  async with context:
669
  result = await manager.call_tool("tool_with_context", {"x": 42})
670
+ assert result.content[0].text == "42" # type: ignore[attr-defined]
671
+ assert result.structured_content == {"result": 42}
672
 
673
  def test_parameterized_context_parameter_detection(self):
674
  """Test that context parameters are properly detected in
 
776
 
777
  # Tool should be callable by its custom name
778
  result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3})
779
+ assert result.content[0].text == "15" # type: ignore[attr-defined]
780
+ assert result.structured_content == {"result": 15}
781
 
782
  # Original name should not be registered
783
  with pytest.raises(NotFoundError, match="Tool 'multiply' not found"):
tests/tools/test_tool_transform.py CHANGED
@@ -4,14 +4,15 @@ from typing import Annotated, Any
4
 
5
  import pytest
6
  from dirty_equals import IsList
7
- from pydantic import BaseModel, Field
 
8
  from typing_extensions import TypedDict
9
 
10
  from fastmcp import FastMCP
11
  from fastmcp.client.client import Client
12
  from fastmcp.exceptions import ToolError
13
  from fastmcp.tools import Tool, forward, forward_raw
14
- from fastmcp.tools.tool import FunctionTool
15
  from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
16
 
17
 
@@ -52,7 +53,8 @@ async def test_tool_defaults_are_maintained_on_unmapped_args(add_tool):
52
  add_tool, transform_args={"old_x": ArgTransform(name="new_x")}
53
  )
54
  result = await new_tool.run(arguments={"new_x": 1})
55
- assert result[0].text == "11" # type: ignore[attr-defined]
 
56
 
57
 
58
  async def test_tool_defaults_are_maintained_on_mapped_args(add_tool):
@@ -60,7 +62,8 @@ async def test_tool_defaults_are_maintained_on_mapped_args(add_tool):
60
  add_tool, transform_args={"old_y": ArgTransform(name="new_y")}
61
  )
62
  result = await new_tool.run(arguments={"old_x": 1})
63
- assert result[0].text == "11" # type: ignore[attr-defined]
 
64
 
65
 
66
  def test_tool_change_arg_name(add_tool):
@@ -87,7 +90,7 @@ async def test_tool_drop_arg(add_tool):
87
  )
88
  assert sorted(new_tool.parameters["properties"]) == ["old_x"]
89
  result = await new_tool.run(arguments={"old_x": 1})
90
- assert result[0].text == "11" # type: ignore[attr-defined]
91
 
92
 
93
  async def test_dropped_args_error_if_provided(add_tool):
@@ -109,7 +112,7 @@ async def test_hidden_arg_with_constant_default(add_tool):
109
  assert sorted(new_tool.parameters["properties"]) == ["old_x"]
110
  # Should pass old_x=5 and old_y=20 to parent
111
  result = await new_tool.run(arguments={"old_x": 5})
112
- assert result[0].text == "25" # type: ignore[attr-defined]
113
 
114
 
115
  async def test_hidden_arg_without_default_uses_parent_default(add_tool):
@@ -121,13 +124,14 @@ async def test_hidden_arg_without_default_uses_parent_default(add_tool):
121
  assert sorted(new_tool.parameters["properties"]) == ["old_x"]
122
  # Should pass old_x=3 and let parent use its default old_y=10
123
  result = await new_tool.run(arguments={"old_x": 3})
124
- assert result[0].text == "13" # type: ignore[attr-defined]
 
125
 
126
 
127
  async def test_mixed_hidden_args_with_custom_function(add_tool):
128
  """Test custom function with both hidden constant and hidden default parameters."""
129
 
130
- async def custom_fn(visible_x: int) -> int:
131
  # This custom function should receive the transformed visible parameter
132
  # and the hidden parameters should be automatically handled
133
  result = await forward(visible_x=visible_x)
@@ -146,7 +150,8 @@ async def test_mixed_hidden_args_with_custom_function(add_tool):
146
  assert sorted(new_tool.parameters["properties"]) == ["visible_x"]
147
  # Should pass visible_x=7 as old_x=7 and old_y=25 to parent
148
  result = await new_tool.run(arguments={"visible_x": 7})
149
- assert result[0].text == "32" # type: ignore[attr-defined]
 
150
 
151
 
152
  async def test_hide_required_param_without_default_raises_error():
@@ -184,13 +189,13 @@ async def test_hide_required_param_with_user_default_works():
184
  assert sorted(new_tool.parameters["properties"]) == ["optional_param"]
185
  # Should pass required_param=5 and optional_param=20 to parent
186
  result = await new_tool.run(arguments={"optional_param": 20})
187
- assert result[0].text == "25" # type: ignore[attr-defined]
188
 
189
 
190
  async def test_forward_with_argument_mapping(add_tool):
191
  """Test that forward() applies argument mapping correctly."""
192
 
193
- async def custom_fn(new_x: int, new_y: int = 5) -> int:
194
  return await forward(new_x=new_x, new_y=new_y)
195
 
196
  new_tool = Tool.from_tool(
@@ -203,11 +208,12 @@ async def test_forward_with_argument_mapping(add_tool):
203
  )
204
 
205
  result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
206
- assert result[0].text == "5" # type: ignore[attr-defined]
 
207
 
208
 
209
  async def test_forward_with_incorrect_args_raises_error(add_tool):
210
- async def custom_fn(new_x: int, new_y: int = 5) -> int:
211
  # the forward should use the new args, not the old ones
212
  return await forward(old_x=new_x, old_y=new_y)
213
 
@@ -228,7 +234,7 @@ async def test_forward_with_incorrect_args_raises_error(add_tool):
228
  async def test_forward_raw_without_argument_mapping(add_tool):
229
  """Test that forward_raw() calls parent directly without mapping."""
230
 
231
- async def custom_fn(new_x: int, new_y: int = 5) -> int:
232
  # Call parent directly with original argument names
233
  result = await forward_raw(old_x=new_x, old_y=new_y)
234
  return result
@@ -243,17 +249,19 @@ async def test_forward_raw_without_argument_mapping(add_tool):
243
  )
244
 
245
  result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
246
- assert result[0].text == "5" # type: ignore[attr-defined]
 
247
 
248
 
249
  async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
250
  async def custom_fn(extra: int, **kwargs) -> int:
251
  sum = await forward(**kwargs)
252
- return int(sum[0].text) + extra # type: ignore[attr-defined]
253
 
254
  new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
255
  result = await new_tool.run(arguments={"extra": 1, "old_x": 2, "old_y": 3})
256
- assert result[0].text == "6" # type: ignore[attr-defined]
 
257
  assert new_tool.parameters["required"] == IsList(
258
  "extra", "old_x", check_order=False
259
  )
@@ -263,20 +271,21 @@ async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
263
 
264
 
265
  async def test_fn_with_kwargs_passes_through_original_args(add_tool):
266
- async def custom_fn(new_y: int = 5, **kwargs) -> int:
267
  assert kwargs == {"old_y": 3}
268
  result = await forward(old_x=new_y, **kwargs)
269
  return result
270
 
271
  new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
272
  result = await new_tool.run(arguments={"new_y": 2, "old_y": 3})
273
- assert result[0].text == "5" # type: ignore[attr-defined]
 
274
 
275
 
276
  async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool):
277
  """Test that **kwargs receives arguments with their transformed names from transform_args."""
278
 
279
- async def custom_fn(new_x: int, **kwargs) -> int:
280
  # kwargs should contain 'old_y': 3 (transformed name), not 'old_y': 3 (original name)
281
  assert kwargs == {"old_y": 3}
282
  result = await forward(new_x=new_x, **kwargs)
@@ -288,13 +297,16 @@ async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool):
288
  transform_args={"old_x": ArgTransform(name="new_x")},
289
  )
290
  result = await new_tool.run(arguments={"new_x": 2, "old_y": 3})
291
- assert result[0].text == "5" # type: ignore[attr-defined]
 
292
 
293
 
294
  async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool):
295
  """Test that function can explicitly handle some transformed args while others pass through kwargs."""
296
 
297
- async def custom_fn(new_x: int, some_other_param: str = "default", **kwargs) -> int:
 
 
298
  # x is explicitly handled, y should come through kwargs with transformed name
299
  assert kwargs == {"old_y": 7}
300
  result = await forward(new_x=new_x, **kwargs)
@@ -308,13 +320,14 @@ async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool):
308
  result = await new_tool.run(
309
  arguments={"new_x": 3, "old_y": 7, "some_other_param": "test"}
310
  )
311
- assert result[0].text == "10" # type: ignore[attr-defined]
 
312
 
313
 
314
  async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool):
315
  """Test **kwargs behavior with mix of mapped and unmapped arguments."""
316
 
317
- async def custom_fn(new_x: int, **kwargs) -> int:
318
  # new_x is explicitly handled, old_y should pass through kwargs with original name (unmapped)
319
  assert kwargs == {"old_y": 5}
320
  result = await forward(new_x=new_x, **kwargs)
@@ -326,13 +339,14 @@ async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool):
326
  transform_args={"old_x": ArgTransform(name="new_x")},
327
  ) # only map 'a'
328
  result = await new_tool.run(arguments={"new_x": 1, "old_y": 5})
329
- assert result[0].text == "6" # type: ignore[attr-defined]
 
330
 
331
 
332
  async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool):
333
  """Test that dropped arguments don't appear in **kwargs."""
334
 
335
- async def custom_fn(new_x: int, **kwargs) -> int:
336
  # 'b' was dropped, so kwargs should be empty
337
  assert kwargs == {}
338
  # Can't use 'old_y' since it was dropped, so just use 'old_x' mapped to 'new_x'
@@ -349,7 +363,7 @@ async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool):
349
  ) # drop 'old_y'
350
  result = await new_tool.run(arguments={"new_x": 8})
351
  # 8 + 10 (default value of b in parent)
352
- assert result[0].text == "18" # type: ignore[attr-defined]
353
 
354
 
355
  async def test_forward_outside_context_raises_error():
@@ -469,18 +483,18 @@ async def test_tool_transform_chaining(add_tool):
469
  tool2 = Tool.from_tool(tool1, transform_args={"x": ArgTransform(name="final_x")})
470
 
471
  result = await tool2.run(arguments={"final_x": 5})
472
- assert result[0].text == "15" # type: ignore[attr-defined]
473
 
474
  # Transform tool1 with custom function that handles all parameters
475
  async def custom(final_x: int, **kwargs) -> str:
476
  result = await forward(final_x=final_x, **kwargs)
477
- return f"custom {result[0].text}" # Extract text from content
478
 
479
  tool3 = Tool.from_tool(
480
  tool1, transform_fn=custom, transform_args={"x": ArgTransform(name="final_x")}
481
  )
482
  result = await tool3.run(arguments={"final_x": 3, "old_y": 5})
483
- assert result[0].text == "custom 8" # type: ignore[attr-defined]
484
 
485
 
486
  class MyModel(BaseModel):
@@ -608,7 +622,7 @@ async def test_arg_transform_precedence_over_function_with_kwargs():
608
  # Function signature has different types/defaults than ArgTransform
609
  async def custom_fn(x: str = "function_default", **kwargs) -> str:
610
  result = await forward(x=x, **kwargs)
611
- return f"custom: {result}"
612
 
613
  tool = Tool.from_tool(
614
  base,
@@ -635,7 +649,7 @@ async def test_arg_transform_precedence_over_function_with_kwargs():
635
  # Test it works at runtime
636
  result = await tool.run(arguments={"y": "test"})
637
  # Should use ArgTransform default of 42
638
- assert "42: test" in result[0].text # type: ignore[attr-defined]
639
 
640
 
641
  def test_arg_transform_combined_attributes():
@@ -680,7 +694,7 @@ async def test_arg_transform_type_precedence_runtime():
680
  # Convert string back to int for the original function
681
  result = await forward_raw(x=int(x), y=y)
682
  # Extract the text from the result
683
- result_text = result[0].text
684
  return f"String input '{x}' converted to result: {result_text}"
685
 
686
  tool = Tool.from_tool(
@@ -692,8 +706,8 @@ async def test_arg_transform_type_precedence_runtime():
692
 
693
  # Test it works with string input
694
  result = await tool.run(arguments={"x": "5", "y": 3})
695
- assert "String input '5'" in result[0].text # type: ignore[attr-defined]
696
- assert "result: 8" in result[0].text # type: ignore[attr-defined]
697
 
698
 
699
  class TestProxy:
@@ -728,7 +742,7 @@ class TestProxy:
728
  async with Client(proxy_server) as client:
729
  # The tool should be registered with its transformed name
730
  result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2})
731
- assert result[0].text == "3" # type: ignore[attr-defined]
732
 
733
 
734
  async def test_arg_transform_default_factory():
@@ -751,7 +765,7 @@ async def test_arg_transform_default_factory():
751
 
752
  # Should work without providing timestamp (gets value from factory)
753
  result = await new_tool.run(arguments={"x": 42})
754
- assert result[0].text == "42_12345.0" # type: ignore[attr-defined]
755
 
756
 
757
  async def test_arg_transform_default_factory_called_each_time():
@@ -779,11 +793,11 @@ async def test_arg_transform_default_factory_called_each_time():
779
 
780
  # First call
781
  result1 = await new_tool.run(arguments={"x": 1})
782
- assert result1[0].text == "1_1" # type: ignore[attr-defined]
783
 
784
  # Second call should get a different value
785
  result2 = await new_tool.run(arguments={"x": 2})
786
- assert result2[0].text == "2_2" # type: ignore[attr-defined]
787
 
788
 
789
  async def test_arg_transform_hidden_with_default_factory():
@@ -808,7 +822,7 @@ async def test_arg_transform_hidden_with_default_factory():
808
 
809
  # Should pass hidden request_id with factory value
810
  result = await new_tool.run(arguments={"x": 42})
811
- assert result[0].text == "42_req_123" # type: ignore[attr-defined]
812
 
813
 
814
  async def test_arg_transform_default_and_factory_raises_error():
@@ -845,7 +859,7 @@ async def test_arg_transform_required_true():
845
 
846
  # Should work when parameter is provided
847
  result = await new_tool.run(arguments={"optional_param": 100})
848
- assert result[0].text == "value: 100" # type: ignore
849
 
850
  # Should fail when parameter is not provided
851
  with pytest.raises(TypeError, match="Missing required argument"):
@@ -892,7 +906,7 @@ async def test_arg_transform_required_with_rename():
892
 
893
  # Should work with new name
894
  result = await new_tool.run(arguments={"new_param": 200})
895
- assert result[0].text == "value: 200" # type: ignore
896
 
897
 
898
  async def test_arg_transform_required_true_with_default_raises_error():
@@ -934,7 +948,7 @@ async def test_arg_transform_required_no_change():
934
 
935
  # Should work as expected
936
  result = await new_tool.run(arguments={"req": 1})
937
- assert result[0].text == "values: 1, 42" # type: ignore
938
 
939
 
940
  async def test_arg_transform_hide_and_required_raises_error():
@@ -966,7 +980,7 @@ class TestEnableDisable:
966
  assert {tool.name for tool in tools} == {"new_add"}
967
 
968
  result = await client.call_tool("new_add", {"x": 1, "y": 2})
969
- assert result[0].text == "3" # type: ignore[attr-defined]
970
 
971
  with pytest.raises(ToolError):
972
  await client.call_tool("add", {"x": 1, "y": 2})
@@ -1019,3 +1033,266 @@ def test_arg_transform_examples_in_schema(add_tool):
1019
  )
1020
  prop3 = get_property(new_tool3, "old_x")
1021
  assert "examples" not in prop3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  import pytest
6
  from dirty_equals import IsList
7
+ from mcp.types import TextContent
8
+ from pydantic import BaseModel, Field, TypeAdapter
9
  from typing_extensions import TypedDict
10
 
11
  from fastmcp import FastMCP
12
  from fastmcp.client.client import Client
13
  from fastmcp.exceptions import ToolError
14
  from fastmcp.tools import Tool, forward, forward_raw
15
+ from fastmcp.tools.tool import FunctionTool, ToolResult
16
  from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
17
 
18
 
 
53
  add_tool, transform_args={"old_x": ArgTransform(name="new_x")}
54
  )
55
  result = await new_tool.run(arguments={"new_x": 1})
56
+ # The parent tool returns int which gets wrapped as structured output
57
+ assert result.structured_content == {"result": 11}
58
 
59
 
60
  async def test_tool_defaults_are_maintained_on_mapped_args(add_tool):
 
62
  add_tool, transform_args={"old_y": ArgTransform(name="new_y")}
63
  )
64
  result = await new_tool.run(arguments={"old_x": 1})
65
+ # The parent tool returns int which gets wrapped as structured output
66
+ assert result.structured_content == {"result": 11}
67
 
68
 
69
  def test_tool_change_arg_name(add_tool):
 
90
  )
91
  assert sorted(new_tool.parameters["properties"]) == ["old_x"]
92
  result = await new_tool.run(arguments={"old_x": 1})
93
+ assert result.structured_content == {"result": 11}
94
 
95
 
96
  async def test_dropped_args_error_if_provided(add_tool):
 
112
  assert sorted(new_tool.parameters["properties"]) == ["old_x"]
113
  # Should pass old_x=5 and old_y=20 to parent
114
  result = await new_tool.run(arguments={"old_x": 5})
115
+ assert result.structured_content == {"result": 25}
116
 
117
 
118
  async def test_hidden_arg_without_default_uses_parent_default(add_tool):
 
124
  assert sorted(new_tool.parameters["properties"]) == ["old_x"]
125
  # Should pass old_x=3 and let parent use its default old_y=10
126
  result = await new_tool.run(arguments={"old_x": 3})
127
+ assert result.content[0].text == "13" # type: ignore[attr-defined]
128
+ assert result.structured_content == {"result": 13}
129
 
130
 
131
  async def test_mixed_hidden_args_with_custom_function(add_tool):
132
  """Test custom function with both hidden constant and hidden default parameters."""
133
 
134
+ async def custom_fn(visible_x: int) -> ToolResult:
135
  # This custom function should receive the transformed visible parameter
136
  # and the hidden parameters should be automatically handled
137
  result = await forward(visible_x=visible_x)
 
150
  assert sorted(new_tool.parameters["properties"]) == ["visible_x"]
151
  # Should pass visible_x=7 as old_x=7 and old_y=25 to parent
152
  result = await new_tool.run(arguments={"visible_x": 7})
153
+ assert result.content[0].text == "32" # type: ignore[attr-defined]
154
+ assert result.structured_content == {"result": 32}
155
 
156
 
157
  async def test_hide_required_param_without_default_raises_error():
 
189
  assert sorted(new_tool.parameters["properties"]) == ["optional_param"]
190
  # Should pass required_param=5 and optional_param=20 to parent
191
  result = await new_tool.run(arguments={"optional_param": 20})
192
+ assert result.structured_content == {"result": 25}
193
 
194
 
195
  async def test_forward_with_argument_mapping(add_tool):
196
  """Test that forward() applies argument mapping correctly."""
197
 
198
+ async def custom_fn(new_x: int, new_y: int = 5) -> ToolResult:
199
  return await forward(new_x=new_x, new_y=new_y)
200
 
201
  new_tool = Tool.from_tool(
 
208
  )
209
 
210
  result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
211
+ assert result.content[0].text == "5" # type: ignore[attr-defined]
212
+ assert result.structured_content == {"result": 5}
213
 
214
 
215
  async def test_forward_with_incorrect_args_raises_error(add_tool):
216
+ async def custom_fn(new_x: int, new_y: int = 5) -> ToolResult:
217
  # the forward should use the new args, not the old ones
218
  return await forward(old_x=new_x, old_y=new_y)
219
 
 
234
  async def test_forward_raw_without_argument_mapping(add_tool):
235
  """Test that forward_raw() calls parent directly without mapping."""
236
 
237
+ async def custom_fn(new_x: int, new_y: int = 5) -> ToolResult:
238
  # Call parent directly with original argument names
239
  result = await forward_raw(old_x=new_x, old_y=new_y)
240
  return result
 
249
  )
250
 
251
  result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
252
+ assert result.content[0].text == "5" # type: ignore[attr-defined]
253
+ assert result.structured_content == {"result": 5}
254
 
255
 
256
  async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
257
  async def custom_fn(extra: int, **kwargs) -> int:
258
  sum = await forward(**kwargs)
259
+ return int(sum.content[0].text) + extra # type: ignore[attr-defined]
260
 
261
  new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
262
  result = await new_tool.run(arguments={"extra": 1, "old_x": 2, "old_y": 3})
263
+ assert result.content[0].text == "6" # type: ignore[attr-defined]
264
+ assert result.structured_content == {"result": 6}
265
  assert new_tool.parameters["required"] == IsList(
266
  "extra", "old_x", check_order=False
267
  )
 
271
 
272
 
273
  async def test_fn_with_kwargs_passes_through_original_args(add_tool):
274
+ async def custom_fn(new_y: int = 5, **kwargs) -> ToolResult:
275
  assert kwargs == {"old_y": 3}
276
  result = await forward(old_x=new_y, **kwargs)
277
  return result
278
 
279
  new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
280
  result = await new_tool.run(arguments={"new_y": 2, "old_y": 3})
281
+ assert result.content[0].text == "5" # type: ignore[attr-defined]
282
+ assert result.structured_content == {"result": 5}
283
 
284
 
285
  async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool):
286
  """Test that **kwargs receives arguments with their transformed names from transform_args."""
287
 
288
+ async def custom_fn(new_x: int, **kwargs) -> ToolResult:
289
  # kwargs should contain 'old_y': 3 (transformed name), not 'old_y': 3 (original name)
290
  assert kwargs == {"old_y": 3}
291
  result = await forward(new_x=new_x, **kwargs)
 
297
  transform_args={"old_x": ArgTransform(name="new_x")},
298
  )
299
  result = await new_tool.run(arguments={"new_x": 2, "old_y": 3})
300
+ assert result.content[0].text == "5" # type: ignore[attr-defined]
301
+ assert result.structured_content == {"result": 5}
302
 
303
 
304
  async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool):
305
  """Test that function can explicitly handle some transformed args while others pass through kwargs."""
306
 
307
+ async def custom_fn(
308
+ new_x: int, some_other_param: str = "default", **kwargs
309
+ ) -> ToolResult:
310
  # x is explicitly handled, y should come through kwargs with transformed name
311
  assert kwargs == {"old_y": 7}
312
  result = await forward(new_x=new_x, **kwargs)
 
320
  result = await new_tool.run(
321
  arguments={"new_x": 3, "old_y": 7, "some_other_param": "test"}
322
  )
323
+ assert result.content[0].text == "10" # type: ignore[attr-defined]
324
+ assert result.structured_content == {"result": 10}
325
 
326
 
327
  async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool):
328
  """Test **kwargs behavior with mix of mapped and unmapped arguments."""
329
 
330
+ async def custom_fn(new_x: int, **kwargs) -> ToolResult:
331
  # new_x is explicitly handled, old_y should pass through kwargs with original name (unmapped)
332
  assert kwargs == {"old_y": 5}
333
  result = await forward(new_x=new_x, **kwargs)
 
339
  transform_args={"old_x": ArgTransform(name="new_x")},
340
  ) # only map 'a'
341
  result = await new_tool.run(arguments={"new_x": 1, "old_y": 5})
342
+ assert result.content[0].text == "6" # type: ignore[attr-defined]
343
+ assert result.structured_content == {"result": 6}
344
 
345
 
346
  async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool):
347
  """Test that dropped arguments don't appear in **kwargs."""
348
 
349
+ async def custom_fn(new_x: int, **kwargs) -> ToolResult:
350
  # 'b' was dropped, so kwargs should be empty
351
  assert kwargs == {}
352
  # Can't use 'old_y' since it was dropped, so just use 'old_x' mapped to 'new_x'
 
363
  ) # drop 'old_y'
364
  result = await new_tool.run(arguments={"new_x": 8})
365
  # 8 + 10 (default value of b in parent)
366
+ assert result.content[0].text == "18" # type: ignore[attr-defined]
367
 
368
 
369
  async def test_forward_outside_context_raises_error():
 
483
  tool2 = Tool.from_tool(tool1, transform_args={"x": ArgTransform(name="final_x")})
484
 
485
  result = await tool2.run(arguments={"final_x": 5})
486
+ assert result.content[0].text == "15" # type: ignore[attr-defined]
487
 
488
  # Transform tool1 with custom function that handles all parameters
489
  async def custom(final_x: int, **kwargs) -> str:
490
  result = await forward(final_x=final_x, **kwargs)
491
+ return f"custom {result.content[0].text}" # Extract text from content # type: ignore[attr-defined]
492
 
493
  tool3 = Tool.from_tool(
494
  tool1, transform_fn=custom, transform_args={"x": ArgTransform(name="final_x")}
495
  )
496
  result = await tool3.run(arguments={"final_x": 3, "old_y": 5})
497
+ assert result.content[0].text == "custom 8" # type: ignore[attr-defined]
498
 
499
 
500
  class MyModel(BaseModel):
 
622
  # Function signature has different types/defaults than ArgTransform
623
  async def custom_fn(x: str = "function_default", **kwargs) -> str:
624
  result = await forward(x=x, **kwargs)
625
+ return f"custom: {result.content[0].text}" # type: ignore[attr-defined]
626
 
627
  tool = Tool.from_tool(
628
  base,
 
649
  # Test it works at runtime
650
  result = await tool.run(arguments={"y": "test"})
651
  # Should use ArgTransform default of 42
652
+ assert "42: test" in result.content[0].text # type: ignore[attr-defined]
653
 
654
 
655
  def test_arg_transform_combined_attributes():
 
694
  # Convert string back to int for the original function
695
  result = await forward_raw(x=int(x), y=y)
696
  # Extract the text from the result
697
+ result_text = result.content[0].text # type: ignore[attr-defined]
698
  return f"String input '{x}' converted to result: {result_text}"
699
 
700
  tool = Tool.from_tool(
 
706
 
707
  # Test it works with string input
708
  result = await tool.run(arguments={"x": "5", "y": 3})
709
+ assert "String input '5'" in result.content[0].text # type: ignore[attr-defined]
710
+ assert "result: 8" in result.content[0].text # type: ignore[attr-defined]
711
 
712
 
713
  class TestProxy:
 
742
  async with Client(proxy_server) as client:
743
  # The tool should be registered with its transformed name
744
  result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2})
745
+ assert result.content[0].text == "3" # type: ignore[attr-defined]
746
 
747
 
748
  async def test_arg_transform_default_factory():
 
765
 
766
  # Should work without providing timestamp (gets value from factory)
767
  result = await new_tool.run(arguments={"x": 42})
768
+ assert result.content[0].text == "42_12345.0" # type: ignore[attr-defined]
769
 
770
 
771
  async def test_arg_transform_default_factory_called_each_time():
 
793
 
794
  # First call
795
  result1 = await new_tool.run(arguments={"x": 1})
796
+ assert result1.content[0].text == "1_1" # type: ignore[attr-defined]
797
 
798
  # Second call should get a different value
799
  result2 = await new_tool.run(arguments={"x": 2})
800
+ assert result2.content[0].text == "2_2" # type: ignore[attr-defined]
801
 
802
 
803
  async def test_arg_transform_hidden_with_default_factory():
 
822
 
823
  # Should pass hidden request_id with factory value
824
  result = await new_tool.run(arguments={"x": 42})
825
+ assert result.content[0].text == "42_req_123" # type: ignore[attr-defined]
826
 
827
 
828
  async def test_arg_transform_default_and_factory_raises_error():
 
859
 
860
  # Should work when parameter is provided
861
  result = await new_tool.run(arguments={"optional_param": 100})
862
+ assert result.content[0].text == "value: 100" # type: ignore
863
 
864
  # Should fail when parameter is not provided
865
  with pytest.raises(TypeError, match="Missing required argument"):
 
906
 
907
  # Should work with new name
908
  result = await new_tool.run(arguments={"new_param": 200})
909
+ assert result.content[0].text == "value: 200" # type: ignore
910
 
911
 
912
  async def test_arg_transform_required_true_with_default_raises_error():
 
948
 
949
  # Should work as expected
950
  result = await new_tool.run(arguments={"req": 1})
951
+ assert result.content[0].text == "values: 1, 42" # type: ignore
952
 
953
 
954
  async def test_arg_transform_hide_and_required_raises_error():
 
980
  assert {tool.name for tool in tools} == {"new_add"}
981
 
982
  result = await client.call_tool("new_add", {"x": 1, "y": 2})
983
+ assert result.content[0].text == "3" # type: ignore[attr-defined]
984
 
985
  with pytest.raises(ToolError):
986
  await client.call_tool("add", {"x": 1, "y": 2})
 
1033
  )
1034
  prop3 = get_property(new_tool3, "old_x")
1035
  assert "examples" not in prop3
1036
+
1037
+
1038
+ class TestTransformToolOutputSchema:
1039
+ """Test output schema handling in transformed tools."""
1040
+
1041
+ @pytest.fixture
1042
+ def base_string_tool(self) -> FunctionTool:
1043
+ """Tool that returns a string (gets wrapped)."""
1044
+
1045
+ def string_tool(x: int) -> str:
1046
+ return f"Result: {x}"
1047
+
1048
+ return Tool.from_function(string_tool)
1049
+
1050
+ @pytest.fixture
1051
+ def base_dict_tool(self) -> FunctionTool:
1052
+ """Tool that returns a dict (object type, not wrapped)."""
1053
+
1054
+ def dict_tool(x: int) -> dict[str, int]:
1055
+ return {"value": x}
1056
+
1057
+ return Tool.from_function(dict_tool)
1058
+
1059
+ def test_transform_inherits_parent_output_schema(self, base_string_tool):
1060
+ """Test that transformed tool inherits parent's output schema by default."""
1061
+ new_tool = Tool.from_tool(base_string_tool)
1062
+
1063
+ # Should inherit parent's wrapped string schema
1064
+ expected_schema = {
1065
+ "type": "object",
1066
+ "properties": {"result": {"type": "string"}},
1067
+ "x-fastmcp-wrap-result": True,
1068
+ }
1069
+ assert new_tool.output_schema == expected_schema
1070
+ assert new_tool.output_schema == base_string_tool.output_schema
1071
+
1072
+ def test_transform_with_explicit_output_schema_false(self, base_string_tool):
1073
+ """Test that output_schema=False disables structured output."""
1074
+ new_tool = Tool.from_tool(base_string_tool, output_schema=False)
1075
+
1076
+ assert new_tool.output_schema is None
1077
+
1078
+ async def test_transform_output_schema_false_runtime(self, base_string_tool):
1079
+ """Test runtime behavior with output_schema=False."""
1080
+ new_tool = Tool.from_tool(base_string_tool, output_schema=False)
1081
+
1082
+ # Debug: check that output_schema is actually None
1083
+ assert new_tool.output_schema is None, (
1084
+ f"Expected None, got {new_tool.output_schema}"
1085
+ )
1086
+
1087
+ result = await new_tool.run({"x": 5})
1088
+ assert result.structured_content is None
1089
+ assert result.content[0].text == "Result: 5" # type: ignore[attr-defined]
1090
+
1091
+ def test_transform_with_explicit_output_schema_dict(self, base_string_tool):
1092
+ """Test that explicit output schema overrides parent."""
1093
+ custom_schema = {
1094
+ "type": "object",
1095
+ "properties": {"message": {"type": "string"}},
1096
+ }
1097
+ new_tool = Tool.from_tool(base_string_tool, output_schema=custom_schema)
1098
+
1099
+ assert new_tool.output_schema == custom_schema
1100
+ assert new_tool.output_schema != base_string_tool.output_schema
1101
+
1102
+ async def test_transform_explicit_schema_runtime(self, base_string_tool):
1103
+ """Test runtime behavior with explicit output schema."""
1104
+ custom_schema = {"type": "string", "minLength": 1}
1105
+ new_tool = Tool.from_tool(base_string_tool, output_schema=custom_schema)
1106
+
1107
+ result = await new_tool.run({"x": 10})
1108
+ # Non-object explicit schemas disable structured content
1109
+ assert result.structured_content is None
1110
+ assert result.content[0].text == "Result: 10" # type: ignore[attr-defined]
1111
+
1112
+ def test_transform_with_custom_function_inferred_schema(self, base_dict_tool):
1113
+ """Test that custom function's output schema is inferred."""
1114
+
1115
+ async def custom_fn(x: int) -> str:
1116
+ result = await forward(x=x)
1117
+ return f"Custom: {result.content[0].text}" # type: ignore[attr-defined]
1118
+
1119
+ new_tool = Tool.from_tool(base_dict_tool, transform_fn=custom_fn)
1120
+
1121
+ # Should infer string schema from custom function and wrap it
1122
+ expected_schema = {
1123
+ "type": "object",
1124
+ "properties": {"result": {"type": "string"}},
1125
+ "x-fastmcp-wrap-result": True,
1126
+ }
1127
+ assert new_tool.output_schema == expected_schema
1128
+
1129
+ async def test_transform_custom_function_runtime(self, base_dict_tool):
1130
+ """Test runtime behavior with custom function that has inferred schema."""
1131
+
1132
+ async def custom_fn(x: int) -> str:
1133
+ result = await forward(x=x)
1134
+ return f"Custom: {result.content[0].text}" # type: ignore[attr-defined]
1135
+
1136
+ new_tool = Tool.from_tool(base_dict_tool, transform_fn=custom_fn)
1137
+
1138
+ result = await new_tool.run({"x": 3})
1139
+ # Should wrap string result
1140
+ assert result.structured_content == {"result": 'Custom: {\n "value": 3\n}'}
1141
+
1142
+ def test_transform_custom_function_fallback_to_parent(self, base_string_tool):
1143
+ """Test that custom function without output annotation falls back to parent."""
1144
+
1145
+ async def custom_fn(x: int):
1146
+ # No return annotation - should fallback to parent schema
1147
+ result = await forward(x=x)
1148
+ return result
1149
+
1150
+ new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn)
1151
+
1152
+ # Should use parent's schema since custom function has no annotation
1153
+ assert new_tool.output_schema == base_string_tool.output_schema
1154
+
1155
+ def test_transform_custom_function_explicit_overrides(self, base_string_tool):
1156
+ """Test that explicit output_schema overrides both custom function and parent."""
1157
+
1158
+ async def custom_fn(x: int) -> dict[str, str]:
1159
+ return {"custom": "value"}
1160
+
1161
+ explicit_schema = {"type": "array", "items": {"type": "number"}}
1162
+ new_tool = Tool.from_tool(
1163
+ base_string_tool, transform_fn=custom_fn, output_schema=explicit_schema
1164
+ )
1165
+
1166
+ # Explicit schema should win
1167
+ assert new_tool.output_schema == explicit_schema
1168
+
1169
+ async def test_transform_custom_function_object_return(self, base_string_tool):
1170
+ """Test custom function returning object type."""
1171
+
1172
+ async def custom_fn(x: int) -> dict[str, int]:
1173
+ await forward(x=x)
1174
+ return {"original": x, "transformed": x * 2}
1175
+
1176
+ new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn)
1177
+
1178
+ # Object types should not be wrapped
1179
+ expected_schema = TypeAdapter(dict[str, int]).json_schema()
1180
+ assert new_tool.output_schema == expected_schema
1181
+ assert "x-fastmcp-wrap-result" not in new_tool.output_schema # type: ignore[attr-defined]
1182
+
1183
+ result = await new_tool.run({"x": 4})
1184
+ # Direct value, not wrapped
1185
+ assert result.structured_content == {"original": 4, "transformed": 8}
1186
+
1187
+ async def test_transform_preserves_wrap_marker_behavior(self, base_string_tool):
1188
+ """Test that wrap marker behavior is preserved through transformation."""
1189
+ new_tool = Tool.from_tool(base_string_tool)
1190
+
1191
+ result = await new_tool.run({"x": 7})
1192
+ # Should wrap because parent schema has wrap marker
1193
+ assert result.structured_content == {"result": "Result: 7"}
1194
+ assert "x-fastmcp-wrap-result" in new_tool.output_schema # type: ignore[attr-defined]
1195
+
1196
+ def test_transform_chained_output_schema_inheritance(self, base_string_tool):
1197
+ """Test output schema inheritance through multiple transformations."""
1198
+ # First transformation keeps parent schema
1199
+ tool1 = Tool.from_tool(base_string_tool)
1200
+ assert tool1.output_schema == base_string_tool.output_schema
1201
+
1202
+ # Second transformation also inherits
1203
+ tool2 = Tool.from_tool(tool1)
1204
+ assert (
1205
+ tool2.output_schema == tool1.output_schema == base_string_tool.output_schema
1206
+ )
1207
+
1208
+ # Third transformation with explicit override
1209
+ custom_schema = {"type": "number"}
1210
+ tool3 = Tool.from_tool(tool2, output_schema=custom_schema)
1211
+ assert tool3.output_schema == custom_schema
1212
+ assert tool3.output_schema != tool2.output_schema
1213
+
1214
+ async def test_transform_mixed_structured_unstructured_content(
1215
+ self, base_string_tool
1216
+ ):
1217
+ """Test transformation handling of mixed content types."""
1218
+
1219
+ async def custom_fn(x: int):
1220
+ # Return mixed content including ToolResult
1221
+ if x == 1:
1222
+ return ["text", {"data": x}]
1223
+ else:
1224
+ # Return ToolResult directly
1225
+ return ToolResult(
1226
+ content=[TextContent(type="text", text=f"Custom: {x}")],
1227
+ structured_content={"custom_value": x},
1228
+ )
1229
+
1230
+ new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn)
1231
+
1232
+ # Test mixed content return
1233
+ result1 = await new_tool.run({"x": 1})
1234
+ assert result1.structured_content == {"result": ["text", {"data": 1}]}
1235
+
1236
+ # Test ToolResult return
1237
+ result2 = await new_tool.run({"x": 2})
1238
+ assert result2.structured_content == {"custom_value": 2}
1239
+ assert result2.content[0].text == "Custom: 2" # type: ignore[attr-defined]
1240
+
1241
+ def test_transform_output_schema_with_arg_transforms(self, base_string_tool):
1242
+ """Test that output schema works correctly with argument transformations."""
1243
+
1244
+ async def custom_fn(new_x: int) -> dict[str, str]:
1245
+ result = await forward(new_x=new_x)
1246
+ return {"transformed": result.content[0].text} # type: ignore[attr-defined]
1247
+
1248
+ new_tool = Tool.from_tool(
1249
+ base_string_tool,
1250
+ transform_fn=custom_fn,
1251
+ transform_args={"x": ArgTransform(name="new_x")},
1252
+ )
1253
+
1254
+ # Should infer object schema from custom function
1255
+ expected_schema = TypeAdapter(dict[str, str]).json_schema()
1256
+ assert new_tool.output_schema == expected_schema
1257
+
1258
+ async def test_transform_output_schema_none_vs_false(self, base_string_tool):
1259
+ """Test None vs False behavior for output_schema in transforms."""
1260
+ # None (default) should use smart fallback (inherit from parent)
1261
+ tool_none = Tool.from_tool(base_string_tool) # default output_schema=None
1262
+ assert tool_none.output_schema == base_string_tool.output_schema # Inherits
1263
+
1264
+ # False should explicitly disable
1265
+ tool_false = Tool.from_tool(base_string_tool, output_schema=False)
1266
+ assert tool_false.output_schema is None
1267
+
1268
+ # Different behavior at runtime
1269
+ result_none = await tool_none.run({"x": 5})
1270
+ result_false = await tool_false.run({"x": 5})
1271
+
1272
+ assert result_none.structured_content == {
1273
+ "result": "Result: 5"
1274
+ } # Inherits wrapping
1275
+ assert result_false.structured_content is None # Disabled
1276
+ assert result_none.content[0].text == result_false.content[0].text # type: ignore[attr-defined]
1277
+
1278
+ async def test_transform_output_schema_with_tool_result_return(
1279
+ self, base_string_tool
1280
+ ):
1281
+ """Test transform when custom function returns ToolResult directly."""
1282
+
1283
+ async def custom_fn(x: int) -> ToolResult:
1284
+ # Custom function returns ToolResult - should bypass schema handling
1285
+ return ToolResult(
1286
+ content=[TextContent(type="text", text=f"Direct: {x}")],
1287
+ structured_content={"direct_value": x, "doubled": x * 2},
1288
+ )
1289
+
1290
+ new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn)
1291
+
1292
+ # ToolResult return type should result in None output schema
1293
+ assert new_tool.output_schema is None
1294
+
1295
+ result = await new_tool.run({"x": 6})
1296
+ # Should use ToolResult content directly
1297
+ assert result.content[0].text == "Direct: 6" # type: ignore[attr-defined]
1298
+ assert result.structured_content == {"direct_value": 6, "doubled": 12}
tests/utilities/openapi/test_openapi.py CHANGED
@@ -687,6 +687,29 @@ def test_multiple_tags_preserved(bookstore_schema):
687
  assert len(get_books.tags) == 3
688
 
689
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
690
  # --- Tests for BookStore schema --- #
691
 
692
 
 
687
  assert len(get_books.tags) == 3
688
 
689
 
690
+ def test_openapi_extensions(petstore_schema):
691
+ """Test that OpenAPI extensions (x-*) are correctly parsed from operations."""
692
+ # Add extensions to a route
693
+ petstore_schema["paths"]["/pets"]["get"]["x-rate-limit"] = 100
694
+ petstore_schema["paths"]["/pets"]["get"]["x-custom-auth"] = "bearer"
695
+ petstore_schema["paths"]["/pets"]["get"]["x-internal"] = True
696
+
697
+ # Parse the modified schema
698
+ routes = parse_openapi_to_http_routes(petstore_schema)
699
+
700
+ # Find the GET /pets route
701
+ get_pets = next(
702
+ (r for r in routes if r.method == "GET" and r.path == "/pets"), None
703
+ )
704
+ assert get_pets is not None
705
+
706
+ # Should have extensions
707
+ assert get_pets.extensions["x-rate-limit"] == 100
708
+ assert get_pets.extensions["x-custom-auth"] == "bearer"
709
+ assert get_pets.extensions["x-internal"] is True
710
+ assert len(get_pets.extensions) == 3
711
+
712
+
713
  # --- Tests for BookStore schema --- #
714
 
715
 
tests/utilities/test_json_schema_type.py ADDED
@@ -0,0 +1,1441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Any, Union
3
+
4
+ import pytest
5
+ from pydantic import AnyUrl, BaseModel, TypeAdapter, ValidationError
6
+
7
+ from fastmcp.utilities.json_schema_type import (
8
+ _hash_schema,
9
+ _merge_defaults,
10
+ json_schema_to_type,
11
+ )
12
+
13
+
14
+ class TestSimpleTypes:
15
+ """Test suite for basic type validation."""
16
+
17
+ @pytest.fixture
18
+ def simple_string(self):
19
+ return json_schema_to_type({"type": "string"})
20
+
21
+ @pytest.fixture
22
+ def simple_number(self):
23
+ return json_schema_to_type({"type": "number"})
24
+
25
+ @pytest.fixture
26
+ def simple_integer(self):
27
+ return json_schema_to_type({"type": "integer"})
28
+
29
+ @pytest.fixture
30
+ def simple_boolean(self):
31
+ return json_schema_to_type({"type": "boolean"})
32
+
33
+ @pytest.fixture
34
+ def simple_null(self):
35
+ return json_schema_to_type({"type": "null"})
36
+
37
+ def test_string_accepts_string(self, simple_string):
38
+ validator = TypeAdapter(simple_string)
39
+ assert validator.validate_python("test") == "test"
40
+
41
+ def test_string_rejects_number(self, simple_string):
42
+ validator = TypeAdapter(simple_string)
43
+ with pytest.raises(ValidationError):
44
+ validator.validate_python(123)
45
+
46
+ def test_number_accepts_float(self, simple_number):
47
+ validator = TypeAdapter(simple_number)
48
+ assert validator.validate_python(123.45) == 123.45
49
+
50
+ def test_number_accepts_integer(self, simple_number):
51
+ validator = TypeAdapter(simple_number)
52
+ assert validator.validate_python(123) == 123
53
+
54
+ def test_number_accepts_numeric_string(self, simple_number):
55
+ validator = TypeAdapter(simple_number)
56
+ assert validator.validate_python("123.45") == 123.45
57
+ assert validator.validate_python("123") == 123
58
+
59
+ def test_number_rejects_invalid_string(self, simple_number):
60
+ validator = TypeAdapter(simple_number)
61
+ with pytest.raises(ValidationError):
62
+ validator.validate_python("not a number")
63
+
64
+ def test_integer_accepts_integer(self, simple_integer):
65
+ validator = TypeAdapter(simple_integer)
66
+ assert validator.validate_python(123) == 123
67
+
68
+ def test_integer_accepts_integer_string(self, simple_integer):
69
+ validator = TypeAdapter(simple_integer)
70
+ assert validator.validate_python("123") == 123
71
+
72
+ def test_integer_rejects_float(self, simple_integer):
73
+ validator = TypeAdapter(simple_integer)
74
+ with pytest.raises(ValidationError):
75
+ validator.validate_python(123.45)
76
+
77
+ def test_integer_rejects_float_string(self, simple_integer):
78
+ validator = TypeAdapter(simple_integer)
79
+ with pytest.raises(ValidationError):
80
+ validator.validate_python("123.45")
81
+
82
+ def test_boolean_accepts_boolean(self, simple_boolean):
83
+ validator = TypeAdapter(simple_boolean)
84
+ assert validator.validate_python(True) is True
85
+ assert validator.validate_python(False) is False
86
+
87
+ def test_boolean_accepts_boolean_strings(self, simple_boolean):
88
+ validator = TypeAdapter(simple_boolean)
89
+ assert validator.validate_python("true") is True
90
+ assert validator.validate_python("True") is True
91
+ assert validator.validate_python("false") is False
92
+ assert validator.validate_python("False") is False
93
+
94
+ def test_boolean_rejects_invalid_string(self, simple_boolean):
95
+ validator = TypeAdapter(simple_boolean)
96
+ with pytest.raises(ValidationError):
97
+ validator.validate_python("not a boolean")
98
+
99
+ def test_null_accepts_none(self, simple_null):
100
+ validator = TypeAdapter(simple_null)
101
+ assert validator.validate_python(None) is None
102
+
103
+ def test_null_rejects_false(self, simple_null):
104
+ validator = TypeAdapter(simple_null)
105
+ with pytest.raises(ValidationError):
106
+ validator.validate_python(False)
107
+
108
+
109
+ class TestStringConstraints:
110
+ """Test suite for string constraint validation."""
111
+
112
+ @pytest.fixture
113
+ def min_length_string(self):
114
+ return json_schema_to_type({"type": "string", "minLength": 3})
115
+
116
+ @pytest.fixture
117
+ def max_length_string(self):
118
+ return json_schema_to_type({"type": "string", "maxLength": 5})
119
+
120
+ @pytest.fixture
121
+ def pattern_string(self):
122
+ return json_schema_to_type({"type": "string", "pattern": "^[A-Z][a-z]+$"})
123
+
124
+ @pytest.fixture
125
+ def email_string(self):
126
+ return json_schema_to_type({"type": "string", "format": "email"})
127
+
128
+ def test_min_length_accepts_valid(self, min_length_string):
129
+ validator = TypeAdapter(min_length_string)
130
+ assert validator.validate_python("test") == "test"
131
+
132
+ def test_min_length_rejects_short(self, min_length_string):
133
+ validator = TypeAdapter(min_length_string)
134
+ with pytest.raises(ValidationError):
135
+ validator.validate_python("ab")
136
+
137
+ def test_max_length_accepts_valid(self, max_length_string):
138
+ validator = TypeAdapter(max_length_string)
139
+ assert validator.validate_python("test") == "test"
140
+
141
+ def test_max_length_rejects_long(self, max_length_string):
142
+ validator = TypeAdapter(max_length_string)
143
+ with pytest.raises(ValidationError):
144
+ validator.validate_python("toolong")
145
+
146
+ def test_pattern_accepts_valid(self, pattern_string):
147
+ validator = TypeAdapter(pattern_string)
148
+ assert validator.validate_python("Hello") == "Hello"
149
+
150
+ def test_pattern_rejects_invalid(self, pattern_string):
151
+ validator = TypeAdapter(pattern_string)
152
+ with pytest.raises(ValidationError):
153
+ validator.validate_python("hello")
154
+
155
+ def test_email_accepts_valid(self, email_string):
156
+ validator = TypeAdapter(email_string)
157
+ result = validator.validate_python("test@example.com")
158
+ assert result == "test@example.com"
159
+
160
+ def test_email_rejects_invalid(self, email_string):
161
+ validator = TypeAdapter(email_string)
162
+ with pytest.raises(ValidationError):
163
+ validator.validate_python("not-an-email")
164
+
165
+
166
+ class TestNumberConstraints:
167
+ """Test suite for numeric constraint validation."""
168
+
169
+ @pytest.fixture
170
+ def multiple_of_number(self):
171
+ return json_schema_to_type({"type": "number", "multipleOf": 0.5})
172
+
173
+ @pytest.fixture
174
+ def min_number(self):
175
+ return json_schema_to_type({"type": "number", "minimum": 0})
176
+
177
+ @pytest.fixture
178
+ def exclusive_min_number(self):
179
+ return json_schema_to_type({"type": "number", "exclusiveMinimum": 0})
180
+
181
+ @pytest.fixture
182
+ def max_number(self):
183
+ return json_schema_to_type({"type": "number", "maximum": 100})
184
+
185
+ @pytest.fixture
186
+ def exclusive_max_number(self):
187
+ return json_schema_to_type({"type": "number", "exclusiveMaximum": 100})
188
+
189
+ def test_multiple_of_accepts_valid(self, multiple_of_number):
190
+ validator = TypeAdapter(multiple_of_number)
191
+ assert validator.validate_python(2.5) == 2.5
192
+
193
+ def test_multiple_of_rejects_invalid(self, multiple_of_number):
194
+ validator = TypeAdapter(multiple_of_number)
195
+ with pytest.raises(ValidationError):
196
+ validator.validate_python(2.7)
197
+
198
+ def test_minimum_accepts_equal(self, min_number):
199
+ validator = TypeAdapter(min_number)
200
+ assert validator.validate_python(0) == 0
201
+
202
+ def test_minimum_rejects_less(self, min_number):
203
+ validator = TypeAdapter(min_number)
204
+ with pytest.raises(ValidationError):
205
+ validator.validate_python(-1)
206
+
207
+ def test_exclusive_minimum_rejects_equal(self, exclusive_min_number):
208
+ validator = TypeAdapter(exclusive_min_number)
209
+ with pytest.raises(ValidationError):
210
+ validator.validate_python(0)
211
+
212
+ def test_maximum_accepts_equal(self, max_number):
213
+ validator = TypeAdapter(max_number)
214
+ assert validator.validate_python(100) == 100
215
+
216
+ def test_maximum_rejects_greater(self, max_number):
217
+ validator = TypeAdapter(max_number)
218
+ with pytest.raises(ValidationError):
219
+ validator.validate_python(101)
220
+
221
+ def test_exclusive_maximum_rejects_equal(self, exclusive_max_number):
222
+ validator = TypeAdapter(exclusive_max_number)
223
+ with pytest.raises(ValidationError):
224
+ validator.validate_python(100)
225
+
226
+
227
+ class TestArrayTypes:
228
+ """Test suite for array validation."""
229
+
230
+ @pytest.fixture
231
+ def string_array(self):
232
+ return json_schema_to_type({"type": "array", "items": {"type": "string"}})
233
+
234
+ @pytest.fixture
235
+ def min_items_array(self):
236
+ return json_schema_to_type(
237
+ {"type": "array", "items": {"type": "string"}, "minItems": 2}
238
+ )
239
+
240
+ @pytest.fixture
241
+ def max_items_array(self):
242
+ return json_schema_to_type(
243
+ {"type": "array", "items": {"type": "string"}, "maxItems": 3}
244
+ )
245
+
246
+ @pytest.fixture
247
+ def unique_items_array(self):
248
+ return json_schema_to_type(
249
+ {"type": "array", "items": {"type": "string"}, "uniqueItems": True}
250
+ )
251
+
252
+ def test_array_accepts_valid_items(self, string_array):
253
+ validator = TypeAdapter(string_array)
254
+ assert validator.validate_python(["a", "b"]) == ["a", "b"]
255
+
256
+ def test_array_rejects_invalid_items(self, string_array):
257
+ validator = TypeAdapter(string_array)
258
+ with pytest.raises(ValidationError):
259
+ validator.validate_python([1, "b"])
260
+
261
+ def test_min_items_accepts_valid(self, min_items_array):
262
+ validator = TypeAdapter(min_items_array)
263
+ assert validator.validate_python(["a", "b"]) == ["a", "b"]
264
+
265
+ def test_min_items_rejects_too_few(self, min_items_array):
266
+ validator = TypeAdapter(min_items_array)
267
+ with pytest.raises(ValidationError):
268
+ validator.validate_python(["a"])
269
+
270
+ def test_max_items_accepts_valid(self, max_items_array):
271
+ validator = TypeAdapter(max_items_array)
272
+ assert validator.validate_python(["a", "b", "c"]) == ["a", "b", "c"]
273
+
274
+ def test_max_items_rejects_too_many(self, max_items_array):
275
+ validator = TypeAdapter(max_items_array)
276
+ with pytest.raises(ValidationError):
277
+ validator.validate_python(["a", "b", "c", "d"])
278
+
279
+ def test_unique_items_accepts_unique(self, unique_items_array):
280
+ validator = TypeAdapter(unique_items_array)
281
+ assert isinstance(validator.validate_python(["a", "b"]), set)
282
+
283
+ def test_unique_items_converts_duplicates(self, unique_items_array):
284
+ validator = TypeAdapter(unique_items_array)
285
+ result = validator.validate_python(["a", "a", "b"])
286
+ assert result == {"a", "b"}
287
+
288
+
289
+ class TestObjectTypes:
290
+ """Test suite for object validation."""
291
+
292
+ @pytest.fixture
293
+ def simple_object(self):
294
+ return json_schema_to_type(
295
+ {
296
+ "type": "object",
297
+ "properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
298
+ }
299
+ )
300
+
301
+ @pytest.fixture
302
+ def required_object(self):
303
+ return json_schema_to_type(
304
+ {
305
+ "type": "object",
306
+ "properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
307
+ "required": ["name"],
308
+ }
309
+ )
310
+
311
+ @pytest.fixture
312
+ def nested_object(self):
313
+ return json_schema_to_type(
314
+ {
315
+ "type": "object",
316
+ "properties": {
317
+ "user": {
318
+ "type": "object",
319
+ "properties": {
320
+ "name": {"type": "string"},
321
+ "age": {"type": "integer"},
322
+ },
323
+ "required": ["name"],
324
+ }
325
+ },
326
+ }
327
+ )
328
+
329
+ @pytest.mark.parametrize(
330
+ "input_type, expected_type",
331
+ [
332
+ # Plain dict becomes dict[str, Any] (JSON Schema accurate)
333
+ (dict, dict[str, Any]),
334
+ # dict[str, Any] stays the same
335
+ (dict[str, Any], dict[str, Any]),
336
+ # Simple typed dicts work correctly
337
+ (dict[str, str], dict[str, str]),
338
+ (dict[str, int], dict[str, int]),
339
+ # Union value types work
340
+ (dict[str, str | int], dict[str, str | int]),
341
+ # Key types are constrained to str in JSON Schema
342
+ (dict[int, list[str]], dict[str, list[str]]),
343
+ # Union key types become str (JSON Schema limitation)
344
+ (dict[str | int, str | None], dict[str, str | None]),
345
+ ],
346
+ )
347
+ def test_dict_types_are_generated_correctly(self, input_type, expected_type):
348
+ schema = TypeAdapter(input_type).json_schema()
349
+ generated_type = json_schema_to_type(schema)
350
+ assert generated_type == expected_type
351
+
352
+ def test_object_accepts_valid(self, simple_object):
353
+ validator = TypeAdapter(simple_object)
354
+ result = validator.validate_python({"name": "test", "age": 30})
355
+ assert result.name == "test"
356
+ assert result.age == 30
357
+
358
+ def test_object_accepts_extra_properties(self, simple_object):
359
+ validator = TypeAdapter(simple_object)
360
+ result = validator.validate_python(
361
+ {"name": "test", "age": 30, "extra": "field"}
362
+ )
363
+ assert result.name == "test"
364
+ assert result.age == 30
365
+ assert not hasattr(result, "extra")
366
+
367
+ def test_required_accepts_valid(self, required_object):
368
+ validator = TypeAdapter(required_object)
369
+ result = validator.validate_python({"name": "test"})
370
+ assert result.name == "test"
371
+ assert result.age is None
372
+
373
+ def test_required_rejects_missing(self, required_object):
374
+ validator = TypeAdapter(required_object)
375
+ with pytest.raises(ValidationError):
376
+ validator.validate_python({})
377
+
378
+ def test_nested_accepts_valid(self, nested_object):
379
+ validator = TypeAdapter(nested_object)
380
+ result = validator.validate_python({"user": {"name": "test", "age": 30}})
381
+ assert result.user.name == "test"
382
+ assert result.user.age == 30
383
+
384
+ def test_nested_rejects_invalid(self, nested_object):
385
+ validator = TypeAdapter(nested_object)
386
+ with pytest.raises(ValidationError):
387
+ validator.validate_python({"user": {"age": 30}})
388
+
389
+
390
+ class TestDefaultValues:
391
+ """Test suite for default value handling."""
392
+
393
+ @pytest.fixture
394
+ def simple_defaults(self):
395
+ return json_schema_to_type(
396
+ {
397
+ "type": "object",
398
+ "properties": {
399
+ "name": {"type": "string", "default": "anonymous"},
400
+ "age": {"type": "integer", "default": 0},
401
+ },
402
+ }
403
+ )
404
+
405
+ @pytest.fixture
406
+ def nested_defaults(self):
407
+ return json_schema_to_type(
408
+ {
409
+ "type": "object",
410
+ "properties": {
411
+ "user": {
412
+ "type": "object",
413
+ "properties": {
414
+ "name": {"type": "string", "default": "anonymous"},
415
+ "settings": {
416
+ "type": "object",
417
+ "properties": {
418
+ "theme": {"type": "string", "default": "light"}
419
+ },
420
+ "default": {"theme": "dark"},
421
+ },
422
+ },
423
+ "default": {"name": "guest", "settings": {"theme": "system"}},
424
+ }
425
+ },
426
+ }
427
+ )
428
+
429
+ def test_simple_defaults_empty_object(self, simple_defaults):
430
+ validator = TypeAdapter(simple_defaults)
431
+ result = validator.validate_python({})
432
+ assert result.name == "anonymous"
433
+ assert result.age == 0
434
+
435
+ def test_simple_defaults_partial_override(self, simple_defaults):
436
+ validator = TypeAdapter(simple_defaults)
437
+ result = validator.validate_python({"name": "test"})
438
+ assert result.name == "test"
439
+ assert result.age == 0
440
+
441
+ def test_nested_defaults_empty_object(self, nested_defaults):
442
+ validator = TypeAdapter(nested_defaults)
443
+ result = validator.validate_python({})
444
+ assert result.user.name == "guest"
445
+ assert result.user.settings.theme == "system"
446
+
447
+ def test_nested_defaults_partial_override(self, nested_defaults):
448
+ validator = TypeAdapter(nested_defaults)
449
+ result = validator.validate_python({"user": {"name": "test"}})
450
+ assert result.user.name == "test"
451
+ assert result.user.settings.theme == "system"
452
+
453
+
454
+ class TestUnionTypes:
455
+ """Test suite for testing union type behaviors."""
456
+
457
+ @pytest.fixture
458
+ def heterogeneous_union(self):
459
+ return json_schema_to_type({"type": ["string", "number", "boolean", "null"]})
460
+
461
+ @pytest.fixture
462
+ def union_with_constraints(self):
463
+ return json_schema_to_type(
464
+ {"type": ["string", "number"], "minLength": 3, "minimum": 0}
465
+ )
466
+
467
+ @pytest.fixture
468
+ def union_with_formats(self):
469
+ return json_schema_to_type({"type": ["string", "null"], "format": "email"})
470
+
471
+ @pytest.fixture
472
+ def nested_union_array(self):
473
+ return json_schema_to_type(
474
+ {"type": "array", "items": {"type": ["string", "number"]}}
475
+ )
476
+
477
+ @pytest.fixture
478
+ def nested_union_object(self):
479
+ return json_schema_to_type(
480
+ {
481
+ "type": "object",
482
+ "properties": {
483
+ "id": {"type": ["string", "integer"]},
484
+ "data": {
485
+ "type": ["object", "null"],
486
+ "properties": {"value": {"type": "string"}},
487
+ },
488
+ },
489
+ }
490
+ )
491
+
492
+ def test_heterogeneous_accepts_string(self, heterogeneous_union):
493
+ validator = TypeAdapter(heterogeneous_union)
494
+ assert validator.validate_python("test") == "test"
495
+
496
+ def test_heterogeneous_accepts_number(self, heterogeneous_union):
497
+ validator = TypeAdapter(heterogeneous_union)
498
+ assert validator.validate_python(123.45) == 123.45
499
+
500
+ def test_heterogeneous_accepts_boolean(self, heterogeneous_union):
501
+ validator = TypeAdapter(heterogeneous_union)
502
+ assert validator.validate_python(True) is True
503
+
504
+ def test_heterogeneous_accepts_null(self, heterogeneous_union):
505
+ validator = TypeAdapter(heterogeneous_union)
506
+ assert validator.validate_python(None) is None
507
+
508
+ def test_heterogeneous_rejects_array(self, heterogeneous_union):
509
+ validator = TypeAdapter(heterogeneous_union)
510
+ with pytest.raises(ValidationError):
511
+ validator.validate_python([])
512
+
513
+ def test_constrained_string_valid(self, union_with_constraints):
514
+ validator = TypeAdapter(union_with_constraints)
515
+ assert validator.validate_python("test") == "test"
516
+
517
+ def test_constrained_string_invalid(self, union_with_constraints):
518
+ validator = TypeAdapter(union_with_constraints)
519
+ with pytest.raises(ValidationError):
520
+ validator.validate_python("ab")
521
+
522
+ def test_constrained_number_valid(self, union_with_constraints):
523
+ validator = TypeAdapter(union_with_constraints)
524
+ assert validator.validate_python(10) == 10
525
+
526
+ def test_constrained_number_invalid(self, union_with_constraints):
527
+ validator = TypeAdapter(union_with_constraints)
528
+ with pytest.raises(ValidationError):
529
+ validator.validate_python(-1)
530
+
531
+ def test_format_valid_email(self, union_with_formats):
532
+ validator = TypeAdapter(union_with_formats)
533
+ result = validator.validate_python("test@example.com")
534
+ assert isinstance(result, str)
535
+
536
+ def test_format_valid_null(self, union_with_formats):
537
+ validator = TypeAdapter(union_with_formats)
538
+ assert validator.validate_python(None) is None
539
+
540
+ def test_format_invalid_email(self, union_with_formats):
541
+ validator = TypeAdapter(union_with_formats)
542
+ with pytest.raises(ValidationError):
543
+ validator.validate_python("not-an-email")
544
+
545
+ def test_nested_array_mixed_types(self, nested_union_array):
546
+ validator = TypeAdapter(nested_union_array)
547
+ result = validator.validate_python(["test", 123, "abc"])
548
+ assert result == ["test", 123, "abc"]
549
+
550
+ def test_nested_array_rejects_invalid(self, nested_union_array):
551
+ validator = TypeAdapter(nested_union_array)
552
+ with pytest.raises(ValidationError):
553
+ validator.validate_python(["test", ["not", "allowed"], "abc"])
554
+
555
+ def test_nested_object_string_id(self, nested_union_object):
556
+ validator = TypeAdapter(nested_union_object)
557
+ result = validator.validate_python({"id": "abc123", "data": {"value": "test"}})
558
+ assert result.id == "abc123"
559
+ assert result.data.value == "test"
560
+
561
+ def test_nested_object_integer_id(self, nested_union_object):
562
+ validator = TypeAdapter(nested_union_object)
563
+ result = validator.validate_python({"id": 123, "data": None})
564
+ assert result.id == 123
565
+ assert result.data is None
566
+
567
+
568
+ class TestFormatTypes:
569
+ """Test suite for format type validation."""
570
+
571
+ @pytest.fixture
572
+ def datetime_format(self):
573
+ return json_schema_to_type({"type": "string", "format": "date-time"})
574
+
575
+ @pytest.fixture
576
+ def email_format(self):
577
+ return json_schema_to_type({"type": "string", "format": "email"})
578
+
579
+ @pytest.fixture
580
+ def uri_format(self):
581
+ return json_schema_to_type({"type": "string", "format": "uri"})
582
+
583
+ @pytest.fixture
584
+ def uri_reference_format(self):
585
+ return json_schema_to_type({"type": "string", "format": "uri-reference"})
586
+
587
+ @pytest.fixture
588
+ def json_format(self):
589
+ return json_schema_to_type({"type": "string", "format": "json"})
590
+
591
+ @pytest.fixture
592
+ def mixed_formats_object(self):
593
+ return json_schema_to_type(
594
+ {
595
+ "type": "object",
596
+ "properties": {
597
+ "full_uri": {"type": "string", "format": "uri"},
598
+ "ref_uri": {"type": "string", "format": "uri-reference"},
599
+ },
600
+ }
601
+ )
602
+
603
+ def test_datetime_valid(self, datetime_format):
604
+ validator = TypeAdapter(datetime_format)
605
+ result = validator.validate_python("2024-01-17T12:34:56Z")
606
+ assert isinstance(result, datetime)
607
+
608
+ def test_datetime_invalid(self, datetime_format):
609
+ validator = TypeAdapter(datetime_format)
610
+ with pytest.raises(ValidationError):
611
+ validator.validate_python("not-a-date")
612
+
613
+ def test_email_valid(self, email_format):
614
+ validator = TypeAdapter(email_format)
615
+ result = validator.validate_python("test@example.com")
616
+ assert isinstance(result, str)
617
+
618
+ def test_email_invalid(self, email_format):
619
+ validator = TypeAdapter(email_format)
620
+ with pytest.raises(ValidationError):
621
+ validator.validate_python("not-an-email")
622
+
623
+ def test_uri_valid(self, uri_format):
624
+ validator = TypeAdapter(uri_format)
625
+ result = validator.validate_python("https://example.com")
626
+ assert isinstance(result, AnyUrl)
627
+
628
+ def test_uri_invalid(self, uri_format):
629
+ validator = TypeAdapter(uri_format)
630
+ with pytest.raises(ValidationError):
631
+ validator.validate_python("not-a-uri")
632
+
633
+ def test_uri_reference_valid(self, uri_reference_format):
634
+ validator = TypeAdapter(uri_reference_format)
635
+ result = validator.validate_python("https://example.com")
636
+ assert isinstance(result, str)
637
+
638
+ def test_uri_reference_relative_valid(self, uri_reference_format):
639
+ validator = TypeAdapter(uri_reference_format)
640
+ result = validator.validate_python("/path/to/resource")
641
+ assert isinstance(result, str)
642
+
643
+ def test_uri_reference_invalid(self, uri_reference_format):
644
+ validator = TypeAdapter(uri_reference_format)
645
+ result = validator.validate_python("not a uri")
646
+ assert isinstance(result, str)
647
+
648
+ def test_json_valid(self, json_format):
649
+ validator = TypeAdapter(json_format)
650
+ result = validator.validate_python('{"key": "value"}')
651
+ assert isinstance(result, dict)
652
+
653
+ def test_json_invalid(self, json_format):
654
+ validator = TypeAdapter(json_format)
655
+ with pytest.raises(ValidationError):
656
+ validator.validate_python("{invalid json}")
657
+
658
+ def test_mixed_formats_object(self, mixed_formats_object):
659
+ validator = TypeAdapter(mixed_formats_object)
660
+ result = validator.validate_python(
661
+ {"full_uri": "https://example.com", "ref_uri": "/path/to/resource"}
662
+ )
663
+ assert isinstance(result.full_uri, AnyUrl)
664
+ assert isinstance(result.ref_uri, str)
665
+
666
+
667
+ class TestCircularReferences:
668
+ """Test suite for circular reference handling."""
669
+
670
+ @pytest.fixture
671
+ def self_referential(self):
672
+ return json_schema_to_type(
673
+ {
674
+ "type": "object",
675
+ "properties": {"name": {"type": "string"}, "child": {"$ref": "#"}},
676
+ }
677
+ )
678
+
679
+ @pytest.fixture
680
+ def mutually_recursive(self):
681
+ return json_schema_to_type(
682
+ {
683
+ "type": "object",
684
+ "definitions": {
685
+ "Person": {
686
+ "type": "object",
687
+ "properties": {
688
+ "name": {"type": "string"},
689
+ "friend": {"$ref": "#/definitions/Pet"},
690
+ },
691
+ },
692
+ "Pet": {
693
+ "type": "object",
694
+ "properties": {
695
+ "name": {"type": "string"},
696
+ "owner": {"$ref": "#/definitions/Person"},
697
+ },
698
+ },
699
+ },
700
+ "properties": {"person": {"$ref": "#/definitions/Person"}},
701
+ }
702
+ )
703
+
704
+ def test_self_ref_single_level(self, self_referential):
705
+ validator = TypeAdapter(self_referential)
706
+ result = validator.validate_python(
707
+ {"name": "parent", "child": {"name": "child"}}
708
+ )
709
+ assert result.name == "parent"
710
+ assert result.child.name == "child"
711
+ assert result.child.child is None
712
+
713
+ def test_self_ref_multiple_levels(self, self_referential):
714
+ validator = TypeAdapter(self_referential)
715
+ result = validator.validate_python(
716
+ {
717
+ "name": "grandparent",
718
+ "child": {"name": "parent", "child": {"name": "child"}},
719
+ }
720
+ )
721
+ assert result.name == "grandparent"
722
+ assert result.child.name == "parent"
723
+ assert result.child.child.name == "child"
724
+
725
+ def test_mutual_recursion_single_level(self, mutually_recursive):
726
+ validator = TypeAdapter(mutually_recursive)
727
+ result = validator.validate_python(
728
+ {"person": {"name": "Alice", "friend": {"name": "Spot"}}}
729
+ )
730
+ assert result.person.name == "Alice"
731
+ assert result.person.friend.name == "Spot"
732
+ assert result.person.friend.owner is None
733
+
734
+ def test_mutual_recursion_multiple_levels(self, mutually_recursive):
735
+ validator = TypeAdapter(mutually_recursive)
736
+ result = validator.validate_python(
737
+ {
738
+ "person": {
739
+ "name": "Alice",
740
+ "friend": {"name": "Spot", "owner": {"name": "Bob"}},
741
+ }
742
+ }
743
+ )
744
+ assert result.person.name == "Alice"
745
+ assert result.person.friend.name == "Spot"
746
+ assert result.person.friend.owner.name == "Bob"
747
+
748
+
749
+ class TestIdentifierNormalization:
750
+ """Test suite for handling non-standard property names."""
751
+
752
+ @pytest.fixture
753
+ def special_chars(self):
754
+ return json_schema_to_type(
755
+ {
756
+ "type": "object",
757
+ "properties": {
758
+ "@type": {"type": "string"},
759
+ "first-name": {"type": "string"},
760
+ "last.name": {"type": "string"},
761
+ "2nd_address": {"type": "string"},
762
+ "$ref": {"type": "string"},
763
+ },
764
+ }
765
+ )
766
+
767
+ def test_normalizes_special_chars(self, special_chars):
768
+ validator = TypeAdapter(special_chars)
769
+ result = validator.validate_python(
770
+ {
771
+ "@type": "person",
772
+ "first-name": "Alice",
773
+ "last.name": "Smith",
774
+ "2nd_address": "456 Oak St",
775
+ "$ref": "12345",
776
+ }
777
+ )
778
+ assert result.field_type == "person" # @type -> field_type
779
+ assert result.first_name == "Alice" # first-name -> first_name
780
+ assert result.last_name == "Smith" # last.name -> last_name
781
+ assert (
782
+ result.field_2nd_address == "456 Oak St"
783
+ ) # 2nd_address -> field_2nd_address
784
+ assert result.field_ref == "12345" # $ref -> field_ref
785
+
786
+
787
+ class TestConstantValues:
788
+ """Test suite for constant value validation."""
789
+
790
+ @pytest.fixture
791
+ def string_const(self):
792
+ return json_schema_to_type({"type": "string", "const": "production"})
793
+
794
+ @pytest.fixture
795
+ def number_const(self):
796
+ return json_schema_to_type({"type": "number", "const": 42.5})
797
+
798
+ @pytest.fixture
799
+ def boolean_const(self):
800
+ return json_schema_to_type({"type": "boolean", "const": True})
801
+
802
+ @pytest.fixture
803
+ def null_const(self):
804
+ return json_schema_to_type({"type": "null", "const": None})
805
+
806
+ @pytest.fixture
807
+ def object_with_consts(self):
808
+ return json_schema_to_type(
809
+ {
810
+ "type": "object",
811
+ "properties": {
812
+ "env": {"const": "production"},
813
+ "version": {"const": 1},
814
+ "enabled": {"const": True},
815
+ },
816
+ }
817
+ )
818
+
819
+ def test_string_const_valid(self, string_const):
820
+ validator = TypeAdapter(string_const)
821
+ assert validator.validate_python("production") == "production"
822
+
823
+ def test_string_const_invalid(self, string_const):
824
+ validator = TypeAdapter(string_const)
825
+ with pytest.raises(ValidationError):
826
+ validator.validate_python("development")
827
+
828
+ def test_number_const_valid(self, number_const):
829
+ validator = TypeAdapter(number_const)
830
+ assert validator.validate_python(42.5) == 42.5
831
+
832
+ def test_number_const_invalid(self, number_const):
833
+ validator = TypeAdapter(number_const)
834
+ with pytest.raises(ValidationError):
835
+ validator.validate_python(42)
836
+
837
+ def test_boolean_const_valid(self, boolean_const):
838
+ validator = TypeAdapter(boolean_const)
839
+ assert validator.validate_python(True) is True
840
+
841
+ def test_boolean_const_invalid(self, boolean_const):
842
+ validator = TypeAdapter(boolean_const)
843
+ with pytest.raises(ValidationError):
844
+ validator.validate_python(False)
845
+
846
+ def test_null_const_valid(self, null_const):
847
+ validator = TypeAdapter(null_const)
848
+ assert validator.validate_python(None) is None
849
+
850
+ def test_null_const_invalid(self, null_const):
851
+ validator = TypeAdapter(null_const)
852
+ with pytest.raises(ValidationError):
853
+ validator.validate_python(False)
854
+
855
+ def test_object_consts_valid(self, object_with_consts):
856
+ validator = TypeAdapter(object_with_consts)
857
+ result = validator.validate_python(
858
+ {"env": "production", "version": 1, "enabled": True}
859
+ )
860
+ assert result.env == "production"
861
+ assert result.version == 1
862
+ assert result.enabled is True
863
+
864
+ def test_object_consts_invalid(self, object_with_consts):
865
+ validator = TypeAdapter(object_with_consts)
866
+ with pytest.raises(ValidationError):
867
+ validator.validate_python(
868
+ {
869
+ "env": "production",
870
+ "version": 2, # Wrong constant
871
+ "enabled": True,
872
+ }
873
+ )
874
+
875
+
876
+ class TestSchemaCaching:
877
+ """Test suite for schema caching behavior."""
878
+
879
+ def test_identical_schemas_reuse_class(self):
880
+ schema = {"type": "object", "properties": {"name": {"type": "string"}}}
881
+
882
+ class1 = json_schema_to_type(schema)
883
+ class2 = json_schema_to_type(schema)
884
+ assert class1 is class2
885
+
886
+ def test_different_names_different_classes(self):
887
+ schema = {"type": "object", "properties": {"name": {"type": "string"}}}
888
+
889
+ class1 = json_schema_to_type(schema, name="Class1")
890
+ class2 = json_schema_to_type(schema, name="Class2")
891
+ assert class1 is not class2
892
+ assert class1.__name__ == "Class1"
893
+ assert class2.__name__ == "Class2"
894
+
895
+ def test_nested_schema_caching(self):
896
+ schema = {
897
+ "type": "object",
898
+ "properties": {
899
+ "nested": {"type": "object", "properties": {"name": {"type": "string"}}}
900
+ },
901
+ }
902
+
903
+ class1 = json_schema_to_type(schema)
904
+ class2 = json_schema_to_type(schema)
905
+
906
+ # Both main classes and their nested classes should be identical
907
+ assert class1 is class2
908
+ assert (
909
+ class1.__dataclass_fields__["nested"].type
910
+ is class2.__dataclass_fields__["nested"].type
911
+ )
912
+
913
+
914
+ class TestSchemaHashing:
915
+ """Test suite for schema hashing utility."""
916
+
917
+ def test_deterministic_hash(self):
918
+ schema = {"type": "object", "properties": {"name": {"type": "string"}}}
919
+ hash1 = _hash_schema(schema)
920
+ hash2 = _hash_schema(schema)
921
+ assert hash1 == hash2
922
+ assert isinstance(hash1, str)
923
+ assert len(hash1) == 64 # SHA-256 hash length
924
+
925
+ def test_different_schemas_different_hashes(self):
926
+ schema1 = {"type": "object", "properties": {"name": {"type": "string"}}}
927
+ schema2 = {"type": "object", "properties": {"age": {"type": "integer"}}}
928
+ assert _hash_schema(schema1) != _hash_schema(schema2)
929
+
930
+ def test_order_independent_hash(self):
931
+ schema1 = {"properties": {"name": {"type": "string"}}, "type": "object"}
932
+ schema2 = {"type": "object", "properties": {"name": {"type": "string"}}}
933
+ assert _hash_schema(schema1) == _hash_schema(schema2)
934
+
935
+ def test_nested_schema_hash(self):
936
+ schema = {
937
+ "type": "object",
938
+ "properties": {
939
+ "nested": {"type": "object", "properties": {"name": {"type": "string"}}}
940
+ },
941
+ }
942
+ hash1 = _hash_schema(schema)
943
+ assert isinstance(hash1, str)
944
+ assert len(hash1) == 64
945
+
946
+
947
+ class TestDefaultMerging:
948
+ """Test suite for default value merging behavior."""
949
+
950
+ def test_simple_merge(self):
951
+ defaults = {"name": "anonymous", "age": 0}
952
+ data = {"name": "test"}
953
+ result = _merge_defaults(data, {"properties": {}}, defaults)
954
+ assert result["name"] == "test"
955
+ assert result["age"] == 0
956
+
957
+ def test_nested_merge(self):
958
+ defaults = {"user": {"name": "anonymous", "settings": {"theme": "light"}}}
959
+ data = {"user": {"name": "test"}}
960
+ result = _merge_defaults(data, {"properties": {}}, defaults)
961
+ assert result["user"]["name"] == "test"
962
+ assert result["user"]["settings"]["theme"] == "light"
963
+
964
+ def test_array_merge(self):
965
+ defaults = {
966
+ "items": [
967
+ {"name": "item1", "done": False},
968
+ {"name": "item2", "done": False},
969
+ ]
970
+ }
971
+ data = {"items": [{"name": "custom", "done": True}]}
972
+ result = _merge_defaults(data, {"properties": {}}, defaults)
973
+ assert len(result["items"]) == 1
974
+ assert result["items"][0]["name"] == "custom"
975
+ assert result["items"][0]["done"] is True
976
+
977
+ def test_empty_data_uses_defaults(self):
978
+ schema = {
979
+ "properties": {
980
+ "user": {
981
+ "type": "object",
982
+ "properties": {
983
+ "name": {"type": "string", "default": "anonymous"},
984
+ "settings": {"type": "object", "default": {"theme": "light"}},
985
+ },
986
+ "default": {"name": "guest", "settings": {"theme": "dark"}},
987
+ }
988
+ }
989
+ }
990
+ result = _merge_defaults({}, schema)
991
+ assert result["user"]["name"] == "guest"
992
+ assert result["user"]["settings"]["theme"] == "dark"
993
+
994
+ def test_property_level_defaults(self):
995
+ schema = {
996
+ "properties": {
997
+ "name": {"type": "string", "default": "anonymous"},
998
+ "age": {"type": "integer", "default": 0},
999
+ }
1000
+ }
1001
+ result = _merge_defaults({}, schema)
1002
+ assert result["name"] == "anonymous"
1003
+ assert result["age"] == 0
1004
+
1005
+ def test_nested_property_defaults(self):
1006
+ schema = {
1007
+ "properties": {
1008
+ "user": {
1009
+ "type": "object",
1010
+ "properties": {
1011
+ "name": {"type": "string", "default": "anonymous"},
1012
+ "settings": {
1013
+ "type": "object",
1014
+ "properties": {
1015
+ "theme": {"type": "string", "default": "light"}
1016
+ },
1017
+ },
1018
+ },
1019
+ }
1020
+ }
1021
+ }
1022
+ result = _merge_defaults({"user": {"settings": {}}}, schema)
1023
+ assert result["user"]["name"] == "anonymous"
1024
+ assert result["user"]["settings"]["theme"] == "light"
1025
+
1026
+ def test_default_priority(self):
1027
+ schema = {
1028
+ "properties": {
1029
+ "settings": {
1030
+ "type": "object",
1031
+ "properties": {"theme": {"type": "string", "default": "light"}},
1032
+ "default": {"theme": "dark"},
1033
+ }
1034
+ },
1035
+ "default": {"settings": {"theme": "system"}},
1036
+ }
1037
+
1038
+ # Test priority: data > parent default > object default > property default
1039
+ result1 = _merge_defaults({}, schema) # Uses schema default
1040
+ assert result1["settings"]["theme"] == "system"
1041
+
1042
+ result2 = _merge_defaults({"settings": {}}, schema) # Uses object default
1043
+ assert result2["settings"]["theme"] == "dark"
1044
+
1045
+ result3 = _merge_defaults(
1046
+ {"settings": {"theme": "custom"}}, schema
1047
+ ) # Uses provided data
1048
+ assert result3["settings"]["theme"] == "custom"
1049
+
1050
+
1051
+ class TestEdgeCases:
1052
+ """Test suite for edge cases and corner scenarios."""
1053
+
1054
+ def test_empty_schema(self):
1055
+ schema = {}
1056
+ result = json_schema_to_type(schema)
1057
+ assert result is object
1058
+
1059
+ def test_schema_without_type(self):
1060
+ schema = {"properties": {"name": {"type": "string"}}}
1061
+ Type = json_schema_to_type(schema)
1062
+ validator = TypeAdapter(Type)
1063
+ result = validator.validate_python({"name": "test"})
1064
+ assert result.name == "test"
1065
+
1066
+ def test_recursive_defaults(self):
1067
+ schema = {
1068
+ "type": "object",
1069
+ "properties": {
1070
+ "node": {
1071
+ "type": "object",
1072
+ "properties": {"value": {"type": "string"}, "next": {"$ref": "#"}},
1073
+ "default": {"value": "default", "next": None},
1074
+ }
1075
+ },
1076
+ }
1077
+ Type = json_schema_to_type(schema)
1078
+ validator = TypeAdapter(Type)
1079
+ result = validator.validate_python({})
1080
+ assert result.node.value == "default"
1081
+ assert result.node.next is None
1082
+
1083
+ def test_mixed_type_array(self):
1084
+ schema = {
1085
+ "type": "array",
1086
+ "items": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}],
1087
+ }
1088
+ Type = json_schema_to_type(schema)
1089
+ validator = TypeAdapter(Type)
1090
+ result = validator.validate_python(["test", 123, True])
1091
+ assert result == ["test", 123, True]
1092
+
1093
+
1094
+ class TestNameHandling:
1095
+ """Test suite for schema name handling."""
1096
+
1097
+ def test_name_from_title(self):
1098
+ schema = {
1099
+ "type": "object",
1100
+ "title": "Person",
1101
+ "properties": {"name": {"type": "string"}},
1102
+ }
1103
+ Type = json_schema_to_type(schema)
1104
+ assert Type.__name__ == "Person"
1105
+
1106
+ def test_explicit_name_overrides_title(self):
1107
+ schema = {
1108
+ "type": "object",
1109
+ "title": "Person",
1110
+ "properties": {"name": {"type": "string"}},
1111
+ }
1112
+ Type = json_schema_to_type(schema, name="CustomPerson")
1113
+ assert Type.__name__ == "CustomPerson"
1114
+
1115
+ def test_default_name_without_title(self):
1116
+ schema = {"type": "object", "properties": {"name": {"type": "string"}}}
1117
+ Type = json_schema_to_type(schema)
1118
+ assert Type.__name__ == "Root"
1119
+
1120
+ def test_name_only_allowed_for_objects(self):
1121
+ schema = {"type": "string"}
1122
+ with pytest.raises(ValueError, match="Can not apply name to non-object schema"):
1123
+ json_schema_to_type(schema, name="StringType")
1124
+
1125
+ def test_nested_object_names(self):
1126
+ schema = {
1127
+ "type": "object",
1128
+ "title": "Parent",
1129
+ "properties": {
1130
+ "child": {
1131
+ "type": "object",
1132
+ "title": "Child",
1133
+ "properties": {"name": {"type": "string"}},
1134
+ }
1135
+ },
1136
+ }
1137
+ Type = json_schema_to_type(schema)
1138
+ assert Type.__name__ == "Parent"
1139
+ assert Type.__dataclass_fields__["child"].type.__origin__ is Union
1140
+ assert Type.__dataclass_fields__["child"].type.__args__[0].__name__ == "Child"
1141
+ assert Type.__dataclass_fields__["child"].type.__args__[1] is type(None)
1142
+
1143
+ def test_recursive_schema_naming(self):
1144
+ schema = {
1145
+ "type": "object",
1146
+ "title": "Node",
1147
+ "properties": {"next": {"$ref": "#"}},
1148
+ }
1149
+ Type = json_schema_to_type(schema)
1150
+ assert Type.__name__ == "Node"
1151
+ assert Type.__dataclass_fields__["next"].type.__origin__ is Union
1152
+ assert (
1153
+ Type.__dataclass_fields__["next"].type.__args__[0].__forward_arg__ == "Node"
1154
+ )
1155
+ assert Type.__dataclass_fields__["next"].type.__args__[1] is type(None)
1156
+
1157
+ def test_name_caching_with_different_titles(self):
1158
+ """Ensure schemas with different titles create different cached classes"""
1159
+ schema1 = {
1160
+ "type": "object",
1161
+ "title": "Type1",
1162
+ "properties": {"name": {"type": "string"}},
1163
+ }
1164
+ schema2 = {
1165
+ "type": "object",
1166
+ "title": "Type2",
1167
+ "properties": {"name": {"type": "string"}},
1168
+ }
1169
+ Type1 = json_schema_to_type(schema1)
1170
+ Type2 = json_schema_to_type(schema2)
1171
+ assert Type1 is not Type2
1172
+ assert Type1.__name__ == "Type1"
1173
+ assert Type2.__name__ == "Type2"
1174
+
1175
+ def test_recursive_schema_with_invalid_python_name(self):
1176
+ """Test that recursive schemas work with titles that aren't valid Python identifiers"""
1177
+ schema = {
1178
+ "type": "object",
1179
+ "title": "My Complex Type!",
1180
+ "properties": {"name": {"type": "string"}, "child": {"$ref": "#"}},
1181
+ }
1182
+ Type = json_schema_to_type(schema)
1183
+ # The class should get a sanitized name
1184
+ assert Type.__name__ == "My_Complex_Type"
1185
+ # Create an instance to verify the recursive reference works
1186
+ validator = TypeAdapter(Type)
1187
+ result = validator.validate_python(
1188
+ {"name": "parent", "child": {"name": "child", "child": None}}
1189
+ )
1190
+ assert result.name == "parent"
1191
+ assert result.child.name == "child"
1192
+ assert result.child.child is None
1193
+
1194
+
1195
+ class TestAdditionalProperties:
1196
+ """Test suite for additionalProperties handling."""
1197
+
1198
+ @pytest.fixture
1199
+ def dict_only_schema(self):
1200
+ """Schema with no properties but additionalProperties=True -> dict[str, Any]"""
1201
+ return json_schema_to_type({"type": "object", "additionalProperties": True})
1202
+
1203
+ @pytest.fixture
1204
+ def properties_with_additional(self):
1205
+ """Schema with properties AND additionalProperties=True -> BaseModel"""
1206
+ return json_schema_to_type(
1207
+ {
1208
+ "type": "object",
1209
+ "properties": {
1210
+ "name": {"type": "string"},
1211
+ "age": {"type": "integer"},
1212
+ },
1213
+ "additionalProperties": True,
1214
+ }
1215
+ )
1216
+
1217
+ @pytest.fixture
1218
+ def properties_without_additional(self):
1219
+ """Schema with properties but no additionalProperties -> dataclass"""
1220
+ return json_schema_to_type(
1221
+ {
1222
+ "type": "object",
1223
+ "properties": {
1224
+ "name": {"type": "string"},
1225
+ "age": {"type": "integer"},
1226
+ },
1227
+ }
1228
+ )
1229
+
1230
+ @pytest.fixture
1231
+ def required_properties_with_additional(self):
1232
+ """Schema with required properties AND additionalProperties=True -> BaseModel"""
1233
+ return json_schema_to_type(
1234
+ {
1235
+ "type": "object",
1236
+ "properties": {
1237
+ "name": {"type": "string"},
1238
+ "age": {"type": "integer"},
1239
+ },
1240
+ "required": ["name"],
1241
+ "additionalProperties": True,
1242
+ }
1243
+ )
1244
+
1245
+ def test_dict_only_returns_dict_type(self, dict_only_schema):
1246
+ """Test that schema with no properties + additionalProperties=True returns dict[str, Any]"""
1247
+ import typing
1248
+
1249
+ assert dict_only_schema == dict[str, typing.Any]
1250
+
1251
+ def test_dict_only_accepts_any_data(self, dict_only_schema):
1252
+ """Test that pure dict accepts arbitrary key-value pairs"""
1253
+ validator = TypeAdapter(dict_only_schema)
1254
+ data = {"anything": "works", "numbers": 123, "nested": {"key": "value"}}
1255
+ result = validator.validate_python(data)
1256
+ assert result == data
1257
+ assert isinstance(result, dict)
1258
+
1259
+ def test_properties_with_additional_returns_basemodel(
1260
+ self, properties_with_additional
1261
+ ):
1262
+ """Test that schema with properties + additionalProperties=True returns BaseModel"""
1263
+ assert issubclass(properties_with_additional, BaseModel)
1264
+
1265
+ def test_properties_with_additional_accepts_extra_fields(
1266
+ self, properties_with_additional
1267
+ ):
1268
+ """Test that BaseModel with extra='allow' accepts additional properties"""
1269
+ validator = TypeAdapter(properties_with_additional)
1270
+ data = {
1271
+ "name": "Alice",
1272
+ "age": 30,
1273
+ "extra": "field",
1274
+ "another": {"nested": "data"},
1275
+ }
1276
+ result = validator.validate_python(data)
1277
+
1278
+ # Check standard properties
1279
+ assert result.name == "Alice"
1280
+ assert result.age == 30
1281
+
1282
+ # Check extra properties are preserved with dot access
1283
+ assert hasattr(result, "extra")
1284
+ assert result.extra == "field"
1285
+ assert hasattr(result, "another")
1286
+ assert result.another == {"nested": "data"}
1287
+
1288
+ def test_properties_with_additional_validates_known_fields(
1289
+ self, properties_with_additional
1290
+ ):
1291
+ """Test that BaseModel still validates known fields"""
1292
+ validator = TypeAdapter(properties_with_additional)
1293
+
1294
+ # Should accept valid data
1295
+ result = validator.validate_python({"name": "Alice", "age": 30, "extra": "ok"})
1296
+ assert result.name == "Alice"
1297
+ assert result.age == 30
1298
+ assert result.extra == "ok"
1299
+
1300
+ # Should reject invalid types for known fields
1301
+ with pytest.raises(ValidationError):
1302
+ validator.validate_python({"name": "Alice", "age": "not_a_number"})
1303
+
1304
+ def test_properties_without_additional_is_dataclass(
1305
+ self, properties_without_additional
1306
+ ):
1307
+ """Test that schema with properties but no additionalProperties returns dataclass"""
1308
+ assert not issubclass(properties_without_additional, BaseModel)
1309
+ assert hasattr(properties_without_additional, "__dataclass_fields__")
1310
+
1311
+ def test_properties_without_additional_ignores_extra_fields(
1312
+ self, properties_without_additional
1313
+ ):
1314
+ """Test that dataclass ignores extra properties (current behavior)"""
1315
+ validator = TypeAdapter(properties_without_additional)
1316
+ data = {"name": "Alice", "age": 30, "extra": "ignored"}
1317
+ result = validator.validate_python(data)
1318
+
1319
+ # Check standard properties
1320
+ assert result.name == "Alice"
1321
+ assert result.age == 30
1322
+
1323
+ # Check extra property is ignored
1324
+ assert not hasattr(result, "extra")
1325
+
1326
+ def test_required_properties_with_additional(
1327
+ self, required_properties_with_additional
1328
+ ):
1329
+ """Test BaseModel with required fields and additional properties"""
1330
+ validator = TypeAdapter(required_properties_with_additional)
1331
+
1332
+ # Should accept valid data with required field
1333
+ result = validator.validate_python({"name": "Alice", "extra": "field"})
1334
+ assert result.name == "Alice"
1335
+ assert result.age is None # Optional field
1336
+ assert result.extra == "field"
1337
+
1338
+ # Should reject missing required field
1339
+ with pytest.raises(ValidationError):
1340
+ validator.validate_python({"age": 30, "extra": "field"})
1341
+
1342
+ def test_nested_additional_properties(self):
1343
+ """Test nested objects with additionalProperties"""
1344
+ schema = {
1345
+ "type": "object",
1346
+ "properties": {
1347
+ "user": {
1348
+ "type": "object",
1349
+ "properties": {"name": {"type": "string"}},
1350
+ "additionalProperties": True,
1351
+ },
1352
+ "settings": {
1353
+ "type": "object",
1354
+ "properties": {"theme": {"type": "string"}},
1355
+ },
1356
+ },
1357
+ "additionalProperties": True,
1358
+ }
1359
+
1360
+ Type = json_schema_to_type(schema)
1361
+ validator = TypeAdapter(Type)
1362
+
1363
+ data = {
1364
+ "user": {"name": "Alice", "extra_user_field": "value"},
1365
+ "settings": {"theme": "dark", "extra_settings_field": "ignored"},
1366
+ "top_level_extra": "preserved",
1367
+ }
1368
+
1369
+ result = validator.validate_python(data)
1370
+
1371
+ # Check top-level extra field (BaseModel)
1372
+ assert result.top_level_extra == "preserved"
1373
+
1374
+ # Check nested user extra field (BaseModel)
1375
+ assert result.user.name == "Alice"
1376
+ assert result.user.extra_user_field == "value"
1377
+
1378
+ # Check nested settings - should be dataclass
1379
+ assert result.settings.theme == "dark"
1380
+ # Note: When nested in BaseModel with extra='allow', Pydantic may preserve extra fields
1381
+ # even on dataclass children. The important thing is that settings is still a dataclass.
1382
+ assert not issubclass(type(result.settings), BaseModel)
1383
+
1384
+ def test_additional_properties_false_vs_missing(self):
1385
+ """Test difference between additionalProperties: false and missing additionalProperties"""
1386
+ # Schema with explicit additionalProperties: false
1387
+ schema_false = {
1388
+ "type": "object",
1389
+ "properties": {"name": {"type": "string"}},
1390
+ "additionalProperties": False,
1391
+ }
1392
+
1393
+ # Schema with no additionalProperties key
1394
+ schema_missing = {
1395
+ "type": "object",
1396
+ "properties": {"name": {"type": "string"}},
1397
+ }
1398
+
1399
+ Type_false = json_schema_to_type(schema_false)
1400
+ Type_missing = json_schema_to_type(schema_missing)
1401
+
1402
+ # Both should create dataclasses (not BaseModel)
1403
+ assert not issubclass(Type_false, BaseModel)
1404
+ assert not issubclass(Type_missing, BaseModel)
1405
+ assert hasattr(Type_false, "__dataclass_fields__")
1406
+ assert hasattr(Type_missing, "__dataclass_fields__")
1407
+
1408
+ def test_additional_properties_with_defaults(self):
1409
+ """Test additionalProperties with default values"""
1410
+ schema = {
1411
+ "type": "object",
1412
+ "properties": {
1413
+ "name": {"type": "string", "default": "anonymous"},
1414
+ "age": {"type": "integer", "default": 0},
1415
+ },
1416
+ "additionalProperties": True,
1417
+ }
1418
+
1419
+ Type = json_schema_to_type(schema)
1420
+ validator = TypeAdapter(Type)
1421
+
1422
+ # Test with extra fields and defaults
1423
+ result = validator.validate_python({"extra": "field"})
1424
+ assert result.name == "anonymous"
1425
+ assert result.age == 0
1426
+ assert result.extra == "field"
1427
+
1428
+ def test_additional_properties_type_consistency(self):
1429
+ """Test that the same schema always returns the same type"""
1430
+ schema = {
1431
+ "type": "object",
1432
+ "properties": {"name": {"type": "string"}},
1433
+ "additionalProperties": True,
1434
+ }
1435
+
1436
+ Type1 = json_schema_to_type(schema)
1437
+ Type2 = json_schema_to_type(schema)
1438
+
1439
+ # Should be the same cached class
1440
+ assert Type1 is Type2
1441
+ assert issubclass(Type1, BaseModel)
tests/utilities/test_mcp_config.py CHANGED
@@ -136,8 +136,8 @@ async def test_multi_client(tmp_path: Path):
136
 
137
  result_1 = await client.call_tool("test_1_add", {"a": 1, "b": 2})
138
  result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2})
139
- assert result_1[0].text == "3" # type: ignore[attr-dict]
140
- assert result_2[0].text == "3" # type: ignore[attr-dict]
141
 
142
 
143
  async def test_remote_config_default_no_auth():
 
136
 
137
  result_1 = await client.call_tool("test_1_add", {"a": 1, "b": 2})
138
  result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2})
139
+ assert result_1.data == 3
140
+ assert result_2.data == 3
141
 
142
 
143
  async def test_remote_config_default_no_auth():
tests/utilities/test_types.py CHANGED
@@ -12,6 +12,7 @@ from fastmcp.utilities.types import (
12
  find_kwarg_by_type,
13
  is_class_member_of_type,
14
  issubclass_safe,
 
15
  )
16
 
17
 
@@ -536,3 +537,29 @@ class TestFindKwargByType:
536
  pass
537
 
538
  assert find_kwarg_by_type(func, str) == "c"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  find_kwarg_by_type,
13
  is_class_member_of_type,
14
  issubclass_safe,
15
+ replace_type,
16
  )
17
 
18
 
 
537
  pass
538
 
539
  assert find_kwarg_by_type(func, str) == "c"
540
+
541
+
542
+ class TestReplaceType:
543
+ @pytest.mark.parametrize(
544
+ "input,type_map,expected",
545
+ [
546
+ (int, {}, int),
547
+ (int, {int: str}, str),
548
+ (int, {int: int}, int),
549
+ (int, {int: float, bool: str}, float),
550
+ (bool, {int: float, bool: str}, str),
551
+ (int, {int: list[int]}, list[int]),
552
+ (list[int], {int: str}, list[str]),
553
+ (list[int], {int: list[str]}, list[list[str]]),
554
+ (
555
+ list[int],
556
+ {int: float, list[int]: bool},
557
+ bool,
558
+ ), # list[int] will match before int
559
+ (list[int | bool], {int: str}, list[str | bool]),
560
+ (list[list[int]], {int: str}, list[list[str]]),
561
+ ],
562
+ )
563
+ def test_replace_type(self, input, type_map, expected):
564
+ """Test replacing a type with another type."""
565
+ assert replace_type(input, type_map) == expected