Skip to content

Commit 6bff0f2

Browse files
author
SpiliosDmk
committed
fix(server): respect Accept header q=0 (RFC 7231 'not acceptable')
check_accept_headers() discarded every Accept-header parameter after the media type, so \�pplication/json;q=0\ (explicitly rejecting JSON) was parsed the same as plain \�pplication/json\ (accepting it). Parse the q value per RFC 7231 §5.3.1 and treat q<=0 as not-acceptable, with a specific type's rejection taking precedence over a broader wildcard. Fixes #3154
1 parent 837ef90 commit 6bff0f2

2 files changed

Lines changed: 91 additions & 5 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,19 +77,56 @@
7777

7878

7979
def check_accept_headers(request: Request) -> tuple[bool, bool]:
80-
"""Return (has_json, has_sse) for the request's Accept header, with RFC 7231 wildcard handling.
80+
"""Return (has_json, has_sse) for the request's Accept header, with RFC 7231 wildcard
81+
and q-value handling.
8182
8283
Supports wildcard media types per RFC 7231, section 5.3.2:
8384
- */* matches any media type
8485
- application/* matches any application/ subtype
8586
- text/* matches any text/ subtype
87+
88+
A media-range weighted q=0 is explicitly "not acceptable" per RFC 7231, section 5.3.1.
89+
An explicit q=0 for a specific type (or its family wildcard) takes precedence over a
90+
broader range that would otherwise accept it — e.g. "application/json;q=0, */*" still
91+
rejects JSON even though */* is also present.
8692
"""
8793
accept_header = request.headers.get("accept", "")
88-
accept_types = [media_type.strip().split(";")[0].strip().lower() for media_type in accept_header.split(",")]
94+
accepted: set[str] = set()
95+
rejected: set[str] = set()
96+
97+
for media_range in accept_header.split(","):
98+
params = [p.strip() for p in media_range.split(";")]
99+
media_type = params[0].lower()
100+
if not media_type:
101+
continue
102+
103+
q = 1.0
104+
for param in params[1:]:
105+
name, _, value = param.partition("=")
106+
if name.strip().lower() == "q":
107+
try:
108+
q = float(value.strip())
109+
except ValueError:
110+
q = 1.0
111+
break
112+
113+
(rejected if q <= 0 else accepted).add(media_type)
114+
115+
def is_acceptable(media_type: str, family_wildcard: str) -> bool:
116+
# Most specific match wins: an exact media type overrides its family wildcard,
117+
# which in turn overrides the global "*/*".
118+
if media_type in accepted:
119+
return True
120+
if media_type in rejected:
121+
return False
122+
if family_wildcard in accepted:
123+
return True
124+
if family_wildcard in rejected:
125+
return False
126+
return "*/*" in accepted
89127

90-
has_wildcard = "*/*" in accept_types
91-
has_json = has_wildcard or any(t in (CONTENT_TYPE_JSON, "application/*") for t in accept_types)
92-
has_sse = has_wildcard or any(t in (CONTENT_TYPE_SSE, "text/*") for t in accept_types)
128+
has_json = is_acceptable(CONTENT_TYPE_JSON, "application/*")
129+
has_sse = is_acceptable(CONTENT_TYPE_SSE, "text/*")
93130

94131
return has_json, has_sse
95132

tests/server/test_streamable_http_modern.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,55 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams |
625625
assert response.status_code == 200
626626

627627

628+
async def test_accept_q0_for_the_required_type_is_not_acceptable() -> None:
629+
"""RFC 7231 5.3.1: a media-range weighted q=0 is explicitly "not acceptable" -- a client
630+
that sends `application/json;q=0` has ruled JSON out even though it's the only other type
631+
on the line, so a JSON-mode request must get 406, not 200."""
632+
633+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
634+
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public") # pragma: no cover
635+
636+
async with _asgi_client(
637+
Server("test", on_list_tools=list_tools),
638+
json_response=True,
639+
accept="application/json;q=0, text/event-stream;q=1.0",
640+
) as http:
641+
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
642+
assert response.status_code == 406
643+
644+
645+
async def test_accept_q0_for_a_specific_type_overrides_a_present_wildcard() -> None:
646+
"""A specific "not acceptable" entry is more specific than `*/*` and must win: the client
647+
is still rejecting JSON even though it also (redundantly) accepts everything."""
648+
649+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
650+
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public") # pragma: no cover
651+
652+
async with _asgi_client(
653+
Server("test", on_list_tools=list_tools),
654+
json_response=True,
655+
accept="application/json;q=0, */*",
656+
) as http:
657+
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
658+
assert response.status_code == 406
659+
660+
661+
async def test_accept_nonzero_q_for_the_required_type_is_still_acceptable() -> None:
662+
"""A low but nonzero weight is still a "yes" -- q only gates acceptability at 0, this SDK
663+
doesn't rank multiple acceptable representations by preference."""
664+
665+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
666+
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
667+
668+
async with _asgi_client(
669+
Server("test", on_list_tools=list_tools),
670+
json_response=True,
671+
accept="application/json;q=0.1",
672+
) as http:
673+
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
674+
assert response.status_code == 200
675+
676+
628677
async def test_late_notify_after_terminal_dropped() -> None:
629678
"""SDK-defined: a `notify()` after the SSE sink has closed is silently dropped — the closed
630679
stream must not propagate as an exception out of the dispatch context."""

0 commit comments

Comments
 (0)