Skip to content

Commit acc62be

Browse files
author
skyvanguard
committed
fix: support wildcard media types in Accept header validation
The server rejected requests with wildcard Accept headers like `*/*`, `application/*`, or `text/*`, returning 406 Not Acceptable. Per RFC 9110 Section 12.5.1, these wildcard media types are valid and should match the required content types. This affected clients that send `Accept: */*` (the default for many HTTP libraries including python-httpx), making the server non-compliant with the HTTP specification. Changes: - `*/*` now satisfies both application/json and text/event-stream - `application/*` satisfies application/json - `text/*` satisfies text/event-stream - Quality parameters (`;q=0.9`) are stripped before matching Github-Issue: #1641 Reported-by: rh-fr
1 parent a7ddfda commit acc62be

File tree

2 files changed

+270
-4
lines changed

2 files changed

+270
-4
lines changed

src/mcp/server/streamable_http.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -389,12 +389,25 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No
389389
await self._handle_unsupported_request(request, send)
390390

391391
def _check_accept_headers(self, request: Request) -> tuple[bool, bool]:
392-
"""Check if the request accepts the required media types."""
392+
"""Check if the request accepts the required media types.
393+
394+
Supports wildcard media types per RFC 9110 Section 12.5.1:
395+
- */* matches any media type
396+
- application/* matches any application subtype (e.g., application/json)
397+
- text/* matches any text subtype (e.g., text/event-stream)
398+
"""
393399
accept_header = request.headers.get("accept", "")
394-
accept_types = [media_type.strip() for media_type in accept_header.split(",")]
400+
# Strip quality parameters (e.g., ";q=0.9") before matching
401+
accept_types = [media_type.strip().split(";")[0].strip() for media_type in accept_header.split(",")]
395402

396-
has_json = any(media_type.startswith(CONTENT_TYPE_JSON) for media_type in accept_types)
397-
has_sse = any(media_type.startswith(CONTENT_TYPE_SSE) for media_type in accept_types)
403+
has_json = any(
404+
media_type.startswith(CONTENT_TYPE_JSON) or media_type in {"*/*", "application/*"}
405+
for media_type in accept_types
406+
)
407+
has_sse = any(
408+
media_type.startswith(CONTENT_TYPE_SSE) or media_type in {"*/*", "text/*"}
409+
for media_type in accept_types
410+
)
398411

399412
return has_json, has_sse
400413

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
"""Test for issue #1641 - Accept header wildcard support.
2+
3+
The MCP server was rejecting requests with wildcard Accept headers like `*/*`
4+
or `application/*`, returning 406 Not Acceptable. Per RFC 9110 Section 12.5.1,
5+
wildcard media types are valid and should match the required content types.
6+
"""
7+
8+
import threading
9+
from collections.abc import AsyncGenerator
10+
from contextlib import asynccontextmanager
11+
12+
import anyio
13+
import httpx
14+
import pytest
15+
from starlette.applications import Starlette
16+
from starlette.routing import Mount
17+
18+
from mcp.server import Server
19+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
20+
from mcp.types import Tool
21+
22+
SERVER_NAME = "test_accept_wildcard_server"
23+
24+
INIT_REQUEST = {
25+
"jsonrpc": "2.0",
26+
"method": "initialize",
27+
"id": "init-1",
28+
"params": {
29+
"clientInfo": {"name": "test-client", "version": "1.0"},
30+
"protocolVersion": "2025-03-26",
31+
"capabilities": {},
32+
},
33+
}
34+
35+
36+
class SimpleServer(Server):
37+
def __init__(self):
38+
super().__init__(SERVER_NAME)
39+
40+
@self.list_tools()
41+
async def handle_list_tools() -> list[Tool]:
42+
return []
43+
44+
45+
def create_app(json_response: bool = False) -> Starlette:
46+
server = SimpleServer()
47+
session_manager = StreamableHTTPSessionManager(
48+
app=server,
49+
json_response=json_response,
50+
stateless=True,
51+
)
52+
53+
@asynccontextmanager
54+
async def lifespan(app: Starlette) -> AsyncGenerator[None, None]:
55+
async with session_manager.run():
56+
yield
57+
58+
routes = [Mount("/", app=session_manager.handle_request)]
59+
return Starlette(routes=routes, lifespan=lifespan)
60+
61+
62+
class ServerThread(threading.Thread):
63+
def __init__(self, app: Starlette):
64+
super().__init__(daemon=True)
65+
self.app = app
66+
self._stop_event = threading.Event()
67+
68+
def run(self) -> None:
69+
async def run_lifespan():
70+
lifespan_context = getattr(self.app.router, "lifespan_context", None)
71+
assert lifespan_context is not None
72+
async with lifespan_context(self.app):
73+
while not self._stop_event.is_set():
74+
await anyio.sleep(0.1)
75+
76+
anyio.run(run_lifespan)
77+
78+
def stop(self) -> None:
79+
self._stop_event.set()
80+
81+
82+
@pytest.mark.anyio
83+
async def test_accept_wildcard_star_star_json_mode():
84+
"""Accept: */* should be accepted in JSON response mode."""
85+
app = create_app(json_response=True)
86+
server_thread = ServerThread(app)
87+
server_thread.start()
88+
89+
try:
90+
await anyio.sleep(0.2)
91+
async with httpx.AsyncClient(
92+
transport=httpx.ASGITransport(app=app),
93+
base_url="http://testserver",
94+
) as client:
95+
response = await client.post(
96+
"/",
97+
json=INIT_REQUEST,
98+
headers={"Accept": "*/*", "Content-Type": "application/json"},
99+
)
100+
assert response.status_code == 200
101+
finally:
102+
server_thread.stop()
103+
server_thread.join(timeout=2)
104+
105+
106+
@pytest.mark.anyio
107+
async def test_accept_wildcard_star_star_sse_mode():
108+
"""Accept: */* should be accepted in SSE response mode (satisfies both JSON and SSE)."""
109+
app = create_app(json_response=False)
110+
server_thread = ServerThread(app)
111+
server_thread.start()
112+
113+
try:
114+
await anyio.sleep(0.2)
115+
async with httpx.AsyncClient(
116+
transport=httpx.ASGITransport(app=app),
117+
base_url="http://testserver",
118+
) as client:
119+
response = await client.post(
120+
"/",
121+
json=INIT_REQUEST,
122+
headers={"Accept": "*/*", "Content-Type": "application/json"},
123+
)
124+
assert response.status_code == 200
125+
finally:
126+
server_thread.stop()
127+
server_thread.join(timeout=2)
128+
129+
130+
@pytest.mark.anyio
131+
@pytest.mark.filterwarnings("ignore::ResourceWarning")
132+
async def test_accept_application_wildcard():
133+
"""Accept: application/* should satisfy the application/json requirement."""
134+
app = create_app(json_response=True)
135+
server_thread = ServerThread(app)
136+
server_thread.start()
137+
138+
try:
139+
await anyio.sleep(0.2)
140+
async with httpx.AsyncClient(
141+
transport=httpx.ASGITransport(app=app),
142+
base_url="http://testserver",
143+
) as client:
144+
response = await client.post(
145+
"/",
146+
json=INIT_REQUEST,
147+
headers={"Accept": "application/*", "Content-Type": "application/json"},
148+
)
149+
assert response.status_code == 200
150+
finally:
151+
server_thread.stop()
152+
server_thread.join(timeout=2)
153+
154+
155+
@pytest.mark.anyio
156+
async def test_accept_text_wildcard_with_json():
157+
"""Accept: application/json, text/* should satisfy both requirements in SSE mode."""
158+
app = create_app(json_response=False)
159+
server_thread = ServerThread(app)
160+
server_thread.start()
161+
162+
try:
163+
await anyio.sleep(0.2)
164+
async with httpx.AsyncClient(
165+
transport=httpx.ASGITransport(app=app),
166+
base_url="http://testserver",
167+
) as client:
168+
response = await client.post(
169+
"/",
170+
json=INIT_REQUEST,
171+
headers={
172+
"Accept": "application/json, text/*",
173+
"Content-Type": "application/json",
174+
},
175+
)
176+
assert response.status_code == 200
177+
finally:
178+
server_thread.stop()
179+
server_thread.join(timeout=2)
180+
181+
182+
@pytest.mark.anyio
183+
async def test_accept_wildcard_with_quality_parameter():
184+
"""Accept: */*;q=0.8 should be accepted (quality parameters stripped before matching)."""
185+
app = create_app(json_response=True)
186+
server_thread = ServerThread(app)
187+
server_thread.start()
188+
189+
try:
190+
await anyio.sleep(0.2)
191+
async with httpx.AsyncClient(
192+
transport=httpx.ASGITransport(app=app),
193+
base_url="http://testserver",
194+
) as client:
195+
response = await client.post(
196+
"/",
197+
json=INIT_REQUEST,
198+
headers={"Accept": "*/*;q=0.8", "Content-Type": "application/json"},
199+
)
200+
assert response.status_code == 200
201+
finally:
202+
server_thread.stop()
203+
server_thread.join(timeout=2)
204+
205+
206+
@pytest.mark.anyio
207+
@pytest.mark.filterwarnings("ignore::ResourceWarning")
208+
async def test_accept_invalid_still_rejected():
209+
"""Accept: text/plain should still be rejected with 406."""
210+
app = create_app(json_response=True)
211+
server_thread = ServerThread(app)
212+
server_thread.start()
213+
214+
try:
215+
await anyio.sleep(0.2)
216+
async with httpx.AsyncClient(
217+
transport=httpx.ASGITransport(app=app),
218+
base_url="http://testserver",
219+
) as client:
220+
response = await client.post(
221+
"/",
222+
json=INIT_REQUEST,
223+
headers={"Accept": "text/plain", "Content-Type": "application/json"},
224+
)
225+
assert response.status_code == 406
226+
finally:
227+
server_thread.stop()
228+
server_thread.join(timeout=2)
229+
230+
231+
@pytest.mark.anyio
232+
async def test_accept_partial_wildcard_sse_mode_rejected():
233+
"""Accept: application/* alone should be rejected in SSE mode (missing text/event-stream)."""
234+
app = create_app(json_response=False)
235+
server_thread = ServerThread(app)
236+
server_thread.start()
237+
238+
try:
239+
await anyio.sleep(0.2)
240+
async with httpx.AsyncClient(
241+
transport=httpx.ASGITransport(app=app),
242+
base_url="http://testserver",
243+
) as client:
244+
response = await client.post(
245+
"/",
246+
json=INIT_REQUEST,
247+
headers={"Accept": "application/*", "Content-Type": "application/json"},
248+
)
249+
# application/* matches JSON but not SSE, should be rejected
250+
assert response.status_code == 406
251+
finally:
252+
server_thread.stop()
253+
server_thread.join(timeout=2)

0 commit comments

Comments
 (0)