From 748a374f428599c671105902ddf6d7e4023da238 Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 14 Apr 2026 09:52:28 -0500 Subject: [PATCH 1/8] fix: OpenAPI request director content-type dispatch and cookie params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fastmcp/utilities/openapi/director.py | 53 +++++- tests/utilities/openapi/test_director.py | 200 +++++++++++++++++++++- 2 files changed, 240 insertions(+), 13 deletions(-) diff --git a/src/fastmcp/utilities/openapi/director.py b/src/fastmcp/utilities/openapi/director.py index 8980e3d6a..fa03aa992 100644 --- a/src/fastmcp/utilities/openapi/director.py +++ b/src/fastmcp/utilities/openapi/director.py @@ -54,8 +54,8 @@ class RequestDirector: ) # Step 1: Un-flatten arguments into path, query, body, etc. using parameter map - path_params, query_params, header_params, body = self._unflatten_arguments( - route, flat_args + path_params, query_params, header_params, cookie_params, body = ( + self._unflatten_arguments(route, flat_args) ) logger.debug( @@ -80,9 +80,40 @@ class RequestDirector: if route.request_body and route.request_body.content_schema: declared_content_type = next(iter(route.request_body.content_schema)) + cookies = cookie_params if cookie_params else None + # Step 6: Handle request body if body is not None: - if isinstance(body, dict | list): + if declared_content_type == "multipart/form-data" and isinstance( + body, dict + ): + # httpx requires files= for multipart/form-data encoding. + # Wrap plain values as (None, value) tuples so httpx treats + # them as form fields rather than file uploads. + files = { + k: v if isinstance(v, tuple) else (None, v) for k, v in body.items() + } + return httpx.Request( + method=method, + url=url, + params=params, + headers=headers, + files=files, + cookies=cookies, + ) + elif ( + declared_content_type == "application/x-www-form-urlencoded" + and isinstance(body, dict) + ): + return httpx.Request( + method=method, + url=url, + params=params, + headers=headers, + data=body, + cookies=cookies, + ) + elif isinstance(body, dict | list): if ( declared_content_type is not None and declared_content_type != "application/json" @@ -108,11 +139,12 @@ class RequestDirector: headers=headers, json=json_body, content=content, + cookies=cookies, ) def _unflatten_arguments( self, route: HTTPRoute, flat_args: dict[str, Any] - ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], Any]: + ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any], Any]: """ Maps flat arguments back to their OpenAPI locations using the parameter map. @@ -121,11 +153,12 @@ class RequestDirector: flat_args: Flat arguments from LLM call Returns: - Tuple of (path_params, query_params, header_params, body) + Tuple of (path_params, query_params, header_params, cookie_params, body) """ path_params = {} query_params = {} header_params = {} + cookie_params = {} body_props = {} # Use parameter map to route arguments to correct locations @@ -150,6 +183,8 @@ class RequestDirector: query_params[openapi_name] = value elif location == "header": header_params[openapi_name] = value + elif location == "cookie": + cookie_params[openapi_name] = value elif location == "body": body_props[openapi_name] = value else: @@ -173,13 +208,15 @@ class RequestDirector: # Check if it's a suffixed parameter (e.g., id__path) if "__" in arg_name: base_name, location = arg_name.rsplit("__", 1) - if location in ["path", "query", "header"]: + if location in ["path", "query", "header", "cookie"]: if location == "path": path_params[base_name] = value elif location == "query": query_params[base_name] = value elif location == "header": header_params[base_name] = value + elif location == "cookie": + cookie_params[base_name] = value continue # Check if it's a known parameter @@ -191,6 +228,8 @@ class RequestDirector: query_params[arg_name] = value elif location == "header": header_params[arg_name] = value + elif location == "cookie": + cookie_params[arg_name] = value else: # Assume it's a body property body_props[arg_name] = value @@ -222,7 +261,7 @@ class RequestDirector: else: body = body_props - return path_params, query_params, header_params, body + return path_params, query_params, header_params, cookie_params, body # Delimiter per OpenAPI style when explode=false _STYLE_DELIMITERS: ClassVar[dict[str, str]] = { diff --git a/tests/utilities/openapi/test_director.py b/tests/utilities/openapi/test_director.py index 56008b241..c3e1a4f3b 100644 --- a/tests/utilities/openapi/test_director.py +++ b/tests/utilities/openapi/test_director.py @@ -530,8 +530,8 @@ class TestContentTypeHandling: 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.""" + def test_non_json_content_type_uses_native_encoding(self, director): + """Non-JSON types like multipart/form-data use httpx's native encoding.""" route = HTTPRoute( path="/upload", method="POST", @@ -551,10 +551,8 @@ class TestContentTypeHandling: ) 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" + # multipart/form-data should be sent as multipart, not JSON + assert "multipart/form-data" in request.headers["content-type"] class TestQueryParameterSerialization: @@ -1152,3 +1150,193 @@ class TestPathTraversalPrevention: # Verify traversal didn't escape the users/ prefix assert decoded.startswith("https://api.example.com/api/v1/users/") assert url.startswith("https://api.example.com/api/v1/users/") + + +class TestContentTypeDispatch: + """Test that non-JSON content types are dispatched correctly.""" + + @pytest.fixture + def director(self): + spec = SchemaPath.from_dict( + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": {}, + } + ) + return RequestDirector(spec) + + @pytest.fixture + def multipart_route(self): + return HTTPRoute( + path="/upload", + method="POST", + operation_id="upload_file", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "multipart/form-data": { + "type": "object", + "properties": { + "filename": {"type": "string"}, + "data": {"type": "string"}, + }, + } + }, + ), + parameter_map={ + "filename": {"location": "body", "openapi_name": "filename"}, + "data": {"location": "body", "openapi_name": "data"}, + }, + ) + + @pytest.fixture + def form_urlencoded_route(self): + return HTTPRoute( + path="/login", + method="POST", + operation_id="login", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/x-www-form-urlencoded": { + "type": "object", + "properties": { + "username": {"type": "string"}, + "password": {"type": "string"}, + }, + } + }, + ), + parameter_map={ + "username": {"location": "body", "openapi_name": "username"}, + "password": {"location": "body", "openapi_name": "password"}, + }, + ) + + @pytest.fixture + def json_route(self): + return HTTPRoute( + path="/items", + method="POST", + operation_id="create_item", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": { + "type": "object", + "properties": { + "name": {"type": "string"}, + }, + } + }, + ), + parameter_map={ + "name": {"location": "body", "openapi_name": "name"}, + }, + ) + + def test_multipart_form_data_not_sent_as_json(self, director, multipart_route): + """multipart/form-data bodies should use httpx data= not json=.""" + request = director.build( + multipart_route, + {"filename": "test.txt", "data": "hello"}, + ) + content_type = request.headers.get("content-type", "") + assert "multipart/form-data" in content_type + # Should NOT be JSON-encoded + assert "application/json" not in content_type + + def test_form_urlencoded_not_sent_as_json(self, director, form_urlencoded_route): + """application/x-www-form-urlencoded bodies should use httpx data=.""" + request = director.build( + form_urlencoded_route, + {"username": "alice", "password": "secret"}, + ) + content_type = request.headers.get("content-type", "") + assert "application/x-www-form-urlencoded" in content_type + assert "application/json" not in content_type + # Verify the body is form-encoded + body_text = request.content.decode("utf-8") + assert "username=alice" in body_text + assert "password=secret" in body_text + + def test_json_body_still_works(self, director, json_route): + """application/json bodies should still be sent as JSON (regression).""" + request = director.build( + json_route, + {"name": "widget"}, + ) + content_type = request.headers.get("content-type", "") + assert "application/json" in content_type + body = json.loads(request.content.decode("utf-8")) + assert body == {"name": "widget"} + + +class TestCookieParameters: + """Test that cookie parameters are properly routed.""" + + @pytest.fixture + def director(self): + spec = SchemaPath.from_dict( + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": {}, + } + ) + return RequestDirector(spec) + + @pytest.fixture + def cookie_route(self): + return HTTPRoute( + path="/dashboard", + method="GET", + operation_id="get_dashboard", + parameters=[ + ParameterInfo( + name="session_id", + location="cookie", + required=True, + schema={"type": "string"}, + ), + ], + parameter_map={ + "session_id": { + "location": "cookie", + "openapi_name": "session_id", + }, + }, + ) + + def test_cookie_parameter_set_on_request(self, director, cookie_route): + """Cookie parameters should appear as cookies on the request.""" + request = director.build( + cookie_route, + {"session_id": "abc123"}, + ) + assert "session_id" in request.headers.get("cookie", "") + assert "abc123" in request.headers.get("cookie", "") + + def test_cookie_parameter_with_fallback_mapping(self, director): + """Cookie parameters should work in fallback (no parameter_map) mode.""" + route = HTTPRoute( + path="/dashboard", + method="GET", + operation_id="get_dashboard", + parameters=[ + ParameterInfo( + name="session_id", + location="cookie", + required=True, + schema={"type": "string"}, + ), + ], + # No parameter_map — triggers fallback path + ) + request = director.build( + route, + {"session_id": "xyz789"}, + ) + assert "session_id" in request.headers.get("cookie", "") + assert "xyz789" in request.headers.get("cookie", "") From b3970f50e3819054b5139ff332c1eee51e939d0a Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 14 Apr 2026 10:04:41 -0500 Subject: [PATCH 2/8] Stringify multipart form values and cookie params for httpx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit httpx rejects non-string scalars in files= and cookies=. 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fastmcp/utilities/openapi/director.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/utilities/openapi/director.py b/src/fastmcp/utilities/openapi/director.py index fa03aa992..806130f5d 100644 --- a/src/fastmcp/utilities/openapi/director.py +++ b/src/fastmcp/utilities/openapi/director.py @@ -80,7 +80,10 @@ class RequestDirector: if route.request_body and route.request_body.content_schema: declared_content_type = next(iter(route.request_body.content_schema)) - cookies = cookie_params if cookie_params else None + # httpx requires cookie values to be strings + cookies = ( + {k: str(v) for k, v in cookie_params.items()} if cookie_params else None + ) # Step 6: Handle request body if body is not None: @@ -88,10 +91,12 @@ class RequestDirector: body, dict ): # httpx requires files= for multipart/form-data encoding. - # Wrap plain values as (None, value) tuples so httpx treats - # them as form fields rather than file uploads. + # Wrap plain values as (None, str(value)) tuples so httpx + # treats them as form fields. Scalars must be stringified + # because httpx rejects non-string/bytes values. files = { - k: v if isinstance(v, tuple) else (None, v) for k, v in body.items() + k: v if isinstance(v, tuple) else (None, str(v)) + for k, v in body.items() } return httpx.Request( method=method, From 9af9827c9fe86faf042eb2d9c7b3f346dd509eb4 Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 14 Apr 2026 10:14:44 -0500 Subject: [PATCH 3/8] Add tests for non-string multipart values and cookie stringification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/utilities/openapi/test_director.py | 55 ++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/utilities/openapi/test_director.py b/tests/utilities/openapi/test_director.py index c3e1a4f3b..d2828e76a 100644 --- a/tests/utilities/openapi/test_director.py +++ b/tests/utilities/openapi/test_director.py @@ -1261,6 +1261,39 @@ class TestContentTypeDispatch: assert "username=alice" in body_text assert "password=secret" in body_text + def test_multipart_stringifies_non_string_values(self, director): + """Non-string scalars (int, bool) must be stringified for httpx files=.""" + route = HTTPRoute( + path="/submit", + method="POST", + operation_id="submit", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "multipart/form-data": { + "type": "object", + "properties": { + "count": {"type": "integer"}, + "active": {"type": "boolean"}, + }, + } + }, + ), + parameter_map={ + "count": {"location": "body", "openapi_name": "count"}, + "active": {"location": "body", "openapi_name": "active"}, + }, + ) + # Should not raise — non-string values get str() converted + request = director.build(route, {"count": 42, "active": True}) + content_type = request.headers.get("content-type", "") + assert "multipart/form-data" in content_type + # Multipart requests are streaming; read to verify content + request.read() + body = request.content.decode("utf-8") + assert "42" in body + assert "True" in body + def test_json_body_still_works(self, director, json_route): """application/json bodies should still be sent as JSON (regression).""" request = director.build( @@ -1340,3 +1373,25 @@ class TestCookieParameters: ) assert "session_id" in request.headers.get("cookie", "") assert "xyz789" in request.headers.get("cookie", "") + + def test_cookie_non_string_value_stringified(self, director): + """Non-string cookie values (e.g. int) must be stringified for httpx.""" + route = HTTPRoute( + path="/api", + method="GET", + operation_id="get_api", + parameters=[ + ParameterInfo( + name="version", + location="cookie", + required=True, + schema={"type": "integer"}, + ), + ], + parameter_map={ + "version": {"location": "cookie", "openapi_name": "version"}, + }, + ) + request = director.build(route, {"version": 3}) + assert "version" in request.headers.get("cookie", "") + assert "3" in request.headers.get("cookie", "") From 400932d3ac46dcd9eb158896b102a9afa8eaf80b Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 14 Apr 2026 10:20:32 -0500 Subject: [PATCH 4/8] Consolidate to single httpx.Request construction point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminate early returns by using variables for files/data kwargs. All httpx body kwargs accept None, so we set exactly one and pass all to a single Request() call. 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fastmcp/utilities/openapi/director.py | 28 ++++++++--------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/fastmcp/utilities/openapi/director.py b/src/fastmcp/utilities/openapi/director.py index 806130f5d..82ca17dd9 100644 --- a/src/fastmcp/utilities/openapi/director.py +++ b/src/fastmcp/utilities/openapi/director.py @@ -85,39 +85,27 @@ class RequestDirector: {k: str(v) for k, v in cookie_params.items()} if cookie_params else None ) - # Step 6: Handle request body + # Step 6: Handle request body — dispatch on declared content type. + # httpx body kwargs (json, content, data, files) are mutually exclusive + # but all accept None, so we set exactly one and pass all to Request(). + files: dict[str, Any] | None = None + data: dict[str, Any] | None = None if body is not None: if declared_content_type == "multipart/form-data" and isinstance( body, dict ): - # httpx requires files= for multipart/form-data encoding. # Wrap plain values as (None, str(value)) tuples so httpx # treats them as form fields. Scalars must be stringified - # because httpx rejects non-string/bytes values. + # because httpx rejects non-string/bytes values in files=. files = { k: v if isinstance(v, tuple) else (None, str(v)) for k, v in body.items() } - return httpx.Request( - method=method, - url=url, - params=params, - headers=headers, - files=files, - cookies=cookies, - ) elif ( declared_content_type == "application/x-www-form-urlencoded" and isinstance(body, dict) ): - return httpx.Request( - method=method, - url=url, - params=params, - headers=headers, - data=body, - cookies=cookies, - ) + data = body elif isinstance(body, dict | list): if ( declared_content_type is not None @@ -144,6 +132,8 @@ class RequestDirector: headers=headers, json=json_body, content=content, + data=data, + files=files, cookies=cookies, ) From ea1439a009c2fc6cc9619836accd8d6476bcd085 Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 14 Apr 2026 10:23:40 -0500 Subject: [PATCH 5/8] Use _query_scalar_to_str for multipart booleans, add tuple passthrough test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse existing boolean serialization (true/false not True/False) for multipart form fields. Add test for file-like tuple passthrough. 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fastmcp/utilities/openapi/director.py | 5 ++-- tests/utilities/openapi/test_director.py | 30 ++++++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/utilities/openapi/director.py b/src/fastmcp/utilities/openapi/director.py index 82ca17dd9..c3225af92 100644 --- a/src/fastmcp/utilities/openapi/director.py +++ b/src/fastmcp/utilities/openapi/director.py @@ -94,11 +94,12 @@ class RequestDirector: if declared_content_type == "multipart/form-data" and isinstance( body, dict ): - # Wrap plain values as (None, str(value)) tuples so httpx + # Wrap plain values as (None, stringified) tuples so httpx # 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, str(v)) + k: v if isinstance(v, tuple) else (None, _query_scalar_to_str(v)) for k, v in body.items() } elif ( diff --git a/tests/utilities/openapi/test_director.py b/tests/utilities/openapi/test_director.py index d2828e76a..82035a79c 100644 --- a/tests/utilities/openapi/test_director.py +++ b/tests/utilities/openapi/test_director.py @@ -1292,7 +1292,35 @@ class TestContentTypeDispatch: request.read() body = request.content.decode("utf-8") assert "42" in body - assert "True" in body + assert "true" in body + + def test_multipart_preserves_tuple_values(self, director): + """Tuple values (file-like) are passed through unchanged to httpx files=.""" + 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"}, + }, + ) + # Tuple values represent file-like objects for httpx + request = director.build(route, {"file": ("report.csv", b"a,b,c")}) + content_type = request.headers.get("content-type", "") + assert "multipart/form-data" in content_type + request.read() + body = request.content.decode("utf-8", errors="replace") + assert "report.csv" in body + assert "a,b,c" in body def test_json_body_still_works(self, director, json_route): """application/json bodies should still be sent as JSON (regression).""" From ba8db8ccc33b3c28eae3390780b8787751b6f333 Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 14 Apr 2026 10:30:45 -0500 Subject: [PATCH 6/8] Normalize media type for dispatch, use OpenAPI serialization for cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strip content-type parameters (e.g. "; charset=utf-8") and lowercase before matching, so variants like "Multipart/Form-Data" match correctly - Use _query_scalar_to_str for cookie values (true/false not True/False) - Add boolean cookie test 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fastmcp/utilities/openapi/director.py | 13 +++++++++---- tests/utilities/openapi/test_director.py | 17 +++++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/fastmcp/utilities/openapi/director.py b/src/fastmcp/utilities/openapi/director.py index c3225af92..b0b29f0ba 100644 --- a/src/fastmcp/utilities/openapi/director.py +++ b/src/fastmcp/utilities/openapi/director.py @@ -75,14 +75,19 @@ class RequestDirector: json_body: dict[str, Any] | list[Any] | None = None content: str | bytes | None = None - # Step 5: Determine the declared content type from the OpenAPI spec + # Step 5: Determine the declared content type from the OpenAPI spec. + # Strip parameters (e.g. "; charset=utf-8") for dispatch matching. 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)) + raw_ct = next(iter(route.request_body.content_schema)) + declared_content_type = raw_ct.split(";")[0].strip().lower() - # httpx requires cookie values to be strings + # httpx requires cookie values to be strings; use OpenAPI-style + # serialization (e.g. true/false for booleans, not True/False) cookies = ( - {k: str(v) for k, v in cookie_params.items()} if cookie_params else None + {k: _query_scalar_to_str(v) for k, v in cookie_params.items()} + if cookie_params + else None ) # Step 6: Handle request body — dispatch on declared content type. diff --git a/tests/utilities/openapi/test_director.py b/tests/utilities/openapi/test_director.py index 82035a79c..acd50425f 100644 --- a/tests/utilities/openapi/test_director.py +++ b/tests/utilities/openapi/test_director.py @@ -1403,7 +1403,7 @@ class TestCookieParameters: assert "xyz789" in request.headers.get("cookie", "") def test_cookie_non_string_value_stringified(self, director): - """Non-string cookie values (e.g. int) must be stringified for httpx.""" + """Non-string cookie values (e.g. int, bool) use OpenAPI serialization.""" route = HTTPRoute( path="/api", method="GET", @@ -1415,11 +1415,20 @@ class TestCookieParameters: required=True, schema={"type": "integer"}, ), + ParameterInfo( + name="debug", + location="cookie", + required=False, + schema={"type": "boolean"}, + ), ], parameter_map={ "version": {"location": "cookie", "openapi_name": "version"}, + "debug": {"location": "cookie", "openapi_name": "debug"}, }, ) - request = director.build(route, {"version": 3}) - assert "version" in request.headers.get("cookie", "") - assert "3" in request.headers.get("cookie", "") + request = director.build(route, {"version": 3, "debug": True}) + cookie = request.headers.get("cookie", "") + assert "version=3" in cookie + # Booleans use OpenAPI convention (true/false, not True/False) + assert "debug=true" in cookie From 7c30184f3b2c605ae47a748fe3435162e8a15924 Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 14 Apr 2026 11:14:18 -0500 Subject: [PATCH 7/8] Preserve media-type parameters in Content-Type header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use raw_content_type (with charset etc.) for the outgoing header, normalized form only for dispatch matching. 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fastmcp/utilities/openapi/director.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/utilities/openapi/director.py b/src/fastmcp/utilities/openapi/director.py index b0b29f0ba..5cd33153d 100644 --- a/src/fastmcp/utilities/openapi/director.py +++ b/src/fastmcp/utilities/openapi/director.py @@ -76,11 +76,13 @@ class RequestDirector: content: str | bytes | None = None # Step 5: Determine the declared content type from the OpenAPI spec. - # Strip parameters (e.g. "; charset=utf-8") for dispatch matching. + # Use the raw value for outgoing Content-Type headers (preserves + # parameters like charset), but normalize for dispatch matching. + raw_content_type: str | None = None declared_content_type: str | None = None if route.request_body and route.request_body.content_schema: - raw_ct = next(iter(route.request_body.content_schema)) - declared_content_type = raw_ct.split(";")[0].strip().lower() + raw_content_type = next(iter(route.request_body.content_schema)) + declared_content_type = raw_content_type.split(";")[0].strip().lower() # httpx requires cookie values to be strings; use OpenAPI-style # serialization (e.g. true/false for booleans, not True/False) @@ -124,7 +126,7 @@ class RequestDirector: # 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 + headers["Content-Type"] = raw_content_type else: json_body = body else: From aad942cc86d0be20f559880a5e9d6e894eb91ba8 Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 14 Apr 2026 13:31:25 -0500 Subject: [PATCH 8/8] Pass bytes/file-like values directly in multipart, add charset preservation test Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fastmcp/utilities/openapi/director.py | 16 +++++-- tests/utilities/openapi/test_director.py | 57 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) 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(