Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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#L228" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove edits under auto-generated python-sdk docs

This change modifies docs/python-sdk/**, but the repository guideline in /workspace/fastmcp/AGENTS.md states "Never modify docs/python-sdk/** (auto-generated)." Committing these generated line-reference updates creates avoidable churn and risks stale docs diffs being overwritten by generation tooling.

Useful? React with 👍 / 👎.



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#L258" 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#L342" 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#L374" 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
6 changes: 6 additions & 0 deletions src/fastmcp/server/providers/openapi/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,12 @@ async def run(self, arguments: dict[str, Any]) -> ToolResult:
except httpx.RequestError as e:
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e

except Exception as e:
raise ValueError(
f"Error building request for {self._route.method.upper()} "
f"{self._route.path}: {type(e).__name__}: {e}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit catch-all to request-building failures

The new except Exception in OpenAPITool.run catches non-HTTPX errors from the entire method, not just request construction, so failures that happen after send() (for example response parsing or ToolResult validation) are now rethrown as Error building request ..., which is misleading and makes production debugging harder. Please scope this handler to the build step or use a message that matches all covered phases.

Useful? React with 👍 / 👎.

) from e


class OpenAPIResource(Resource):
"""Resource implementation for OpenAPI endpoints."""
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"})