diff --git a/src/fastmcp/utilities/openapi/director.py b/src/fastmcp/utilities/openapi/director.py index 6b2d6eb95..c2f3e92d4 100644 --- a/src/fastmcp/utilities/openapi/director.py +++ b/src/fastmcp/utilities/openapi/director.py @@ -1,5 +1,6 @@ """Request director using openapi-core for stateless HTTP request building.""" +import json as _json from typing import Any, ClassVar from urllib.parse import quote, urljoin @@ -74,14 +75,32 @@ class RequestDirector: json_body: dict[str, Any] | list[Any] | None = None content: str | bytes | None = None - # Step 5: Handle request body + # Step 5: Determine the declared content type from the OpenAPI spec + declared_content_type: str | None = None + if route.request_body and route.request_body.content_schema: + declared_content_type = next(iter(route.request_body.content_schema)) + + # Step 6: Handle request body if body is not None: if isinstance(body, dict | list): - json_body = body + if ( + declared_content_type is not None + and declared_content_type != "application/json" + and "json" in declared_content_type + ): + # JSON-compatible types like application/json-patch+json + # or application/merge-patch+json need an explicit + # Content-Type header since httpx's json= always + # sets application/json. + content = _json.dumps(body, allow_nan=False).encode("utf-8") + headers = dict(headers) if headers else {} + headers["Content-Type"] = declared_content_type + else: + json_body = body else: content = body - # Step 6: Create httpx.Request + # Step 7: Create httpx.Request return httpx.Request( method=method, url=url, diff --git a/tests/utilities/openapi/test_director.py b/tests/utilities/openapi/test_director.py index 4f7a8f514..bcde4f69d 100644 --- a/tests/utilities/openapi/test_director.py +++ b/tests/utilities/openapi/test_director.py @@ -1,5 +1,6 @@ """Unit tests for RequestDirector.""" +import json from urllib.parse import unquote import pytest @@ -196,8 +197,6 @@ class TestRequestDirector: ) # httpx normalizes headers to lowercase # Check body - import json - assert request.content is not None body_data = json.loads(request.content) assert body_data["title"] == "Updated Title" @@ -217,8 +216,6 @@ class TestRequestDirector: assert "123" in str(request.url) # Path ID should be 123 # Check body - import json - body_data = json.loads(request.content) assert body_data["id"] == 456 # Body ID should be 456 assert body_data["name"] == "John Doe" @@ -242,8 +239,6 @@ class TestRequestDirector: headers = dict(request.headers) if request.headers else {} assert "X-Client-Version" not in headers - import json - body_data = json.loads(request.content) assert body_data["title"] == "Required Title" assert "description" not in body_data # Should not include None description @@ -311,8 +306,6 @@ class TestRequestDirector: assert request.method == "POST" assert "123" in str(request.url) - import json - body_data = json.loads(request.content) assert body_data["name"] == "John Doe" @@ -379,12 +372,191 @@ class TestRequestDirector: assert request.method == "POST" # Should wrap in object when multiple properties but schema is not object - import json - body_data = json.loads(request.content) assert body_data == {"prop1": "value1", "prop2": "value2"} +class TestContentTypeHandling: + """Test that request Content-Type respects the OpenAPI spec.""" + + @pytest.fixture + def director(self, basic_openapi_30_spec): + spec = SchemaPath.from_dict(basic_openapi_30_spec) + return RequestDirector(spec) + + def test_application_json_uses_httpx_json(self, director): + """Standard application/json uses httpx's json= parameter.""" + route = HTTPRoute( + path="/items", + method="PATCH", + operation_id="update_item", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": { + "type": "object", + "properties": {"name": {"type": "string"}}, + } + }, + ), + parameter_map={ + "name": {"location": "body", "openapi_name": "name"}, + }, + ) + + request = director.build(route, {"name": "test"}, "https://example.com") + assert request.headers["content-type"] == "application/json" + assert json.loads(request.content) == {"name": "test"} + + def test_json_patch_content_type_preserved(self, director): + """application/json-patch+json is sent as the Content-Type header.""" + route = HTTPRoute( + path="/items/{id}", + method="PATCH", + operation_id="patch_item", + parameters=[ + ParameterInfo( + name="id", + location="path", + required=True, + schema={"type": "string"}, + ), + ], + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json-patch+json": { + "type": "array", + "items": { + "type": "object", + "properties": { + "op": {"type": "string"}, + "path": {"type": "string"}, + "value": {}, + }, + }, + } + }, + ), + parameter_map={ + "id": {"location": "path", "openapi_name": "id"}, + "body": {"location": "body", "openapi_name": "body"}, + }, + ) + + patch_ops = [{"op": "replace", "path": "/name", "value": "new-name"}] + request = director.build( + route, {"id": "123", "body": patch_ops}, "https://example.com" + ) + + assert request.headers["content-type"] == "application/json-patch+json" + assert json.loads(request.content) == patch_ops + + def test_custom_json_content_type_with_dict_body(self, director): + """Any non-standard JSON content type gets the correct header.""" + route = HTTPRoute( + path="/items", + method="POST", + operation_id="create_item", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/merge-patch+json": { + "type": "object", + "properties": {"name": {"type": "string"}}, + } + }, + ), + parameter_map={ + "name": {"location": "body", "openapi_name": "name"}, + }, + ) + + request = director.build(route, {"name": "test"}, "https://example.com") + assert request.headers["content-type"] == "application/merge-patch+json" + assert json.loads(request.content) == {"name": "test"} + + def test_custom_content_type_preserves_other_headers(self, director): + """Custom content type doesn't clobber other headers from parameters.""" + route = HTTPRoute( + path="/items", + method="PATCH", + operation_id="patch_item", + parameters=[ + ParameterInfo( + name="X-Request-Id", + location="header", + required=True, + schema={"type": "string"}, + ), + ], + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json-patch+json": { + "type": "object", + "properties": {"name": {"type": "string"}}, + } + }, + ), + parameter_map={ + "X-Request-Id": { + "location": "header", + "openapi_name": "X-Request-Id", + }, + "name": {"location": "body", "openapi_name": "name"}, + }, + ) + + request = director.build( + route, + {"X-Request-Id": "abc-123", "name": "test"}, + "https://example.com", + ) + assert request.headers["content-type"] == "application/json-patch+json" + assert request.headers["x-request-id"] == "abc-123" + + def test_no_request_body_info_defaults_to_json(self, director): + """When route has no request_body metadata, dict body uses application/json.""" + route = HTTPRoute( + path="/items", + method="POST", + operation_id="create_item", + parameter_map={ + "name": {"location": "body", "openapi_name": "name"}, + }, + ) + + request = director.build(route, {"name": "test"}, "https://example.com") + assert request.headers["content-type"] == "application/json" + + def test_non_json_content_type_falls_through(self, director): + """Non-JSON types like multipart/form-data don't get JSON-serialized.""" + route = HTTPRoute( + path="/upload", + method="POST", + operation_id="upload", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "multipart/form-data": { + "type": "object", + "properties": {"file": {"type": "string"}}, + } + }, + ), + parameter_map={ + "file": {"location": "body", "openapi_name": "file"}, + }, + ) + + request = director.build(route, {"file": "data"}, "https://example.com") + # Should fall through to httpx's json= path (not manually serialized + # with a multipart/form-data header), since the content type isn't + # JSON-compatible. + assert request.headers["content-type"] == "application/json" + + class TestQueryParameterSerialization: """Test that query parameters respect OpenAPI explode/style settings."""