Repository navigation
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
A tools/call whose Mcp-Name header uses the =?base64?...?= form with a non-ASCII byte in the payload gets a 500. It is one failed request, and the server keeps running.
The docstring on decode_header_value says "A malformed sentinel (bad base64, non-canonical base64, or bad UTF-8) yields None". None should become a HEADER_MISMATCH rejection.
It catches only binascii.Error, but b64decode raises a plain ValueError for non-ASCII text:
python-sdk/src/mcp/shared/inbound.py
Lines 177 to 180 in af4c6f4
| try: | |
| decoded = base64.b64decode(payload, validate=True) | |
| except binascii.Error: | |
| return None |
The modern POST handler does not catch it:
| verdict = classify_inbound_request(decoded, headers=dict(request.headers)) |
The Mcp-Param-* check calls the same function, from reading the code:
python-sdk/src/mcp/shared/inbound.py
Line 584 in af4c6f4
| decoded = decode_header_value(raw) |
except ValueError: fixes it, since binascii.Error is a subclass.
Results on 2.3.0, with the script below. inbound.py is unchanged on main at af4c6f4.
b'echo' 200
b'=?base64?ZWNobw==?=' 200
b'=?base64?\xe9?=' 500
Example Code
import anyio, httpx2 as httpx
from mcp.server.mcpserver import MCPServer
from mcp_types.version import LATEST_MODERN_VERSION
mcp = MCPServer("demo")
@mcp.tool()
def echo(text: str) -> str:
return text
async def main():
app = mcp.streamable_http_app()
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "echo", "arguments": {"text": "hi"},
"_meta": {"io.modelcontextprotocol/protocolVersion": LATEST_MODERN_VERSION,
"io.modelcontextprotocol/clientCapabilities": {}}}}
async with app.router.lifespan_context(app):
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as c:
for name in [b"echo", b"=?base64?ZWNobw==?=", b"=?base64?\xe9?="]:
r = await c.post("/mcp", json=body, headers=[(b"mcp-protocol-version", LATEST_MODERN_VERSION.encode()),
(b"accept", b"application/json, text/event-stream"), (b"mcp-method", b"tools/call"), (b"mcp-name", name)])
print(name, r.status_code)
anyio.run(main)Python & MCP Python SDK
Python 3.12.12, MCP Python SDK 2.3.0
AI disclosure
refuzz, an LLM-driven fuzzer I built (Meta Muse Spark), found this bug. Claude Code wrote this report and ran the reproduction on 2.3.0.
Activity
media-ctrl commentedon Nov 5, 2026
Confirmed the exception split on CPython 3.14.9, and it is narrower than the report suggests in a way that decides where the fix belongs.
binascii.Erroris a subclass ofValueError(issubclass(binascii.Error, ValueError)is True), soexcept ValueErrorcovers the alphabet and padding failures as well. Measured with the exact call the function makes,b64decode(payload, validate=True):'ZWNobw'->binascii.Error: Incorrect padding'!!!!'->binascii.Error: Only base64 data is allowedb'\xe9'->binascii.Error: Only base64 data is allowed(already caught today)'\xe9'as str ->ValueError: string argument should contain only ASCII characters(the escape)So the same corrupt header is a
HEADER_MISMATCHor a 500 depending only on the type of the argument. The mechanism is worth naming in the patch: for a str payload the failure happens in_bytes_from_decode_data, at the ASCII encode, which runs beforevalidate=Trueever inspects the alphabet.The str form is the contract, not an edge case. The signature at line 162 is
decode_header_value(value: str | None),payloadcomes out of_B64_SENTINEL.fullmatchas a str, and ASGI hands the handler latin-1 decoded header text, so a byte at or above 0x80 arrives as U+0080 to U+00FF and takes the uncaught path._streamable_http_modern.py:436passesdict(request.headers)straight intoclassify_inbound_request, and Starlette header values are str.Placing the fix inside
decode_header_valuerather than at the POST handler is right for a second reason: the function is exported (__all__, line 50), and the body-value comparison at line 467 calls it on a client-controlled header just as directly as theMcp-Param-*check at line 584. Those are the only two call sites and both reach it with str, so one change covers both and stops third-party importers getting an exception out of a function whose docstring promisesNone.Three things to put in the patch: the
except ValueError; a test that feeds the str form"=?base64?\xe9?="and assertsis None, since the byte form already passes and would not catch a regression; and a decision on whether a non-ASCII payload should be rejected before decode at all, because after the catch a corrupt and an empty payload are indistinguishable to every caller.One detail that needs no change: no message edit is required. Once the exception reaches the existing
return None, the caller already emitsHEADER_MISMATCHwith "carries a malformed base64 sentinel value", which is the right code for this case.techaurah commentedon Nov 5, 2026
Hi, we are TechAura. We can reproduce this and implement the scoped fix in decode_header_value — catching ValueError so a non-ASCII base64 sentinel returns None and becomes a HEADER_MISMATCH rejection instead of a 500 — with a test for the string form. Could a maintainer please assign this issue to @techaurah? We will open a PR only after assignment.