Fix unhandled exceptions in OpenAPI POST tool calls (#3133)

Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
This commit is contained in:
Jeremiah Lowin 2026-02-10 13:13:45 -05:00 committed by GitHub
commit a715176499
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 217 additions and 11 deletions

View file

@ -27,7 +27,7 @@ run(self, arguments: dict[str, Any]) -> ToolResult
Execute the HTTP request using RequestDirector.
### `OpenAPIResource` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L222" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `OpenAPIResource` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L227" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Resource implementation for OpenAPI endpoints.
@ -35,7 +35,7 @@ Resource implementation for OpenAPI endpoints.
**Methods:**
#### `read` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L252" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `read` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L257" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
read(self) -> ResourceResult
@ -44,7 +44,7 @@ read(self) -> ResourceResult
Fetch the resource data by making an HTTP request.
### `OpenAPIResourceTemplate` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L336" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `OpenAPIResourceTemplate` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L341" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Resource template implementation for OpenAPI endpoints.
@ -52,7 +52,7 @@ Resource template implementation for OpenAPI endpoints.
**Methods:**
#### `create_resource` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L368" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `create_resource` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L373" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> Resource

View file

@ -159,23 +159,28 @@ class OpenAPITool(Tool):
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)

View file

@ -166,12 +166,18 @@ class RequestDirector:
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,

View file

@ -763,3 +763,198 @@ class TestOpenAPIComprehensive:
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"})