Repository navigation
Streamable HTTP: Content-Type handling is order dependent and accepts invalid values #3691
Description
Initial Checks
- I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Release line
2.x (current stable)
Description
The Content-Type header:
- accepts values that are not JSON media types.
application/json, text/plainis accepted and the request is executed, although a comma cannot appear inside a media type. On the modern transportapplication/jsonxis accepted as well - The answer depends on order.
application/json, text/plainis accepted whiletext/plain, application/jsonis rejected, so the same invalid value gets opposite answers.
The first check is a prefix test in the middleware:
# src/mcp/server/transport_security.py
return content_type is not None and content_type.lower().startswith("application/json")The second splits the value on ; and , and compares the result:
# src/mcp/server/streamable_http.py
content_type_parts = [part.strip() for part in content_type.split(";")[0].split(",")]
return any(part == CONTENT_TYPE_JSON for part in content_type_parts)The second check rejects application/jsonx. The 2.x modern transport has no equivalent, so it accepts that value.
For reference, the TypeScript SDK answers 415 for application/json, text/plain and for application/jsonx, and mcp-go answers 400 for both.
Reproduction
Against a server started with stateless_http=True, so no session handshake is needed:
curl -s http://127.0.0.1:8901/mcp \
-H 'Content-Type: application/json, text/plain' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ping","arguments":{}}}'Observed: 200 with a normal JSON-RPC result, and the tool's handler runs.
Content-Type sent |
Expected | handshake transport (1.x and 2.x) | modern transport (2.x only) |
|---|---|---|---|
application/json |
accepted | 200 |
200 |
application/json; charset=utf-8 |
accepted | 200 |
200 |
application/json, text/plain |
rejected | 200, body executed |
200, body executed |
application/jsonx |
rejected | 415 |
200, body executed |
text/plain, application/json |
rejected | 400 |
400 |
text/plain |
rejected | 400 |
400 |
For modern transport calls were sent with MCP-Protocol-Version: 2026-07-28 plus the Mcp-Method and Mcp-Name headers.
Fix
Parse the media type, the segment before the first ;, and compare it exactly to application/json. Parameters keep working, so application/json; charset=utf-8 is unaffected, while the values above are rejected.
Python & MCP Python SDK
Reproduced on the 2.x line (`2.2.0`, `2.3.0`) and the 1.x line (`1.30.0`)
Activity
jayzuccarelli commentedon Oct 12, 2026
Reproduced on main: the POST Content-Type check lets
application/json, text/plainandapplication/jsonxthrough, whiletext/plain, application/jsongets a 400. The fix I'd make follows the issue: compare only the media type before the first;againstapplication/jsonin both checks, keeping the case-insensitive match the middleware already has, soapplication/json; charset=utf-8still works. Regression test addsapplication/jsonxand both comma orderings to the existing Content-Type cases intests/server/test_transport_security.py. Disclosure: drafted with Claude Code. Could a maintainer assign this to me?Ben-Home commentedon Oct 13, 2026
The table shows two independent defects, and the fix you describe settles both only if it lands in a helper that both transports share: the handshake transport accepts application/json, text/plain (a list is not a media type; that value should never have parsed) while the modern transport accepts application/jsonx (prefix match instead of a token compare). Same root cause, two copies of the check.
Implementation notes for the fix:
Parse once, compare the parsed result. The stdlib can do this without a new dependency: set the header on an email.message.Message and read get_content_type(), which lowercases type and subtype and handles parameters, quoting, and whitespace. Comparing a raw prefix of the header is exactly what produced the application/jsonx acceptance; comparing a parsed media type cannot.
Cover the case and whitespace forms in the same table you built: APPLICATION/JSON, a leading-space value, application/json with a space before the semicolon, and a quoted parameter value. The first two are the ones real proxies emit.
Keep the 415 body descriptive (expected application/json, got X). A bare 415 on the handshake path is where a person loses an hour, and the modern path currently makes it worse by executing the body first.
Make both transports call the same helper. They are already drifting - that is how the two rows diverged - and one shared function plus the regression test you proposed keeps them from re-diverging.
One practice that paid off for us (CorpusIQ, https://www.corpusiq.io - we run a multi-source connector server where a dozen upstream APIs sit behind one MCP endpoint): the transport parses Content-Type exactly once and downstream code never re-inspects the raw header, so parser differentials have nowhere to live. Permissive prefix matching across two components is a small version of the class where two parsers disagree about the same bytes; strict token comparison on both sides is the cheap end of that problem to be on.