diff --git a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx index 64fba8da3d..568d3d8d34 100644 --- a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx +++ b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx @@ -27,7 +27,7 @@ run(self, arguments: dict[str, Any]) -> ToolResult Execute the HTTP request using RequestDirector. -### `OpenAPIResource` +### `OpenAPIResource` Resource implementation for OpenAPI endpoints. @@ -35,7 +35,7 @@ Resource implementation for OpenAPI endpoints. **Methods:** -#### `read` +#### `read` ```python read(self) -> ResourceResult @@ -44,7 +44,7 @@ read(self) -> ResourceResult Fetch the resource data by making an HTTP request. -### `OpenAPIResourceTemplate` +### `OpenAPIResourceTemplate` Resource template implementation for OpenAPI endpoints. @@ -52,7 +52,7 @@ Resource template implementation for OpenAPI endpoints. **Methods:** -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> Resource diff --git a/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx b/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx index 6892d11746..69c2b0dae8 100644 --- a/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx +++ b/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx @@ -21,7 +21,7 @@ spec. Each component makes HTTP calls to the described API endpoints. **Methods:** -#### `lifespan` +#### `lifespan` ```python lifespan(self) -> AsyncIterator[None] @@ -30,7 +30,7 @@ lifespan(self) -> AsyncIterator[None] Manage the lifecycle of the auto-created httpx client. -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index 56bfd76966..80b45257b0 100644 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ b/docs/python-sdk/fastmcp-server-server.mdx @@ -26,7 +26,7 @@ Default lifespan context manager that does nothing. - An empty dictionary as the lifespan result. -### `create_proxy` +### `create_proxy` ```python create_proxy(target: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -823,7 +823,7 @@ objects are imported with their original names. #### `from_openapi` ```python -from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, **settings: Any) -> Self +from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, validate_output: bool = True, **settings: Any) -> Self ``` Create a FastMCP server from an OpenAPI specification. @@ -839,13 +839,17 @@ server URL from the OpenAPI spec with a 30-second timeout. - `mcp_component_fn`: Optional callable for component customization - `mcp_names`: Optional dictionary mapping operationId to component names - `tags`: Optional set of tags to add to all components +- `validate_output`: If True (default), tools use the output schema +extracted from the OpenAPI spec for response validation. If +False, a permissive schema is used instead, allowing any +response structure while still returning structured JSON. - `**settings`: Additional settings passed to FastMCP **Returns:** - A FastMCP server with an OpenAPIProvider attached. -#### `from_fastapi` +#### `from_fastapi` ```python from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> Self @@ -869,7 +873,7 @@ Use this to configure timeout and other client settings. - A FastMCP server with an OpenAPIProvider attached. -#### `as_proxy` +#### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -887,7 +891,7 @@ instance or any value accepted as the `transport` argument of `fastmcp.client.Client` constructor. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str diff --git a/src/fastmcp/server/providers/openapi/components.py b/src/fastmcp/server/providers/openapi/components.py index aa6ef80828..6b942d22e2 100644 --- a/src/fastmcp/server/providers/openapi/components.py +++ b/src/fastmcp/server/providers/openapi/components.py @@ -201,6 +201,12 @@ async def run(self, arguments: dict[str, Any]) -> ToolResult: else: structured_output = result + # Structured content must be a dict for the MCP protocol. + # Wrap non-dict values that slipped through (e.g. a backend + # returning an array when the schema declared an object). + if not isinstance(structured_output, dict): + structured_output = {"result": structured_output} + return ToolResult(structured_content=structured_output) except json.JSONDecodeError: return ToolResult(content=response.text) diff --git a/src/fastmcp/server/providers/openapi/provider.py b/src/fastmcp/server/providers/openapi/provider.py index ac79af400d..68979c0c1d 100644 --- a/src/fastmcp/server/providers/openapi/provider.py +++ b/src/fastmcp/server/providers/openapi/provider.py @@ -79,6 +79,7 @@ def __init__( mcp_component_fn: ComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, + validate_output: bool = True, ): """Initialize provider by parsing OpenAPI spec and creating components. @@ -93,6 +94,10 @@ def __init__( mcp_component_fn: Optional callable for component customization mcp_names: Optional dictionary mapping operationId to component names tags: Optional set of tags to add to all components + validate_output: If True (default), tools use the output schema + extracted from the OpenAPI spec for response validation. If + False, a permissive schema is used instead, allowing any + response structure while still returning structured JSON. """ super().__init__() @@ -101,6 +106,7 @@ def __init__( client = self._create_default_client(openapi_spec) self._client = client self._mcp_component_fn = mcp_component_fn + self._validate_output = validate_output # Keep track of names to detect collisions self._used_names: dict[str, Counter[str]] = { @@ -232,6 +238,17 @@ def _create_openapi_tool( route.openapi_version, ) + if not self._validate_output and output_schema is not None: + # Use a permissive schema that accepts any object, preserving + # the wrap-result flag so non-object responses still get wrapped + permissive: dict[str, Any] = { + "type": "object", + "additionalProperties": True, + } + if output_schema.get("x-fastmcp-wrap-result"): + permissive["x-fastmcp-wrap-result"] = True + output_schema = permissive + tool_name = self._get_unique_name(name, "tool") base_description = ( route.description diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 7dc7478216..35d717e8cb 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -2029,6 +2029,7 @@ def from_openapi( mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, + validate_output: bool = True, **settings: Any, ) -> Self: """ @@ -2045,6 +2046,10 @@ def from_openapi( mcp_component_fn: Optional callable for component customization mcp_names: Optional dictionary mapping operationId to component names tags: Optional set of tags to add to all components + validate_output: If True (default), tools use the output schema + extracted from the OpenAPI spec for response validation. If + False, a permissive schema is used instead, allowing any + response structure while still returning structured JSON. **settings: Additional settings passed to FastMCP Returns: @@ -2060,6 +2065,7 @@ def from_openapi( mcp_component_fn=mcp_component_fn, mcp_names=mcp_names, tags=tags, + validate_output=validate_output, ) return cls(name=name, providers=[provider], **settings) diff --git a/tests/server/providers/openapi/test_openapi_features.py b/tests/server/providers/openapi/test_openapi_features.py index f55a1e038b..ef4aeb002b 100644 --- a/tests/server/providers/openapi/test_openapi_features.py +++ b/tests/server/providers/openapi/test_openapi_features.py @@ -1,7 +1,10 @@ """Tests for OpenAPI feature support in OpenAPIProvider.""" +from unittest.mock import AsyncMock, Mock + import httpx import pytest +from httpx import Response from fastmcp import FastMCP from fastmcp.client import Client @@ -769,3 +772,213 @@ async def test_resource_mime_type_without_schema(self): resources = await mcp_client.list_resources() assert len(resources) == 1 assert resources[0].mimeType == "text/plain" + + +class TestValidateOutput: + """Tests for the validate_output option on OpenAPIProvider.""" + + @pytest.fixture + def spec_with_output_schema(self): + return { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/users/{id}": { + "get": { + "operationId": "get_user", + "summary": "Get a user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "integer"}, + } + ], + "responses": { + "200": { + "description": "A user", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "email": {"type": "string"}, + }, + "required": ["id", "name"], + } + } + }, + } + }, + } + }, + "/items": { + "get": { + "operationId": "list_items", + "summary": "List items", + "responses": { + "200": { + "description": "An array of items", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + }, + } + } + }, + } + }, + } + }, + }, + } + + async def test_validate_output_true_preserves_extracted_schema( + self, spec_with_output_schema + ): + """Default validate_output=True uses the real extracted schema.""" + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider( + openapi_spec=spec_with_output_schema, + client=client, + ) + + tool = provider._tools["get_user"] + assert tool.output_schema is not None + assert tool.output_schema.get("type") == "object" + assert "properties" in tool.output_schema + assert "id" in tool.output_schema["properties"] + + async def test_validate_output_false_uses_permissive_schema( + self, spec_with_output_schema + ): + """validate_output=False replaces the schema with a permissive one.""" + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider( + openapi_spec=spec_with_output_schema, + client=client, + validate_output=False, + ) + + tool = provider._tools["get_user"] + assert tool.output_schema is not None + assert tool.output_schema == { + "type": "object", + "additionalProperties": True, + } + + async def test_validate_output_false_preserves_wrap_result_flag( + self, spec_with_output_schema + ): + """validate_output=False preserves x-fastmcp-wrap-result for array responses.""" + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider( + openapi_spec=spec_with_output_schema, + client=client, + validate_output=False, + ) + + # The list_items endpoint returns an array, so the extracted schema + # would have had x-fastmcp-wrap-result=True + tool = provider._tools["list_items"] + assert tool.output_schema is not None + assert tool.output_schema.get("x-fastmcp-wrap-result") is True + assert tool.output_schema.get("additionalProperties") is True + + async def test_validate_output_false_allows_nonconforming_response( + self, spec_with_output_schema + ): + """With validate_output=False, responses that don't match the spec succeed.""" + mock_client = Mock(spec=httpx.AsyncClient) + mock_client.base_url = "https://api.example.com" + mock_client.headers = None + + # Return extra fields not in the schema + mock_response = Mock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": 1, + "name": "Alice", + "email": "alice@example.com", + "unexpected_field": "surprise", + "nested": {"deep": True}, + } + mock_response.raise_for_status = Mock() + mock_client.send = AsyncMock(return_value=mock_response) + + provider = OpenAPIProvider( + openapi_spec=spec_with_output_schema, + client=mock_client, + validate_output=False, + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + + async with Client(mcp) as mcp_client: + result = await mcp_client.call_tool("get_user", {"id": 1}) + assert result is not None + # Structured content should have the full response including extra fields + assert result.structured_content is not None + assert result.structured_content["unexpected_field"] == "surprise" + + async def test_validate_output_false_wraps_non_dict_response( + self, spec_with_output_schema + ): + """Non-dict responses are wrapped even when schema says object and validate_output=False.""" + mock_client = Mock(spec=httpx.AsyncClient) + mock_client.base_url = "https://api.example.com" + mock_client.headers = None + + # Backend returns an array even though schema says object + mock_response = Mock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = [{"id": 1}, {"id": 2}] + mock_response.raise_for_status = Mock() + mock_client.send = AsyncMock(return_value=mock_response) + + provider = OpenAPIProvider( + openapi_spec=spec_with_output_schema, + client=mock_client, + validate_output=False, + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + + async with Client(mcp) as mcp_client: + result = await mcp_client.call_tool("get_user", {"id": 1}) + assert result is not None + # Non-dict should be wrapped so structured_content is always a dict + assert result.structured_content is not None + assert isinstance(result.structured_content, dict) + assert result.structured_content["result"] == [{"id": 1}, {"id": 2}] + + async def test_from_openapi_threads_validate_output(self, spec_with_output_schema): + """FastMCP.from_openapi() correctly passes validate_output to the provider.""" + mock_client = Mock(spec=httpx.AsyncClient) + mock_client.base_url = "https://api.example.com" + mock_client.headers = None + + server = FastMCP.from_openapi( + openapi_spec=spec_with_output_schema, + client=mock_client, + validate_output=False, + ) + + async with Client(server) as mcp_client: + tools = await mcp_client.list_tools() + get_user = next(t for t in tools if t.name == "get_user") + # With validate_output=False, the outputSchema should be permissive + assert get_user.outputSchema is not None + assert get_user.outputSchema.get("additionalProperties") is True + # Should NOT have specific properties from the original schema + assert "properties" not in get_user.outputSchema