Jeremiah Lowin commited on
Commit
3a9d761
·
1 Parent(s): ebdeab3

Update all tests

Browse files
src/fastmcp/server/openapi.py CHANGED
@@ -450,8 +450,10 @@ class OpenAPITool(Tool):
450
  # Try to parse as JSON first
451
  try:
452
  result = response.json()
 
 
453
  return ToolResult(structured_content=result)
454
- except (json.JSONDecodeError, ValueError):
455
  return ToolResult(content=response.text)
456
 
457
  except httpx.HTTPStatusError as e:
 
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:
src/fastmcp/tools/tool.py CHANGED
@@ -240,6 +240,13 @@ class FunctionTool(Tool):
240
  output_schema = None
241
  # Note: explicit schemas (dict) are used as-is without auto-wrapping
242
 
 
 
 
 
 
 
 
243
  return cls(
244
  fn=parsed_fn.fn,
245
  name=name or parsed_fn.name,
@@ -264,6 +271,7 @@ class FunctionTool(Tool):
264
 
265
  type_adapter = get_cached_typeadapter(self.fn)
266
  result = type_adapter.validate_python(arguments)
 
267
  if inspect.isawaitable(result):
268
  result = await result
269
 
@@ -275,6 +283,7 @@ class FunctionTool(Tool):
275
  # Handle structured content based on output schema
276
  if self.output_schema is not None:
277
  if self.output_schema.get("x-fastmcp-wrap-result"):
 
278
  structured_output = {"result": result}
279
  else:
280
  structured_output = result
@@ -354,26 +363,43 @@ class ParsedFunction:
354
  output_schema = None
355
  output_type = inspect.signature(fn).return_annotation
356
 
357
- # there are a variety of types that we don't want to attempt to
358
- # serialize because they are either used by FastMCP internally,
359
- # or are MCP content types that explicitly don't form structured
360
- # content. By replacing them with an explicitly unserializable type,
361
- # we ensure that no output schema is automatically generated.
362
-
363
- output_type = replace_type(
364
- output_type,
365
- {
366
- inspect._empty: _UnserializableType,
367
- Image: _UnserializableType,
368
- Audio: _UnserializableType,
369
- File: _UnserializableType,
370
- ToolResult: _UnserializableType,
371
- mcp.types.TextContent: _UnserializableType,
372
- mcp.types.ImageContent: _UnserializableType,
373
- mcp.types.AudioContent: _UnserializableType,
374
- mcp.types.ResourceLink: _UnserializableType,
375
- mcp.types.EmbeddedResource: _UnserializableType,
376
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  )
378
 
379
  try:
@@ -388,7 +414,7 @@ class ParsedFunction:
388
  name=fn_name,
389
  description=fn_doc,
390
  input_schema=input_schema,
391
- output_schema=output_schema,
392
  )
393
 
394
 
 
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,
 
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
 
 
283
  # Handle structured content based on output schema
284
  if self.output_schema is not None:
285
  if self.output_schema.get("x-fastmcp-wrap-result"):
286
+ # Schema says wrap - always wrap in result key
287
  structured_output = {"result": result}
288
  else:
289
  structured_output = result
 
363
  output_schema = None
364
  output_type = inspect.signature(fn).return_annotation
365
 
366
+ if output_type not in (inspect._empty, None, Any, ...):
367
+ # there are a variety of types that we don't want to attempt to
368
+ # serialize because they are either used by FastMCP internally,
369
+ # or are MCP content types that explicitly don't form structured
370
+ # content. By replacing them with an explicitly unserializable type,
371
+ # we ensure that no output schema is automatically generated.
372
+ output_type = replace_type(
373
+ output_type,
374
+ {
375
+ t: _UnserializableType
376
+ for t in (
377
+ Image,
378
+ Audio,
379
+ File,
380
+ ToolResult,
381
+ mcp.types.TextContent,
382
+ mcp.types.ImageContent,
383
+ mcp.types.AudioContent,
384
+ mcp.types.ResourceLink,
385
+ mcp.types.EmbeddedResource,
386
+ )
387
+ },
388
+ )
389
+
390
+ try:
391
+ output_type_adapter = get_cached_typeadapter(output_type)
392
+ output_schema = output_type_adapter.json_schema()
393
+ except PydanticSchemaGenerationError as e:
394
+ if "_UnserializableType" not in str(e):
395
+ logger.debug(f"Unable to generate schema for type {output_type!r}")
396
+
397
+ return cls(
398
+ fn=fn,
399
+ name=fn_name,
400
+ description=fn_doc,
401
+ input_schema=input_schema,
402
+ output_schema=output_schema or None,
403
  )
404
 
405
  try:
 
414
  name=fn_name,
415
  description=fn_doc,
416
  input_schema=input_schema,
417
+ output_schema=output_schema or None,
418
  )
419
 
420
 
src/fastmcp/tools/tool_transform.py CHANGED
@@ -269,9 +269,32 @@ class TransformedTool(Tool):
269
  try:
270
  result = await self.fn(**arguments)
271
 
272
- # If transform function returns ToolResult, use it directly
273
  if isinstance(result, ToolResult):
274
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
 
276
  # Otherwise convert to content and create ToolResult with proper structured content
277
  from fastmcp.tools.tool import _convert_to_content
@@ -283,8 +306,11 @@ class TransformedTool(Tool):
283
  # Handle structured content based on output schema
284
  if self.output_schema is not None:
285
  if self.output_schema.get("x-fastmcp-wrap-result"):
 
286
  structured_output = {"result": result}
287
  else:
 
 
288
  structured_output = result
289
  else:
290
  structured_output = None
@@ -381,7 +407,16 @@ class TransformedTool(Tool):
381
  parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
382
  final_output_schema = _wrap_schema_if_needed(parsed_fn.output_schema)
383
  if final_output_schema is None:
384
- final_output_schema = tool.output_schema
 
 
 
 
 
 
 
 
 
385
  else:
386
  final_output_schema = tool.output_schema
387
 
 
269
  try:
270
  result = await self.fn(**arguments)
271
 
272
+ # If transform function returns ToolResult, respect our output_schema setting
273
  if isinstance(result, ToolResult):
274
+ if self.output_schema is None:
275
+ # Check if this is from a custom function that returns ToolResult
276
+ import inspect
277
+
278
+ return_annotation = inspect.signature(self.fn).return_annotation
279
+ if return_annotation is ToolResult:
280
+ # Custom function returns ToolResult - preserve its content
281
+ return result
282
+ else:
283
+ # Forwarded call with disabled schema - strip structured content
284
+ return ToolResult(
285
+ content=result.content,
286
+ structured_content=None,
287
+ )
288
+ elif self.output_schema.get(
289
+ "type"
290
+ ) != "object" and not self.output_schema.get("x-fastmcp-wrap-result"):
291
+ # Non-object explicit schemas disable structured content
292
+ return ToolResult(
293
+ content=result.content,
294
+ structured_content=None,
295
+ )
296
+ else:
297
+ return result
298
 
299
  # Otherwise convert to content and create ToolResult with proper structured content
300
  from fastmcp.tools.tool import _convert_to_content
 
306
  # Handle structured content based on output schema
307
  if self.output_schema is not None:
308
  if self.output_schema.get("x-fastmcp-wrap-result"):
309
+ # Schema says wrap - always wrap in result key
310
  structured_output = {"result": result}
311
  else:
312
+ # Object schemas - use result directly
313
+ # User is responsible for returning dict-compatible data
314
  structured_output = result
315
  else:
316
  structured_output = None
 
407
  parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
408
  final_output_schema = _wrap_schema_if_needed(parsed_fn.output_schema)
409
  if final_output_schema is None:
410
+ # Check if function returns ToolResult - if so, don't fall back to parent
411
+ import inspect
412
+
413
+ return_annotation = inspect.signature(
414
+ transform_fn
415
+ ).return_annotation
416
+ if return_annotation is ToolResult:
417
+ final_output_schema = None
418
+ else:
419
+ final_output_schema = tool.output_schema
420
  else:
421
  final_output_schema = tool.output_schema
422
 
src/fastmcp/utilities/json_schema_type.py CHANGED
@@ -169,8 +169,17 @@ def json_schema_to_type(
169
  """
170
  # Always use the top-level schema for references
171
  if schema.get("type") == "object":
172
- # If no properties defined but additionalProperties is True, return dict[str, Any]
173
- if not schema.get("properties") and schema.get("additionalProperties") is True:
 
 
 
 
 
 
 
 
 
174
  return dict[str, Any] # type: ignore
175
  # If has properties AND additionalProperties is True, use Pydantic BaseModel
176
  elif schema.get("properties") and schema.get("additionalProperties") is True:
@@ -328,6 +337,43 @@ def _schema_to_type(
328
  if "enum" in schema:
329
  return _create_enum(f"Enum_{len(_classes)}", schema["enum"])
330
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  schema_type = schema.get("type")
332
  if not schema_type:
333
  return Any
 
169
  """
170
  # Always use the top-level schema for references
171
  if schema.get("type") == "object":
172
+ # If no properties defined but has additionalProperties, return typed dict
173
+ if not schema.get("properties") and schema.get("additionalProperties"):
174
+ additional_props = schema["additionalProperties"]
175
+ if additional_props is True:
176
+ return dict[str, Any] # type: ignore - additionalProperties: true means dict[str, Any]
177
+ else:
178
+ # Handle typed dictionaries like dict[str, str]
179
+ value_type = _schema_to_type(additional_props, schemas=schema)
180
+ return dict[str, value_type] # type: ignore
181
+ # If no properties and no additionalProperties, default to dict[str, Any] for safety
182
+ elif not schema.get("properties") and not schema.get("additionalProperties"):
183
  return dict[str, Any] # type: ignore
184
  # If has properties AND additionalProperties is True, use Pydantic BaseModel
185
  elif schema.get("properties") and schema.get("additionalProperties") is True:
 
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 Optional[types[0]] # type: ignore # noqa: UP007
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
tests/contrib/test_bulk_tool_caller.py CHANGED
@@ -45,13 +45,8 @@ async def echo_tool(arg1: str) -> str:
45
  def echo_tool_result_factory(arg1: str) -> CallToolRequestResult:
46
  """A tool that returns a result based on the input arguments."""
47
  return CallToolRequestResult(
48
- isError=True,
49
- content=[
50
- TextContent(
51
- text="Output validation error: outputSchema defined but no structured output returned",
52
- type="text",
53
- )
54
- ],
55
  tool="echo_tool",
56
  arguments={"arg1": arg1},
57
  )
@@ -64,13 +59,8 @@ async def no_return_tool(arg1: str) -> None:
64
  def no_return_tool_result_factory(arg1: str) -> CallToolRequestResult:
65
  """A tool that returns a result based on the input arguments."""
66
  return CallToolRequestResult(
67
- isError=True,
68
- content=[
69
- TextContent(
70
- text="Output validation error: outputSchema defined but no structured output returned",
71
- type="text",
72
- )
73
- ],
74
  tool="no_return_tool",
75
  arguments={"arg1": arg1},
76
  )
 
45
  def echo_tool_result_factory(arg1: str) -> CallToolRequestResult:
46
  """A tool that returns a result based on the input arguments."""
47
  return CallToolRequestResult(
48
+ isError=False,
49
+ content=[TextContent(text=f"{arg1}", type="text")],
 
 
 
 
 
50
  tool="echo_tool",
51
  arguments={"arg1": arg1},
52
  )
 
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
  )
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/test_import_server.py CHANGED
@@ -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.data == "42"
282
 
283
 
284
  async def test_import_with_proxy_tools():
 
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():
tests/server/test_server.py CHANGED
@@ -127,8 +127,9 @@ class TestToolDecorator:
127
  def add(x: int, y: int) -> int:
128
  return x + y
129
 
130
- result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
131
- assert result[0].text == "3" # type: ignore[attr-defined]
 
132
 
133
  async def test_tool_decorator_without_parentheses(self):
134
  """Test that @tool decorator works without parentheses."""
@@ -144,8 +145,9 @@ class TestToolDecorator:
144
  assert "add" in tools
145
 
146
  # Verify it can be called
147
- result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
148
- assert result[0].text == "3" # type: ignore[attr-defined]
 
149
 
150
  async def test_tool_decorator_with_name(self):
151
  mcp = FastMCP()
@@ -154,8 +156,9 @@ class TestToolDecorator:
154
  def add(x: int, y: int) -> int:
155
  return x + y
156
 
157
- result = await mcp._mcp_call_tool("custom-add", {"x": 1, "y": 2})
158
- assert result[0].text == "3" # type: ignore[attr-defined]
 
159
 
160
  async def test_tool_decorator_with_description(self):
161
  mcp = FastMCP()
@@ -181,8 +184,9 @@ class TestToolDecorator:
181
 
182
  obj = MyClass(10)
183
  mcp.add_tool(Tool.from_function(obj.add))
184
- result = await mcp._mcp_call_tool("add", {"y": 2})
185
- assert result[0].text == "12" # type: ignore[attr-defined]
 
186
 
187
  async def test_tool_decorator_classmethod(self):
188
  mcp = FastMCP()
@@ -195,8 +199,9 @@ class TestToolDecorator:
195
  return cls.x + y
196
 
197
  mcp.add_tool(Tool.from_function(MyClass.add))
198
- result = await mcp._mcp_call_tool("add", {"y": 2})
199
- assert result[0].text == "12" # type: ignore[attr-defined]
 
200
 
201
  async def test_tool_decorator_staticmethod(self):
202
  mcp = FastMCP()
@@ -207,8 +212,9 @@ class TestToolDecorator:
207
  def add(x: int, y: int) -> int:
208
  return x + y
209
 
210
- result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
211
- assert result[0].text == "3" # type: ignore[attr-defined]
 
212
 
213
  async def test_tool_decorator_async_function(self):
214
  mcp = FastMCP()
@@ -217,8 +223,9 @@ class TestToolDecorator:
217
  async def add(x: int, y: int) -> int:
218
  return x + y
219
 
220
- result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
221
- assert result[0].text == "3" # type: ignore[attr-defined]
 
222
 
223
  async def test_tool_decorator_classmethod_error(self):
224
  mcp = FastMCP()
@@ -242,8 +249,9 @@ class TestToolDecorator:
242
  return cls.x + y
243
 
244
  mcp.add_tool(Tool.from_function(MyClass.add))
245
- result = await mcp._mcp_call_tool("add", {"y": 2})
246
- assert result[0].text == "12" # type: ignore[attr-defined]
 
247
 
248
  async def test_tool_decorator_staticmethod_async_function(self):
249
  mcp = FastMCP()
@@ -254,8 +262,9 @@ class TestToolDecorator:
254
  return x + y
255
 
256
  mcp.add_tool(Tool.from_function(MyClass.add))
257
- result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
258
- assert result[0].text == "3" # type: ignore[attr-defined]
 
259
 
260
  async def test_tool_decorator_staticmethod_order(self):
261
  """Test that the recommended decorator order works for static methods"""
@@ -268,8 +277,9 @@ class TestToolDecorator:
268
  return x + y
269
 
270
  # Test that the recommended order works
271
- result = await mcp._mcp_call_tool("add_v1", {"x": 1, "y": 2})
272
- assert result[0].text == "3" # type: ignore[attr-defined]
 
273
 
274
  async def test_tool_decorator_with_tags(self):
275
  """Test that the tool decorator properly sets tags."""
@@ -299,8 +309,9 @@ class TestToolDecorator:
299
  assert "custom_multiply" in tools
300
 
301
  # Call the tool by its custom name
302
- result = await mcp._mcp_call_tool("custom_multiply", {"a": 5, "b": 3})
303
- assert result[0].text == "15" # type: ignore[attr-defined]
 
304
 
305
  # Original name should not be registered
306
  assert "multiply" not in tools
@@ -354,8 +365,9 @@ class TestToolDecorator:
354
  assert tools["direct_call_tool"] is result_fn
355
 
356
  # Verify it can be called
357
- result = await mcp._mcp_call_tool("direct_call_tool", {"x": 5, "y": 3})
358
- assert result[0].text == "8" # type: ignore[attr-defined]
 
359
 
360
  async def test_tool_decorator_with_string_name(self):
361
  """Test that @tool("custom_name") syntax works correctly."""
@@ -372,8 +384,9 @@ class TestToolDecorator:
372
  assert "my_function" not in tools # Original name should not be registered
373
 
374
  # Verify it can be called
375
- result = await mcp._mcp_call_tool("string_named_tool", {"x": 42})
376
- assert result[0].text == "Result: 42" # type: ignore[attr-defined]
 
377
 
378
  async def test_tool_decorator_conflicting_names_error(self):
379
  """Test that providing both positional and keyword name raises an error."""
@@ -391,11 +404,13 @@ class TestToolDecorator:
391
  async def test_tool_decorator_with_output_schema(self):
392
  mcp = FastMCP()
393
 
394
- @mcp.tool(output_schema={"type": "integer"})
395
- def my_function(x: int) -> str:
396
- return f"Result: {x}"
397
 
398
- assert my_function.output_schema == {"type": "integer"}
 
 
399
 
400
 
401
  class TestResourceDecorator:
 
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."""
 
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:
tests/server/test_server_interactions.py CHANGED
@@ -5,7 +5,7 @@ import uuid
5
  from dataclasses import dataclass
6
  from enum import Enum
7
  from pathlib import Path
8
- from typing import Annotated, Literal
9
 
10
  import pytest
11
  from mcp import McpError
@@ -946,6 +946,198 @@ class TestToolOutputSchema:
946
  assert result.structured_content == {"message": "Hello, world!"}
947
  assert result.data == {"message": "Hello, world!"}
948
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
949
 
950
  class TestToolContextInjection:
951
  """Test context injection in tools."""
 
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
 
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 handshake."""
951
+ mcp = FastMCP()
952
+
953
+ @mcp.tool(output_schema=False)
954
+ def simple_tool() -> dict[str, str]:
955
+ return {"message": "Hello from disabled schema"}
956
+
957
+ async with Client(mcp) as client:
958
+ # List tools and verify output schema is None
959
+ tools = await client.list_tools()
960
+ tool = next(t for t in tools if t.name == "simple_tool")
961
+ assert tool.outputSchema is None
962
+
963
+ # Call tool and verify no structured content
964
+ result = await client.call_tool("simple_tool", {})
965
+ assert result.structured_content is None
966
+ assert result.data is None
967
+ assert json.loads(result.content[0].text) == {
968
+ "message": "Hello from disabled schema"
969
+ } # 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 "x-fastmcp-wrap-result" not in tool.outputSchema
1080
+
1081
+ # Call tool and verify structured content is direct
1082
+ result = await client.call_tool("dataclass_tool", {})
1083
+ assert result.structured_content == {"name": "Alice", "age": 30}
1084
+ # Client deserializes according to schema
1085
+ assert result.data.name == "Alice" # type: ignore[attr-defined]
1086
+ assert result.data.age == 30 # type: ignore[attr-defined]
1087
+
1088
+ async def test_output_schema_mixed_content_types(self):
1089
+ """Test tools with mixed content and output schemas."""
1090
+ mcp = FastMCP()
1091
+
1092
+ @mcp.tool
1093
+ def mixed_output() -> list[Any]:
1094
+ # Return mixed content that includes MCP types and regular data
1095
+ return [
1096
+ "text message",
1097
+ {"structured": "data"},
1098
+ TextContent(type="text", text="direct MCP content"),
1099
+ ]
1100
+
1101
+ async with Client(mcp) as client:
1102
+ result = await client.call_tool("mixed_output", {})
1103
+
1104
+ # Should have multiple content blocks
1105
+ assert len(result.content) >= 2
1106
+
1107
+ # Should have structured output with wrapped result
1108
+ expected_data = [
1109
+ "text message",
1110
+ {"structured": "data"},
1111
+ {
1112
+ "type": "text",
1113
+ "text": "direct MCP content",
1114
+ "annotations": None,
1115
+ "_meta": None,
1116
+ },
1117
+ ]
1118
+ assert result.structured_content == {"result": expected_data}
1119
+
1120
+ async def test_output_schema_serialization_edge_cases(self):
1121
+ """Test edge cases in output schema serialization."""
1122
+ mcp = FastMCP()
1123
+
1124
+ @mcp.tool
1125
+ def edge_case_tool() -> tuple[int, str]:
1126
+ return (42, "hello")
1127
+
1128
+ async with Client(mcp) as client:
1129
+ # Verify tuple gets proper schema
1130
+ tools = await client.list_tools()
1131
+ tool = next(t for t in tools if t.name == "edge_case_tool")
1132
+
1133
+ # Tuples should be wrapped since they're not object type
1134
+ assert "x-fastmcp-wrap-result" in tool.outputSchema
1135
+
1136
+ result = await client.call_tool("edge_case_tool", {})
1137
+ # Should be wrapped with result key
1138
+ assert result.structured_content == {"result": [42, "hello"]}
1139
+ assert result.data == [42, "hello"]
1140
+
1141
 
1142
  class TestToolContextInjection:
1143
  """Test context injection in tools."""
tests/tools/test_tool.py CHANGED
@@ -263,14 +263,16 @@ class TestToolFromFunctionOutputSchema:
263
  @pytest.mark.parametrize(
264
  "annotation",
265
  [
266
- None,
267
  int,
268
  float,
269
  bool,
270
  str,
271
  int | float,
 
272
  list[int],
273
  list[int | float],
 
 
274
  dict[str, int | None],
275
  tuple[int, str],
276
  set[int],
@@ -304,7 +306,6 @@ class TestToolFromFunctionOutputSchema:
304
  @pytest.mark.parametrize(
305
  "annotation",
306
  [
307
- Any,
308
  AnyUrl,
309
  Annotated[int, Field(ge=1)],
310
  Annotated[int, Field(ge=1)],
@@ -317,17 +318,26 @@ class TestToolFromFunctionOutputSchema:
317
  tool = Tool.from_function(func)
318
  base_schema = TypeAdapter(annotation).json_schema()
319
 
320
- # Special case for Any type - it generates an empty schema and doesn't get wrapped
321
- if annotation is Any:
322
- assert tool.output_schema == base_schema # Should be {}
323
- else:
324
- # All other non-object types get wrapped, including complex constrained types
325
- expected_schema = {
326
- "type": "object",
327
- "properties": {"result": base_schema},
328
- "x-fastmcp-wrap-result": True,
329
- }
330
- assert tool.output_schema == expected_schema
 
 
 
 
 
 
 
 
 
331
 
332
  @pytest.mark.parametrize(
333
  "annotation, expected",
@@ -413,7 +423,7 @@ class TestToolFromFunctionOutputSchema:
413
  return {"a": 1, "b": 2}
414
 
415
  # Provide a custom output schema that differs from the inferred one
416
- custom_schema = {"type": "string", "description": "Custom schema"}
417
 
418
  tool = Tool.from_function(func, output_schema=custom_schema)
419
  assert tool.output_schema == custom_schema
@@ -445,7 +455,10 @@ class TestToolFromFunctionOutputSchema:
445
  return Unserializable(data="test")
446
 
447
  # Provide a custom output schema even though the annotation is unserializable
448
- custom_schema = {"type": "array", "items": {"type": "string"}}
 
 
 
449
 
450
  tool = Tool.from_function(func, output_schema=custom_schema)
451
  assert tool.output_schema == custom_schema
@@ -457,7 +470,10 @@ class TestToolFromFunctionOutputSchema:
457
  return "hello"
458
 
459
  # Provide a custom output schema even though there's no return annotation
460
- custom_schema = {"type": "number", "minimum": 0}
 
 
 
461
 
462
  tool = Tool.from_function(func, output_schema=custom_schema)
463
  assert tool.output_schema == custom_schema
@@ -486,7 +502,7 @@ class TestToolFromFunctionOutputSchema:
486
  return "hello"
487
 
488
  # Provide a custom output schema that differs from the inferred union schema
489
- custom_schema = {"type": "boolean"}
490
 
491
  tool = Tool.from_function(func, output_schema=custom_schema)
492
  assert tool.output_schema == custom_schema
@@ -504,11 +520,215 @@ class TestToolFromFunctionOutputSchema:
504
  return Person(name="John", age=30)
505
 
506
  # Provide a custom output schema that differs from the inferred Person schema
507
- custom_schema = {"type": "array", "items": {"type": "number"}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
508
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
  tool = Tool.from_function(func, output_schema=custom_schema)
510
  assert tool.output_schema == custom_schema
511
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
 
513
  class TestConvertResultToContent:
514
  """Tests for the _convert_to_content helper function."""
 
263
  @pytest.mark.parametrize(
264
  "annotation",
265
  [
 
266
  int,
267
  float,
268
  bool,
269
  str,
270
  int | float,
271
+ list,
272
  list[int],
273
  list[int | float],
274
+ dict,
275
+ dict[str, Any],
276
  dict[str, int | None],
277
  tuple[int, str],
278
  set[int],
 
306
  @pytest.mark.parametrize(
307
  "annotation",
308
  [
 
309
  AnyUrl,
310
  Annotated[int, Field(ge=1)],
311
  Annotated[int, Field(ge=1)],
 
318
  tool = Tool.from_function(func)
319
  base_schema = TypeAdapter(annotation).json_schema()
320
 
321
+ expected_schema = {
322
+ "type": "object",
323
+ "properties": {"result": base_schema},
324
+ "x-fastmcp-wrap-result": True,
325
+ }
326
+ assert tool.output_schema == expected_schema
327
+
328
+ async def test_none_return_annotation(self):
329
+ def func() -> None:
330
+ pass
331
+
332
+ tool = Tool.from_function(func)
333
+ assert tool.output_schema is None
334
+
335
+ async def test_any_return_annotation(self):
336
+ def func() -> Any:
337
+ return 1
338
+
339
+ tool = Tool.from_function(func)
340
+ assert tool.output_schema is None
341
 
342
  @pytest.mark.parametrize(
343
  "annotation, expected",
 
423
  return {"a": 1, "b": 2}
424
 
425
  # Provide a custom output schema that differs from the inferred one
426
+ custom_schema = {"type": "object", "description": "Custom schema"}
427
 
428
  tool = Tool.from_function(func, output_schema=custom_schema)
429
  assert tool.output_schema == custom_schema
 
455
  return Unserializable(data="test")
456
 
457
  # Provide a custom output schema even though the annotation is unserializable
458
+ custom_schema = {
459
+ "type": "object",
460
+ "properties": {"items": {"type": "array", "items": {"type": "string"}}},
461
+ }
462
 
463
  tool = Tool.from_function(func, output_schema=custom_schema)
464
  assert tool.output_schema == custom_schema
 
470
  return "hello"
471
 
472
  # Provide a custom output schema even though there's no return annotation
473
+ custom_schema = {
474
+ "type": "object",
475
+ "properties": {"value": {"type": "number", "minimum": 0}},
476
+ }
477
 
478
  tool = Tool.from_function(func, output_schema=custom_schema)
479
  assert tool.output_schema == custom_schema
 
502
  return "hello"
503
 
504
  # Provide a custom output schema that differs from the inferred union schema
505
+ custom_schema = {"type": "object", "properties": {"flag": {"type": "boolean"}}}
506
 
507
  tool = Tool.from_function(func, output_schema=custom_schema)
508
  assert tool.output_schema == custom_schema
 
520
  return Person(name="John", age=30)
521
 
522
  # Provide a custom output schema that differs from the inferred Person schema
523
+ custom_schema = {
524
+ "type": "object",
525
+ "properties": {"numbers": {"type": "array", "items": {"type": "number"}}},
526
+ }
527
+
528
+ tool = Tool.from_function(func, output_schema=custom_schema)
529
+ assert tool.output_schema == custom_schema
530
+
531
+ async def test_output_schema_false_disables_structured_content(self):
532
+ """Test that output_schema=False disables structured content generation."""
533
+
534
+ def func() -> dict[str, str]:
535
+ return {"message": "Hello, world!"}
536
+
537
+ tool = Tool.from_function(func, output_schema=False)
538
+ assert tool.output_schema is None
539
+
540
+ result = await tool.run({})
541
+ assert result.structured_content is None
542
+ assert len(result.content) == 1
543
+ assert result.content[0].text == '{\n "message": "Hello, world!"\n}'
544
+
545
+ async def test_output_schema_none_disables_structured_content(self):
546
+ """Test that output_schema=None explicitly disables structured content."""
547
+
548
+ def func() -> int:
549
+ return 42
550
+
551
+ tool = Tool.from_function(func, output_schema=None)
552
+ assert tool.output_schema is None
553
+
554
+ result = await tool.run({})
555
+ assert result.structured_content is None
556
+ assert len(result.content) == 1
557
+ assert result.content[0].text == "42"
558
+
559
+ async def test_output_schema_inferred_when_not_specified(self):
560
+ """Test that output schema is inferred when not explicitly specified."""
561
+
562
+ def func() -> int:
563
+ return 42
564
+
565
+ # Don't specify output_schema - should infer and wrap
566
+ tool = Tool.from_function(func)
567
+ expected_schema = {
568
+ "type": "object",
569
+ "properties": {"result": {"type": "integer"}},
570
+ "x-fastmcp-wrap-result": True,
571
+ }
572
+ assert tool.output_schema == expected_schema
573
 
574
+ result = await tool.run({})
575
+ assert result.structured_content == {"result": 42}
576
+
577
+ async def test_explicit_object_schema_with_dict_return(self):
578
+ """Test that explicit object schemas work when function returns a dict."""
579
+
580
+ def func() -> dict[str, int]:
581
+ return {"value": 42}
582
+
583
+ # Provide explicit object schema
584
+ explicit_schema = {
585
+ "type": "object",
586
+ "properties": {"value": {"type": "integer", "minimum": 0}},
587
+ }
588
+ tool = Tool.from_function(func, output_schema=explicit_schema)
589
+ assert tool.output_schema == explicit_schema # Schema not wrapped
590
+ assert "x-fastmcp-wrap-result" not in tool.output_schema
591
+
592
+ result = await tool.run({})
593
+ # Dict result with object schema is used directly
594
+ assert result.structured_content == {"value": 42}
595
+ assert result.content[0].text == '{\n "value": 42\n}'
596
+
597
+ async def test_explicit_object_schema_with_non_dict_return_fails(self):
598
+ """Test that explicit object schemas fail when function returns non-dict."""
599
+
600
+ def func() -> int:
601
+ return 42
602
+
603
+ # Provide explicit object schema but return non-dict
604
+ explicit_schema = {
605
+ "type": "object",
606
+ "properties": {"value": {"type": "integer"}},
607
+ }
608
+ tool = Tool.from_function(func, output_schema=explicit_schema)
609
+
610
+ # Should fail because int is not dict-compatible with object schema
611
+ with pytest.raises(ValueError, match="structured_content must be a dict"):
612
+ await tool.run({})
613
+
614
+ async def test_object_output_schema_not_wrapped(self):
615
+ """Test that object-type output schemas are never wrapped."""
616
+
617
+ def func() -> dict[str, int]:
618
+ return {"value": 42}
619
+
620
+ # Object schemas should never be wrapped, even when inferred
621
+ tool = Tool.from_function(func)
622
+ expected_schema = TypeAdapter(dict[str, int]).json_schema()
623
+ assert tool.output_schema == expected_schema # Not wrapped
624
+ assert "x-fastmcp-wrap-result" not in tool.output_schema
625
+
626
+ result = await tool.run({})
627
+ assert result.structured_content == {"value": 42} # Direct value
628
+
629
+ async def test_structured_content_interaction_with_wrapping(self):
630
+ """Test that structured content works correctly with schema wrapping."""
631
+
632
+ def func() -> str:
633
+ return "hello"
634
+
635
+ # Inferred schema should wrap string type
636
+ tool = Tool.from_function(func)
637
+ expected_schema = {
638
+ "type": "object",
639
+ "properties": {"result": {"type": "string"}},
640
+ "x-fastmcp-wrap-result": True,
641
+ }
642
+ assert tool.output_schema == expected_schema
643
+
644
+ result = await tool.run({})
645
+ # Unstructured content
646
+ assert len(result.content) == 1
647
+ assert result.content[0].text == "hello"
648
+ # Structured content should be wrapped
649
+ assert result.structured_content == {"result": "hello"}
650
+
651
+ async def test_structured_content_with_explicit_object_schema(self):
652
+ """Test structured content with explicit object schema."""
653
+
654
+ def func() -> dict[str, str]:
655
+ return {"greeting": "hello"}
656
+
657
+ # Provide explicit object schema
658
+ explicit_schema = {
659
+ "type": "object",
660
+ "properties": {"greeting": {"type": "string"}},
661
+ "required": ["greeting"],
662
+ }
663
+ tool = Tool.from_function(func, output_schema=explicit_schema)
664
+ assert tool.output_schema == explicit_schema
665
+
666
+ result = await tool.run({})
667
+ # Should use direct value since explicit schema doesn't have wrap marker
668
+ assert result.structured_content == {"greeting": "hello"}
669
+
670
+ async def test_structured_content_with_custom_wrapper_schema(self):
671
+ """Test structured content with custom schema that includes wrap marker."""
672
+
673
+ def func() -> str:
674
+ return "world"
675
+
676
+ # Custom schema with wrap marker
677
+ custom_schema = {
678
+ "type": "object",
679
+ "properties": {"message": {"type": "string"}},
680
+ "x-fastmcp-wrap-result": True,
681
+ }
682
  tool = Tool.from_function(func, output_schema=custom_schema)
683
  assert tool.output_schema == custom_schema
684
 
685
+ result = await tool.run({})
686
+ # Should wrap with "result" key due to wrap marker
687
+ assert result.structured_content == {"result": "world"}
688
+
689
+ async def test_none_vs_false_output_schema_behavior(self):
690
+ """Test the difference between None and False for output_schema."""
691
+
692
+ def func() -> int:
693
+ return 123
694
+
695
+ # None should disable
696
+ tool_none = Tool.from_function(func, output_schema=None)
697
+ assert tool_none.output_schema is None
698
+
699
+ # False should also disable
700
+ tool_false = Tool.from_function(func, output_schema=False)
701
+ assert tool_false.output_schema is None
702
+
703
+ # Both should have same behavior
704
+ result_none = await tool_none.run({})
705
+ result_false = await tool_false.run({})
706
+
707
+ assert result_none.structured_content is None
708
+ assert result_false.structured_content is None
709
+ assert result_none.content[0].text == result_false.content[0].text == "123"
710
+
711
+ async def test_non_object_output_schema_raises_error(self):
712
+ """Test that providing a non-object output schema raises a ValueError."""
713
+
714
+ def func() -> int:
715
+ return 42
716
+
717
+ # Test various non-object schemas that should raise errors
718
+ non_object_schemas = [
719
+ {"type": "string"},
720
+ {"type": "integer", "minimum": 0},
721
+ {"type": "number"},
722
+ {"type": "boolean"},
723
+ {"type": "array", "items": {"type": "string"}},
724
+ ]
725
+
726
+ for schema in non_object_schemas:
727
+ with pytest.raises(
728
+ ValueError, match='Output schemas must have "type" set to "object"'
729
+ ):
730
+ Tool.from_function(func, output_schema=schema)
731
+
732
 
733
  class TestConvertResultToContent:
734
  """Tests for the _convert_to_content helper function."""
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
 
@@ -691,7 +692,7 @@ async def test_arg_transform_type_precedence_runtime():
691
  # Convert string back to int for the original function
692
  result = await forward_raw(x=int(x), y=y)
693
  # Extract the text from the result
694
- result_text = result.content[0].text
695
  return f"String input '{x}' converted to result: {result_text}"
696
 
697
  tool = Tool.from_tool(
@@ -1030,3 +1031,266 @@ def test_arg_transform_examples_in_schema(add_tool):
1030
  )
1031
  prop3 = get_property(new_tool3, "old_x")
1032
  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
 
 
692
  # Convert string back to int for the original function
693
  result = await forward_raw(x=int(x), y=y)
694
  # Extract the text from the result
695
+ result_text = result.content[0].text # type: ignore[attr-defined]
696
  return f"String input '{x}' converted to result: {result_text}"
697
 
698
  tool = Tool.from_tool(
 
1031
  )
1032
  prop3 = get_property(new_tool3, "old_x")
1033
  assert "examples" not in prop3
1034
+
1035
+
1036
+ class TestTransformToolOutputSchema:
1037
+ """Test output schema handling in transformed tools."""
1038
+
1039
+ @pytest.fixture
1040
+ def base_string_tool(self) -> FunctionTool:
1041
+ """Tool that returns a string (gets wrapped)."""
1042
+
1043
+ def string_tool(x: int) -> str:
1044
+ return f"Result: {x}"
1045
+
1046
+ return Tool.from_function(string_tool)
1047
+
1048
+ @pytest.fixture
1049
+ def base_dict_tool(self) -> FunctionTool:
1050
+ """Tool that returns a dict (object type, not wrapped)."""
1051
+
1052
+ def dict_tool(x: int) -> dict[str, int]:
1053
+ return {"value": x}
1054
+
1055
+ return Tool.from_function(dict_tool)
1056
+
1057
+ def test_transform_inherits_parent_output_schema(self, base_string_tool):
1058
+ """Test that transformed tool inherits parent's output schema by default."""
1059
+ new_tool = Tool.from_tool(base_string_tool)
1060
+
1061
+ # Should inherit parent's wrapped string schema
1062
+ expected_schema = {
1063
+ "type": "object",
1064
+ "properties": {"result": {"type": "string"}},
1065
+ "x-fastmcp-wrap-result": True,
1066
+ }
1067
+ assert new_tool.output_schema == expected_schema
1068
+ assert new_tool.output_schema == base_string_tool.output_schema
1069
+
1070
+ def test_transform_with_explicit_output_schema_false(self, base_string_tool):
1071
+ """Test that output_schema=False disables structured output."""
1072
+ new_tool = Tool.from_tool(base_string_tool, output_schema=False)
1073
+
1074
+ assert new_tool.output_schema is None
1075
+
1076
+ async def test_transform_output_schema_false_runtime(self, base_string_tool):
1077
+ """Test runtime behavior with output_schema=False."""
1078
+ new_tool = Tool.from_tool(base_string_tool, output_schema=False)
1079
+
1080
+ # Debug: check that output_schema is actually None
1081
+ assert new_tool.output_schema is None, (
1082
+ f"Expected None, got {new_tool.output_schema}"
1083
+ )
1084
+
1085
+ result = await new_tool.run({"x": 5})
1086
+ assert result.structured_content is None
1087
+ assert result.content[0].text == "Result: 5" # type: ignore[attr-defined]
1088
+
1089
+ def test_transform_with_explicit_output_schema_dict(self, base_string_tool):
1090
+ """Test that explicit output schema overrides parent."""
1091
+ custom_schema = {
1092
+ "type": "object",
1093
+ "properties": {"message": {"type": "string"}},
1094
+ }
1095
+ new_tool = Tool.from_tool(base_string_tool, output_schema=custom_schema)
1096
+
1097
+ assert new_tool.output_schema == custom_schema
1098
+ assert new_tool.output_schema != base_string_tool.output_schema
1099
+
1100
+ async def test_transform_explicit_schema_runtime(self, base_string_tool):
1101
+ """Test runtime behavior with explicit output schema."""
1102
+ custom_schema = {"type": "string", "minLength": 1}
1103
+ new_tool = Tool.from_tool(base_string_tool, output_schema=custom_schema)
1104
+
1105
+ result = await new_tool.run({"x": 10})
1106
+ # Non-object explicit schemas disable structured content
1107
+ assert result.structured_content is None
1108
+ assert result.content[0].text == "Result: 10" # type: ignore[attr-defined]
1109
+
1110
+ def test_transform_with_custom_function_inferred_schema(self, base_dict_tool):
1111
+ """Test that custom function's output schema is inferred."""
1112
+
1113
+ async def custom_fn(x: int) -> str:
1114
+ result = await forward(x=x)
1115
+ return f"Custom: {result.content[0].text}" # type: ignore[attr-defined]
1116
+
1117
+ new_tool = Tool.from_tool(base_dict_tool, transform_fn=custom_fn)
1118
+
1119
+ # Should infer string schema from custom function and wrap it
1120
+ expected_schema = {
1121
+ "type": "object",
1122
+ "properties": {"result": {"type": "string"}},
1123
+ "x-fastmcp-wrap-result": True,
1124
+ }
1125
+ assert new_tool.output_schema == expected_schema
1126
+
1127
+ async def test_transform_custom_function_runtime(self, base_dict_tool):
1128
+ """Test runtime behavior with custom function that has inferred schema."""
1129
+
1130
+ async def custom_fn(x: int) -> str:
1131
+ result = await forward(x=x)
1132
+ return f"Custom: {result.content[0].text}" # type: ignore[attr-defined]
1133
+
1134
+ new_tool = Tool.from_tool(base_dict_tool, transform_fn=custom_fn)
1135
+
1136
+ result = await new_tool.run({"x": 3})
1137
+ # Should wrap string result
1138
+ assert result.structured_content == {"result": 'Custom: {\n "value": 3\n}'}
1139
+
1140
+ def test_transform_custom_function_fallback_to_parent(self, base_string_tool):
1141
+ """Test that custom function without output annotation falls back to parent."""
1142
+
1143
+ async def custom_fn(x: int):
1144
+ # No return annotation - should fallback to parent schema
1145
+ result = await forward(x=x)
1146
+ return result
1147
+
1148
+ new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn)
1149
+
1150
+ # Should use parent's schema since custom function has no annotation
1151
+ assert new_tool.output_schema == base_string_tool.output_schema
1152
+
1153
+ def test_transform_custom_function_explicit_overrides(self, base_string_tool):
1154
+ """Test that explicit output_schema overrides both custom function and parent."""
1155
+
1156
+ async def custom_fn(x: int) -> dict[str, str]:
1157
+ return {"custom": "value"}
1158
+
1159
+ explicit_schema = {"type": "array", "items": {"type": "number"}}
1160
+ new_tool = Tool.from_tool(
1161
+ base_string_tool, transform_fn=custom_fn, output_schema=explicit_schema
1162
+ )
1163
+
1164
+ # Explicit schema should win
1165
+ assert new_tool.output_schema == explicit_schema
1166
+
1167
+ async def test_transform_custom_function_object_return(self, base_string_tool):
1168
+ """Test custom function returning object type."""
1169
+
1170
+ async def custom_fn(x: int) -> dict[str, int]:
1171
+ result = await forward(x=x)
1172
+ return {"original": x, "transformed": x * 2}
1173
+
1174
+ new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn)
1175
+
1176
+ # Object types should not be wrapped
1177
+ expected_schema = TypeAdapter(dict[str, int]).json_schema()
1178
+ assert new_tool.output_schema == expected_schema
1179
+ assert "x-fastmcp-wrap-result" not in new_tool.output_schema
1180
+
1181
+ result = await new_tool.run({"x": 4})
1182
+ # Direct value, not wrapped
1183
+ assert result.structured_content == {"original": 4, "transformed": 8}
1184
+
1185
+ async def test_transform_preserves_wrap_marker_behavior(self, base_string_tool):
1186
+ """Test that wrap marker behavior is preserved through transformation."""
1187
+ new_tool = Tool.from_tool(base_string_tool)
1188
+
1189
+ result = await new_tool.run({"x": 7})
1190
+ # Should wrap because parent schema has wrap marker
1191
+ assert result.structured_content == {"result": "Result: 7"}
1192
+ assert "x-fastmcp-wrap-result" in new_tool.output_schema
1193
+
1194
+ def test_transform_chained_output_schema_inheritance(self, base_string_tool):
1195
+ """Test output schema inheritance through multiple transformations."""
1196
+ # First transformation keeps parent schema
1197
+ tool1 = Tool.from_tool(base_string_tool)
1198
+ assert tool1.output_schema == base_string_tool.output_schema
1199
+
1200
+ # Second transformation also inherits
1201
+ tool2 = Tool.from_tool(tool1)
1202
+ assert (
1203
+ tool2.output_schema == tool1.output_schema == base_string_tool.output_schema
1204
+ )
1205
+
1206
+ # Third transformation with explicit override
1207
+ custom_schema = {"type": "number"}
1208
+ tool3 = Tool.from_tool(tool2, output_schema=custom_schema)
1209
+ assert tool3.output_schema == custom_schema
1210
+ assert tool3.output_schema != tool2.output_schema
1211
+
1212
+ async def test_transform_mixed_structured_unstructured_content(
1213
+ self, base_string_tool
1214
+ ):
1215
+ """Test transformation handling of mixed content types."""
1216
+
1217
+ async def custom_fn(x: int) -> list:
1218
+ # Return mixed content including ToolResult
1219
+ if x == 1:
1220
+ return ["text", {"data": x}]
1221
+ else:
1222
+ # Return ToolResult directly
1223
+ return ToolResult(
1224
+ content=[TextContent(type="text", text=f"Custom: {x}")],
1225
+ structured_content={"custom_value": x},
1226
+ )
1227
+
1228
+ new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn)
1229
+
1230
+ # Test mixed content return
1231
+ result1 = await new_tool.run({"x": 1})
1232
+ assert result1.structured_content == {"result": ["text", {"data": 1}]}
1233
+
1234
+ # Test ToolResult return
1235
+ result2 = await new_tool.run({"x": 2})
1236
+ assert result2.structured_content == {"custom_value": 2}
1237
+ assert result2.content[0].text == "Custom: 2" # type: ignore[attr-defined]
1238
+
1239
+ def test_transform_output_schema_with_arg_transforms(self, base_string_tool):
1240
+ """Test that output schema works correctly with argument transformations."""
1241
+
1242
+ async def custom_fn(new_x: int) -> dict[str, str]:
1243
+ result = await forward(new_x=new_x)
1244
+ return {"transformed": result.content[0].text} # type: ignore[attr-defined]
1245
+
1246
+ new_tool = Tool.from_tool(
1247
+ base_string_tool,
1248
+ transform_fn=custom_fn,
1249
+ transform_args={"x": ArgTransform(name="new_x")},
1250
+ )
1251
+
1252
+ # Should infer object schema from custom function
1253
+ expected_schema = TypeAdapter(dict[str, str]).json_schema()
1254
+ assert new_tool.output_schema == expected_schema
1255
+
1256
+ async def test_transform_output_schema_none_vs_false(self, base_string_tool):
1257
+ """Test None vs False behavior for output_schema in transforms."""
1258
+ # None (default) should use smart fallback (inherit from parent)
1259
+ tool_none = Tool.from_tool(base_string_tool) # default output_schema=None
1260
+ assert tool_none.output_schema == base_string_tool.output_schema # Inherits
1261
+
1262
+ # False should explicitly disable
1263
+ tool_false = Tool.from_tool(base_string_tool, output_schema=False)
1264
+ assert tool_false.output_schema is None
1265
+
1266
+ # Different behavior at runtime
1267
+ result_none = await tool_none.run({"x": 5})
1268
+ result_false = await tool_false.run({"x": 5})
1269
+
1270
+ assert result_none.structured_content == {
1271
+ "result": "Result: 5"
1272
+ } # Inherits wrapping
1273
+ assert result_false.structured_content is None # Disabled
1274
+ assert result_none.content[0].text == result_false.content[0].text
1275
+
1276
+ async def test_transform_output_schema_with_tool_result_return(
1277
+ self, base_string_tool
1278
+ ):
1279
+ """Test transform when custom function returns ToolResult directly."""
1280
+
1281
+ async def custom_fn(x: int) -> ToolResult:
1282
+ # Custom function returns ToolResult - should bypass schema handling
1283
+ return ToolResult(
1284
+ content=[TextContent(type="text", text=f"Direct: {x}")],
1285
+ structured_content={"direct_value": x, "doubled": x * 2},
1286
+ )
1287
+
1288
+ new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn)
1289
+
1290
+ # ToolResult return type should result in None output schema
1291
+ assert new_tool.output_schema is None
1292
+
1293
+ result = await new_tool.run({"x": 6})
1294
+ # Should use ToolResult content directly
1295
+ assert result.content[0].text == "Direct: 6" # type: ignore[attr-defined]
1296
+ assert result.structured_content == {"direct_value": 6, "doubled": 12}
tests/utilities/test_json_schema_type.py CHANGED
@@ -1,5 +1,5 @@
1
  from datetime import datetime
2
- from typing import Union
3
 
4
  import pytest
5
  from pydantic import AnyUrl, BaseModel, TypeAdapter, ValidationError
@@ -326,6 +326,29 @@ class TestObjectTypes:
326
  }
327
  )
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  def test_object_accepts_valid(self, simple_object):
330
  validator = TypeAdapter(simple_object)
331
  result = validator.validate_python({"name": "test", "age": 30})
 
1
  from datetime import datetime
2
+ from typing import Any, Union
3
 
4
  import pytest
5
  from pydantic import AnyUrl, BaseModel, TypeAdapter, ValidationError
 
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})