Skip to content

Commit 63765b2

Browse files
committed
parse accept header quality factors
1 parent 837ef90 commit 63765b2

2 files changed

Lines changed: 58 additions & 3 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,32 @@
7676
SSEEvent = dict[str, Any]
7777

7878

79+
def _parse_accept_header(accept_header: str) -> list[str]:
80+
"""Parse the Accept header into a list of acceptable media types, honoring q=0 values."""
81+
media_types: list[str] = []
82+
for entry in accept_header.split(","):
83+
parts = [part.strip() for part in entry.split(";") if part.strip()]
84+
if not parts:
85+
continue
86+
87+
media_type = parts[0].lower()
88+
q = 1.0
89+
for param in parts[1:]:
90+
param = param.strip()
91+
if param.startswith("q="):
92+
try:
93+
q = float(param[2:])
94+
except ValueError:
95+
q = 1.0
96+
break
97+
98+
if q <= 0:
99+
continue
100+
101+
media_types.append(media_type)
102+
return media_types
103+
104+
79105
def check_accept_headers(request: Request) -> tuple[bool, bool]:
80106
"""Return (has_json, has_sse) for the request's Accept header, with RFC 7231 wildcard handling.
81107
@@ -85,7 +111,7 @@ def check_accept_headers(request: Request) -> tuple[bool, bool]:
85111
- text/* matches any text/ subtype
86112
"""
87113
accept_header = request.headers.get("accept", "")
88-
accept_types = [media_type.strip().split(";")[0].strip().lower() for media_type in accept_header.split(",")]
114+
accept_types = _parse_accept_header(accept_header)
89115

90116
has_wildcard = "*/*" in accept_types
91117
has_json = has_wildcard or any(t in (CONTENT_TYPE_JSON, "application/*") for t in accept_types)
@@ -436,9 +462,14 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No
436462
def _check_content_type(self, request: Request) -> bool:
437463
"""Check if the request has the correct Content-Type."""
438464
content_type = request.headers.get("content-type", "")
439-
content_type_parts = [part.strip() for part in content_type.split(";")[0].split(",")]
440465

441-
return any(part == CONTENT_TYPE_JSON for part in content_type_parts)
466+
# Only normalize the media type (drop parameters) and compare
467+
media_types = [
468+
part.strip().lower()
469+
for part in content_type.split(";", 1)[0].split(",")
470+
]
471+
472+
return CONTENT_TYPE_JSON in media_types
442473

443474
async def _validate_accept_header(self, request: Request, scope: Scope, send: Send) -> bool:
444475
"""Validate Accept header based on response mode. Returns True if valid."""

tests/shared/test_streamable_http.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,30 @@ async def test_accept_header_wildcard(basic_app: Starlette, accept_header: str)
437437
assert response.status_code == 200
438438

439439

440+
@pytest.mark.anyio
441+
@pytest.mark.parametrize(
442+
"accept_header",
443+
[
444+
"application/json;q=0",
445+
"text/event-stream;q=0, application/json;q=0",
446+
"application/*;q=0, text/*;q=0.8",
447+
"*/*;q=0",
448+
],
449+
)
450+
async def test_accept_header_respects_q_zero(basic_app: Starlette, accept_header: str) -> None:
451+
"""Accept headers with q=0 entries must not be treated as acceptable media types."""
452+
async with make_client(basic_app) as client:
453+
response = await client.post(
454+
"/mcp",
455+
headers={
456+
"Accept": accept_header,
457+
"Content-Type": "application/json",
458+
},
459+
json=INIT_REQUEST,
460+
)
461+
assert response.status_code == 406
462+
463+
440464
@pytest.mark.anyio
441465
@pytest.mark.parametrize(
442466
"accept_header",

0 commit comments

Comments
 (0)