gabriel / musehub public
test_mcp_streamable_http.py python
449 lines 15.0 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 172 days 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.muse_contracts.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 @pytest.mark.anyio
92 async def test_post_mcp_no_origin_allowed(http_client: AsyncClient) -> None:
93 """Requests without Origin header (e.g. curl) must be allowed."""
94 resp = await http_client.post(
95 "/mcp",
96 json=_init_body(),
97 headers={"Content-Type": "application/json"},
98 )
99 assert resp.status_code == 200
100
101
102 @pytest.mark.anyio
103 async def test_post_mcp_localhost_origin_allowed(http_client: AsyncClient) -> None:
104 """localhost Origin must always be permitted."""
105 resp = await http_client.post(
106 "/mcp",
107 json=_init_body(),
108 headers={
109 "Content-Type": "application/json",
110 "Origin": "http://localhost",
111 },
112 )
113 assert resp.status_code == 200
114
115
116 @pytest.mark.anyio
117 async def test_post_mcp_invalid_origin_rejected(http_client: AsyncClient) -> None:
118 """Requests from non-allow-listed Origins must be rejected with 403."""
119 resp = await http_client.post(
120 "/mcp",
121 json=_init_body(),
122 headers={
123 "Content-Type": "application/json",
124 "Origin": "https://evil-attacker.example.com",
125 },
126 )
127 assert resp.status_code == 403
128
129
130 # ── POST /mcp — initialize ────────────────────────────────────────────────────
131
132
133 @pytest.mark.anyio
134 async def test_post_mcp_initialize_returns_session_id(http_client: AsyncClient) -> None:
135 """POST initialize must return Mcp-Session-Id header and 2025-11-25 version."""
136 resp = await http_client.post(
137 "/mcp",
138 json=_init_body(),
139 headers={"Content-Type": "application/json"},
140 )
141 assert resp.status_code == 200
142 assert "mcp-session-id" in resp.headers
143 session_id = resp.headers["mcp-session-id"]
144 assert len(session_id) > 10
145
146 data = resp.json()
147 assert data["result"]["protocolVersion"] == "2025-11-25"
148 assert "elicitation" in data["result"]["capabilities"]
149
150
151 @pytest.mark.anyio
152 async def test_post_mcp_initialize_session_persists(http_client: AsyncClient) -> None:
153 """Session created by initialize must be retrievable by get_session."""
154 resp = await http_client.post(
155 "/mcp",
156 json=_init_body(),
157 headers={"Content-Type": "application/json"},
158 )
159 assert resp.status_code == 200
160 session_id = resp.headers["mcp-session-id"]
161 session = get_session(session_id)
162 assert session is not None
163 assert session.session_id == session_id
164
165 delete_session(session_id)
166
167
168 # ── POST /mcp — protocol version validation ───────────────────────────────────
169
170
171 @pytest.mark.anyio
172 async def test_post_mcp_unsupported_protocol_version_rejected(
173 http_client: AsyncClient,
174 ) -> None:
175 """Non-initialize POST with an unsupported MCP-Protocol-Version must return 400."""
176 session = create_session(None, {"elicitation": {}})
177 try:
178 resp = await http_client.post(
179 "/mcp",
180 json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
181 headers={
182 "Content-Type": "application/json",
183 "Mcp-Session-Id": session.session_id,
184 "MCP-Protocol-Version": "9999-99-99",
185 },
186 )
187 assert resp.status_code == 400
188 assert "error" in resp.json()
189 finally:
190 delete_session(session.session_id)
191
192
193 @pytest.mark.anyio
194 async def test_post_mcp_missing_session_returns_404(http_client: AsyncClient) -> None:
195 """Non-initialize POST with an unknown session ID must return 404."""
196 resp = await http_client.post(
197 "/mcp",
198 json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
199 headers={
200 "Content-Type": "application/json",
201 "Mcp-Session-Id": "nonexistent-session-id",
202 },
203 )
204 assert resp.status_code == 404
205
206
207 # ── POST /mcp — misc ──────────────────────────────────────────────────────────
208
209
210 @pytest.mark.anyio
211 async def test_post_mcp_notification_returns_202(http_client: AsyncClient) -> None:
212 """JSON-RPC notifications (no id) must return 202 Accepted."""
213 resp = await http_client.post(
214 "/mcp",
215 json={"jsonrpc": "2.0", "method": "notifications/initialized"},
216 headers={"Content-Type": "application/json"},
217 )
218 assert resp.status_code == 202
219
220
221 @pytest.mark.anyio
222 async def test_post_mcp_json_parse_error_returns_400(http_client: AsyncClient) -> None:
223 """Malformed JSON body must return 400."""
224 resp = await http_client.post(
225 "/mcp",
226 content=b"{invalid json}",
227 headers={"Content-Type": "application/json"},
228 )
229 assert resp.status_code == 400
230 data = resp.json()
231 assert data["error"]["code"] == -32700
232
233
234 @pytest.mark.anyio
235 async def test_post_mcp_batch_returns_list(http_client: AsyncClient) -> None:
236 """Batch requests must return a list of responses."""
237 batch = [
238 {"jsonrpc": "2.0", "id": 1, "method": "ping"},
239 {"jsonrpc": "2.0", "id": 2, "method": "ping"},
240 ]
241 resp = await http_client.post(
242 "/mcp",
243 json=batch,
244 headers={"Content-Type": "application/json"},
245 )
246 assert resp.status_code == 200
247 data = resp.json()
248 assert isinstance(data, list)
249 assert len(data) == 2
250
251
252 @pytest.mark.anyio
253 async def test_post_mcp_elicitation_response_returns_202(http_client: AsyncClient) -> None:
254 """A JSON-RPC response (no 'method') from the client must return 202."""
255 session = create_session(None, {"elicitation": {"form": {}}})
256 try:
257 resp = await http_client.post(
258 "/mcp",
259 json={"jsonrpc": "2.0", "id": "elicit-1", "result": {"action": "decline"}},
260 headers={
261 "Content-Type": "application/json",
262 "Mcp-Session-Id": session.session_id,
263 },
264 )
265 assert resp.status_code == 202
266 finally:
267 delete_session(session.session_id)
268
269
270 # ── GET /mcp ──────────────────────────────────────────────────────────────────
271
272
273 @pytest.mark.anyio
274 async def test_get_mcp_requires_sse_accept(http_client: AsyncClient) -> None:
275 """GET /mcp without Accept: text/event-stream must return 405."""
276 session = create_session(None, {})
277 try:
278 resp = await http_client.get(
279 "/mcp",
280 headers={"Mcp-Session-Id": session.session_id},
281 )
282 assert resp.status_code == 405
283 finally:
284 delete_session(session.session_id)
285
286
287 @pytest.mark.anyio
288 async def test_get_mcp_requires_session_id(http_client: AsyncClient) -> None:
289 """GET /mcp without Mcp-Session-Id must return 400."""
290 resp = await http_client.get(
291 "/mcp",
292 headers={"Accept": "text/event-stream"},
293 )
294 assert resp.status_code == 400
295
296
297 @pytest.mark.anyio
298 async def test_get_mcp_unknown_session_returns_404(http_client: AsyncClient) -> None:
299 """GET /mcp with an unknown session ID must return 404."""
300 resp = await http_client.get(
301 "/mcp",
302 headers={
303 "Accept": "text/event-stream",
304 "Mcp-Session-Id": "unknown-session-xyz",
305 },
306 )
307 assert resp.status_code == 404
308
309
310 # ── DELETE /mcp ───────────────────────────────────────────────────────────────
311
312
313 @pytest.mark.anyio
314 async def test_delete_mcp_requires_session_id(http_client: AsyncClient) -> None:
315 """DELETE /mcp without Mcp-Session-Id must return 400."""
316 resp = await http_client.delete("/mcp")
317 assert resp.status_code == 400
318
319
320 @pytest.mark.anyio
321 async def test_delete_mcp_unknown_session_returns_404(http_client: AsyncClient) -> None:
322 """DELETE /mcp with an unknown session must return 404."""
323 resp = await http_client.delete(
324 "/mcp",
325 headers={"Mcp-Session-Id": "unknown-session-xyz"},
326 )
327 assert resp.status_code == 404
328
329
330 @pytest.mark.anyio
331 async def test_delete_mcp_valid_session_returns_200(http_client: AsyncClient) -> None:
332 """DELETE /mcp with a valid session must return 200 and remove the session."""
333 # First initialize to get a session.
334 init_resp = await http_client.post(
335 "/mcp",
336 json=_init_body(),
337 headers={"Content-Type": "application/json"},
338 )
339 assert init_resp.status_code == 200
340 session_id = init_resp.headers["mcp-session-id"]
341
342 # Delete it.
343 del_resp = await http_client.delete(
344 "/mcp",
345 headers={"Mcp-Session-Id": session_id},
346 )
347 assert del_resp.status_code == 200
348
349 # Confirm it's gone.
350 assert get_session(session_id) is None
351
352
353 # ── Session store unit tests ──────────────────────────────────────────────────
354
355
356 def test_session_create_and_get() -> None:
357 """create_session + get_session should round-trip."""
358 session = create_session("user-123", {"elicitation": {"form": {}}})
359 try:
360 fetched = get_session(session.session_id)
361 assert fetched is not None
362 assert fetched.user_id == "user-123"
363 assert fetched.supports_elicitation_form()
364 finally:
365 delete_session(session.session_id)
366
367
368 def test_session_delete() -> None:
369 """delete_session should remove the session from the store."""
370 session = create_session(None, {})
371 sid = session.session_id
372 assert delete_session(sid) is True
373 assert get_session(sid) is None
374
375
376 def test_session_double_delete() -> None:
377 """Deleting a session twice should return False the second time."""
378 session = create_session(None, {})
379 sid = session.session_id
380 assert delete_session(sid) is True
381 assert delete_session(sid) is False
382
383
384 def test_session_elicitation_form_support() -> None:
385 """Session should correctly report form elicitation support."""
386 session_with = create_session(None, {"elicitation": {"form": {}}})
387 session_without = create_session(None, {})
388 try:
389 assert session_with.supports_elicitation_form() is True
390 assert session_without.supports_elicitation_form() is False
391 finally:
392 delete_session(session_with.session_id)
393 delete_session(session_without.session_id)
394
395
396 def test_session_url_elicitation_support() -> None:
397 """Session should correctly report URL elicitation support."""
398 session_both = create_session(None, {"elicitation": {"form": {}, "url": {}}})
399 session_form_only = create_session(None, {"elicitation": {"form": {}}})
400 try:
401 assert session_both.supports_elicitation_url() is True
402 assert session_form_only.supports_elicitation_url() is False
403 finally:
404 delete_session(session_both.session_id)
405 delete_session(session_form_only.session_id)
406
407
408 @pytest.mark.anyio
409 async def test_elicitation_future_resolve() -> None:
410 """create_pending_elicitation + resolve_elicitation should set the Future result."""
411 session = create_session(None, {"elicitation": {"form": {}}})
412 try:
413 fut = create_pending_elicitation(session, "elicit-1")
414 result = {"action": "accept", "content": {"key": "C major"}}
415 resolved = resolve_elicitation(session, "elicit-1", result)
416 assert resolved is True
417 assert fut.done()
418 assert fut.result() == result
419 finally:
420 delete_session(session.session_id)
421
422
423 @pytest.mark.anyio
424 async def test_elicitation_future_cancel() -> None:
425 """cancel_elicitation should cancel the Future."""
426 session = create_session(None, {"elicitation": {"form": {}}})
427 try:
428 fut = create_pending_elicitation(session, "elicit-2")
429 cancelled = cancel_elicitation(session, "elicit-2")
430 assert cancelled is True
431 assert fut.cancelled()
432 finally:
433 delete_session(session.session_id)
434
435
436 @pytest.mark.anyio
437 async def test_push_to_session_delivers_to_queue() -> None:
438 """push_to_session should deliver events to all registered SSE queues."""
439 session = create_session(None, {})
440 try:
441 queue: asyncio.Queue[str | None] = asyncio.Queue()
442 session.sse_queues.append(queue)
443
444 push_to_session(session, "data: test\n\n")
445
446 item = queue.get_nowait()
447 assert item == "data: test\n\n"
448 finally:
449 delete_session(session.session_id)
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 172 days ago