Jeremiah Lowin commited on
Commit
677a8fb
·
1 Parent(s): a027af7

Fix schema wrapping for non-object union types in output schemas

Browse files
src/fastmcp/tools/tool.py CHANGED
@@ -3,7 +3,7 @@ from __future__ import annotations
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
@@ -31,34 +31,22 @@ if TYPE_CHECKING:
31
 
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:
@@ -246,7 +234,7 @@ class FunctionTool(Tool):
246
  raise ValueError("You must provide a name for lambda functions")
247
 
248
  if isinstance(output_schema, NotSetT):
249
- output_schema = _wrap_schema_if_needed(parsed_fn.output_schema)
250
  elif output_schema is False:
251
  output_schema = None
252
  # Note: explicit schemas (dict) are used as-is without auto-wrapping
@@ -329,8 +317,8 @@ class ParsedFunction:
329
  cls,
330
  fn: Callable[..., Any],
331
  exclude_args: list[str] | None = None,
332
- ignore_response_types: list[type] | None = None,
333
  validate: bool = True,
 
334
  ) -> ParsedFunction:
335
  from fastmcp.server.context import Context
336
 
@@ -389,7 +377,7 @@ class ParsedFunction:
389
  # or are MCP content types that explicitly don't form structured
390
  # content. By replacing them with an explicitly unserializable type,
391
  # we ensure that no output schema is automatically generated.
392
- output_type = replace_type(
393
  output_type,
394
  {
395
  t: _UnserializableType
@@ -408,8 +396,25 @@ class ParsedFunction:
408
  )
409
 
410
  try:
411
- output_type_adapter = get_cached_typeadapter(output_type)
412
- output_schema = output_type_adapter.json_schema()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
  except PydanticSchemaGenerationError as e:
414
  if "_UnserializableType" not in str(e):
415
  logger.debug(f"Unable to generate schema for type {output_type!r}")
@@ -422,21 +427,6 @@ class ParsedFunction:
422
  output_schema=output_schema or None,
423
  )
424
 
425
- try:
426
- output_type_adapter = get_cached_typeadapter(output_type)
427
- output_schema = output_type_adapter.json_schema()
428
- except PydanticSchemaGenerationError as e:
429
- if "_UnserializableType" not in str(e):
430
- logger.debug(f"Unable to generate schema for type {output_type!r}")
431
-
432
- return cls(
433
- fn=fn,
434
- name=fn_name,
435
- description=fn_doc,
436
- input_schema=input_schema,
437
- output_schema=output_schema or None,
438
- )
439
-
440
 
441
  def _convert_to_content(
442
  result: Any,
 
3
  import inspect
4
  from collections.abc import Callable
5
  from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Annotated, Any, Generic, Literal, TypeVar
7
 
8
  import mcp.types
9
  import pydantic_core
 
31
 
32
  logger = get_logger(__name__)
33
 
34
+ T = TypeVar("T")
 
 
35
 
36
 
37
+ @dataclass
38
+ class _WrappedResult(Generic[T]):
39
+ """Generic wrapper for non-object return types."""
40
 
41
+ result: T
42
 
 
 
43
 
44
+ class _UnserializableType:
45
+ pass
46
 
 
 
47
 
48
+ def default_serializer(data: Any) -> str:
49
+ return pydantic_core.to_json(data, fallback=str, indent=2).decode()
 
 
 
 
 
 
 
 
50
 
51
 
52
  class ToolResult:
 
234
  raise ValueError("You must provide a name for lambda functions")
235
 
236
  if isinstance(output_schema, NotSetT):
237
+ output_schema = parsed_fn.output_schema
238
  elif output_schema is False:
239
  output_schema = None
240
  # Note: explicit schemas (dict) are used as-is without auto-wrapping
 
317
  cls,
318
  fn: Callable[..., Any],
319
  exclude_args: list[str] | None = None,
 
320
  validate: bool = True,
321
+ wrap_non_object_output_schema: bool = True,
322
  ) -> ParsedFunction:
323
  from fastmcp.server.context import Context
324
 
 
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
+ clean_output_type = replace_type(
381
  output_type,
382
  {
383
  t: _UnserializableType
 
396
  )
397
 
398
  try:
399
+ type_adapter = get_cached_typeadapter(clean_output_type)
400
+ base_schema = type_adapter.json_schema()
401
+
402
+ # Generate schema for wrapped type if it's non-object
403
+ # because MCP requires that output schemas are objects
404
+ if (
405
+ wrap_non_object_output_schema
406
+ and base_schema.get("type") != "object"
407
+ ):
408
+ # Use the wrapped result schema directly
409
+ wrapped_type = _WrappedResult[clean_output_type]
410
+ wrapped_adapter = get_cached_typeadapter(wrapped_type)
411
+ output_schema = wrapped_adapter.json_schema()
412
+ output_schema["x-fastmcp-wrap-result"] = True
413
+ else:
414
+ output_schema = base_schema
415
+
416
+ output_schema = compress_schema(output_schema)
417
+
418
  except PydanticSchemaGenerationError as e:
419
  if "_UnserializableType" not in str(e):
420
  logger.debug(f"Unable to generate schema for type {output_type!r}")
 
427
  output_schema=output_schema or None,
428
  )
429
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
 
431
  def _convert_to_content(
432
  result: Any,
src/fastmcp/tools/tool_transform.py CHANGED
@@ -9,7 +9,7 @@ from typing import Any, Literal
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
 
@@ -430,7 +430,7 @@ class TransformedTool(Tool):
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
 
9
  from mcp.types import ToolAnnotations
10
  from pydantic import ConfigDict
11
 
12
+ from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult
13
  from fastmcp.utilities.logging import get_logger
14
  from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter
15
 
 
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 = 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
tests/server/test_server_interactions.py CHANGED
@@ -27,6 +27,7 @@ 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
 
@@ -894,7 +895,9 @@ class TestToolOutputSchema:
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
 
@@ -912,7 +915,7 @@ class TestToolOutputSchema:
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
 
@@ -1020,7 +1023,9 @@ class TestToolOutputSchema:
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
@@ -1045,7 +1050,9 @@ class TestToolOutputSchema:
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
@@ -1074,7 +1081,7 @@ class TestToolOutputSchema:
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
 
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.json_schema import compress_schema
31
  from fastmcp.utilities.types import Audio, File, Image
32
 
33
 
 
895
  # this line will fail until MCP adds output schemas!!
896
  assert tools[0].outputSchema == {
897
  "type": "object",
898
+ "properties": {"result": {**type_schema, "title": "Result"}},
899
+ "required": ["result"],
900
+ "title": "_WrappedResult",
901
  "x-fastmcp-wrap-result": True,
902
  }
903
 
 
915
  async with Client(mcp) as client:
916
  tools = await client.list_tools()
917
 
918
+ type_schema = compress_schema(TypeAdapter(annotation).json_schema())
919
  assert len(tools) == 1
920
  assert tools[0].outputSchema == type_schema
921
 
 
1023
  tool = next(t for t in tools if t.name == "primitive_tool")
1024
  expected_schema = {
1025
  "type": "object",
1026
+ "properties": {"result": {"type": "string", "title": "Result"}},
1027
+ "required": ["result"],
1028
+ "title": "_WrappedResult",
1029
  "x-fastmcp-wrap-result": True,
1030
  }
1031
  assert tool.outputSchema == expected_schema
 
1050
  expected_inner_schema = TypeAdapter(list[dict[str, int]]).json_schema()
1051
  expected_schema = {
1052
  "type": "object",
1053
+ "properties": {"result": {**expected_inner_schema, "title": "Result"}},
1054
+ "required": ["result"],
1055
+ "title": "_WrappedResult",
1056
  "x-fastmcp-wrap-result": True,
1057
  }
1058
  assert tool.outputSchema == expected_schema
 
1081
  # List tools and verify schema is object type (not wrapped)
1082
  tools = await client.list_tools()
1083
  tool = next(t for t in tools if t.name == "dataclass_tool")
1084
+ expected_schema = compress_schema(TypeAdapter(User).json_schema())
1085
  assert tool.outputSchema == expected_schema
1086
  assert (
1087
  tool.outputSchema and "x-fastmcp-wrap-result" not in tool.outputSchema
tests/tools/test_tool.py CHANGED
@@ -14,6 +14,7 @@ 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
18
 
19
 
@@ -35,7 +36,9 @@ class TestToolFromFunction:
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
@@ -296,7 +299,9 @@ class TestToolFromFunctionOutputSchema:
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
@@ -321,7 +326,9 @@ class TestToolFromFunctionOutputSchema:
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
@@ -369,7 +376,8 @@ class TestToolFromFunctionOutputSchema:
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):
@@ -380,7 +388,8 @@ class TestToolFromFunctionOutputSchema:
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):
@@ -391,7 +400,8 @@ class TestToolFromFunctionOutputSchema:
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:
@@ -568,7 +578,9 @@ class TestToolFromFunctionOutputSchema:
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
@@ -638,7 +650,9 @@ class TestToolFromFunctionOutputSchema:
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
@@ -1220,13 +1234,39 @@ class TestAutomaticStructuredContent:
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
1228
 
1229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1230
  class TestToolTitle:
1231
  """Tests for tool title functionality."""
1232
 
 
14
  from typing_extensions import TypedDict
15
 
16
  from fastmcp.tools.tool import Tool, _convert_to_content
17
+ from fastmcp.utilities.json_schema import compress_schema
18
  from fastmcp.utilities.types import Audio, File, Image
19
 
20
 
 
36
  # With primitive wrapping, int return type becomes object with result property
37
  expected_schema = {
38
  "type": "object",
39
+ "properties": {"result": {"type": "integer", "title": "Result"}},
40
+ "required": ["result"],
41
+ "title": "_WrappedResult",
42
  "x-fastmcp-wrap-result": True,
43
  }
44
  assert tool.output_schema == expected_schema
 
299
  # Non-object types get wrapped
300
  expected_schema = {
301
  "type": "object",
302
+ "properties": {"result": {**base_schema, "title": "Result"}},
303
+ "required": ["result"],
304
+ "title": "_WrappedResult",
305
  "x-fastmcp-wrap-result": True,
306
  }
307
  assert tool.output_schema == expected_schema
 
326
 
327
  expected_schema = {
328
  "type": "object",
329
+ "properties": {"result": {**base_schema, "title": "Result"}},
330
+ "required": ["result"],
331
+ "title": "_WrappedResult",
332
  "x-fastmcp-wrap-result": True,
333
  }
334
  assert tool.output_schema == expected_schema
 
376
  return Person(name="John", age=30)
377
 
378
  tool = Tool.from_function(func)
379
+ expected_schema = compress_schema(TypeAdapter(Person).json_schema())
380
+ assert tool.output_schema == expected_schema
381
 
382
  async def test_base_model_return_annotation(self):
383
  class Person(BaseModel):
 
388
  return Person(name="John", age=30)
389
 
390
  tool = Tool.from_function(func)
391
+ expected_schema = compress_schema(TypeAdapter(Person).json_schema())
392
+ assert tool.output_schema == expected_schema
393
 
394
  async def test_typeddict_return_annotation(self):
395
  class Person(TypedDict):
 
400
  return Person(name="John", age=30)
401
 
402
  tool = Tool.from_function(func)
403
+ expected_schema = compress_schema(TypeAdapter(Person).json_schema())
404
+ assert tool.output_schema == expected_schema
405
 
406
  async def test_unserializable_return_annotation(self):
407
  class Unserializable:
 
578
  tool = Tool.from_function(func)
579
  expected_schema = {
580
  "type": "object",
581
+ "properties": {"result": {"type": "integer", "title": "Result"}},
582
+ "required": ["result"],
583
+ "title": "_WrappedResult",
584
  "x-fastmcp-wrap-result": True,
585
  }
586
  assert tool.output_schema == expected_schema
 
650
  tool = Tool.from_function(func)
651
  expected_schema = {
652
  "type": "object",
653
+ "properties": {"result": {"type": "string", "title": "Result"}},
654
+ "required": ["result"],
655
+ "title": "_WrappedResult",
656
  "x-fastmcp-wrap-result": True,
657
  }
658
  assert tool.output_schema == expected_schema
 
1234
  async with Client(mcp) as client:
1235
  result = await client.call_tool("get_profile", {"user_id": "456"})
1236
 
1237
+ # Client should deserialize back to a dataclass (type name preserved with new compression)
1238
  assert result.data.__class__.__name__ == "UserProfile"
1239
  assert result.data.name == "Bob"
1240
  assert result.data.age == 25
1241
  assert result.data.verified is True
1242
 
1243
 
1244
+ class TestUnionReturnTypes:
1245
+ """Tests for tools with union return types."""
1246
+
1247
+ async def test_dataclass_union_string_works(self):
1248
+ """Test that union of dataclass and string works correctly."""
1249
+
1250
+ @dataclass
1251
+ class Data:
1252
+ value: int
1253
+
1254
+ def get_data(return_error: bool) -> Data | str:
1255
+ if return_error:
1256
+ return "error occurred"
1257
+ return Data(value=42)
1258
+
1259
+ tool = Tool.from_function(get_data)
1260
+
1261
+ # Test returning dataclass
1262
+ result1 = await tool.run({"return_error": False})
1263
+ assert result1.structured_content == {"result": {"value": 42}}
1264
+
1265
+ # Test returning string
1266
+ result2 = await tool.run({"return_error": True})
1267
+ assert result2.structured_content == {"result": "error occurred"}
1268
+
1269
+
1270
  class TestToolTitle:
1271
  """Tests for tool title functionality."""
1272
 
tests/tools/test_tool_transform.py CHANGED
@@ -1063,7 +1063,9 @@ class TestTransformToolOutputSchema:
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
@@ -1121,7 +1123,9 @@ class TestTransformToolOutputSchema:
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
 
1063
  # Should inherit parent's wrapped string schema
1064
  expected_schema = {
1065
  "type": "object",
1066
+ "properties": {"result": {"type": "string", "title": "Result"}},
1067
+ "required": ["result"],
1068
+ "title": "_WrappedResult",
1069
  "x-fastmcp-wrap-result": True,
1070
  }
1071
  assert new_tool.output_schema == expected_schema
 
1123
  # Should infer string schema from custom function and wrap it
1124
  expected_schema = {
1125
  "type": "object",
1126
+ "properties": {"result": {"type": "string", "title": "Result"}},
1127
+ "required": ["result"],
1128
+ "title": "_WrappedResult",
1129
  "x-fastmcp-wrap-result": True,
1130
  }
1131
  assert new_tool.output_schema == expected_schema