Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ 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.


**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
Expand All @@ -44,15 +44,15 @@ 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.


**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
Expand Down
13 changes: 9 additions & 4 deletions src/fastmcp/server/providers/openapi/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 9 additions & 3 deletions src/fastmcp/utilities/openapi/director.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
195 changes: 195 additions & 0 deletions tests/server/providers/openapi/test_comprehensive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})