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