gabriel / musehub public

test_mcp_streamable_http.py file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:0 fix: fall back to any indexed mpack in read_object_bytes when push mpac… · gabriel · Jun 17, 2026
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.types.json_types import JSONObject
42 from musehub.mcp.session import (
43 MCPSession,
44 create_session,
45 delete_session,
46 get_session,
47 create_pending_elicitation,
48 resolve_elicitation,
49 cancel_elicitation,
50 push_to_session,
51 register_sse_queue,
52 )
53
54
55 # ── Test fixtures ─────────────────────────────────────────────────────────────
56
57
58 @pytest.fixture
59 def anyio_backend() -> str:
60 return "asyncio"
61
62
63 @pytest_asyncio.fixture
64 async def http_client(db_session: AsyncSession) -> AsyncClient:
65 async with AsyncClient(
66 transport=ASGITransport(app=app),
67 base_url="http://localhost",
68 ) as client:
69 yield client
70
71
72 # ── Helpers ───────────────────────────────────────────────────────────────────
73
74
75 def _init_body() -> JSONObject:
76 return {
77 "jsonrpc": "2.0",
78 "id": 1,
79 "method": "initialize",
80 "params": {
81 "protocolVersion": "2025-11-25",
82 "clientInfo": {"name": "test-client", "version": "1.0"},
83 "capabilities": {"elicitation": {"form": {}, "url": {}}},
84 },
85 }
86
87
88 # ── Origin validation ─────────────────────────────────────────────────────────
89
90
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 async def test_post_mcp_localhost_origin_allowed(http_client: AsyncClient) -> None:
102 """localhost Origin must always be permitted."""
103 resp = await http_client.post(
104 "/mcp",
105 json=_init_body(),
106 headers={
107 "Content-Type": "application/json",
108 "Origin": "http://localhost",
109 },
110 )
111 assert resp.status_code == 200
112
113
114 async def test_post_mcp_invalid_origin_rejected(http_client: AsyncClient) -> None:
115 """Requests from non-allow-listed Origins must be rejected with 403."""
116 resp = await http_client.post(
117 "/mcp",
118 json=_init_body(),
119 headers={
120 "Content-Type": "application/json",
121 "Origin": "https://evil-attacker.example.com",
122 },
123 )
124 assert resp.status_code == 403
125
126
127 # ── POST /mcp β€” initialize ────────────────────────────────────────────────────
128
129
130 async def test_post_mcp_initialize_returns_session_id(http_client: AsyncClient) -> None:
131 """POST initialize must return Mcp-Session-Id header and 2025-11-25 version."""
132 resp = await http_client.post(
133 "/mcp",
134 json=_init_body(),
135 headers={"Content-Type": "application/json"},
136 )
137 assert resp.status_code == 200
138 assert "mcp-session-id" in resp.headers
139 session_id = resp.headers["mcp-session-id"]
140 assert len(session_id) > 10
141
142 data = resp.json()
143 assert data["result"]["protocolVersion"] == "2025-11-25"
144 assert "elicitation" in data["result"]["capabilities"]
145
146
147 async def test_post_mcp_initialize_session_persists(http_client: AsyncClient) -> None:
148 """Session created by initialize must be retrievable by get_session."""
149 resp = await http_client.post(
150 "/mcp",
151 json=_init_body(),
152 headers={"Content-Type": "application/json"},
153 )
154 assert resp.status_code == 200
155 session_id = resp.headers["mcp-session-id"]
156 session = get_session(session_id)
157 assert session is not None
158 assert session.session_id == session_id
159
160 delete_session(session_id)
161
162
163 # ── POST /mcp β€” protocol version validation ───────────────────────────────────
164
165
166 async def test_post_mcp_unsupported_protocol_version_rejected(
167 http_client: AsyncClient,
168 ) -> None:
169 """Non-initialize POST with an unsupported MCP-Protocol-Version must return 400."""
170 session = create_session(None, {"elicitation": {}})
171 try:
172 resp = await http_client.post(
173 "/mcp",
174 json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
175 headers={
176 "Content-Type": "application/json",
177 "Mcp-Session-Id": session.session_id,
178 "MCP-Protocol-Version": "9999-99-99",
179 },
180 )
181 assert resp.status_code == 400
182 assert "error" in resp.json()
183 finally:
184 delete_session(session.session_id)
185
186
187 async def test_post_mcp_missing_session_returns_404(http_client: AsyncClient) -> None:
188 """Non-initialize POST with an unknown session ID must return 404."""
189 resp = await http_client.post(
190 "/mcp",
191 json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
192 headers={
193 "Content-Type": "application/json",
194 "Mcp-Session-Id": "nonexistent-session-id",
195 },
196 )
197 assert resp.status_code == 404
198
199
200 # ── POST /mcp β€” misc ──────────────────────────────────────────────────────────
201
202
203 async def test_post_mcp_notification_returns_202(http_client: AsyncClient) -> None:
204 """JSON-RPC notifications (no id) must return 202 Accepted."""
205 resp = await http_client.post(
206 "/mcp",
207 json={"jsonrpc": "2.0", "method": "notifications/initialized"},
208 headers={"Content-Type": "application/json"},
209 )
210 assert resp.status_code == 202
211
212
213 async def test_post_mcp_json_parse_error_returns_400(http_client: AsyncClient) -> None:
214 """Malformed JSON body must return 400."""
215 resp = await http_client.post(
216 "/mcp",
217 content=b"{invalid json}",
218 headers={"Content-Type": "application/json"},
219 )
220 assert resp.status_code == 400
221 data = resp.json()
222 assert data["error"]["code"] == -32700
223
224
225 async def test_post_mcp_batch_returns_list(http_client: AsyncClient) -> None:
226 """Batch requests must return a list of responses."""
227 batch = [
228 {"jsonrpc": "2.0", "id": 1, "method": "ping"},
229 {"jsonrpc": "2.0", "id": 2, "method": "ping"},
230 ]
231 resp = await http_client.post(
232 "/mcp",
233 json=batch,
234 headers={"Content-Type": "application/json"},
235 )
236 assert resp.status_code == 200
237 data = resp.json()
238 assert isinstance(data, list)
239 assert len(data) == 2
240
241
242 async def test_post_mcp_elicitation_response_returns_202(http_client: AsyncClient) -> None:
243 """A JSON-RPC response (no 'method') from the client must return 202."""
244 session = create_session(None, {"elicitation": {"form": {}}})
245 try:
246 resp = await http_client.post(
247 "/mcp",
248 json={"jsonrpc": "2.0", "id": "elicit-1", "result": {"action": "decline"}},
249 headers={
250 "Content-Type": "application/json",
251 "Mcp-Session-Id": session.session_id,
252 },
253 )
254 assert resp.status_code == 202
255 finally:
256 delete_session(session.session_id)
257
258
259 # ── GET /mcp ──────────────────────────────────────────────────────────────────
260
261
262 async def test_get_mcp_requires_sse_accept(http_client: AsyncClient) -> None:
263 """GET /mcp without Accept: text/event-stream must return 405."""
264 session = create_session(None, {})
265 try:
266 resp = await http_client.get(
267 "/mcp",
268 headers={"Mcp-Session-Id": session.session_id},
269 )
270 assert resp.status_code == 405
271 finally:
272 delete_session(session.session_id)
273
274
275 async def test_get_mcp_requires_session_id(http_client: AsyncClient) -> None:
276 """GET /mcp without Mcp-Session-Id must return 400."""
277 resp = await http_client.get(
278 "/mcp",
279 headers={"Accept": "text/event-stream"},
280 )
281 assert resp.status_code == 400
282
283
284 async def test_get_mcp_unknown_session_returns_404(http_client: AsyncClient) -> None:
285 """GET /mcp with an unknown session ID must return 404."""
286 resp = await http_client.get(
287 "/mcp",
288 headers={
289 "Accept": "text/event-stream",
290 "Mcp-Session-Id": "unknown-session-xyz",
291 },
292 )
293 assert resp.status_code == 404
294
295
296 # ── DELETE /mcp ───────────────────────────────────────────────────────────────
297
298
299 async def test_delete_mcp_requires_session_id(http_client: AsyncClient) -> None:
300 """DELETE /mcp without Mcp-Session-Id must return 400."""
301 resp = await http_client.delete("/mcp")
302 assert resp.status_code == 400
303
304
305 async def test_delete_mcp_unknown_session_returns_404(http_client: AsyncClient) -> None:
306 """DELETE /mcp with an unknown session must return 404."""
307 resp = await http_client.delete(
308 "/mcp",
309 headers={"Mcp-Session-Id": "unknown-session-xyz"},
310 )
311 assert resp.status_code == 404
312
313
314 async def test_delete_mcp_valid_session_returns_200(http_client: AsyncClient) -> None:
315 """DELETE /mcp with a valid session must return 200 and remove the session."""
316 # First initialize to get a session.
317 init_resp = await http_client.post(
318 "/mcp",
319 json=_init_body(),
320 headers={"Content-Type": "application/json"},
321 )
322 assert init_resp.status_code == 200
323 session_id = init_resp.headers["mcp-session-id"]
324
325 # Delete it.
326 del_resp = await http_client.delete(
327 "/mcp",
328 headers={"Mcp-Session-Id": session_id},
329 )
330 assert del_resp.status_code == 200
331
332 # Confirm it's gone.
333 assert get_session(session_id) is None
334
335
336 # ── Session store unit tests ──────────────────────────────────────────────────
337
338
339 def test_session_create_and_get() -> None:
340 """create_session + get_session should round-trip."""
341 session = create_session("user-123", {"elicitation": {"form": {}}})
342 try:
343 fetched = get_session(session.session_id)
344 assert fetched is not None
345 assert fetched.user_id == "user-123"
346 assert fetched.supports_elicitation_form()
347 finally:
348 delete_session(session.session_id)
349
350
351 def test_session_delete() -> None:
352 """delete_session should remove the session from the store."""
353 session = create_session(None, {})
354 sid = session.session_id
355 assert delete_session(sid) is True
356 assert get_session(sid) is None
357
358
359 def test_session_double_delete() -> None:
360 """Deleting a session twice should return False the second time."""
361 session = create_session(None, {})
362 sid = session.session_id
363 assert delete_session(sid) is True
364 assert delete_session(sid) is False
365
366
367 def test_session_elicitation_form_support() -> None:
368 """Session should correctly report form elicitation support."""
369 session_with = create_session(None, {"elicitation": {"form": {}}})
370 session_without = create_session(None, {})
371 try:
372 assert session_with.supports_elicitation_form() is True
373 assert session_without.supports_elicitation_form() is False
374 finally:
375 delete_session(session_with.session_id)
376 delete_session(session_without.session_id)
377
378
379 def test_session_url_elicitation_support() -> None:
380 """Session should correctly report URL elicitation support."""
381 session_both = create_session(None, {"elicitation": {"form": {}, "url": {}}})
382 session_form_only = create_session(None, {"elicitation": {"form": {}}})
383 try:
384 assert session_both.supports_elicitation_url() is True
385 assert session_form_only.supports_elicitation_url() is False
386 finally:
387 delete_session(session_both.session_id)
388 delete_session(session_form_only.session_id)
389
390
391 async def test_elicitation_future_resolve() -> None:
392 """create_pending_elicitation + resolve_elicitation should set the Future result."""
393 session = create_session(None, {"elicitation": {"form": {}}})
394 try:
395 fut = create_pending_elicitation(session, "elicit-1")
396 result = {"action": "accept", "content": {"key": "C major"}}
397 resolved = resolve_elicitation(session, "elicit-1", result)
398 assert resolved is True
399 assert fut.done()
400 assert fut.result() == result
401 finally:
402 delete_session(session.session_id)
403
404
405 async def test_elicitation_future_cancel() -> None:
406 """cancel_elicitation should cancel the Future."""
407 session = create_session(None, {"elicitation": {"form": {}}})
408 try:
409 fut = create_pending_elicitation(session, "elicit-2")
410 cancelled = cancel_elicitation(session, "elicit-2")
411 assert cancelled is True
412 assert fut.cancelled()
413 finally:
414 delete_session(session.session_id)
415
416
417 async def test_push_to_session_delivers_to_queue() -> None:
418 """push_to_session should deliver events to all registered SSE queues."""
419 session = create_session(None, {})
420 try:
421 queue: asyncio.Queue[str | None] = asyncio.Queue()
422 session.sse_queues.append(queue)
423
424 push_to_session(session, "data: test\n\n")
425
426 item = queue.get_nowait()
427 assert item == "data: test\n\n"
428 finally:
429 delete_session(session.session_id)