33The MCP server was rejecting requests with wildcard Accept headers like `*/*`
44or `application/*`, returning 406 Not Acceptable. Per RFC 9110 Section 12.5.1,
55wildcard media types are valid and should match the required content types.
6- """
76
8- import threading
9- from collections .abc import AsyncGenerator
10- from contextlib import asynccontextmanager
7+ These tests verify the `_check_accept_headers` method directly, ensuring
8+ wildcard media types are properly matched against the required content types
9+ (application/json and text/event-stream).
10+ """
1111
12- import anyio
13- import httpx
1412import 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- # Suppress warnings from unclosed MemoryObjectReceiveStream in stateless transport mode
25- # (pre-existing issue, not related to the Accept header fix)
26- pytestmark = [
27- pytest .mark .filterwarnings ("ignore::ResourceWarning" ),
28- pytest .mark .filterwarnings ("ignore::pytest.PytestUnraisableExceptionWarning" ),
29- ]
30-
31- INIT_REQUEST = {
32- "jsonrpc" : "2.0" ,
33- "method" : "initialize" ,
34- "id" : "init-1" ,
35- "params" : {
36- "clientInfo" : {"name" : "test-client" , "version" : "1.0" },
37- "protocolVersion" : "2025-03-26" ,
38- "capabilities" : {},
39- },
40- }
41-
42-
43- class SimpleServer (Server ):
44- def __init__ (self ):
45- super ().__init__ (SERVER_NAME )
46-
47- @self .list_tools ()
48- async def handle_list_tools () -> list [Tool ]: # pragma: no cover
49- return []
50-
13+ from starlette .requests import Request
5114
52- def create_app (json_response : bool = False ) -> Starlette :
53- server = SimpleServer ()
54- session_manager = StreamableHTTPSessionManager (
55- app = server ,
56- json_response = json_response ,
57- stateless = True ,
58- )
59-
60- @asynccontextmanager
61- async def lifespan (app : Starlette ) -> AsyncGenerator [None , None ]:
62- async with session_manager .run ():
63- yield
64-
65- routes = [Mount ("/" , app = session_manager .handle_request )]
66- return Starlette (routes = routes , lifespan = lifespan )
67-
68-
69- class ServerThread (threading .Thread ):
70- def __init__ (self , app : Starlette ):
71- super ().__init__ (daemon = True )
72- self .app = app
73- self ._stop_event = threading .Event ()
15+ from mcp .server .streamable_http import StreamableHTTPServerTransport
7416
75- def run (self ) -> None :
76- async def run_lifespan ():
77- lifespan_context = getattr (self .app .router , "lifespan_context" , None )
78- assert lifespan_context is not None
79- async with lifespan_context (self .app ):
80- while not self ._stop_event .is_set ():
81- await anyio .sleep (0.1 )
8217
83- try :
84- anyio .run (run_lifespan )
85- except BaseException : # pragma: no cover
86- # Suppress cleanup exceptions (e.g., ResourceWarning from
87- # unclosed streams in stateless transport mode)
88- pass
89-
90- def stop (self ) -> None :
91- self ._stop_event .set ()
18+ def _make_request (accept : str ) -> Request :
19+ """Create a minimal Request with the given Accept header."""
20+ scope = {
21+ "type" : "http" ,
22+ "method" : "POST" ,
23+ "headers" : [(b"accept" , accept .encode ())],
24+ }
25+ return Request (scope )
9226
9327
9428@pytest .mark .anyio
9529async def test_accept_wildcard_star_star_json_mode ():
96- """Accept: */* should be accepted in JSON response mode."""
97- app = create_app (json_response = True )
98- server_thread = ServerThread (app )
99- server_thread .start ()
100-
101- try :
102- await anyio .sleep (0.2 )
103- async with httpx .AsyncClient (
104- transport = httpx .ASGITransport (app = app ),
105- base_url = "http://testserver" ,
106- ) as client :
107- response = await client .post (
108- "/" ,
109- json = INIT_REQUEST ,
110- headers = {"Accept" : "*/*" , "Content-Type" : "application/json" },
111- )
112- assert response .status_code == 200
113- finally :
114- server_thread .stop ()
115- server_thread .join (timeout = 2 )
30+ """Accept: */* should satisfy application/json requirement."""
31+ transport = StreamableHTTPServerTransport (
32+ mcp_session_id = None ,
33+ is_json_response_enabled = True ,
34+ )
35+ request = _make_request ("*/*" )
36+ has_json , has_sse = transport ._check_accept_headers (request )
37+ assert has_json , "*/* should match application/json"
38+ assert has_sse , "*/* should match text/event-stream"
11639
11740
11841@pytest .mark .anyio
11942async def test_accept_wildcard_star_star_sse_mode ():
120- """Accept: */* should be accepted in SSE response mode (satisfies both JSON and SSE)."""
121- app = create_app (json_response = False )
122- server_thread = ServerThread (app )
123- server_thread .start ()
124-
125- try :
126- await anyio .sleep (0.2 )
127- async with httpx .AsyncClient (
128- transport = httpx .ASGITransport (app = app ),
129- base_url = "http://testserver" ,
130- ) as client :
131- response = await client .post (
132- "/" ,
133- json = INIT_REQUEST ,
134- headers = {"Accept" : "*/*" , "Content-Type" : "application/json" },
135- )
136- assert response .status_code == 200
137- finally :
138- server_thread .stop ()
139- server_thread .join (timeout = 2 )
43+ """Accept: */* should satisfy both JSON and SSE requirements."""
44+ transport = StreamableHTTPServerTransport (
45+ mcp_session_id = None ,
46+ is_json_response_enabled = False ,
47+ )
48+ request = _make_request ("*/*" )
49+ has_json , has_sse = transport ._check_accept_headers (request )
50+ assert has_json , "*/* should match application/json"
51+ assert has_sse , "*/* should match text/event-stream"
14052
14153
14254@pytest .mark .anyio
14355async def test_accept_application_wildcard ():
144- """Accept: application/* should satisfy the application/json requirement."""
145- app = create_app (json_response = True )
146- server_thread = ServerThread (app )
147- server_thread .start ()
148-
149- try :
150- await anyio .sleep (0.2 )
151- async with httpx .AsyncClient (
152- transport = httpx .ASGITransport (app = app ),
153- base_url = "http://testserver" ,
154- ) as client :
155- response = await client .post (
156- "/" ,
157- json = INIT_REQUEST ,
158- headers = {"Accept" : "application/*" , "Content-Type" : "application/json" },
159- )
160- assert response .status_code == 200
161- finally :
162- server_thread .stop ()
163- server_thread .join (timeout = 2 )
56+ """Accept: application/* should satisfy application/json but not text/event-stream."""
57+ transport = StreamableHTTPServerTransport (
58+ mcp_session_id = None ,
59+ is_json_response_enabled = True ,
60+ )
61+ request = _make_request ("application/*" )
62+ has_json , has_sse = transport ._check_accept_headers (request )
63+ assert has_json , "application/* should match application/json"
64+ assert not has_sse , "application/* should NOT match text/event-stream"
16465
16566
16667@pytest .mark .anyio
16768async def test_accept_text_wildcard_with_json ():
168- """Accept: application/json, text/* should satisfy both requirements in SSE mode.
169-
170- Tests the Accept header parsing directly to verify text/* matches
171- text/event-stream. A full HTTP round-trip in SSE mode is not used because
172- EventSourceResponse behavior varies across sse-starlette versions.
173- """
174- from starlette .requests import Request
175-
176- from mcp .server .streamable_http import StreamableHTTPServerTransport
177-
69+ """Accept: application/json, text/* should satisfy both requirements in SSE mode."""
17870 transport = StreamableHTTPServerTransport (
17971 mcp_session_id = None ,
18072 is_json_response_enabled = False ,
18173 )
182- scope = {
183- "type" : "http" ,
184- "method" : "POST" ,
185- "headers" : [(b"accept" , b"application/json, text/*" )],
186- }
187- request = Request (scope )
74+ request = _make_request ("application/json, text/*" )
18875 has_json , has_sse = transport ._check_accept_headers (request )
18976 assert has_json , "application/json should match JSON content type"
19077 assert has_sse , "text/* should match text/event-stream"
@@ -193,71 +80,76 @@ async def test_accept_text_wildcard_with_json():
19380@pytest .mark .anyio
19481async def test_accept_wildcard_with_quality_parameter ():
19582 """Accept: */*;q=0.8 should be accepted (quality parameters stripped before matching)."""
196- app = create_app (json_response = True )
197- server_thread = ServerThread (app )
198- server_thread .start ()
199-
200- try :
201- await anyio .sleep (0.2 )
202- async with httpx .AsyncClient (
203- transport = httpx .ASGITransport (app = app ),
204- base_url = "http://testserver" ,
205- ) as client :
206- response = await client .post (
207- "/" ,
208- json = INIT_REQUEST ,
209- headers = {"Accept" : "*/*;q=0.8" , "Content-Type" : "application/json" },
210- )
211- assert response .status_code == 200
212- finally :
213- server_thread .stop ()
214- server_thread .join (timeout = 2 )
83+ transport = StreamableHTTPServerTransport (
84+ mcp_session_id = None ,
85+ is_json_response_enabled = True ,
86+ )
87+ request = _make_request ("*/*;q=0.8" )
88+ has_json , has_sse = transport ._check_accept_headers (request )
89+ assert has_json , "*/*;q=0.8 should match application/json after stripping quality"
90+ assert has_sse , "*/*;q=0.8 should match text/event-stream after stripping quality"
21591
21692
21793@pytest .mark .anyio
21894async def test_accept_invalid_still_rejected ():
219- """Accept: text/plain should still be rejected with 406."""
220- app = create_app (json_response = True )
221- server_thread = ServerThread (app )
222- server_thread .start ()
95+ """Accept: text/plain should not match JSON or SSE content types."""
96+ transport = StreamableHTTPServerTransport (
97+ mcp_session_id = None ,
98+ is_json_response_enabled = True ,
99+ )
100+ request = _make_request ("text/plain" )
101+ has_json , has_sse = transport ._check_accept_headers (request )
102+ assert not has_json , "text/plain should NOT match application/json"
103+ assert not has_sse , "text/plain should NOT match text/event-stream"
104+
105+
106+ @pytest .mark .anyio
107+ async def test_accept_partial_wildcard_sse_mode ():
108+ """Accept: application/* alone should not satisfy SSE requirement."""
109+ transport = StreamableHTTPServerTransport (
110+ mcp_session_id = None ,
111+ is_json_response_enabled = False ,
112+ )
113+ request = _make_request ("application/*" )
114+ has_json , has_sse = transport ._check_accept_headers (request )
115+ assert has_json , "application/* should match application/json"
116+ assert not has_sse , "application/* should NOT match text/event-stream"
223117
224- try :
225- await anyio .sleep (0.2 )
226- async with httpx .AsyncClient (
227- transport = httpx .ASGITransport (app = app ),
228- base_url = "http://testserver" ,
229- ) as client :
230- response = await client .post (
231- "/" ,
232- json = INIT_REQUEST ,
233- headers = {"Accept" : "text/plain" , "Content-Type" : "application/json" },
234- )
235- assert response .status_code == 406
236- finally :
237- server_thread .stop ()
238- server_thread .join (timeout = 2 )
118+
119+ @pytest .mark .anyio
120+ async def test_accept_explicit_types ():
121+ """Accept: application/json, text/event-stream should match both explicitly."""
122+ transport = StreamableHTTPServerTransport (
123+ mcp_session_id = None ,
124+ is_json_response_enabled = False ,
125+ )
126+ request = _make_request ("application/json, text/event-stream" )
127+ has_json , has_sse = transport ._check_accept_headers (request )
128+ assert has_json , "application/json should match"
129+ assert has_sse , "text/event-stream should match"
239130
240131
241132@pytest .mark .anyio
242- async def test_accept_partial_wildcard_sse_mode_rejected ():
243- """Accept: application/* alone should be rejected in SSE mode (missing text/event-stream)."""
244- app = create_app (json_response = False )
245- server_thread = ServerThread (app )
246- server_thread .start ()
133+ async def test_accept_text_wildcard_alone ():
134+ """Accept: text/* alone should match SSE but not JSON."""
135+ transport = StreamableHTTPServerTransport (
136+ mcp_session_id = None ,
137+ is_json_response_enabled = False ,
138+ )
139+ request = _make_request ("text/*" )
140+ has_json , has_sse = transport ._check_accept_headers (request )
141+ assert not has_json , "text/* should NOT match application/json"
142+ assert has_sse , "text/* should match text/event-stream"
247143
248- try :
249- await anyio .sleep (0.2 )
250- async with httpx .AsyncClient (
251- transport = httpx .ASGITransport (app = app ),
252- base_url = "http://testserver" ,
253- ) as client :
254- response = await client .post (
255- "/" ,
256- json = INIT_REQUEST ,
257- headers = {"Accept" : "application/*" , "Content-Type" : "application/json" },
258- )
259- # application/* matches JSON but not SSE, should be rejected
260- assert response .status_code == 406
261- finally :
262- server_thread .stop ()
263- server_thread .join (timeout = 2 )
144+
145+ @pytest .mark .anyio
146+ async def test_accept_multiple_quality_parameters ():
147+ """Multiple types with quality parameters should all be parsed correctly."""
148+ transport = StreamableHTTPServerTransport (
149+ mcp_session_id = None ,
150+ is_json_response_enabled = False ,
151+ )
152+ request = _make_request ("application/json;q=1.0, text/event-stream;q=0.9" )
153+ has_json , has_sse = transport ._check_accept_headers (request )
154+ assert has_json , "application/json;q=1.0 should match after stripping quality"
155+ assert has_sse , "text/event-stream;q=0.9 should match after stripping quality"
0 commit comments