Skip to content

Commit 292622c

Browse files
author
skyvanguard
committed
ci: rewrite accept header tests as unit tests
Replace HTTP round-trip tests with direct unit tests of _check_accept_headers to eliminate cross-event-loop issues that caused PytestUnraisableExceptionWarning on Python 3.14/Windows.
1 parent 160d3bc commit 292622c

File tree

1 file changed

+107
-215
lines changed

1 file changed

+107
-215
lines changed

tests/issues/test_1641_accept_header_wildcard.py

Lines changed: 107 additions & 215 deletions
Original file line numberDiff line numberDiff line change
@@ -3,188 +3,75 @@
33
The MCP server was rejecting requests with wildcard Accept headers like `*/*`
44
or `application/*`, returning 406 Not Acceptable. Per RFC 9110 Section 12.5.1,
55
wildcard 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
1412
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-
# 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
9529
async 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
11942
async 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
14355
async 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
16768
async 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
19481
async 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
21894
async 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

Comments
 (0)