gabriel / musehub public
test_mcp_streamable_http.py python
448 lines 15.0 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """Tests for MCP 2025-11-25 Streamable HTTP transport.
2
3 Covers:
4 POST /mcp:
5 - Origin header validation (valid, invalid, absent)
6 - initialize: returns Mcp-Session-Id header, correct protocolVersion
7 - Non-initialize with Mcp-Session-Id: routes correctly
8 - Non-initialize without Mcp-Session-Id: still routes (no strict requirement)
9 - Unsupported MCP-Protocol-Version header: 400
10 - Elicitation response routing (client sends result back)
11 - Batch request handling
12 - Notification returns 202
13 - JSON parse error returns 400
14
15 GET /mcp:
16 - Requires Accept: text/event-stream (405 otherwise)
17 - Requires Mcp-Session-Id (400 otherwise)
18 - Valid session: opens SSE stream
19 - Unknown session: 404
20
21 DELETE /mcp:
22 - Requires Mcp-Session-Id (400 otherwise)
23 - Valid session: 200
24 - Unknown session: 404
25
26 Session store:
27 - create_session, get_session, delete_session, TTL, SSE queue, elicitation Futures
28 """
29 from __future__ import annotations
30
31 import asyncio
32 import json
33 from unittest.mock import AsyncMock, patch
34
35 import pytest
36 import pytest_asyncio
37 from httpx import AsyncClient, ASGITransport
38 from sqlalchemy.ext.asyncio import AsyncSession
39
40 from musehub.main import app
41 from musehub.mcp.session import (
42 MCPSession,
43 create_session,
44 delete_session,
45 get_session,
46 create_pending_elicitation,
47 resolve_elicitation,
48 cancel_elicitation,
49 push_to_session,
50 register_sse_queue,
51 )
52
53
54 # ── Test fixtures ─────────────────────────────────────────────────────────────
55
56
57 @pytest.fixture
58 def anyio_backend() -> str:
59 return "asyncio"
60
61
62 @pytest_asyncio.fixture
63 async def http_client(db_session: AsyncSession) -> AsyncClient:
64 async with AsyncClient(
65 transport=ASGITransport(app=app),
66 base_url="http://localhost",
67 ) as client:
68 yield client
69
70
71 # ── Helpers ───────────────────────────────────────────────────────────────────
72
73
74 def _init_body() -> dict:
75 return {
76 "jsonrpc": "2.0",
77 "id": 1,
78 "method": "initialize",
79 "params": {
80 "protocolVersion": "2025-11-25",
81 "clientInfo": {"name": "test-client", "version": "1.0"},
82 "capabilities": {"elicitation": {"form": {}, "url": {}}},
83 },
84 }
85
86
87 # ── Origin validation ─────────────────────────────────────────────────────────
88
89
90 @pytest.mark.anyio
91 async def test_post_mcp_no_origin_allowed(http_client: AsyncClient) -> None:
92 """Requests without Origin header (e.g. curl) must be allowed."""
93 resp = await http_client.post(
94 "/mcp",
95 json=_init_body(),
96 headers={"Content-Type": "application/json"},
97 )
98 assert resp.status_code == 200
99
100
101 @pytest.mark.anyio
102 async def test_post_mcp_localhost_origin_allowed(http_client: AsyncClient) -> None:
103 """localhost Origin must always be permitted."""
104 resp = await http_client.post(
105 "/mcp",
106 json=_init_body(),
107 headers={
108 "Content-Type": "application/json",
109 "Origin": "http://localhost",
110 },
111 )
112 assert resp.status_code == 200
113
114
115 @pytest.mark.anyio
116 async def test_post_mcp_invalid_origin_rejected(http_client: AsyncClient) -> None:
117 """Requests from non-allow-listed Origins must be rejected with 403."""
118 resp = await http_client.post(
119 "/mcp",
120 json=_init_body(),
121 headers={
122 "Content-Type": "application/json",
123 "Origin": "https://evil-attacker.example.com",
124 },
125 )
126 assert resp.status_code == 403
127
128
129 # ── POST /mcp — initialize ────────────────────────────────────────────────────
130
131
132 @pytest.mark.anyio
133 async def test_post_mcp_initialize_returns_session_id(http_client: AsyncClient) -> None:
134 """POST initialize must return Mcp-Session-Id header and 2025-11-25 version."""
135 resp = await http_client.post(
136 "/mcp",
137 json=_init_body(),
138 headers={"Content-Type": "application/json"},
139 )
140 assert resp.status_code == 200
141 assert "mcp-session-id" in resp.headers
142 session_id = resp.headers["mcp-session-id"]
143 assert len(session_id) > 10
144
145 data = resp.json()
146 assert data["result"]["protocolVersion"] == "2025-11-25"
147 assert "elicitation" in data["result"]["capabilities"]
148
149
150 @pytest.mark.anyio
151 async def test_post_mcp_initialize_session_persists(http_client: AsyncClient) -> None:
152 """Session created by initialize must be retrievable by get_session."""
153 resp = await http_client.post(
154 "/mcp",
155 json=_init_body(),
156 headers={"Content-Type": "application/json"},
157 )
158 assert resp.status_code == 200
159 session_id = resp.headers["mcp-session-id"]
160 session = get_session(session_id)
161 assert session is not None
162 assert session.session_id == session_id
163
164 delete_session(session_id)
165
166
167 # ── POST /mcp — protocol version validation ───────────────────────────────────
168
169
170 @pytest.mark.anyio
171 async def test_post_mcp_unsupported_protocol_version_rejected(
172 http_client: AsyncClient,
173 ) -> None:
174 """Non-initialize POST with an unsupported MCP-Protocol-Version must return 400."""
175 session = create_session(None, {"elicitation": {}})
176 try:
177 resp = await http_client.post(
178 "/mcp",
179 json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
180 headers={
181 "Content-Type": "application/json",
182 "Mcp-Session-Id": session.session_id,
183 "MCP-Protocol-Version": "9999-99-99",
184 },
185 )
186 assert resp.status_code == 400
187 assert "error" in resp.json()
188 finally:
189 delete_session(session.session_id)
190
191
192 @pytest.mark.anyio
193 async def test_post_mcp_missing_session_returns_404(http_client: AsyncClient) -> None:
194 """Non-initialize POST with an unknown session ID must return 404."""
195 resp = await http_client.post(
196 "/mcp",
197 json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
198 headers={
199 "Content-Type": "application/json",
200 "Mcp-Session-Id": "nonexistent-session-id",
201 },
202 )
203 assert resp.status_code == 404
204
205
206 # ── POST /mcp — misc ──────────────────────────────────────────────────────────
207
208
209 @pytest.mark.anyio
210 async def test_post_mcp_notification_returns_202(http_client: AsyncClient) -> None:
211 """JSON-RPC notifications (no id) must return 202 Accepted."""
212 resp = await http_client.post(
213 "/mcp",
214 json={"jsonrpc": "2.0", "method": "notifications/initialized"},
215 headers={"Content-Type": "application/json"},
216 )
217 assert resp.status_code == 202
218
219
220 @pytest.mark.anyio
221 async def test_post_mcp_json_parse_error_returns_400(http_client: AsyncClient) -> None:
222 """Malformed JSON body must return 400."""
223 resp = await http_client.post(
224 "/mcp",
225 content=b"{invalid json}",
226 headers={"Content-Type": "application/json"},
227 )
228 assert resp.status_code == 400
229 data = resp.json()
230 assert data["error"]["code"] == -32700
231
232
233 @pytest.mark.anyio
234 async def test_post_mcp_batch_returns_list(http_client: AsyncClient) -> None:
235 """Batch requests must return a list of responses."""
236 batch = [
237 {"jsonrpc": "2.0", "id": 1, "method": "ping"},
238 {"jsonrpc": "2.0", "id": 2, "method": "ping"},
239 ]
240 resp = await http_client.post(
241 "/mcp",
242 json=batch,
243 headers={"Content-Type": "application/json"},
244 )
245 assert resp.status_code == 200
246 data = resp.json()
247 assert isinstance(data, list)
248 assert len(data) == 2
249
250
251 @pytest.mark.anyio
252 async def test_post_mcp_elicitation_response_returns_202(http_client: AsyncClient) -> None:
253 """A JSON-RPC response (no 'method') from the client must return 202."""
254 session = create_session(None, {"elicitation": {"form": {}}})
255 try:
256 resp = await http_client.post(
257 "/mcp",
258 json={"jsonrpc": "2.0", "id": "elicit-1", "result": {"action": "decline"}},
259 headers={
260 "Content-Type": "application/json",
261 "Mcp-Session-Id": session.session_id,
262 },
263 )
264 assert resp.status_code == 202
265 finally:
266 delete_session(session.session_id)
267
268
269 # ── GET /mcp ──────────────────────────────────────────────────────────────────
270
271
272 @pytest.mark.anyio
273 async def test_get_mcp_requires_sse_accept(http_client: AsyncClient) -> None:
274 """GET /mcp without Accept: text/event-stream must return 405."""
275 session = create_session(None, {})
276 try:
277 resp = await http_client.get(
278 "/mcp",
279 headers={"Mcp-Session-Id": session.session_id},
280 )
281 assert resp.status_code == 405
282 finally:
283 delete_session(session.session_id)
284
285
286 @pytest.mark.anyio
287 async def test_get_mcp_requires_session_id(http_client: AsyncClient) -> None:
288 """GET /mcp without Mcp-Session-Id must return 400."""
289 resp = await http_client.get(
290 "/mcp",
291 headers={"Accept": "text/event-stream"},
292 )
293 assert resp.status_code == 400
294
295
296 @pytest.mark.anyio
297 async def test_get_mcp_unknown_session_returns_404(http_client: AsyncClient) -> None:
298 """GET /mcp with an unknown session ID must return 404."""
299 resp = await http_client.get(
300 "/mcp",
301 headers={
302 "Accept": "text/event-stream",
303 "Mcp-Session-Id": "unknown-session-xyz",
304 },
305 )
306 assert resp.status_code == 404
307
308
309 # ── DELETE /mcp ───────────────────────────────────────────────────────────────
310
311
312 @pytest.mark.anyio
313 async def test_delete_mcp_requires_session_id(http_client: AsyncClient) -> None:
314 """DELETE /mcp without Mcp-Session-Id must return 400."""
315 resp = await http_client.delete("/mcp")
316 assert resp.status_code == 400
317
318
319 @pytest.mark.anyio
320 async def test_delete_mcp_unknown_session_returns_404(http_client: AsyncClient) -> None:
321 """DELETE /mcp with an unknown session must return 404."""
322 resp = await http_client.delete(
323 "/mcp",
324 headers={"Mcp-Session-Id": "unknown-session-xyz"},
325 )
326 assert resp.status_code == 404
327
328
329 @pytest.mark.anyio
330 async def test_delete_mcp_valid_session_returns_200(http_client: AsyncClient) -> None:
331 """DELETE /mcp with a valid session must return 200 and remove the session."""
332 # First initialize to get a session.
333 init_resp = await http_client.post(
334 "/mcp",
335 json=_init_body(),
336 headers={"Content-Type": "application/json"},
337 )
338 assert init_resp.status_code == 200
339 session_id = init_resp.headers["mcp-session-id"]
340
341 # Delete it.
342 del_resp = await http_client.delete(
343 "/mcp",
344 headers={"Mcp-Session-Id": session_id},
345 )
346 assert del_resp.status_code == 200
347
348 # Confirm it's gone.
349 assert get_session(session_id) is None
350
351
352 # ── Session store unit tests ──────────────────────────────────────────────────
353
354
355 def test_session_create_and_get() -> None:
356 """create_session + get_session should round-trip."""
357 session = create_session("user-123", {"elicitation": {"form": {}}})
358 try:
359 fetched = get_session(session.session_id)
360 assert fetched is not None
361 assert fetched.user_id == "user-123"
362 assert fetched.supports_elicitation_form()
363 finally:
364 delete_session(session.session_id)
365
366
367 def test_session_delete() -> None:
368 """delete_session should remove the session from the store."""
369 session = create_session(None, {})
370 sid = session.session_id
371 assert delete_session(sid) is True
372 assert get_session(sid) is None
373
374
375 def test_session_double_delete() -> None:
376 """Deleting a session twice should return False the second time."""
377 session = create_session(None, {})
378 sid = session.session_id
379 assert delete_session(sid) is True
380 assert delete_session(sid) is False
381
382
383 def test_session_elicitation_form_support() -> None:
384 """Session should correctly report form elicitation support."""
385 session_with = create_session(None, {"elicitation": {"form": {}}})
386 session_without = create_session(None, {})
387 try:
388 assert session_with.supports_elicitation_form() is True
389 assert session_without.supports_elicitation_form() is False
390 finally:
391 delete_session(session_with.session_id)
392 delete_session(session_without.session_id)
393
394
395 def test_session_url_elicitation_support() -> None:
396 """Session should correctly report URL elicitation support."""
397 session_both = create_session(None, {"elicitation": {"form": {}, "url": {}}})
398 session_form_only = create_session(None, {"elicitation": {"form": {}}})
399 try:
400 assert session_both.supports_elicitation_url() is True
401 assert session_form_only.supports_elicitation_url() is False
402 finally:
403 delete_session(session_both.session_id)
404 delete_session(session_form_only.session_id)
405
406
407 @pytest.mark.anyio
408 async def test_elicitation_future_resolve() -> None:
409 """create_pending_elicitation + resolve_elicitation should set the Future result."""
410 session = create_session(None, {"elicitation": {"form": {}}})
411 try:
412 fut = create_pending_elicitation(session, "elicit-1")
413 result = {"action": "accept", "content": {"key": "C major"}}
414 resolved = resolve_elicitation(session, "elicit-1", result)
415 assert resolved is True
416 assert fut.done()
417 assert fut.result() == result
418 finally:
419 delete_session(session.session_id)
420
421
422 @pytest.mark.anyio
423 async def test_elicitation_future_cancel() -> None:
424 """cancel_elicitation should cancel the Future."""
425 session = create_session(None, {"elicitation": {"form": {}}})
426 try:
427 fut = create_pending_elicitation(session, "elicit-2")
428 cancelled = cancel_elicitation(session, "elicit-2")
429 assert cancelled is True
430 assert fut.cancelled()
431 finally:
432 delete_session(session.session_id)
433
434
435 @pytest.mark.anyio
436 async def test_push_to_session_delivers_to_queue() -> None:
437 """push_to_session should deliver events to all registered SSE queues."""
438 session = create_session(None, {})
439 try:
440 queue: asyncio.Queue[str | None] = asyncio.Queue()
441 session.sse_queues.append(queue)
442
443 push_to_session(session, "data: test\n\n")
444
445 item = queue.get_nowait()
446 assert item == "data: test\n\n"
447 finally:
448 delete_session(session.session_id)