gabriel / musehub public

test_mcp_dispatcher.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 the MuseHub MCP dispatcher, resources, and prompts.
2
3 Covers:
4 - JSON-RPC 2.0 protocol correctness (initialize, tools/list, resources/list,
5 resources/templates/list, prompts/list, prompts/get, ping, unknown method)
6 - tools/call routing: known read tools, unknown tool, write tool auth gate
7 - resources/read: musehub:// URI dispatch and unknown URI handling
8 - prompts/get: known prompt assembly and unknown prompt error
9 - Batch request handling
10 - Notification handling (no id β†’ returns None)
11 - Tool catalogue completeness (95 tools)
12 - Resource catalogue completeness (12 static, 17 templated)
13 - Prompt catalogue completeness (12 prompts)
14 - MCP 2025-11-25: elicitation capability in initialize, new notifications
15 """
16 from __future__ import annotations
17
18 import json
19 from unittest.mock import AsyncMock, MagicMock, patch
20
21 import pytest
22
23 from musehub.mcp.dispatcher import handle_batch, handle_request
24 from musehub.mcp.prompts import PROMPT_CATALOGUE, get_prompt
25 from musehub.mcp.resources import RESOURCE_TEMPLATES, STATIC_RESOURCES, read_resource
26 from musehub.mcp.tools import MCP_TOOLS, MUSEHUB_WRITE_TOOL_NAMES
27 from musehub.types.json_types import JSONObject
28
29
30 # ── Helpers ───────────────────────────────────────────────────────────────────
31
32
33 def _req(method: str, params: JSONObject | None = None, req_id: int = 1) -> JSONObject:
34 """Build a minimal JSON-RPC 2.0 request dict."""
35 msg = {"jsonrpc": "2.0", "id": req_id, "method": method}
36 if params is not None:
37 msg["params"] = params
38 return msg
39
40
41 def _unwrap_tool_text(text: str) -> str:
42 """Strip <musehub_tool_result> wrapper tags added by the dispatcher."""
43 text = text.strip()
44 if text.startswith("<musehub_tool_result>"):
45 text = text[len("<musehub_tool_result>"):].strip()
46 if text.endswith("</musehub_tool_result>"):
47 text = text[: -len("</musehub_tool_result>")].strip()
48 return text
49
50
51 def _notification(method: str, params: JSONObject | None = None) -> JSONObject:
52 """Build a JSON-RPC 2.0 notification (no id)."""
53 msg = {"jsonrpc": "2.0", "method": method}
54 if params is not None:
55 msg["params"] = params
56 return msg
57
58
59 # ── Protocol correctness ──────────────────────────────────────────────────────
60
61
62 @pytest.mark.asyncio
63 async def test_initialize_returns_capabilities() -> None:
64 """initialize should return protocolVersion 2025-11-25 and capabilities."""
65 resp = await handle_request(_req("initialize", {"protocolVersion": "2025-11-25"}))
66 assert resp is not None
67 assert resp["jsonrpc"] == "2.0"
68 assert resp["id"] == 1
69 result = resp["result"]
70 assert isinstance(result, dict)
71 assert result["protocolVersion"] == "2025-11-25"
72 assert "capabilities" in result
73 assert "tools" in result["capabilities"]
74 assert "resources" in result["capabilities"]
75 assert "prompts" in result["capabilities"]
76 assert "elicitation" in result["capabilities"]
77 assert "form" in result["capabilities"]["elicitation"]
78 assert "url" in result["capabilities"]["elicitation"]
79 # serverInfo must only contain name and version (not capabilities)
80 assert "name" in result["serverInfo"]
81 assert "version" in result["serverInfo"]
82 assert "capabilities" not in result["serverInfo"]
83
84
85 @pytest.mark.asyncio
86 async def test_ping_returns_empty_result() -> None:
87 """ping should return an empty result dict."""
88 resp = await handle_request(_req("ping"))
89 assert resp is not None
90 assert resp["result"] == {}
91
92
93 @pytest.mark.asyncio
94 async def test_unknown_method_returns_error() -> None:
95 """Unknown methods should return a JSON-RPC method-not-found error."""
96 resp = await handle_request(_req("musehub/does-not-exist"))
97 assert resp is not None
98 assert "error" in resp
99 assert resp["error"]["code"] == -32601
100
101
102 @pytest.mark.asyncio
103 async def test_completions_complete_returns_empty() -> None:
104 """completions/complete stub returns empty values list (MCP 2025-11-25)."""
105 resp = await handle_request(_req("completions/complete", {"ref": {}, "argument": {"name": "x", "value": "y"}}))
106 assert resp is not None
107 assert "result" in resp
108 assert resp["result"]["completion"]["values"] == []
109
110
111 @pytest.mark.asyncio
112 async def test_logging_set_level_requires_auth_anonymous_rejected() -> None:
113 """logging/setLevel must return an error for unauthenticated callers (M5)."""
114 resp = await handle_request(_req("logging/setLevel", {"level": "info"}), user_id=None)
115 assert resp is not None
116 assert "error" in resp
117
118
119 @pytest.mark.asyncio
120 async def test_logging_set_level_authenticated_returns_empty() -> None:
121 """logging/setLevel returns empty result for authenticated callers (MCP 2025-11-25)."""
122 resp = await handle_request(_req("logging/setLevel", {"level": "info"}), user_id="test-user")
123 assert resp is not None
124 assert resp["result"] == {}
125
126
127 @pytest.mark.asyncio
128 async def test_notification_returns_none() -> None:
129 """Notifications (no id) should return None from handle_request."""
130 result = await handle_request(_notification("ping"))
131 assert result is None
132
133
134 # ── Tool catalogue ────────────────────────────────────────────────────────────
135
136
137 @pytest.mark.asyncio
138 async def test_tools_list_returns_95_tools() -> None:
139 """tools/list should return all 95 registered tools."""
140 resp = await handle_request(_req("tools/list"))
141 assert resp is not None
142 result = resp["result"]
143 assert isinstance(result, dict)
144 tools = result["tools"]
145 assert isinstance(tools, list)
146 assert len(tools) == 124
147
148
149 @pytest.mark.asyncio
150 async def test_tools_list_no_server_side_field() -> None:
151 """tools/list should strip the internal server_side field."""
152 resp = await handle_request(_req("tools/list"))
153 assert resp is not None
154 for tool in resp["result"]["tools"]:
155 assert "server_side" not in tool, f"Tool {tool['name']} exposes server_side"
156
157
158 @pytest.mark.asyncio
159 async def test_tools_list_all_have_required_fields() -> None:
160 """Every tool in tools/list must have name, description, inputSchema, and annotations."""
161 resp = await handle_request(_req("tools/list"))
162 assert resp is not None
163 for tool in resp["result"]["tools"]:
164 assert "name" in tool, f"Missing name: {tool}"
165 assert "description" in tool, f"Missing description for {tool.get('name')}"
166 assert "inputSchema" in tool, f"Missing inputSchema for {tool.get('name')}"
167 assert "annotations" in tool, f"Missing MCP 2025-11-25 annotations for {tool.get('name')}"
168
169
170 def test_tool_catalogue_has_95_tools() -> None:
171 """The MCP_TOOLS list must contain exactly 120 tools."""
172 assert len(MCP_TOOLS) == 124
173
174
175 def test_write_tool_names_all_in_catalogue() -> None:
176 """Every write tool name must appear in the full catalogue."""
177 all_names = {t["name"] for t in MCP_TOOLS}
178 for name in MUSEHUB_WRITE_TOOL_NAMES:
179 assert name in all_names, f"Write tool {name!r} not in MCP_TOOLS"
180
181
182 # ── tools/call routing ────────────────────────────────────────────────────────
183
184
185 @pytest.mark.asyncio
186 async def test_tools_call_unknown_tool_returns_iserror() -> None:
187 """Calling an unknown tool should return isError=true (not a JSON-RPC error)."""
188 resp = await handle_request(
189 _req("tools/call", {"name": "nonexistent_tool", "arguments": {}})
190 )
191 assert resp is not None
192 # Envelope is success (has "result", not "error")
193 assert "result" in resp
194 result = resp["result"]
195 assert result.get("isError") is True
196
197
198 @pytest.mark.asyncio
199 async def test_tools_call_write_tool_requires_auth() -> None:
200 """Calling a write tool without a user_id should return isError=true."""
201 resp = await handle_request(
202 _req("tools/call", {"name": "musehub_create_repo", "arguments": {"name": "test"}}),
203 user_id=None,
204 )
205 assert resp is not None
206 assert "result" in resp
207 assert resp["result"].get("isError") is True
208
209
210 @pytest.mark.asyncio
211 async def test_tools_call_write_tool_passes_with_auth() -> None:
212 """Calling a write tool with user_id should reach the executor (not auth-gate)."""
213 mock_result = MagicMock()
214 mock_result.ok = True
215 mock_result.data = {"repo_id": "test-123", "name": "Test", "slug": "test",
216 "owner": "alice", "visibility": "public", "clone_url": "musehub://alice/test",
217 "created_at": None}
218
219 with patch(
220 "musehub.mcp.write_tools.repos.execute_create_repo",
221 new_callable=AsyncMock,
222 return_value=mock_result,
223 ):
224 resp = await handle_request(
225 _req("tools/call", {"name": "musehub_create_repo", "arguments": {"name": "Test"}}),
226 user_id="alice",
227 )
228 assert resp is not None
229 assert "result" in resp
230 assert resp["result"].get("isError") is False
231
232
233 @pytest.mark.asyncio
234 async def test_tools_call_read_tool_with_mock_executor() -> None:
235 """Read tools should delegate to the executor and return text content."""
236 mock_result = MagicMock()
237 mock_result.ok = True
238 mock_result.data = {"repo_id": "r123", "branches": []}
239
240 with patch(
241 "musehub.services.musehub_mcp_executor.execute_list_branches",
242 new_callable=AsyncMock,
243 return_value=mock_result,
244 ):
245 resp = await handle_request(
246 _req("tools/call", {"name": "musehub_list_branches", "arguments": {"repo_id": "r123"}})
247 )
248
249 assert resp is not None
250 assert "result" in resp
251 result = resp["result"]
252 assert result.get("isError") is False
253 content = result["content"]
254 assert isinstance(content, list)
255 assert len(content) == 1
256 assert content[0]["type"] == "text"
257 # Text is wrapped in <musehub_tool_result> tags β€” unwrap before parsing.
258 data = json.loads(_unwrap_tool_text(content[0]["text"]))
259 assert data["repo_id"] == "r123"
260
261
262 # ── Resource catalogue ────────────────────────────────────────────────────────
263
264
265 @pytest.mark.asyncio
266 async def test_resources_list_returns_12_static() -> None:
267 """resources/list should return all static resources (musehub:// + muse:// docs/domains)."""
268 resp = await handle_request(_req("resources/list"))
269 assert resp is not None
270 resources = resp["result"]["resources"]
271 assert len(resources) == 9
272
273
274 @pytest.mark.asyncio
275 async def test_resources_templates_list_returns_17_templates() -> None:
276 """resources/templates/list should return the 19 URI templates."""
277 resp = await handle_request(_req("resources/templates/list"))
278 assert resp is not None
279 templates = resp["result"]["resourceTemplates"]
280 assert len(templates) == 19
281
282
283 def test_static_resources_have_required_fields() -> None:
284 """Each static resource must have uri, name, and mimeType."""
285 _VALID_PREFIXES = ("musehub://", "muse://")
286 for r in STATIC_RESOURCES:
287 assert "uri" in r
288 assert "name" in r
289 assert r["uri"].startswith(_VALID_PREFIXES), f"Unexpected URI scheme: {r['uri']}"
290
291
292 def test_resource_templates_have_required_fields() -> None:
293 """Each resource template must have uriTemplate, name, and mimeType."""
294 _VALID_PREFIXES = ("musehub://", "muse://")
295 for t in RESOURCE_TEMPLATES:
296 assert "uriTemplate" in t
297 assert "name" in t
298 assert t["uriTemplate"].startswith(_VALID_PREFIXES), f"Unexpected URI scheme: {t['uriTemplate']}"
299
300
301 @pytest.mark.asyncio
302 async def test_resources_read_unknown_uri_returns_error_content() -> None:
303 """resources/read with an unknown URI should return an error in the text content."""
304 resp = await handle_request(
305 _req("resources/read", {"uri": "musehub://nonexistent/path/that/does/not/exist"})
306 )
307 assert resp is not None
308 assert "result" in resp
309 contents = resp["result"]["contents"]
310 assert isinstance(contents, list)
311 assert len(contents) == 1
312 data = json.loads(contents[0]["text"])
313 assert "error" in data
314
315
316 @pytest.mark.asyncio
317 async def test_resources_read_missing_uri_returns_error() -> None:
318 """resources/read without a uri parameter should return an InvalidParams error."""
319 resp = await handle_request(_req("resources/read", {}))
320 assert resp is not None
321 assert "error" in resp
322 assert resp["error"]["code"] == -32602
323
324
325 @pytest.mark.asyncio
326 async def test_resources_read_unsupported_scheme() -> None:
327 """resources/read with a non-musehub:// URI should return an error in content."""
328 result = await read_resource("https://example.com/foo")
329 assert "error" in result
330
331
332 @pytest.mark.asyncio
333 async def test_resources_read_me_requires_auth() -> None:
334 """musehub://me should return an error when user_id is None."""
335 from musehub.mcp.resources import _read_me
336 result = await _read_me(None)
337 assert "error" in result
338
339
340 # ── Prompt catalogue ──────────────────────────────────────────────────────────
341
342
343 @pytest.mark.asyncio
344 async def test_prompts_list_returns_10_prompts() -> None:
345 """prompts/list should return all 10 workflow prompts."""
346 resp = await handle_request(_req("prompts/list"))
347 assert resp is not None
348 prompts = resp["result"]["prompts"]
349 assert len(prompts) == 11 # prompt count post-MCP cleanup
350
351
352 def test_prompt_catalogue_completeness() -> None:
353 """PROMPT_CATALOGUE must have exactly 12 entries."""
354 assert len(PROMPT_CATALOGUE) == 11 # prompt count post-MCP cleanup
355
356
357 def test_prompt_names_are_correct() -> None:
358 """All 10 expected prompt names must be present."""
359 names = {p["name"] for p in PROMPT_CATALOGUE}
360 assert "musehub/orientation" in names
361 assert "musehub/contribute" in names
362 assert "musehub/create" in names
363 assert "musehub/review_proposal" in names
364 assert "musehub/issue_triage" in names
365 assert "musehub/release_prep" in names
366 assert "musehub/onboard" in names
367
368
369 @pytest.mark.asyncio
370 async def test_prompts_get_orientation_returns_messages() -> None:
371 """prompts/get for musehub/orientation should return messages."""
372 resp = await handle_request(
373 _req("prompts/get", {"name": "musehub/orientation", "arguments": {}})
374 )
375 assert resp is not None
376 assert "result" in resp
377 result = resp["result"]
378 assert "messages" in result
379 messages = result["messages"]
380 assert len(messages) == 2
381 assert messages[0]["role"] == "user"
382 assert messages[1]["role"] == "assistant"
383
384
385 @pytest.mark.asyncio
386 async def test_prompts_get_contribute_interpolates_args() -> None:
387 """prompts/get for musehub/contribute should accept repo_id, owner, slug args."""
388 resp = await handle_request(
389 _req("prompts/get", {
390 "name": "musehub/contribute",
391 "arguments": {"repo_id": "abc-123", "owner": "alice", "slug": "jazz-session"},
392 })
393 )
394 assert resp is not None
395 assert "result" in resp
396 text = resp["result"]["messages"][1]["content"]["text"]
397 assert "jazz-session" in text
398
399
400 @pytest.mark.asyncio
401 async def test_prompts_get_unknown_returns_method_not_found() -> None:
402 """prompts/get for an unknown name should return a -32601 JSON-RPC error."""
403 resp = await handle_request(
404 _req("prompts/get", {"name": "musehub/nonexistent"})
405 )
406 assert resp is not None
407 assert "error" in resp
408 assert resp["error"]["code"] == -32601
409
410
411 def test_get_prompt_all_prompts_assemble() -> None:
412 """All 12 prompts should assemble without raising exceptions."""
413 for prompt_def in PROMPT_CATALOGUE:
414 name = prompt_def["name"]
415 result = get_prompt(name, {"repo_id": "test-id", "proposal_id": "proposal-id", "owner": "user", "slug": "repo"})
416 assert result is not None, f"get_prompt({name!r}) returned None"
417 assert "messages" in result
418 assert len(result["messages"]) >= 2
419
420
421 def test_get_prompt_unknown_returns_none() -> None:
422 """get_prompt for an unknown name should return None."""
423 result = get_prompt("musehub/unknown")
424 assert result is None
425
426
427 # ── Batch handling ────────────────────────────────────────────────────────────
428
429
430 @pytest.mark.asyncio
431 async def test_batch_handles_multiple_requests() -> None:
432 """handle_batch should return responses for all non-notifications."""
433 batch = [
434 _req("initialize", {"protocolVersion": "2025-03-26"}, req_id=1),
435 _req("tools/list", req_id=2),
436 _req("prompts/list", req_id=3),
437 ]
438 responses = await handle_batch(batch)
439 assert len(responses) == 3
440 ids = {r["id"] for r in responses}
441 assert ids == {1, 2, 3}
442
443
444 @pytest.mark.asyncio
445 async def test_batch_excludes_notifications() -> None:
446 """handle_batch should not include responses for notifications."""
447 batch = [
448 _req("ping", req_id=1),
449 _notification("ping"), # no id β†’ no response
450 ]
451 responses = await handle_batch(batch)
452 assert len(responses) == 1
453 assert responses[0]["id"] == 1
454
455
456 # ── Label tool dispatcher routing ─────────────────────────────────────────────
457
458
459 @pytest.mark.asyncio
460 async def test_musehub_list_labels_routes_to_executor() -> None:
461 """musehub_list_labels must delegate to execute_list_labels."""
462 mock_result = MagicMock()
463 mock_result.ok = True
464 mock_result.data = {"repo_id": "r1", "total": 0, "labels": []}
465
466 with patch(
467 "musehub.services.musehub_mcp_executor.execute_list_labels",
468 new_callable=AsyncMock,
469 return_value=mock_result,
470 ):
471 resp = await handle_request(
472 _req("tools/call", {"name": "musehub_list_labels", "arguments": {"repo_id": "r1"}})
473 )
474
475 assert resp is not None
476 assert resp["result"].get("isError") is False
477
478
479 @pytest.mark.asyncio
480 async def test_musehub_update_label_requires_auth() -> None:
481 """musehub_update_label without user_id should return isError=true."""
482 resp = await handle_request(
483 _req("tools/call", {
484 "name": "musehub_update_label",
485 "arguments": {"repo_id": "r1", "label_id": "lid", "name": "new"},
486 }),
487 user_id=None,
488 )
489 assert resp is not None
490 assert resp["result"].get("isError") is True
491
492
493 @pytest.mark.asyncio
494 async def test_musehub_delete_label_requires_auth() -> None:
495 """musehub_delete_label without user_id should return isError=true."""
496 resp = await handle_request(
497 _req("tools/call", {
498 "name": "musehub_delete_label",
499 "arguments": {"repo_id": "r1", "label_id": "lid"},
500 }),
501 user_id=None,
502 )
503 assert resp is not None
504 assert resp["result"].get("isError") is True
505
506
507 @pytest.mark.asyncio
508 async def test_label_tools_in_write_tool_names() -> None:
509 """musehub_update_label and musehub_delete_label must be in MUSEHUB_WRITE_TOOL_NAMES."""
510 assert "musehub_create_label" in MUSEHUB_WRITE_TOOL_NAMES
511 assert "musehub_update_label" in MUSEHUB_WRITE_TOOL_NAMES
512 assert "musehub_delete_label" in MUSEHUB_WRITE_TOOL_NAMES
513
514
515 # ── Fork tool dispatcher routing ──────────────────────────────────────────────
516
517
518 @pytest.mark.asyncio
519 async def test_musehub_list_repo_forks_routes_to_executor() -> None:
520 """musehub_list_repo_forks must delegate to execute_list_repo_forks."""
521 mock_result = MagicMock()
522 mock_result.ok = True
523 mock_result.data = {"total": 0, "forks": []}
524
525 with patch(
526 "musehub.services.musehub_mcp_executor.musehub_repository",
527 autospec=True,
528 ) as mock_repo:
529 mock_repo.list_repo_forks_flat = AsyncMock(return_value=MagicMock(total=0, forks=[]))
530 resp = await handle_request(
531 _req("tools/call", {"name": "musehub_list_repo_forks", "arguments": {"repo_id": "r1"}})
532 )
533
534 assert resp is not None
535 assert "result" in resp
536 assert "content" in resp["result"]
537
538
539 @pytest.mark.asyncio
540 async def test_musehub_get_fork_network_routes_to_executor() -> None:
541 """musehub_get_fork_network must delegate to execute_get_fork_network."""
542 from musehub.models.musehub import ForkNetworkNode, ForkNetworkResponse
543
544 mock_network = ForkNetworkResponse(
545 root=ForkNetworkNode(
546 owner="alice",
547 repo_slug="beats",
548 repo_id="r1",
549 divergence_commits=0,
550 forked_by="",
551 forked_at=None,
552 children=[],
553 ),
554 total_forks=0,
555 )
556
557 with patch(
558 "musehub.services.musehub_mcp_executor.musehub_repository",
559 autospec=True,
560 ) as mock_repo:
561 mock_repo.list_repo_forks = AsyncMock(return_value=mock_network)
562 resp = await handle_request(
563 _req("tools/call", {"name": "musehub_get_fork_network", "arguments": {"repo_id": "r1"}})
564 )
565
566 assert resp is not None
567 assert "result" in resp
568 assert "content" in resp["result"]
569
570
571 @pytest.mark.asyncio
572 async def test_musehub_get_user_forks_routes_to_executor() -> None:
573 """musehub_get_user_forks must delegate to execute_get_user_forks."""
574 with patch(
575 "musehub.services.musehub_mcp_executor.musehub_repository",
576 autospec=True,
577 ) as mock_repo:
578 mock_repo.get_user_forks = AsyncMock(return_value=MagicMock(total=0, forks=[]))
579 resp = await handle_request(
580 _req("tools/call", {"name": "musehub_get_user_forks", "arguments": {"username": "alice"}})
581 )
582
583 assert resp is not None
584 assert "result" in resp
585 assert "content" in resp["result"]
586
587
588 @pytest.mark.asyncio
589 async def test_musehub_fork_repo_requires_auth() -> None:
590 """musehub_fork_repo without user_id must return isError=true."""
591 resp = await handle_request(
592 _req("tools/call", {
593 "name": "musehub_fork_repo",
594 "arguments": {"source_repo_id": "r1"},
595 }),
596 user_id=None,
597 )
598 assert resp is not None
599 assert resp["result"].get("isError") is True
600
601
602 @pytest.mark.asyncio
603 async def test_musehub_fork_repo_in_write_tool_names() -> None:
604 """musehub_fork_repo must be in MUSEHUB_WRITE_TOOL_NAMES."""
605 assert "musehub_fork_repo" in MUSEHUB_WRITE_TOOL_NAMES