diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 60e31c1a7..30b9abf78 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -27,6 +27,7 @@ from fastmcp.utilities.logging import get_logger from fastmcp.utilities.openapi import ( HTTPRoute, _combine_schemas, + extract_output_schema_from_responses, format_array_parameter, format_description_with_responses, ) @@ -234,6 +235,7 @@ class OpenAPITool(Tool): name: str, description: str, parameters: dict[str, Any], + output_schema: dict[str, Any] | None = None, tags: set[str] | None = None, timeout: float | None = None, annotations: ToolAnnotations | None = None, @@ -243,6 +245,7 @@ class OpenAPITool(Tool): name=name, description=description, parameters=parameters, + output_schema=output_schema, tags=tags or set(), annotations=annotations, serializer=serializer, @@ -392,9 +395,22 @@ class OpenAPITool(Tool): # Try to parse as JSON first try: result = response.json() - if not isinstance(result, dict): - result = {"result": result} - return ToolResult(structured_content=result) + + # Handle structured content based on output schema, if any + structured_output = None + if self.output_schema is not None: + if self.output_schema.get("x-fastmcp-wrap-result"): + # Schema says wrap - always wrap in result key + structured_output = {"result": result} + else: + structured_output = result + # If no output schema, use fallback logic for backward compatibility + elif not isinstance(result, dict): + structured_output = {"result": result} + else: + structured_output = result + + return ToolResult(structured_content=structured_output) except json.JSONDecodeError: return ToolResult(content=response.text) @@ -787,6 +803,11 @@ class FastMCPOpenAPI(FastMCP): """Creates and registers an OpenAPITool with enhanced description.""" combined_schema = _combine_schemas(route) + # Extract output schema from OpenAPI responses + output_schema = extract_output_schema_from_responses( + route.responses, route.schema_definitions + ) + # Get a unique tool name tool_name = self._get_unique_name(name, "tool") @@ -810,6 +831,7 @@ class FastMCPOpenAPI(FastMCP): name=tool_name, description=enhanced_description, parameters=combined_schema, + output_schema=output_schema, tags=set(route.tags or []) | tags, timeout=self._timeout, ) diff --git a/src/fastmcp/utilities/openapi.py b/src/fastmcp/utilities/openapi.py index 366706065..be08d4dc3 100644 --- a/src/fastmcp/utilities/openapi.py +++ b/src/fastmcp/utilities/openapi.py @@ -152,6 +152,7 @@ __all__ = [ "ParameterLocation", "JsonSchema", "parse_openapi_to_http_routes", + "extract_output_schema_from_responses", ] # Type variables for generic parser @@ -1107,3 +1108,94 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]: result = compress_schema(result) return result + + +def extract_output_schema_from_responses( + responses: dict[str, ResponseInfo], schema_definitions: dict[str, Any] | None = None +) -> dict[str, Any] | None: + """ + Extract output schema from OpenAPI responses for use as MCP tool output schema. + + This function finds the first successful response (200, 201, 202, 204) with a + JSON-compatible content type and extracts its schema. If the schema is not an + object type, it wraps it to comply with MCP requirements. + + Args: + responses: Dictionary of ResponseInfo objects keyed by status code + schema_definitions: Optional schema definitions to include in the output schema + + Returns: + dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found + """ + if not responses: + return None + + # Priority order for success status codes + success_codes = ["200", "201", "202", "204"] + + # Find the first successful response + response_info = None + for status_code in success_codes: + if status_code in responses: + response_info = responses[status_code] + break + + # If no explicit success codes, try any 2xx response + if response_info is None: + for status_code, resp_info in responses.items(): + if status_code.startswith("2"): + response_info = resp_info + break + + if response_info is None or not response_info.content_schema: + return None + + # Prefer application/json, then fall back to other JSON-compatible types + json_compatible_types = [ + "application/json", + "application/vnd.api+json", + "application/hal+json", + "application/ld+json", + "text/json", + ] + + schema = None + for content_type in json_compatible_types: + if content_type in response_info.content_schema: + schema = response_info.content_schema[content_type] + break + + # If no JSON-compatible type found, try the first available content type + if schema is None and response_info.content_schema: + first_content_type = next(iter(response_info.content_schema)) + schema = response_info.content_schema[first_content_type] + logger.debug( + f"Using non-JSON content type for output schema: {first_content_type}" + ) + + if not schema or not isinstance(schema, dict): + return None + + # Clean and copy the schema + output_schema = schema.copy() + + # MCP requires output schemas to be objects. If this schema is not an object, + # we need to wrap it similar to how ParsedFunction.from_function() does it + if output_schema.get("type") != "object": + # Create a wrapped schema that contains the original schema under a "result" key + wrapped_schema = { + "type": "object", + "properties": {"result": output_schema}, + "required": ["result"], + "x-fastmcp-wrap-result": True, + } + output_schema = wrapped_schema + + # Add schema definitions if available + if schema_definitions: + output_schema["$defs"] = schema_definitions + + # Use compress_schema to remove unused definitions + output_schema = compress_schema(output_schema) + + return output_schema diff --git a/tests/server/openapi/test_basic_functionality.py b/tests/server/openapi/test_basic_functionality.py index ae7ec614b..4fd2d2221 100644 --- a/tests/server/openapi/test_basic_functionality.py +++ b/tests/server/openapi/test_basic_functionality.py @@ -109,7 +109,16 @@ class TestTools: }, "required": ["name", "active"], }, - outputSchema=None, + outputSchema={ + "type": "object", + "properties": { + "id": {"type": "integer", "title": "Id"}, + "name": {"type": "string", "title": "Name"}, + "active": {"type": "boolean", "title": "Active"}, + }, + "required": ["id", "name", "active"], + "title": "User", + }, ) assert tools[1].model_dump() == dict( name="update_user_name_users", @@ -127,7 +136,16 @@ class TestTools: }, "required": ["user_id", "name"], }, - outputSchema=None, + outputSchema={ + "type": "object", + "properties": { + "id": {"type": "integer", "title": "Id"}, + "name": {"type": "string", "title": "Name"}, + "active": {"type": "boolean", "title": "Active"}, + }, + "required": ["id", "name", "active"], + "title": "User", + }, ) async def test_call_create_user_tool( @@ -143,8 +161,11 @@ class TestTools: "create_user_users_post", {"name": "David", "active": False} ) - expected_user = User(id=4, name="David", active=False).model_dump() - assert tool_response.data == expected_user + expected_user = User(id=4, name="David", active=False) + # Compare the data content since MCP client creates different class instances + assert tool_response.data.id == expected_user.id + assert tool_response.data.name == expected_user.name + assert tool_response.data.active == expected_user.active # Check that the user was created via API response = await api_client.get("/users") @@ -155,7 +176,7 @@ class TestTools: user_response = await client.read_resource("resource://get_user_users/4") response_text = user_response[0].text # type: ignore[attr-defined] user = json.loads(response_text) - assert user == expected_user + assert user == expected_user.model_dump() async def test_call_update_user_name_tool( self, @@ -171,19 +192,22 @@ class TestTools: {"user_id": 1, "name": "XYZ"}, ) - expected_data = dict(id=1, name="XYZ", active=True) - assert tool_response.data == expected_data + expected_user = User(id=1, name="XYZ", active=True) + # Compare the data content since MCP client creates different class instances + assert tool_response.data.id == expected_user.id + assert tool_response.data.name == expected_user.name + assert tool_response.data.active == expected_user.active # Check that the user was updated via API response = await api_client.get("/users") - assert expected_data in response.json() + assert expected_user.model_dump() in response.json() # Check that the user was updated via MCP async with Client(fastmcp_openapi_server) as client: user_response = await client.read_resource("resource://get_user_users/1") response_text = user_response[0].text # type: ignore[attr-defined] user = json.loads(response_text) - assert user == expected_data + assert user == expected_user.model_dump() async def test_call_tool_return_list( self, @@ -204,12 +228,11 @@ class TestTools: ) async with Client(mcp_server) as client: tool_response = await client.call_tool("get_users_users_get", {}) - assert tool_response.data == { - "result": [ - user.model_dump() - for user in sorted(users_db.values(), key=lambda x: x.id) - ] - } + # The tool response should now be unwrapped since we have output schema + assert tool_response.data == [ + user.model_dump() + for user in sorted(users_db.values(), key=lambda x: x.id) + ] class TestResources: diff --git a/tests/utilities/openapi/test_openapi_output_schemas.py b/tests/utilities/openapi/test_openapi_output_schemas.py new file mode 100644 index 000000000..6c66cbd73 --- /dev/null +++ b/tests/utilities/openapi/test_openapi_output_schemas.py @@ -0,0 +1,236 @@ +"""Tests for OpenAPI output schema extraction functionality.""" + +from fastmcp.utilities.openapi import ( + ResponseInfo, + extract_output_schema_from_responses, +) + + +class TestExtractOutputSchema: + """Test the extract_output_schema_from_responses function.""" + + def test_extract_object_schema(self): + """Test extracting object output schema (no wrapping needed).""" + responses = { + "200": ResponseInfo( + description="Success", + content_schema={ + "application/json": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + "required": ["id", "name"], + } + }, + ) + } + + result = extract_output_schema_from_responses(responses) + + assert result == { + "type": "object", + "properties": {"id": {"type": "integer"}, "name": {"type": "string"}}, + "required": ["id", "name"], + } + assert result is not None and "x-fastmcp-wrap-result" not in result + + def test_extract_array_schema_with_wrapping(self): + """Test extracting array output schema (should be wrapped).""" + responses = { + "200": ResponseInfo( + description="Success", + content_schema={ + "application/json": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + }, + } + }, + ) + } + + result = extract_output_schema_from_responses(responses) + + assert result == { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + }, + } + }, + "required": ["result"], + "x-fastmcp-wrap-result": True, + } + + def test_extract_primitive_schema_with_wrapping(self): + """Test extracting primitive output schema (should be wrapped).""" + responses = { + "201": ResponseInfo( + description="Created", + content_schema={ + "application/json": { + "type": "string", + "description": "ID of created resource", + } + }, + ) + } + + result = extract_output_schema_from_responses(responses) + + assert result == { + "type": "object", + "properties": { + "result": {"type": "string", "description": "ID of created resource"} + }, + "required": ["result"], + "x-fastmcp-wrap-result": True, + } + + def test_priority_of_success_codes(self): + """Test that 200 takes priority over other success codes.""" + responses = { + "201": ResponseInfo( + description="Created", + content_schema={"application/json": {"type": "string"}}, + ), + "200": ResponseInfo( + description="Success", + content_schema={ + "application/json": { + "type": "object", + "properties": {"id": {"type": "integer"}}, + } + }, + ), + } + + result = extract_output_schema_from_responses(responses) + + # Should use the 200 response (object), not 201 (string) + assert result is not None and result["type"] == "object" + assert result is not None and "x-fastmcp-wrap-result" not in result + + def test_prefer_json_content_type(self): + """Test that application/json is preferred over other content types.""" + responses = { + "200": ResponseInfo( + description="Success", + content_schema={ + "text/plain": {"type": "string"}, + "application/json": { + "type": "object", + "properties": {"id": {"type": "integer"}}, + }, + }, + ) + } + + result = extract_output_schema_from_responses(responses) + + # Should use the application/json schema (object), not text/plain (string) + assert result is not None and result["type"] == "object" + assert result is not None and "x-fastmcp-wrap-result" not in result + + def test_no_responses(self): + """Test that None is returned when no responses are provided.""" + result = extract_output_schema_from_responses({}) + assert result is None + + def test_no_success_responses(self): + """Test that None is returned when no success responses are found.""" + responses = { + "400": ResponseInfo( + description="Bad Request", + content_schema={ + "application/json": { + "type": "object", + "properties": {"error": {"type": "string"}}, + } + }, + ) + } + + result = extract_output_schema_from_responses(responses) + assert result is None + + def test_no_content_schema(self): + """Test that None is returned when response has no content schema.""" + responses = {"204": ResponseInfo(description="No Content")} + + result = extract_output_schema_from_responses(responses) + assert result is None + + def test_schema_definitions_included(self): + """Test that schema definitions are properly included in output schema.""" + responses = { + "200": ResponseInfo( + description="Success", + content_schema={ + "application/json": { + "type": "object", + "properties": {"user": {"$ref": "#/$defs/User"}}, + } + }, + ) + } + + schema_definitions = { + "User": { + "type": "object", + "properties": {"id": {"type": "integer"}, "name": {"type": "string"}}, + "required": ["id", "name"], + } + } + + result = extract_output_schema_from_responses(responses, schema_definitions) + + assert result is not None + assert "$defs" in result + assert "User" in result["$defs"] + assert result["$defs"]["User"] == schema_definitions["User"] + + def test_wrapped_schema_with_definitions(self): + """Test that wrapped schemas properly include schema definitions.""" + responses = { + "200": ResponseInfo( + description="Success", + content_schema={ + "application/json": { + "type": "array", + "items": {"$ref": "#/$defs/User"}, + } + }, + ) + } + + schema_definitions = { + "User": { + "type": "object", + "properties": {"id": {"type": "integer"}, "name": {"type": "string"}}, + "required": ["id", "name"], + } + } + + result = extract_output_schema_from_responses(responses, schema_definitions) + + assert result is not None + assert result["x-fastmcp-wrap-result"] is True + assert "$defs" in result + assert "User" in result["$defs"] + assert result["properties"]["result"]["type"] == "array" + assert result["properties"]["result"]["items"]["$ref"] == "#/$defs/User"