Spaces:
Running
Running
Jeremiah Lowin commited on
Ensure transformed tools generate structured content (#1443)
Browse files- src/fastmcp/tools/tool.py +7 -7
- src/fastmcp/tools/tool_transform.py +41 -27
- tests/server/test_tool_transformation.py +28 -0
- tests/tools/test_tool.py +18 -12
- tests/tools/test_tool_transform.py +30 -22
src/fastmcp/tools/tool.py
CHANGED
|
@@ -8,7 +8,6 @@ from typing import (
|
|
| 8 |
Annotated,
|
| 9 |
Any,
|
| 10 |
Generic,
|
| 11 |
-
Literal,
|
| 12 |
TypeVar,
|
| 13 |
get_type_hints,
|
| 14 |
)
|
|
@@ -163,7 +162,7 @@ class Tool(FastMCPComponent):
|
|
| 163 |
tags: set[str] | None = None,
|
| 164 |
annotations: ToolAnnotations | None = None,
|
| 165 |
exclude_args: list[str] | None = None,
|
| 166 |
-
output_schema: dict[str, Any] | None | NotSetT
|
| 167 |
serializer: Callable[[Any], str] | None = None,
|
| 168 |
meta: dict[str, Any] | None = None,
|
| 169 |
enabled: bool | None = None,
|
|
@@ -199,17 +198,18 @@ class Tool(FastMCPComponent):
|
|
| 199 |
def from_tool(
|
| 200 |
cls,
|
| 201 |
tool: Tool,
|
| 202 |
-
|
| 203 |
name: str | None = None,
|
| 204 |
title: str | None | NotSetT = NotSet,
|
| 205 |
-
transform_args: dict[str, ArgTransform] | None = None,
|
| 206 |
description: str | None | NotSetT = NotSet,
|
| 207 |
tags: set[str] | None = None,
|
| 208 |
-
annotations: ToolAnnotations | None =
|
| 209 |
-
output_schema: dict[str, Any] | None |
|
| 210 |
serializer: Callable[[Any], str] | None = None,
|
| 211 |
meta: dict[str, Any] | None | NotSetT = NotSet,
|
|
|
|
| 212 |
enabled: bool | None = None,
|
|
|
|
| 213 |
) -> TransformedTool:
|
| 214 |
from fastmcp.tools.tool_transform import TransformedTool
|
| 215 |
|
|
@@ -242,7 +242,7 @@ class FunctionTool(Tool):
|
|
| 242 |
tags: set[str] | None = None,
|
| 243 |
annotations: ToolAnnotations | None = None,
|
| 244 |
exclude_args: list[str] | None = None,
|
| 245 |
-
output_schema: dict[str, Any] | None | NotSetT
|
| 246 |
serializer: Callable[[Any], str] | None = None,
|
| 247 |
meta: dict[str, Any] | None = None,
|
| 248 |
enabled: bool | None = None,
|
|
|
|
| 8 |
Annotated,
|
| 9 |
Any,
|
| 10 |
Generic,
|
|
|
|
| 11 |
TypeVar,
|
| 12 |
get_type_hints,
|
| 13 |
)
|
|
|
|
| 162 |
tags: set[str] | None = None,
|
| 163 |
annotations: ToolAnnotations | None = None,
|
| 164 |
exclude_args: list[str] | None = None,
|
| 165 |
+
output_schema: dict[str, Any] | None | NotSetT = NotSet,
|
| 166 |
serializer: Callable[[Any], str] | None = None,
|
| 167 |
meta: dict[str, Any] | None = None,
|
| 168 |
enabled: bool | None = None,
|
|
|
|
| 198 |
def from_tool(
|
| 199 |
cls,
|
| 200 |
tool: Tool,
|
| 201 |
+
*,
|
| 202 |
name: str | None = None,
|
| 203 |
title: str | None | NotSetT = NotSet,
|
|
|
|
| 204 |
description: str | None | NotSetT = NotSet,
|
| 205 |
tags: set[str] | None = None,
|
| 206 |
+
annotations: ToolAnnotations | None | NotSetT = NotSet,
|
| 207 |
+
output_schema: dict[str, Any] | None | NotSetT = NotSet,
|
| 208 |
serializer: Callable[[Any], str] | None = None,
|
| 209 |
meta: dict[str, Any] | None | NotSetT = NotSet,
|
| 210 |
+
transform_args: dict[str, ArgTransform] | None = None,
|
| 211 |
enabled: bool | None = None,
|
| 212 |
+
transform_fn: Callable[..., Any] | None = None,
|
| 213 |
) -> TransformedTool:
|
| 214 |
from fastmcp.tools.tool_transform import TransformedTool
|
| 215 |
|
|
|
|
| 242 |
tags: set[str] | None = None,
|
| 243 |
annotations: ToolAnnotations | None = None,
|
| 244 |
exclude_args: list[str] | None = None,
|
| 245 |
+
output_schema: dict[str, Any] | None | NotSetT = NotSet,
|
| 246 |
serializer: Callable[[Any], str] | None = None,
|
| 247 |
meta: dict[str, Any] | None = None,
|
| 248 |
enabled: bool | None = None,
|
src/fastmcp/tools/tool_transform.py
CHANGED
|
@@ -6,6 +6,7 @@ from contextvars import ContextVar
|
|
| 6 |
from dataclasses import dataclass
|
| 7 |
from typing import Annotated, Any, Literal
|
| 8 |
|
|
|
|
| 9 |
from mcp.types import ToolAnnotations
|
| 10 |
from pydantic import ConfigDict
|
| 11 |
from pydantic.fields import Field
|
|
@@ -312,11 +313,9 @@ class TransformedTool(Tool):
|
|
| 312 |
# Custom function returns ToolResult - preserve its content
|
| 313 |
return result
|
| 314 |
else:
|
| 315 |
-
# Forwarded call with
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
structured_content=None,
|
| 319 |
-
)
|
| 320 |
elif self.output_schema.get(
|
| 321 |
"type"
|
| 322 |
) != "object" and not self.output_schema.get("x-fastmcp-wrap-result"):
|
|
@@ -334,17 +333,23 @@ class TransformedTool(Tool):
|
|
| 334 |
result, serializer=self.serializer
|
| 335 |
)
|
| 336 |
|
| 337 |
-
|
|
|
|
| 338 |
if self.output_schema is not None:
|
| 339 |
if self.output_schema.get("x-fastmcp-wrap-result"):
|
| 340 |
# Schema says wrap - always wrap in result key
|
| 341 |
structured_output = {"result": result}
|
| 342 |
else:
|
| 343 |
-
# Object schemas - use result directly
|
| 344 |
-
# User is responsible for returning dict-compatible data
|
| 345 |
structured_output = result
|
| 346 |
-
|
| 347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
|
| 349 |
return ToolResult(
|
| 350 |
content=unstructured_result,
|
|
@@ -363,9 +368,9 @@ class TransformedTool(Tool):
|
|
| 363 |
tags: set[str] | None = None,
|
| 364 |
transform_fn: Callable[..., Any] | None = None,
|
| 365 |
transform_args: dict[str, ArgTransform] | None = None,
|
| 366 |
-
annotations: ToolAnnotations | None =
|
| 367 |
-
output_schema: dict[str, Any] | None |
|
| 368 |
-
serializer: Callable[[Any], str] | None =
|
| 369 |
meta: dict[str, Any] | None | NotSetT = NotSet,
|
| 370 |
enabled: bool | None = None,
|
| 371 |
) -> TransformedTool:
|
|
@@ -445,6 +450,11 @@ class TransformedTool(Tool):
|
|
| 445 |
"""
|
| 446 |
transform_args = transform_args or {}
|
| 447 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
# Validate transform_args
|
| 449 |
parent_params = set(tool.parameters.get("properties", {}).keys())
|
| 450 |
unknown_args = set(transform_args.keys()) - parent_params
|
|
@@ -457,16 +467,11 @@ class TransformedTool(Tool):
|
|
| 457 |
# Always create the forwarding transform
|
| 458 |
schema, forwarding_fn = cls._create_forwarding_transform(tool, transform_args)
|
| 459 |
|
| 460 |
-
# Handle output schema
|
| 461 |
-
if output_schema is
|
| 462 |
-
|
| 463 |
-
elif output_schema is not None:
|
| 464 |
-
# Explicit schema provided - use as-is
|
| 465 |
-
final_output_schema = output_schema
|
| 466 |
-
else:
|
| 467 |
-
# Smart fallback: try custom function, then parent, then None
|
| 468 |
if transform_fn is not None:
|
| 469 |
-
parsed_fn
|
| 470 |
final_output_schema = parsed_fn.output_schema
|
| 471 |
if final_output_schema is None:
|
| 472 |
# Check if function returns ToolResult - if so, don't fall back to parent
|
|
@@ -479,15 +484,17 @@ class TransformedTool(Tool):
|
|
| 479 |
final_output_schema = tool.output_schema
|
| 480 |
else:
|
| 481 |
final_output_schema = tool.output_schema
|
|
|
|
|
|
|
|
|
|
| 482 |
|
| 483 |
if transform_fn is None:
|
| 484 |
# User wants pure transformation - use forwarding_fn as the main function
|
| 485 |
final_fn = forwarding_fn
|
| 486 |
final_schema = schema
|
| 487 |
else:
|
|
|
|
| 488 |
# User provided custom function - merge schemas
|
| 489 |
-
if "parsed_fn" not in locals():
|
| 490 |
-
parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
|
| 491 |
final_fn = transform_fn
|
| 492 |
|
| 493 |
has_kwargs = cls._function_has_kwargs(transform_fn)
|
|
@@ -552,6 +559,13 @@ class TransformedTool(Tool):
|
|
| 552 |
)
|
| 553 |
final_title = title if not isinstance(title, NotSetT) else tool.title
|
| 554 |
final_meta = meta if not isinstance(meta, NotSetT) else tool.meta
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
|
| 556 |
transformed_tool = cls(
|
| 557 |
fn=final_fn,
|
|
@@ -563,11 +577,11 @@ class TransformedTool(Tool):
|
|
| 563 |
parameters=final_schema,
|
| 564 |
output_schema=final_output_schema,
|
| 565 |
tags=tags or tool.tags,
|
| 566 |
-
annotations=
|
| 567 |
-
serializer=
|
| 568 |
meta=final_meta,
|
| 569 |
transform_args=transform_args,
|
| 570 |
-
enabled=
|
| 571 |
)
|
| 572 |
|
| 573 |
return transformed_tool
|
|
|
|
| 6 |
from dataclasses import dataclass
|
| 7 |
from typing import Annotated, Any, Literal
|
| 8 |
|
| 9 |
+
import pydantic_core
|
| 10 |
from mcp.types import ToolAnnotations
|
| 11 |
from pydantic import ConfigDict
|
| 12 |
from pydantic.fields import Field
|
|
|
|
| 313 |
# Custom function returns ToolResult - preserve its content
|
| 314 |
return result
|
| 315 |
else:
|
| 316 |
+
# Forwarded call with no explicit schema - preserve parent's structured content
|
| 317 |
+
# The parent tool may have generated structured content via its own fallback logic
|
| 318 |
+
return result
|
|
|
|
|
|
|
| 319 |
elif self.output_schema.get(
|
| 320 |
"type"
|
| 321 |
) != "object" and not self.output_schema.get("x-fastmcp-wrap-result"):
|
|
|
|
| 333 |
result, serializer=self.serializer
|
| 334 |
)
|
| 335 |
|
| 336 |
+
structured_output = None
|
| 337 |
+
# First handle structured content based on output schema, if any
|
| 338 |
if self.output_schema is not None:
|
| 339 |
if self.output_schema.get("x-fastmcp-wrap-result"):
|
| 340 |
# Schema says wrap - always wrap in result key
|
| 341 |
structured_output = {"result": result}
|
| 342 |
else:
|
|
|
|
|
|
|
| 343 |
structured_output = result
|
| 344 |
+
# If no output schema, try to serialize the result. If it is a dict, use
|
| 345 |
+
# it as structured content. If it is not a dict, ignore it.
|
| 346 |
+
if structured_output is None:
|
| 347 |
+
try:
|
| 348 |
+
structured_output = pydantic_core.to_jsonable_python(result)
|
| 349 |
+
if not isinstance(structured_output, dict):
|
| 350 |
+
structured_output = None
|
| 351 |
+
except Exception:
|
| 352 |
+
pass
|
| 353 |
|
| 354 |
return ToolResult(
|
| 355 |
content=unstructured_result,
|
|
|
|
| 368 |
tags: set[str] | None = None,
|
| 369 |
transform_fn: Callable[..., Any] | None = None,
|
| 370 |
transform_args: dict[str, ArgTransform] | None = None,
|
| 371 |
+
annotations: ToolAnnotations | None | NotSetT = NotSet,
|
| 372 |
+
output_schema: dict[str, Any] | None | NotSetT = NotSet,
|
| 373 |
+
serializer: Callable[[Any], str] | None | NotSetT = NotSet,
|
| 374 |
meta: dict[str, Any] | None | NotSetT = NotSet,
|
| 375 |
enabled: bool | None = None,
|
| 376 |
) -> TransformedTool:
|
|
|
|
| 450 |
"""
|
| 451 |
transform_args = transform_args or {}
|
| 452 |
|
| 453 |
+
if transform_fn is not None:
|
| 454 |
+
parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
|
| 455 |
+
else:
|
| 456 |
+
parsed_fn = None
|
| 457 |
+
|
| 458 |
# Validate transform_args
|
| 459 |
parent_params = set(tool.parameters.get("properties", {}).keys())
|
| 460 |
unknown_args = set(transform_args.keys()) - parent_params
|
|
|
|
| 467 |
# Always create the forwarding transform
|
| 468 |
schema, forwarding_fn = cls._create_forwarding_transform(tool, transform_args)
|
| 469 |
|
| 470 |
+
# Handle output schema
|
| 471 |
+
if output_schema is NotSet:
|
| 472 |
+
# Use smart fallback: try custom function, then parent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
if transform_fn is not None:
|
| 474 |
+
assert parsed_fn is not None
|
| 475 |
final_output_schema = parsed_fn.output_schema
|
| 476 |
if final_output_schema is None:
|
| 477 |
# Check if function returns ToolResult - if so, don't fall back to parent
|
|
|
|
| 484 |
final_output_schema = tool.output_schema
|
| 485 |
else:
|
| 486 |
final_output_schema = tool.output_schema
|
| 487 |
+
else:
|
| 488 |
+
assert isinstance(output_schema, dict | None)
|
| 489 |
+
final_output_schema = output_schema
|
| 490 |
|
| 491 |
if transform_fn is None:
|
| 492 |
# User wants pure transformation - use forwarding_fn as the main function
|
| 493 |
final_fn = forwarding_fn
|
| 494 |
final_schema = schema
|
| 495 |
else:
|
| 496 |
+
assert parsed_fn is not None
|
| 497 |
# User provided custom function - merge schemas
|
|
|
|
|
|
|
| 498 |
final_fn = transform_fn
|
| 499 |
|
| 500 |
has_kwargs = cls._function_has_kwargs(transform_fn)
|
|
|
|
| 559 |
)
|
| 560 |
final_title = title if not isinstance(title, NotSetT) else tool.title
|
| 561 |
final_meta = meta if not isinstance(meta, NotSetT) else tool.meta
|
| 562 |
+
final_annotations = (
|
| 563 |
+
annotations if not isinstance(annotations, NotSetT) else tool.annotations
|
| 564 |
+
)
|
| 565 |
+
final_serializer = (
|
| 566 |
+
serializer if not isinstance(serializer, NotSetT) else tool.serializer
|
| 567 |
+
)
|
| 568 |
+
final_enabled = enabled if enabled is not None else tool.enabled
|
| 569 |
|
| 570 |
transformed_tool = cls(
|
| 571 |
fn=final_fn,
|
|
|
|
| 577 |
parameters=final_schema,
|
| 578 |
output_schema=final_output_schema,
|
| 579 |
tags=tags or tool.tags,
|
| 580 |
+
annotations=final_annotations,
|
| 581 |
+
serializer=final_serializer,
|
| 582 |
meta=final_meta,
|
| 583 |
transform_args=transform_args,
|
| 584 |
+
enabled=final_enabled,
|
| 585 |
)
|
| 586 |
|
| 587 |
return transformed_tool
|
tests/server/test_tool_transformation.py
CHANGED
|
@@ -38,3 +38,31 @@ async def test_transformed_tool_filtering():
|
|
| 38 |
|
| 39 |
tools = list(await mcp._list_tools())
|
| 40 |
assert len(tools) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
tools = list(await mcp._list_tools())
|
| 40 |
assert len(tools) == 1
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def test_transformed_tool_structured_output_without_annotation():
|
| 44 |
+
"""Test that transformed tools generate structured output when original tool has no return annotation.
|
| 45 |
+
|
| 46 |
+
Ref: https://github.com/jlowin/fastmcp/issues/1369
|
| 47 |
+
"""
|
| 48 |
+
from fastmcp.client import Client
|
| 49 |
+
|
| 50 |
+
mcp = FastMCP("Test Server")
|
| 51 |
+
|
| 52 |
+
@mcp.tool()
|
| 53 |
+
def tool_without_annotation(message: str): # No return annotation
|
| 54 |
+
"""A tool without return type annotation."""
|
| 55 |
+
return {"result": "processed", "input": message}
|
| 56 |
+
|
| 57 |
+
# Create a transformed tool
|
| 58 |
+
mcp.add_tool_transformation(
|
| 59 |
+
"tool_without_annotation", ToolTransformConfig(name="transformed_tool")
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# Test with client to verify structured output is populated
|
| 63 |
+
async with Client(mcp) as client:
|
| 64 |
+
result = await client.call_tool("transformed_tool", {"message": "test"})
|
| 65 |
+
|
| 66 |
+
# Structured output should be populated even without return annotation
|
| 67 |
+
assert result.data is not None
|
| 68 |
+
assert result.data == {"result": "processed", "input": "test"}
|
tests/tools/test_tool.py
CHANGED
|
@@ -562,7 +562,7 @@ class TestToolFromFunctionOutputSchema:
|
|
| 562 |
def func() -> dict[str, str]:
|
| 563 |
return {"message": "Hello, world!"}
|
| 564 |
|
| 565 |
-
tool = Tool.from_function(func, output_schema=
|
| 566 |
assert tool.output_schema is None
|
| 567 |
|
| 568 |
result = await tool.run({})
|
|
@@ -729,17 +729,23 @@ class TestToolFromFunctionOutputSchema:
|
|
| 729 |
tool_none = Tool.from_function(func, output_schema=None)
|
| 730 |
assert tool_none.output_schema is None
|
| 731 |
|
| 732 |
-
#
|
| 733 |
-
|
| 734 |
-
assert
|
|
|
|
|
|
|
| 735 |
|
| 736 |
-
#
|
| 737 |
result_none = await tool_none.run({})
|
| 738 |
-
|
| 739 |
|
| 740 |
-
|
| 741 |
-
assert
|
| 742 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 743 |
|
| 744 |
async def test_non_object_output_schema_raises_error(self):
|
| 745 |
"""Test that providing a non-object output schema raises a ValueError."""
|
|
@@ -1117,7 +1123,7 @@ class TestAutomaticStructuredContent:
|
|
| 1117 |
return UserProfile(name="Bob", age=25, email="bob@example.com")
|
| 1118 |
|
| 1119 |
# No explicit output schema, but dataclass should still create structured content
|
| 1120 |
-
tool = Tool.from_function(get_profile, output_schema=
|
| 1121 |
|
| 1122 |
result = await tool.run({"user_id": "456"})
|
| 1123 |
|
|
@@ -1144,8 +1150,8 @@ class TestAutomaticStructuredContent:
|
|
| 1144 |
def get_user_stats(user_id: str) -> UserData:
|
| 1145 |
return UserData(username="charlie", score=100, verified=True)
|
| 1146 |
|
| 1147 |
-
# Explicitly
|
| 1148 |
-
tool = Tool.from_function(get_user_stats, output_schema=
|
| 1149 |
|
| 1150 |
result = await tool.run({"user_id": "789"})
|
| 1151 |
|
|
|
|
| 562 |
def func() -> dict[str, str]:
|
| 563 |
return {"message": "Hello, world!"}
|
| 564 |
|
| 565 |
+
tool = Tool.from_function(func, output_schema=None)
|
| 566 |
assert tool.output_schema is None
|
| 567 |
|
| 568 |
result = await tool.run({})
|
|
|
|
| 729 |
tool_none = Tool.from_function(func, output_schema=None)
|
| 730 |
assert tool_none.output_schema is None
|
| 731 |
|
| 732 |
+
# Default (NotSet) should infer from return type
|
| 733 |
+
tool_default = Tool.from_function(func)
|
| 734 |
+
assert (
|
| 735 |
+
tool_default.output_schema is not None
|
| 736 |
+
) # Should infer schema from dict return type
|
| 737 |
|
| 738 |
+
# Different behavior: None vs inferred
|
| 739 |
result_none = await tool_none.run({})
|
| 740 |
+
result_default = await tool_default.run({})
|
| 741 |
|
| 742 |
+
# None should still try fallback generation but fail for non-dict
|
| 743 |
+
assert result_none.structured_content is None # Fallback fails for int
|
| 744 |
+
# Default should use proper schema and wrap the result
|
| 745 |
+
assert result_default.structured_content == {
|
| 746 |
+
"result": 123
|
| 747 |
+
} # Schema-based generation with wrapping
|
| 748 |
+
assert result_none.content[0].text == result_default.content[0].text == "123" # type: ignore[attr-defined]
|
| 749 |
|
| 750 |
async def test_non_object_output_schema_raises_error(self):
|
| 751 |
"""Test that providing a non-object output schema raises a ValueError."""
|
|
|
|
| 1123 |
return UserProfile(name="Bob", age=25, email="bob@example.com")
|
| 1124 |
|
| 1125 |
# No explicit output schema, but dataclass should still create structured content
|
| 1126 |
+
tool = Tool.from_function(get_profile, output_schema=None)
|
| 1127 |
|
| 1128 |
result = await tool.run({"user_id": "456"})
|
| 1129 |
|
|
|
|
| 1150 |
def get_user_stats(user_id: str) -> UserData:
|
| 1151 |
return UserData(username="charlie", score=100, verified=True)
|
| 1152 |
|
| 1153 |
+
# Explicitly set output schema to None to test automatic structured content
|
| 1154 |
+
tool = Tool.from_function(get_user_stats, output_schema=None)
|
| 1155 |
|
| 1156 |
result = await tool.run({"user_id": "789"})
|
| 1157 |
|
tests/tools/test_tool_transform.py
CHANGED
|
@@ -1020,7 +1020,12 @@ class TestEnableDisable:
|
|
| 1020 |
new_add = Tool.from_tool(add, name="new_add")
|
| 1021 |
mcp.add_tool(new_add)
|
| 1022 |
|
| 1023 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1024 |
|
| 1025 |
async with Client(mcp) as client:
|
| 1026 |
tools = await client.list_tools()
|
|
@@ -1118,15 +1123,15 @@ class TestTransformToolOutputSchema:
|
|
| 1118 |
assert new_tool.output_schema == expected_schema
|
| 1119 |
assert new_tool.output_schema == base_string_tool.output_schema
|
| 1120 |
|
| 1121 |
-
def
|
| 1122 |
-
"""Test that output_schema=
|
| 1123 |
-
new_tool = Tool.from_tool(base_string_tool, output_schema=
|
| 1124 |
|
| 1125 |
assert new_tool.output_schema is None
|
| 1126 |
|
| 1127 |
-
async def
|
| 1128 |
-
"""Test runtime behavior with output_schema=
|
| 1129 |
-
new_tool = Tool.from_tool(base_string_tool, output_schema=
|
| 1130 |
|
| 1131 |
# Debug: check that output_schema is actually None
|
| 1132 |
assert new_tool.output_schema is None, (
|
|
@@ -1134,7 +1139,8 @@ class TestTransformToolOutputSchema:
|
|
| 1134 |
)
|
| 1135 |
|
| 1136 |
result = await new_tool.run({"x": 5})
|
| 1137 |
-
|
|
|
|
| 1138 |
assert result.content[0].text == "Result: 5" # type: ignore[attr-defined]
|
| 1139 |
|
| 1140 |
def test_transform_with_explicit_output_schema_dict(self, base_string_tool):
|
|
@@ -1306,25 +1312,27 @@ class TestTransformToolOutputSchema:
|
|
| 1306 |
expected_schema = TypeAdapter(dict[str, str]).json_schema()
|
| 1307 |
assert new_tool.output_schema == expected_schema
|
| 1308 |
|
| 1309 |
-
async def
|
| 1310 |
-
"""Test
|
| 1311 |
-
#
|
| 1312 |
-
|
| 1313 |
-
assert
|
| 1314 |
|
| 1315 |
-
#
|
| 1316 |
-
|
| 1317 |
-
assert
|
| 1318 |
|
| 1319 |
-
#
|
| 1320 |
-
|
| 1321 |
-
|
| 1322 |
|
| 1323 |
-
assert
|
| 1324 |
"result": "Result: 5"
|
| 1325 |
} # Inherits wrapping
|
| 1326 |
-
assert
|
| 1327 |
-
|
|
|
|
|
|
|
| 1328 |
|
| 1329 |
async def test_transform_output_schema_with_tool_result_return(
|
| 1330 |
self, base_string_tool
|
|
|
|
| 1020 |
new_add = Tool.from_tool(add, name="new_add")
|
| 1021 |
mcp.add_tool(new_add)
|
| 1022 |
|
| 1023 |
+
# the new tool inherits the disabled state from the parent tool
|
| 1024 |
+
assert new_add.enabled is False
|
| 1025 |
+
|
| 1026 |
+
new_add.enable()
|
| 1027 |
+
assert new_add.enabled is True
|
| 1028 |
+
assert add.enabled is False
|
| 1029 |
|
| 1030 |
async with Client(mcp) as client:
|
| 1031 |
tools = await client.list_tools()
|
|
|
|
| 1123 |
assert new_tool.output_schema == expected_schema
|
| 1124 |
assert new_tool.output_schema == base_string_tool.output_schema
|
| 1125 |
|
| 1126 |
+
def test_transform_with_explicit_output_schema_none(self, base_string_tool):
|
| 1127 |
+
"""Test that output_schema=None sets output schema to None."""
|
| 1128 |
+
new_tool = Tool.from_tool(base_string_tool, output_schema=None)
|
| 1129 |
|
| 1130 |
assert new_tool.output_schema is None
|
| 1131 |
|
| 1132 |
+
async def test_transform_output_schema_none_runtime(self, base_string_tool):
|
| 1133 |
+
"""Test runtime behavior with output_schema=None."""
|
| 1134 |
+
new_tool = Tool.from_tool(base_string_tool, output_schema=None)
|
| 1135 |
|
| 1136 |
# Debug: check that output_schema is actually None
|
| 1137 |
assert new_tool.output_schema is None, (
|
|
|
|
| 1139 |
)
|
| 1140 |
|
| 1141 |
result = await new_tool.run({"x": 5})
|
| 1142 |
+
# Even with output_schema=None, structured content should be generated via fallback logic
|
| 1143 |
+
assert result.structured_content == {"result": "Result: 5"}
|
| 1144 |
assert result.content[0].text == "Result: 5" # type: ignore[attr-defined]
|
| 1145 |
|
| 1146 |
def test_transform_with_explicit_output_schema_dict(self, base_string_tool):
|
|
|
|
| 1312 |
expected_schema = TypeAdapter(dict[str, str]).json_schema()
|
| 1313 |
assert new_tool.output_schema == expected_schema
|
| 1314 |
|
| 1315 |
+
async def test_transform_output_schema_default_vs_none(self, base_string_tool):
|
| 1316 |
+
"""Test default (NotSet) vs explicit None behavior for output_schema in transforms."""
|
| 1317 |
+
# Default (NotSet) should use smart fallback (inherit from parent)
|
| 1318 |
+
tool_default = Tool.from_tool(base_string_tool) # default output_schema=NotSet
|
| 1319 |
+
assert tool_default.output_schema == base_string_tool.output_schema # Inherits
|
| 1320 |
|
| 1321 |
+
# None should explicitly set output_schema to None but still generate structured content via fallback
|
| 1322 |
+
tool_explicit_none = Tool.from_tool(base_string_tool, output_schema=None)
|
| 1323 |
+
assert tool_explicit_none.output_schema is None
|
| 1324 |
|
| 1325 |
+
# Both should generate structured content now (via different paths)
|
| 1326 |
+
result_default = await tool_default.run({"x": 5})
|
| 1327 |
+
result_explicit_none = await tool_explicit_none.run({"x": 5})
|
| 1328 |
|
| 1329 |
+
assert result_default.structured_content == {
|
| 1330 |
"result": "Result: 5"
|
| 1331 |
} # Inherits wrapping
|
| 1332 |
+
assert result_explicit_none.structured_content == {
|
| 1333 |
+
"result": "Result: 5"
|
| 1334 |
+
} # Generated via fallback logic
|
| 1335 |
+
assert result_default.content[0].text == result_explicit_none.content[0].text # type: ignore[attr-defined]
|
| 1336 |
|
| 1337 |
async def test_transform_output_schema_with_tool_result_return(
|
| 1338 |
self, base_string_tool
|