Skip to content

Commit bc83212

Browse files
committed
fix(server): accept mixed-case JSON Content-Type
Media types are case-insensitive (RFC 9110 section 8.3.1). Adds interaction test with mixed-case variants plus direct unit tests of _check_content_type.
1 parent 91941ed commit bc83212

3 files changed

Lines changed: 60 additions & 1 deletion

File tree

‎src/mcp/server/streamable_http.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -529,7 +529,7 @@ async def _handle_request(self, scope: Scope, receive: Receive, send: Send) -> N
529529
def _check_content_type(self, request: Request) -> bool:
530530
"""Check if the request has the correct Content-Type."""
531531
content_type = request.headers.get("content-type", "")
532-
content_type_parts = [part.strip() for part in content_type.split(";")[0].split(",")]
532+
content_type_parts = [part.strip().lower() for part in content_type.split(";")[0].split(",")]
533533

534534
return any(part == CONTENT_TYPE_JSON for part in content_type_parts)
535535

‎tests/interaction/transports/test_hosting_http.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,25 @@ async def test_non_json_content_type_is_rejected() -> None:
131131
assert (response.status_code, response.text) == snapshot((400, "Invalid Content-Type header"))
132132

133133

134+
@requirement("hosting:http:content-type-415")
135+
async def test_mixed_case_json_content_type_is_accepted() -> None:
136+
"""Media types are case-insensitive (RFC 9110 §8.3.1): a mixed-case JSON
137+
Content-Type must pass the transport check like its siblings do."""
138+
async with mounted_app(_server()) as (http, _):
139+
for value in (
140+
"application/json",
141+
"Application/JSON",
142+
"APPLICATION/JSON",
143+
"Application/JSON; Charset=UTF-8",
144+
):
145+
response = await http.post("/mcp", json=initialize_body(), headers=base_headers() | {"content-type": value})
146+
assert response.status_code == 200, value
147+
refused = await http.post(
148+
"/mcp", content=b"<not-json/>", headers=base_headers() | {"content-type": "text/plain"}
149+
)
150+
assert refused.status_code == 400
151+
152+
134153
@requirement("hosting:http:parse-error-400")
135154
@requirement("hosting:http:batch")
136155
async def test_malformed_and_batched_bodies_return_400() -> None:
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Direct unit tests for StreamableHTTPServerTransport._check_content_type.
2+
3+
Covers the transport-level 415 path (pragma: no cover through the public
4+
entry, since the security middleware rejects non-JSON first) for
5+
modelcontextprotocol/python-sdk#3670. Media types are case-insensitive
6+
(RFC 9110 §8.3.1).
7+
"""
8+
9+
import pytest
10+
from starlette.requests import Request
11+
12+
from mcp.server.streamable_http import StreamableHTTPServerTransport
13+
14+
15+
def make_transport() -> StreamableHTTPServerTransport:
16+
# _check_content_type touches no instance state; bypass __init__.
17+
return StreamableHTTPServerTransport.__new__(StreamableHTTPServerTransport)
18+
19+
20+
def make_request(content_type: str) -> Request:
21+
return Request({"type": "http", "headers": [(b"content-type", content_type.encode())]})
22+
23+
24+
@pytest.mark.parametrize(
25+
"value",
26+
[
27+
"application/json",
28+
"Application/JSON",
29+
"APPLICATION/JSON",
30+
"Application/JSON; Charset=UTF-8",
31+
"application/json; charset=utf-8",
32+
],
33+
)
34+
def test_check_content_type_accepts_json_case_insensitive(value: str) -> None:
35+
assert make_transport()._check_content_type(make_request(value)) is True
36+
37+
38+
@pytest.mark.parametrize("value", ["", "text/plain", "application/json-patch+json", "text/html; charset=utf-8"])
39+
def test_check_content_type_rejects_non_json(value: str) -> None:
40+
assert make_transport()._check_content_type(make_request(value)) is False

0 commit comments

Comments
 (0)