gabriel / musehub public
test_mcp_dispatcher.py python
415 lines 15.5 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d 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 (32 tools)
12 - Resource catalogue completeness (5 static, 15 templated)
13 - Prompt catalogue completeness (8 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_notification_returns_none() -> None:
93 """Notifications (no id) should return None from handle_request."""
94 result = await handle_request(_notification("ping"))
95 assert result is None
96
97
98 # ── Tool catalogue ────────────────────────────────────────────────────────────
99
100
101 @pytest.mark.asyncio
102 async def test_tools_list_returns_32_tools() -> None:
103 """tools/list should return all 27 registered tools."""
104 resp = await handle_request(_req("tools/list"))
105 assert resp is not None
106 result = resp["result"]
107 assert isinstance(result, dict)
108 tools = result["tools"]
109 assert isinstance(tools, list)
110 assert len(tools) == 32 # 15 read + 12 write + 5 elicitation
111
112
113 @pytest.mark.asyncio
114 async def test_tools_list_no_server_side_field() -> None:
115 """tools/list should strip the internal server_side field."""
116 resp = await handle_request(_req("tools/list"))
117 assert resp is not None
118 for tool in resp["result"]["tools"]:
119 assert "server_side" not in tool, f"Tool {tool['name']} exposes server_side"
120
121
122 @pytest.mark.asyncio
123 async def test_tools_list_all_have_required_fields() -> None:
124 """Every tool in tools/list must have name, description, and inputSchema."""
125 resp = await handle_request(_req("tools/list"))
126 assert resp is not None
127 for tool in resp["result"]["tools"]:
128 assert "name" in tool, f"Missing name: {tool}"
129 assert "description" in tool, f"Missing description for {tool.get('name')}"
130 assert "inputSchema" in tool, f"Missing inputSchema for {tool.get('name')}"
131
132
133 def test_tool_catalogue_has_32_tools() -> None:
134 """The MCP_TOOLS list must contain exactly 32 tools (27 original + 5 elicitation)."""
135 assert len(MCP_TOOLS) == 32
136
137
138 def test_write_tool_names_all_in_catalogue() -> None:
139 """Every write tool name must appear in the full catalogue."""
140 all_names = {t["name"] for t in MCP_TOOLS}
141 for name in MUSEHUB_WRITE_TOOL_NAMES:
142 assert name in all_names, f"Write tool {name!r} not in MCP_TOOLS"
143
144
145 # ── tools/call routing ────────────────────────────────────────────────────────
146
147
148 @pytest.mark.asyncio
149 async def test_tools_call_unknown_tool_returns_iserror() -> None:
150 """Calling an unknown tool should return isError=true (not a JSON-RPC error)."""
151 resp = await handle_request(
152 _req("tools/call", {"name": "nonexistent_tool", "arguments": {}})
153 )
154 assert resp is not None
155 # Envelope is success (has "result", not "error")
156 assert "result" in resp
157 result = resp["result"]
158 assert result.get("isError") is True
159
160
161 @pytest.mark.asyncio
162 async def test_tools_call_write_tool_requires_auth() -> None:
163 """Calling a write tool without a user_id should return isError=true."""
164 resp = await handle_request(
165 _req("tools/call", {"name": "musehub_create_repo", "arguments": {"name": "test"}}),
166 user_id=None,
167 )
168 assert resp is not None
169 assert "result" in resp
170 assert resp["result"].get("isError") is True
171
172
173 @pytest.mark.asyncio
174 async def test_tools_call_write_tool_passes_with_auth() -> None:
175 """Calling a write tool with user_id should reach the executor (not auth-gate)."""
176 mock_result = MagicMock()
177 mock_result.ok = True
178 mock_result.data = {"repo_id": "test-123", "name": "Test", "slug": "test",
179 "owner": "alice", "visibility": "public", "clone_url": "musehub://alice/test",
180 "created_at": None}
181
182 with patch(
183 "musehub.mcp.write_tools.repos.execute_create_repo",
184 new_callable=AsyncMock,
185 return_value=mock_result,
186 ):
187 resp = await handle_request(
188 _req("tools/call", {"name": "musehub_create_repo", "arguments": {"name": "Test"}}),
189 user_id="alice",
190 )
191 assert resp is not None
192 assert "result" in resp
193 assert resp["result"].get("isError") is False
194
195
196 @pytest.mark.asyncio
197 async def test_tools_call_read_tool_with_mock_executor() -> None:
198 """Read tools should delegate to the executor and return text content."""
199 mock_result = MagicMock()
200 mock_result.ok = True
201 mock_result.data = {"repo_id": "r123", "branches": [], "recent_commits": [], "total_commits": 0, "branch_count": 0}
202
203 with patch(
204 "musehub.services.musehub_mcp_executor.execute_browse_repo",
205 new_callable=AsyncMock,
206 return_value=mock_result,
207 ):
208 resp = await handle_request(
209 _req("tools/call", {"name": "musehub_browse_repo", "arguments": {"repo_id": "r123"}})
210 )
211
212 assert resp is not None
213 assert "result" in resp
214 result = resp["result"]
215 assert result.get("isError") is False
216 content = result["content"]
217 assert isinstance(content, list)
218 assert len(content) == 1
219 assert content[0]["type"] == "text"
220 # Text should be valid JSON
221 data = json.loads(content[0]["text"])
222 assert data["repo_id"] == "r123"
223
224
225 # ── Resource catalogue ────────────────────────────────────────────────────────
226
227
228 @pytest.mark.asyncio
229 async def test_resources_list_returns_5_static() -> None:
230 """resources/list should return the 5 static resources."""
231 resp = await handle_request(_req("resources/list"))
232 assert resp is not None
233 resources = resp["result"]["resources"]
234 assert len(resources) == 5
235
236
237 @pytest.mark.asyncio
238 async def test_resources_templates_list_returns_15_templates() -> None:
239 """resources/templates/list should return the 15 URI templates."""
240 resp = await handle_request(_req("resources/templates/list"))
241 assert resp is not None
242 templates = resp["result"]["resourceTemplates"]
243 assert len(templates) == 15
244
245
246 def test_static_resources_have_required_fields() -> None:
247 """Each static resource must have uri, name, and mimeType."""
248 for r in STATIC_RESOURCES:
249 assert "uri" in r
250 assert "name" in r
251 assert r["uri"].startswith("musehub://")
252
253
254 def test_resource_templates_have_required_fields() -> None:
255 """Each resource template must have uriTemplate, name, and mimeType."""
256 for t in RESOURCE_TEMPLATES:
257 assert "uriTemplate" in t
258 assert "name" in t
259 assert t["uriTemplate"].startswith("musehub://")
260
261
262 @pytest.mark.asyncio
263 async def test_resources_read_unknown_uri_returns_error_content() -> None:
264 """resources/read with an unknown URI should return an error in the text content."""
265 resp = await handle_request(
266 _req("resources/read", {"uri": "musehub://nonexistent/path/that/does/not/exist"})
267 )
268 assert resp is not None
269 assert "result" in resp
270 contents = resp["result"]["contents"]
271 assert isinstance(contents, list)
272 assert len(contents) == 1
273 data = json.loads(contents[0]["text"])
274 assert "error" in data
275
276
277 @pytest.mark.asyncio
278 async def test_resources_read_missing_uri_returns_error() -> None:
279 """resources/read without a uri parameter should return an InvalidParams error."""
280 resp = await handle_request(_req("resources/read", {}))
281 assert resp is not None
282 assert "error" in resp
283 assert resp["error"]["code"] == -32602
284
285
286 @pytest.mark.asyncio
287 async def test_resources_read_unsupported_scheme() -> None:
288 """resources/read with a non-musehub:// URI should return an error in content."""
289 result = await read_resource("https://example.com/foo")
290 assert "error" in result
291
292
293 @pytest.mark.asyncio
294 async def test_resources_read_me_requires_auth() -> None:
295 """musehub://me should return an error when user_id is None."""
296 from musehub.mcp.resources import _read_me
297 result = await _read_me(None)
298 assert "error" in result
299
300
301 # ── Prompt catalogue ──────────────────────────────────────────────────────────
302
303
304 @pytest.mark.asyncio
305 async def test_prompts_list_returns_8_prompts() -> None:
306 """prompts/list should return all 8 workflow prompts (6 + 2 elicitation-aware)."""
307 resp = await handle_request(_req("prompts/list"))
308 assert resp is not None
309 prompts = resp["result"]["prompts"]
310 assert len(prompts) == 8
311
312
313 def test_prompt_catalogue_completeness() -> None:
314 """PROMPT_CATALOGUE must have exactly 8 entries."""
315 assert len(PROMPT_CATALOGUE) == 8
316
317
318 def test_prompt_names_are_correct() -> None:
319 """All 8 expected prompt names must be present."""
320 names = {p["name"] for p in PROMPT_CATALOGUE}
321 assert "musehub/orientation" in names
322 assert "musehub/contribute" in names
323 assert "musehub/compose" in names
324 assert "musehub/review_pr" in names
325 assert "musehub/issue_triage" in names
326 assert "musehub/release_prep" in names
327 assert "musehub/onboard" in names
328 assert "musehub/release_to_world" in names
329
330
331 @pytest.mark.asyncio
332 async def test_prompts_get_orientation_returns_messages() -> None:
333 """prompts/get for musehub/orientation should return messages."""
334 resp = await handle_request(
335 _req("prompts/get", {"name": "musehub/orientation", "arguments": {}})
336 )
337 assert resp is not None
338 assert "result" in resp
339 result = resp["result"]
340 assert "messages" in result
341 messages = result["messages"]
342 assert len(messages) == 2
343 assert messages[0]["role"] == "user"
344 assert messages[1]["role"] == "assistant"
345
346
347 @pytest.mark.asyncio
348 async def test_prompts_get_contribute_interpolates_args() -> None:
349 """prompts/get for musehub/contribute should accept repo_id, owner, slug args."""
350 resp = await handle_request(
351 _req("prompts/get", {
352 "name": "musehub/contribute",
353 "arguments": {"repo_id": "abc-123", "owner": "alice", "slug": "jazz-session"},
354 })
355 )
356 assert resp is not None
357 assert "result" in resp
358 text = resp["result"]["messages"][1]["content"]["text"]
359 assert "jazz-session" in text
360
361
362 @pytest.mark.asyncio
363 async def test_prompts_get_unknown_returns_method_not_found() -> None:
364 """prompts/get for an unknown name should return a -32601 JSON-RPC error."""
365 resp = await handle_request(
366 _req("prompts/get", {"name": "musehub/nonexistent"})
367 )
368 assert resp is not None
369 assert "error" in resp
370 assert resp["error"]["code"] == -32601
371
372
373 def test_get_prompt_all_prompts_assemble() -> None:
374 """All 8 prompts should assemble without raising exceptions."""
375 for prompt_def in PROMPT_CATALOGUE:
376 name = prompt_def["name"]
377 result = get_prompt(name, {"repo_id": "test-id", "pr_id": "pr-id", "owner": "user", "slug": "repo"})
378 assert result is not None, f"get_prompt({name!r}) returned None"
379 assert "messages" in result
380 assert len(result["messages"]) >= 2
381
382
383 def test_get_prompt_unknown_returns_none() -> None:
384 """get_prompt for an unknown name should return None."""
385 result = get_prompt("musehub/unknown")
386 assert result is None
387
388
389 # ── Batch handling ────────────────────────────────────────────────────────────
390
391
392 @pytest.mark.asyncio
393 async def test_batch_handles_multiple_requests() -> None:
394 """handle_batch should return responses for all non-notifications."""
395 batch = [
396 _req("initialize", {"protocolVersion": "2025-03-26"}, req_id=1),
397 _req("tools/list", req_id=2),
398 _req("prompts/list", req_id=3),
399 ]
400 responses = await handle_batch(batch)
401 assert len(responses) == 3
402 ids = {r["id"] for r in responses}
403 assert ids == {1, 2, 3}
404
405
406 @pytest.mark.asyncio
407 async def test_batch_excludes_notifications() -> None:
408 """handle_batch should not include responses for notifications."""
409 batch = [
410 _req("ping", req_id=1),
411 _notification("ping"), # no id → no response
412 ]
413 responses = await handle_batch(batch)
414 assert len(responses) == 1
415 assert responses[0]["id"] == 1