diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 6f52a7c8a..6f14af60c 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -768,6 +768,7 @@ class FastMCP(Generic[LifespanResultT]): name: str | None = None, description: str | None = None, tags: set[str] | None = None, + output_schema: dict[str, Any] | None = None, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, enabled: bool | None = None, @@ -781,6 +782,7 @@ class FastMCP(Generic[LifespanResultT]): name: str | None = None, description: str | None = None, tags: set[str] | None = None, + output_schema: dict[str, Any] | None = None, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, enabled: bool | None = None, @@ -793,6 +795,7 @@ class FastMCP(Generic[LifespanResultT]): name: str | None = None, description: str | None = None, tags: set[str] | None = None, + output_schema: dict[str, Any] | None = None, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, enabled: bool | None = None, @@ -815,6 +818,7 @@ class FastMCP(Generic[LifespanResultT]): name: Optional name for the tool (keyword-only, alternative to name_or_fn) description: Optional description of what the tool does tags: Optional set of tags for categorizing the tool + output_schema: Optional JSON schema for the tool's output annotations: Optional annotations about the tool's behavior (e.g. {"is_async": True}) exclude_args: Optional list of argument names to exclude from the tool schema enabled: Optional boolean to enable or disable the tool @@ -867,6 +871,7 @@ class FastMCP(Generic[LifespanResultT]): name=tool_name, description=description, tags=tags, + output_schema=output_schema, annotations=annotations, exclude_args=exclude_args, serializer=self._tool_serializer, @@ -897,6 +902,7 @@ class FastMCP(Generic[LifespanResultT]): name=tool_name, description=description, tags=tags, + output_schema=output_schema, annotations=annotations, exclude_args=exclude_args, enabled=enabled, diff --git a/tests/server/test_server.py b/tests/server/test_server.py index d255ad76c..022f910c0 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -390,6 +390,15 @@ class TestToolDecorator: def my_function(x: int) -> str: return f"Result: {x}" + async def test_tool_decorator_with_output_schema(self): + mcp = FastMCP() + + @mcp.tool(output_schema={"type": "integer"}) + def my_function(x: int) -> str: + return f"Result: {x}" + + assert my_function.output_schema == {"type": "integer"} + class TestResourceDecorator: async def test_no_resources_before_decorator(self): diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index b1ec8b45c..d60ec0a7d 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -369,6 +369,111 @@ class TestToolFromFunctionOutputSchema: tool = Tool.from_function(func) assert tool.output_schema is None + async def test_provided_output_schema_takes_precedence_over_json_compatible_annotation( + self, + ): + """Test that provided output_schema takes precedence over inferred schema from JSON-compatible annotation.""" + + def func() -> dict[str, int]: + return {"a": 1, "b": 2} + + # Provide a custom output schema that differs from the inferred one + custom_schema = {"type": "string", "description": "Custom schema"} + + tool = Tool.from_function(func, output_schema=custom_schema) + assert tool.output_schema == custom_schema + + async def test_provided_output_schema_takes_precedence_over_complex_annotation( + self, + ): + """Test that provided output_schema takes precedence over inferred schema from complex annotation.""" + + def func() -> list[dict[str, int | float]]: + return [{"a": 1, "b": 2.5}] + + # Provide a custom output schema that differs from the inferred one + custom_schema = {"type": "object", "properties": {"custom": {"type": "string"}}} + + tool = Tool.from_function(func, output_schema=custom_schema) + assert tool.output_schema == custom_schema + + async def test_provided_output_schema_takes_precedence_over_unserializable_annotation( + self, + ): + """Test that provided output_schema takes precedence over None schema from unserializable annotation.""" + + class Unserializable: + def __init__(self, data: Any): + self.data = data + + def func() -> Unserializable: + return Unserializable(data="test") + + # Provide a custom output schema even though the annotation is unserializable + custom_schema = {"type": "array", "items": {"type": "string"}} + + tool = Tool.from_function(func, output_schema=custom_schema) + assert tool.output_schema == custom_schema + + async def test_provided_output_schema_takes_precedence_over_no_annotation(self): + """Test that provided output_schema takes precedence over None schema from no annotation.""" + + def func(): + return "hello" + + # Provide a custom output schema even though there's no return annotation + custom_schema = {"type": "number", "minimum": 0} + + tool = Tool.from_function(func, output_schema=custom_schema) + assert tool.output_schema == custom_schema + + async def test_provided_output_schema_takes_precedence_over_converted_annotation( + self, + ): + """Test that provided output_schema takes precedence over converted schema from Image/Audio/File annotations.""" + + def func() -> Image: + return Image(data=b"test") + + # Provide a custom output schema that differs from the converted ImageContent schema + custom_schema = { + "type": "object", + "properties": {"custom_image": {"type": "string"}}, + } + + tool = Tool.from_function(func, output_schema=custom_schema) + assert tool.output_schema == custom_schema + + async def test_provided_output_schema_takes_precedence_over_union_annotation(self): + """Test that provided output_schema takes precedence over inferred schema from union annotation.""" + + def func() -> str | int | None: + return "hello" + + # Provide a custom output schema that differs from the inferred union schema + custom_schema = {"type": "boolean"} + + tool = Tool.from_function(func, output_schema=custom_schema) + assert tool.output_schema == custom_schema + + async def test_provided_output_schema_takes_precedence_over_pydantic_annotation( + self, + ): + """Test that provided output_schema takes precedence over inferred schema from Pydantic model annotation.""" + + class Person(BaseModel): + name: str + age: int + + def func() -> Person: + return Person(name="John", age=30) + + # Provide a custom output schema that differs from the inferred Person schema + custom_schema = {"type": "array", "items": {"type": "number"}} + + tool = Tool.from_function(func, output_schema=custom_schema) + assert tool.output_schema == custom_schema + class TestLegacyToolJsonParsing: """Tests for Tool's JSON pre-parsing functionality."""