diff --git a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx index bc0b40d3cd..64fba8da3d 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/src/fastmcp/server/providers/openapi/components.py b/src/fastmcp/server/providers/openapi/components.py index 4e6d18f1ef..aa6ef80828 100644 --- a/src/fastmcp/server/providers/openapi/components.py +++ b/src/fastmcp/server/providers/openapi/components.py @@ -159,23 +159,28 @@ def __repr__(self) -> str: async def run(self, arguments: dict[str, Any]) -> ToolResult: """Execute the HTTP request using RequestDirector.""" + # Build the request — errors here are programming/schema issues, + # not HTTP failures, so we catch them separately. try: base_url = str(self._client.base_url) or "http://localhost" - - # Build the request using RequestDirector request = self._director.build(self._route, arguments, base_url) - # Add client headers (lowest precedence) if self._client.headers: for key, value in self._client.headers.items(): if key not in request.headers: request.headers[key] = value - # Add MCP transport headers (highest precedence) mcp_headers = get_http_headers() if mcp_headers: request.headers.update(mcp_headers) + except Exception as e: + raise ValueError( + f"Error building request for {self._route.method.upper()} " + f"{self._route.path}: {type(e).__name__}: {e}" + ) from e + # Send the request and process the response. + try: logger.debug(f"run - sending request; headers: {request.headers}") response = await self._client.send(request) diff --git a/src/fastmcp/utilities/openapi/director.py b/src/fastmcp/utilities/openapi/director.py index 2efc8e74cd..58e941ba7e 100644 --- a/src/fastmcp/utilities/openapi/director.py +++ b/src/fastmcp/utilities/openapi/director.py @@ -166,12 +166,18 @@ def _unflatten_arguments( body = None if body_props: # If we have body properties, construct the body object - if route.request_body and route.request_body.content_schema: - # Check if the request body expects an object with properties + if ( + route.request_body + and route.request_body.content_schema + and len(route.request_body.content_schema) > 0 + ): content_type = next(iter(route.request_body.content_schema)) body_schema = route.request_body.content_schema[content_type] - if body_schema.get("type") == "object": + if ( + isinstance(body_schema, dict) + and body_schema.get("type") == "object" + ): body = body_props elif len(body_props) == 1: # If body schema is not an object and we have exactly one property, diff --git a/tests/server/providers/openapi/test_comprehensive.py b/tests/server/providers/openapi/test_comprehensive.py index f55786e65d..a21a764db4 100644 --- a/tests/server/providers/openapi/test_comprehensive.py +++ b/tests/server/providers/openapi/test_comprehensive.py @@ -763,3 +763,198 @@ async def test_timeout_error_produces_useful_message( error_message = str(exc_info.value) assert "timed out" in error_message assert "ReadTimeout" in error_message + + +class TestOpenAPIPostEdgeCases: + """Tests for POST request edge cases that could cause unhandled errors.""" + + @pytest.fixture + def post_spec_with_empty_content_schema(self): + """OpenAPI spec where a POST endpoint has an empty content_schema.""" + return { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/items": { + "post": { + "operationId": "create_item", + "summary": "Create an item", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "value": {"type": "integer"}, + }, + "required": ["name"], + } + } + }, + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + } + } + }, + } + }, + } + }, + "/items/{item_id}": { + "post": { + "operationId": "update_item", + "summary": "Update an item", + "parameters": [ + { + "name": "item_id", + "in": "path", + "required": True, + "schema": {"type": "integer"}, + } + ], + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "value": {"type": "integer"}, + }, + } + } + }, + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + } + } + }, + } + }, + } + }, + }, + } + + async def test_post_with_body_params(self, post_spec_with_empty_content_schema): + """POST with body parameters should build the request correctly.""" + mock_client = Mock(spec=httpx.AsyncClient) + mock_client.base_url = "https://api.example.com" + mock_client.headers = None + + mock_response = Mock(spec=Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"id": 1, "name": "Test"} + mock_response.raise_for_status = Mock() + mock_client.send = AsyncMock(return_value=mock_response) + + server = create_openapi_server( + openapi_spec=post_spec_with_empty_content_schema, + client=mock_client, + ) + + async with Client(server) as mcp_client: + result = await mcp_client.call_tool( + "create_item", {"name": "Test", "value": 42} + ) + + mock_client.send.assert_called_once() + request = mock_client.send.call_args[0][0] + assert request.method == "POST" + body_data = json.loads(request.content) + assert body_data["name"] == "Test" + assert body_data["value"] == 42 + assert result is not None + + async def test_post_with_path_params_and_body( + self, post_spec_with_empty_content_schema + ): + """POST with both path parameters and body should route args correctly.""" + mock_client = Mock(spec=httpx.AsyncClient) + mock_client.base_url = "https://api.example.com" + mock_client.headers = None + + mock_response = Mock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"id": 5, "name": "Updated"} + mock_response.raise_for_status = Mock() + mock_client.send = AsyncMock(return_value=mock_response) + + server = create_openapi_server( + openapi_spec=post_spec_with_empty_content_schema, + client=mock_client, + ) + + async with Client(server) as mcp_client: + result = await mcp_client.call_tool( + "update_item", + {"item_id": 5, "name": "Updated", "value": 99}, + ) + + mock_client.send.assert_called_once() + request = mock_client.send.call_args[0][0] + assert request.method == "POST" + assert "/items/5" in str(request.url) + body_data = json.loads(request.content) + assert body_data["name"] == "Updated" + assert body_data["value"] == 99 + assert "item_id" not in body_data + assert result is not None + + async def test_unexpected_error_in_request_building_gives_useful_message(self): + """Unexpected exceptions during request building should produce useful errors.""" + from fastmcp.server.providers.openapi.components import OpenAPITool + from fastmcp.utilities.openapi.director import RequestDirector + from fastmcp.utilities.openapi.models import HTTPRoute + + mock_client = Mock(spec=httpx.AsyncClient) + mock_client.base_url = "https://api.example.com" + mock_client.headers = None + + route = HTTPRoute( + path="/test", + method="POST", + operation_id="test_op", + parameters=[], + responses={}, + response_schemas={}, + ) + + mock_director = Mock(spec=RequestDirector) + mock_director.build.side_effect = KeyError("missing_param") + + tool = OpenAPITool( + client=mock_client, + route=route, + director=mock_director, + name="test_tool", + description="test", + parameters={}, + ) + + with pytest.raises(ValueError, match="Error building request for POST /test"): + await tool.run({"some_arg": "value"})