diff --git a/src/fastmcp/utilities/openapi/director.py b/src/fastmcp/utilities/openapi/director.py index 5cd33153d..461573960 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 io import json as _json from typing import Any, ClassVar from urllib.parse import quote, urljoin @@ -105,10 +106,17 @@ class RequestDirector: # treats them as form fields. Scalars must be stringified # because httpx rejects non-string/bytes values in files=. # Use _query_scalar_to_str for booleans (true/false, not True/False). - files = { - k: v if isinstance(v, tuple) else (None, _query_scalar_to_str(v)) - for k, v in body.items() - } + files = {} + for k, v in body.items(): + if isinstance(v, tuple): + files[k] = v + elif isinstance(v, bytes | io.IOBase): + # bytes and file-like objects are passed directly so + # httpx can transmit them as binary parts without + # stringifying to their Python repr. + files[k] = (None, v) + else: + files[k] = (None, _query_scalar_to_str(v)) elif ( declared_content_type == "application/x-www-form-urlencoded" and isinstance(body, dict) diff --git a/tests/utilities/openapi/test_director.py b/tests/utilities/openapi/test_director.py index acd50425f..6e8fffb82 100644 --- a/tests/utilities/openapi/test_director.py +++ b/tests/utilities/openapi/test_director.py @@ -476,6 +476,33 @@ class TestContentTypeHandling: assert request.headers["content-type"] == "application/merge-patch+json" assert json.loads(request.content) == {"name": "test"} + def test_content_type_preserves_media_type_parameters(self, director): + """Media-type parameters like charset are preserved on the wire.""" + route = HTTPRoute( + path="/items", + method="POST", + operation_id="create_item", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json-patch+json; charset=utf-8": { + "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-patch+json; charset=utf-8" + ) + 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( @@ -1322,6 +1349,36 @@ class TestContentTypeDispatch: assert "report.csv" in body assert "a,b,c" in body + def test_multipart_bare_bytes_not_stringified(self, director): + """Bare bytes values are passed directly to httpx, not repr-stringified.""" + route = HTTPRoute( + path="/upload", + method="POST", + operation_id="upload_binary", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "multipart/form-data": { + "type": "object", + "properties": { + "payload": {"type": "string", "format": "binary"}, + }, + } + }, + ), + parameter_map={ + "payload": {"location": "body", "openapi_name": "payload"}, + }, + ) + raw = b"\x89PNG\r\n\x1a\nfake-image-data" + request = director.build(route, {"payload": raw}) + content_type = request.headers.get("content-type", "") + assert "multipart/form-data" in content_type + request.read() + # The raw bytes should appear in the body verbatim, not as "b'\\x89PNG...'" + assert raw in request.content + assert b"b'" not in request.content + def test_json_body_still_works(self, director, json_route): """application/json bodies should still be sent as JSON (regression).""" request = director.build(