gabriel / musehub public
test_mcp_dispatcher.py python
437 lines 16.5 KB
d4eb1c39 Theme overhaul: domains, new-repo, MCP docs, copy icons; legacy CSS rem… Gabriel Cardona <cgcardona@gmail.com> 3d ago
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 (40 tools)
12 - Resource catalogue completeness (12 static, 17 templated)
13 - Prompt catalogue completeness (10 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
28
29 # ── Helpers ───────────────────────────────────────────────────────────────────
30
31
32 def _req(method: str, params: dict | None = None, req_id: int = 1) -> dict:
33 """Build a minimal JSON-RPC 2.0 request dict."""
34 msg: dict = {"jsonrpc": "2.0", "id": req_id, "method": method}
35 if params is not None:
36 msg["params"] = params
37 return msg
38
39
40 def _notification(method: str, params: dict | None = None) -> dict:
41 """Build a JSON-RPC 2.0 notification (no id)."""
42 msg: dict = {"jsonrpc": "2.0", "method": method}
43 if params is not None:
44 msg["params"] = params
45 return msg
46
47
48 # ── Protocol correctness ──────────────────────────────────────────────────────
49
50
51 @pytest.mark.asyncio
52 async def test_initialize_returns_capabilities() -> None:
53 """initialize should return protocolVersion 2025-11-25 and capabilities."""
54 resp = await handle_request(_req("initialize", {"protocolVersion": "2025-11-25"}))
55 assert resp is not None
56 assert resp["jsonrpc"] == "2.0"
57 assert resp["id"] == 1
58 result = resp["result"]
59 assert isinstance(result, dict)
60 assert result["protocolVersion"] == "2025-11-25"
61 assert "capabilities" in result
62 assert "tools" in result["capabilities"]
63 assert "resources" in result["capabilities"]
64 assert "prompts" in result["capabilities"]
65 assert "elicitation" in result["capabilities"]
66 assert "form" in result["capabilities"]["elicitation"]
67 assert "url" in result["capabilities"]["elicitation"]
68 # serverInfo must only contain name and version (not capabilities)
69 assert "name" in result["serverInfo"]
70 assert "version" in result["serverInfo"]
71 assert "capabilities" not in result["serverInfo"]
72
73
74 @pytest.mark.asyncio
75 async def test_ping_returns_empty_result() -> None:
76 """ping should return an empty result dict."""
77 resp = await handle_request(_req("ping"))
78 assert resp is not None
79 assert resp["result"] == {}
80
81
82 @pytest.mark.asyncio
83 async def test_unknown_method_returns_error() -> None:
84 """Unknown methods should return a JSON-RPC method-not-found error."""
85 resp = await handle_request(_req("musehub/does-not-exist"))
86 assert resp is not None
87 assert "error" in resp
88 assert resp["error"]["code"] == -32601
89
90
91 @pytest.mark.asyncio
92 async def test_completions_complete_returns_empty() -> None:
93 """completions/complete stub returns empty values list (MCP 2025-11-25)."""
94 resp = await handle_request(_req("completions/complete", {"ref": {}, "argument": {"name": "x", "value": "y"}}))
95 assert resp is not None
96 assert "result" in resp
97 assert resp["result"]["completion"]["values"] == []
98
99
100 @pytest.mark.asyncio
101 async def test_logging_set_level_returns_empty() -> None:
102 """logging/setLevel should return an empty result dict (MCP 2025-11-25)."""
103 resp = await handle_request(_req("logging/setLevel", {"level": "info"}))
104 assert resp is not None
105 assert resp["result"] == {}
106
107
108 @pytest.mark.asyncio
109 async def test_notification_returns_none() -> None:
110 """Notifications (no id) should return None from handle_request."""
111 result = await handle_request(_notification("ping"))
112 assert result is None
113
114
115 # ── Tool catalogue ────────────────────────────────────────────────────────────
116
117
118 @pytest.mark.asyncio
119 async def test_tools_list_returns_40_tools() -> None:
120 """tools/list should return all 40 registered tools."""
121 resp = await handle_request(_req("tools/list"))
122 assert resp is not None
123 result = resp["result"]
124 assert isinstance(result, dict)
125 tools = result["tools"]
126 assert isinstance(tools, list)
127 assert len(tools) == 40 # 20 read + 15 write + 5 elicitation
128
129
130 @pytest.mark.asyncio
131 async def test_tools_list_no_server_side_field() -> None:
132 """tools/list should strip the internal server_side field."""
133 resp = await handle_request(_req("tools/list"))
134 assert resp is not None
135 for tool in resp["result"]["tools"]:
136 assert "server_side" not in tool, f"Tool {tool['name']} exposes server_side"
137
138
139 @pytest.mark.asyncio
140 async def test_tools_list_all_have_required_fields() -> None:
141 """Every tool in tools/list must have name, description, inputSchema, and annotations."""
142 resp = await handle_request(_req("tools/list"))
143 assert resp is not None
144 for tool in resp["result"]["tools"]:
145 assert "name" in tool, f"Missing name: {tool}"
146 assert "description" in tool, f"Missing description for {tool.get('name')}"
147 assert "inputSchema" in tool, f"Missing inputSchema for {tool.get('name')}"
148 assert "annotations" in tool, f"Missing MCP 2025-11-25 annotations for {tool.get('name')}"
149
150
151 def test_tool_catalogue_has_40_tools() -> None:
152 """The MCP_TOOLS list must contain exactly 40 tools."""
153 assert len(MCP_TOOLS) == 40
154
155
156 def test_write_tool_names_all_in_catalogue() -> None:
157 """Every write tool name must appear in the full catalogue."""
158 all_names = {t["name"] for t in MCP_TOOLS}
159 for name in MUSEHUB_WRITE_TOOL_NAMES:
160 assert name in all_names, f"Write tool {name!r} not in MCP_TOOLS"
161
162
163 # ── tools/call routing ────────────────────────────────────────────────────────
164
165
166 @pytest.mark.asyncio
167 async def test_tools_call_unknown_tool_returns_iserror() -> None:
168 """Calling an unknown tool should return isError=true (not a JSON-RPC error)."""
169 resp = await handle_request(
170 _req("tools/call", {"name": "nonexistent_tool", "arguments": {}})
171 )
172 assert resp is not None
173 # Envelope is success (has "result", not "error")
174 assert "result" in resp
175 result = resp["result"]
176 assert result.get("isError") is True
177
178
179 @pytest.mark.asyncio
180 async def test_tools_call_write_tool_requires_auth() -> None:
181 """Calling a write tool without a user_id should return isError=true."""
182 resp = await handle_request(
183 _req("tools/call", {"name": "musehub_create_repo", "arguments": {"name": "test"}}),
184 user_id=None,
185 )
186 assert resp is not None
187 assert "result" in resp
188 assert resp["result"].get("isError") is True
189
190
191 @pytest.mark.asyncio
192 async def test_tools_call_write_tool_passes_with_auth() -> None:
193 """Calling a write tool with user_id should reach the executor (not auth-gate)."""
194 mock_result = MagicMock()
195 mock_result.ok = True
196 mock_result.data = {"repo_id": "test-123", "name": "Test", "slug": "test",
197 "owner": "alice", "visibility": "public", "clone_url": "musehub://alice/test",
198 "created_at": None}
199
200 with patch(
201 "musehub.mcp.write_tools.repos.execute_create_repo",
202 new_callable=AsyncMock,
203 return_value=mock_result,
204 ):
205 resp = await handle_request(
206 _req("tools/call", {"name": "musehub_create_repo", "arguments": {"name": "Test"}}),
207 user_id="alice",
208 )
209 assert resp is not None
210 assert "result" in resp
211 assert resp["result"].get("isError") is False
212
213
214 @pytest.mark.asyncio
215 async def test_tools_call_read_tool_with_mock_executor() -> None:
216 """Read tools should delegate to the executor and return text content."""
217 mock_result = MagicMock()
218 mock_result.ok = True
219 mock_result.data = {"repo_id": "r123", "branches": []}
220
221 with patch(
222 "musehub.services.musehub_mcp_executor.execute_list_branches",
223 new_callable=AsyncMock,
224 return_value=mock_result,
225 ):
226 resp = await handle_request(
227 _req("tools/call", {"name": "musehub_list_branches", "arguments": {"repo_id": "r123"}})
228 )
229
230 assert resp is not None
231 assert "result" in resp
232 result = resp["result"]
233 assert result.get("isError") is False
234 content = result["content"]
235 assert isinstance(content, list)
236 assert len(content) == 1
237 assert content[0]["type"] == "text"
238 # Text should be valid JSON
239 data = json.loads(content[0]["text"])
240 assert data["repo_id"] == "r123"
241
242
243 # ── Resource catalogue ────────────────────────────────────────────────────────
244
245
246 @pytest.mark.asyncio
247 async def test_resources_list_returns_12_static() -> None:
248 """resources/list should return all 12 static resources (musehub:// + muse:// docs/domains)."""
249 resp = await handle_request(_req("resources/list"))
250 assert resp is not None
251 resources = resp["result"]["resources"]
252 assert len(resources) == 12
253
254
255 @pytest.mark.asyncio
256 async def test_resources_templates_list_returns_17_templates() -> None:
257 """resources/templates/list should return the 17 URI templates."""
258 resp = await handle_request(_req("resources/templates/list"))
259 assert resp is not None
260 templates = resp["result"]["resourceTemplates"]
261 assert len(templates) == 17
262
263
264 def test_static_resources_have_required_fields() -> None:
265 """Each static resource must have uri, name, and mimeType."""
266 _VALID_PREFIXES = ("musehub://", "muse://")
267 for r in STATIC_RESOURCES:
268 assert "uri" in r
269 assert "name" in r
270 assert r["uri"].startswith(_VALID_PREFIXES), f"Unexpected URI scheme: {r['uri']}"
271
272
273 def test_resource_templates_have_required_fields() -> None:
274 """Each resource template must have uriTemplate, name, and mimeType."""
275 _VALID_PREFIXES = ("musehub://", "muse://")
276 for t in RESOURCE_TEMPLATES:
277 assert "uriTemplate" in t
278 assert "name" in t
279 assert t["uriTemplate"].startswith(_VALID_PREFIXES), f"Unexpected URI scheme: {t['uriTemplate']}"
280
281
282 @pytest.mark.asyncio
283 async def test_resources_read_unknown_uri_returns_error_content() -> None:
284 """resources/read with an unknown URI should return an error in the text content."""
285 resp = await handle_request(
286 _req("resources/read", {"uri": "musehub://nonexistent/path/that/does/not/exist"})
287 )
288 assert resp is not None
289 assert "result" in resp
290 contents = resp["result"]["contents"]
291 assert isinstance(contents, list)
292 assert len(contents) == 1
293 data = json.loads(contents[0]["text"])
294 assert "error" in data
295
296
297 @pytest.mark.asyncio
298 async def test_resources_read_missing_uri_returns_error() -> None:
299 """resources/read without a uri parameter should return an InvalidParams error."""
300 resp = await handle_request(_req("resources/read", {}))
301 assert resp is not None
302 assert "error" in resp
303 assert resp["error"]["code"] == -32602
304
305
306 @pytest.mark.asyncio
307 async def test_resources_read_unsupported_scheme() -> None:
308 """resources/read with a non-musehub:// URI should return an error in content."""
309 result = await read_resource("https://example.com/foo")
310 assert "error" in result
311
312
313 @pytest.mark.asyncio
314 async def test_resources_read_me_requires_auth() -> None:
315 """musehub://me should return an error when user_id is None."""
316 from musehub.mcp.resources import _read_me
317 result = await _read_me(None)
318 assert "error" in result
319
320
321 # ── Prompt catalogue ──────────────────────────────────────────────────────────
322
323
324 @pytest.mark.asyncio
325 async def test_prompts_list_returns_10_prompts() -> None:
326 """prompts/list should return all 10 workflow prompts."""
327 resp = await handle_request(_req("prompts/list"))
328 assert resp is not None
329 prompts = resp["result"]["prompts"]
330 assert len(prompts) == 10
331
332
333 def test_prompt_catalogue_completeness() -> None:
334 """PROMPT_CATALOGUE must have exactly 10 entries."""
335 assert len(PROMPT_CATALOGUE) == 10
336
337
338 def test_prompt_names_are_correct() -> None:
339 """All 10 expected prompt names must be present."""
340 names = {p["name"] for p in PROMPT_CATALOGUE}
341 assert "musehub/orientation" in names
342 assert "musehub/contribute" in names
343 assert "musehub/create" in names
344 assert "musehub/review_pr" in names
345 assert "musehub/issue_triage" in names
346 assert "musehub/release_prep" in names
347 assert "musehub/onboard" in names
348 assert "musehub/release_to_world" in names
349 assert "musehub/domain-discovery" in names
350 assert "musehub/domain-authoring" in names
351
352
353 @pytest.mark.asyncio
354 async def test_prompts_get_orientation_returns_messages() -> None:
355 """prompts/get for musehub/orientation should return messages."""
356 resp = await handle_request(
357 _req("prompts/get", {"name": "musehub/orientation", "arguments": {}})
358 )
359 assert resp is not None
360 assert "result" in resp
361 result = resp["result"]
362 assert "messages" in result
363 messages = result["messages"]
364 assert len(messages) == 2
365 assert messages[0]["role"] == "user"
366 assert messages[1]["role"] == "assistant"
367
368
369 @pytest.mark.asyncio
370 async def test_prompts_get_contribute_interpolates_args() -> None:
371 """prompts/get for musehub/contribute should accept repo_id, owner, slug args."""
372 resp = await handle_request(
373 _req("prompts/get", {
374 "name": "musehub/contribute",
375 "arguments": {"repo_id": "abc-123", "owner": "alice", "slug": "jazz-session"},
376 })
377 )
378 assert resp is not None
379 assert "result" in resp
380 text = resp["result"]["messages"][1]["content"]["text"]
381 assert "jazz-session" in text
382
383
384 @pytest.mark.asyncio
385 async def test_prompts_get_unknown_returns_method_not_found() -> None:
386 """prompts/get for an unknown name should return a -32601 JSON-RPC error."""
387 resp = await handle_request(
388 _req("prompts/get", {"name": "musehub/nonexistent"})
389 )
390 assert resp is not None
391 assert "error" in resp
392 assert resp["error"]["code"] == -32601
393
394
395 def test_get_prompt_all_prompts_assemble() -> None:
396 """All 10 prompts should assemble without raising exceptions."""
397 for prompt_def in PROMPT_CATALOGUE:
398 name = prompt_def["name"]
399 result = get_prompt(name, {"repo_id": "test-id", "pr_id": "pr-id", "owner": "user", "slug": "repo"})
400 assert result is not None, f"get_prompt({name!r}) returned None"
401 assert "messages" in result
402 assert len(result["messages"]) >= 2
403
404
405 def test_get_prompt_unknown_returns_none() -> None:
406 """get_prompt for an unknown name should return None."""
407 result = get_prompt("musehub/unknown")
408 assert result is None
409
410
411 # ── Batch handling ────────────────────────────────────────────────────────────
412
413
414 @pytest.mark.asyncio
415 async def test_batch_handles_multiple_requests() -> None:
416 """handle_batch should return responses for all non-notifications."""
417 batch = [
418 _req("initialize", {"protocolVersion": "2025-03-26"}, req_id=1),
419 _req("tools/list", req_id=2),
420 _req("prompts/list", req_id=3),
421 ]
422 responses = await handle_batch(batch)
423 assert len(responses) == 3
424 ids = {r["id"] for r in responses}
425 assert ids == {1, 2, 3}
426
427
428 @pytest.mark.asyncio
429 async def test_batch_excludes_notifications() -> None:
430 """handle_batch should not include responses for notifications."""
431 batch = [
432 _req("ping", req_id=1),
433 _notification("ping"), # no id → no response
434 ]
435 responses = await handle_batch(batch)
436 assert len(responses) == 1
437 assert responses[0]["id"] == 1