gabriel / musehub public
test_mcp_smoke.py python
800 lines 31.9 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Full MCP smoke test — exercises every tool in MUSEHUB_TOOLS.
2
3 This is an integration smoke test, not a unit test. It:
4 1. Initializes a real MCP session through the ASGI transport
5 2. Calls every registered tool with minimal valid arguments
6 3. Asserts no tool returns an RPC-level error or an unexpected 5xx
7 4. Reports isError=true results as failures (tool logic broken)
8
9 Run with:
10 python -m pytest tests/test_mcp_smoke.py -v --tb=short
11 """
12 from __future__ import annotations
13
14 import json
15 import re
16
17 import pytest
18 from httpx import AsyncClient
19 from sqlalchemy.ext.asyncio import AsyncSession
20
21 from musehub.db import musehub_models as db
22 from musehub.db.musehub_models import MusehubIdentity
23 from tests.factories import create_issue, create_proposal, create_repo
24 from musehub.muse_contracts.json_types import JSONObject, StrDict
25
26 type _McpCtx = tuple[AsyncClient, str, StrDict, db.MusehubRepo]
27
28 # ── helpers ──────────────────────────────────────────────────────────────────
29
30 def _text(result: JSONObject) -> str:
31 """Extract text content from a tools/call result."""
32 content = result.get("result", {}).get("content", [])
33 return " ".join(c.get("text", "") for c in content if c.get("type") == "text")
34
35
36 def _is_error(result: JSONObject) -> bool:
37 return result.get("result", {}).get("isError", False)
38
39
40 def _rpc_error(result: JSONObject) -> str | None:
41 if "error" in result:
42 return result["error"].get("message", "unknown RPC error")
43 return None
44
45
46 async def _init_session(client: AsyncClient, auth_headers: StrDict) -> str:
47 r = await client.post(
48 "/mcp",
49 json={
50 "jsonrpc": "2.0",
51 "id": 0,
52 "method": "initialize",
53 "params": {
54 "protocolVersion": "2025-11-25",
55 "capabilities": {},
56 "clientInfo": {"name": "smoke-test", "version": "1.0"},
57 },
58 },
59 headers=auth_headers,
60 )
61 assert r.status_code == 200, f"MCP initialize failed: {r.text[:200]}"
62 return r.headers["mcp-session-id"]
63
64
65 async def call(
66 client: AsyncClient,
67 sid: str,
68 auth_headers: StrDict,
69 name: str,
70 arguments: JSONObject,
71 rpc_id: int = 1,
72 ) -> JSONObject:
73 r = await client.post(
74 "/mcp",
75 json={
76 "jsonrpc": "2.0",
77 "id": rpc_id,
78 "method": "tools/call",
79 "params": {"name": name, "arguments": arguments},
80 },
81 headers={**auth_headers, "Mcp-Session-Id": sid},
82 )
83 assert r.status_code in (200, 202), f"{name} HTTP {r.status_code}: {r.text[:200]}"
84 if r.status_code == 202:
85 return {"result": {"content": [{"type": "text", "text": "(202 accepted)"}]}}
86 return r.json()
87
88
89 # ── fixtures ──────────────────────────────────────────────────────────────────
90
91 @pytest.fixture
92 async def mcp_ctx(client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict, test_user: MusehubIdentity) -> _McpCtx:
93 """Provides (client, session_id, auth_headers, repo) ready for tool calls.
94
95 The repo has a 'main' branch seeded so proposal tools (which validate branch
96 existence) work without extra setup in each test.
97 """
98 from tests.factories import create_branch
99 repo = await create_repo(db_session, owner=test_user.handle, visibility="public")
100 await create_branch(db_session, repo_id=str(repo.repo_id), name="main")
101 await create_branch(db_session, repo_id=str(repo.repo_id), name="feature/smoke")
102 sid = await _init_session(client, auth_headers)
103 return client, sid, auth_headers, repo
104
105
106 # ── READ TOOLS ────────────────────────────────────────────────────────────────
107
108 @pytest.mark.anyio
109 async def test_mcp_whoami(mcp_ctx: _McpCtx) -> None:
110 c, sid, hdrs, repo = mcp_ctx
111 r = await call(c, sid, hdrs, "musehub_whoami", {})
112 assert _rpc_error(r) is None, _rpc_error(r)
113 assert not _is_error(r), _text(r)
114
115
116 @pytest.mark.anyio
117 async def test_mcp_search_repos(mcp_ctx: _McpCtx) -> None:
118 c, sid, hdrs, repo = mcp_ctx
119 r = await call(c, sid, hdrs, "musehub_search_repos", {"query": repo.slug})
120 assert _rpc_error(r) is None, _rpc_error(r)
121 assert not _is_error(r), _text(r)
122
123
124 @pytest.mark.anyio
125 async def test_mcp_get_context(mcp_ctx: _McpCtx) -> None:
126 c, sid, hdrs, repo = mcp_ctx
127 r = await call(c, sid, hdrs, "musehub_get_context", {"owner": repo.owner, "slug": repo.slug})
128 assert _rpc_error(r) is None, _rpc_error(r)
129 assert not _is_error(r), _text(r)
130
131
132 @pytest.mark.anyio
133 async def test_mcp_list_branches(mcp_ctx: _McpCtx) -> None:
134 c, sid, hdrs, repo = mcp_ctx
135 r = await call(c, sid, hdrs, "musehub_list_branches", {"owner": repo.owner, "slug": repo.slug})
136 assert _rpc_error(r) is None, _rpc_error(r)
137 assert not _is_error(r), _text(r)
138
139
140 @pytest.mark.anyio
141 async def test_mcp_list_commits(mcp_ctx: _McpCtx) -> None:
142 c, sid, hdrs, repo = mcp_ctx
143 r = await call(c, sid, hdrs, "musehub_list_commits", {"owner": repo.owner, "slug": repo.slug})
144 assert _rpc_error(r) is None, _rpc_error(r)
145 assert not _is_error(r), _text(r)
146
147
148 @pytest.mark.anyio
149 async def test_mcp_list_issues(mcp_ctx: _McpCtx) -> None:
150 c, sid, hdrs, repo = mcp_ctx
151 r = await call(c, sid, hdrs, "musehub_list_issues", {"owner": repo.owner, "slug": repo.slug})
152 assert _rpc_error(r) is None, _rpc_error(r)
153 assert not _is_error(r), _text(r)
154
155
156 @pytest.mark.anyio
157 async def test_mcp_get_issue(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
158 c, sid, hdrs, repo = mcp_ctx
159 issue = await create_issue(db_session, repo_id=str(repo.repo_id), author=repo.owner)
160 r = await call(c, sid, hdrs, "musehub_get_issue", {"owner": repo.owner, "slug": repo.slug, "issue_number": issue.number})
161 assert _rpc_error(r) is None, _rpc_error(r)
162 assert not _is_error(r), _text(r)
163
164
165 @pytest.mark.anyio
166 async def test_mcp_list_proposals(mcp_ctx: _McpCtx) -> None:
167 c, sid, hdrs, repo = mcp_ctx
168 r = await call(c, sid, hdrs, "musehub_list_proposals", {"owner": repo.owner, "slug": repo.slug})
169 assert _rpc_error(r) is None, _rpc_error(r)
170 assert not _is_error(r), _text(r)
171
172
173 @pytest.mark.anyio
174 async def test_mcp_get_proposal(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
175 c, sid, hdrs, repo = mcp_ctx
176 proposal = await create_proposal(db_session, repo_id=str(repo.repo_id), author=repo.owner)
177 r = await call(c, sid, hdrs, "musehub_get_proposal", {"owner": repo.owner, "slug": repo.slug, "proposal_id": proposal.proposal_id})
178 assert _rpc_error(r) is None, _rpc_error(r)
179 assert not _is_error(r), _text(r)
180
181
182 @pytest.mark.anyio
183 async def test_mcp_proposal_risk(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
184 c, sid, hdrs, repo = mcp_ctx
185 proposal = await create_proposal(db_session, repo_id=str(repo.repo_id), author=repo.owner)
186 r = await call(c, sid, hdrs, "musehub_proposal_risk", {"owner": repo.owner, "slug": repo.slug, "proposal_id": proposal.proposal_id})
187 assert _rpc_error(r) is None, _rpc_error(r)
188 assert not _is_error(r), _text(r)
189
190
191 @pytest.mark.anyio
192 async def test_mcp_proposal_symbol_diff(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
193 c, sid, hdrs, repo = mcp_ctx
194 proposal = await create_proposal(db_session, repo_id=str(repo.repo_id), author=repo.owner)
195 r = await call(c, sid, hdrs, "musehub_proposal_symbol_diff", {"owner": repo.owner, "slug": repo.slug, "proposal_id": proposal.proposal_id})
196 assert _rpc_error(r) is None, _rpc_error(r)
197 assert not _is_error(r), _text(r)
198
199
200 @pytest.mark.anyio
201 async def test_mcp_proposal_breakage(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
202 c, sid, hdrs, repo = mcp_ctx
203 proposal = await create_proposal(db_session, repo_id=str(repo.repo_id), author=repo.owner)
204 r = await call(c, sid, hdrs, "musehub_proposal_breakage", {"owner": repo.owner, "slug": repo.slug, "proposal_id": proposal.proposal_id})
205 assert _rpc_error(r) is None, _rpc_error(r)
206 assert not _is_error(r), _text(r)
207
208
209 @pytest.mark.anyio
210 async def test_mcp_list_releases(mcp_ctx: _McpCtx) -> None:
211 c, sid, hdrs, repo = mcp_ctx
212 r = await call(c, sid, hdrs, "musehub_list_releases", {"owner": repo.owner, "slug": repo.slug})
213 assert _rpc_error(r) is None, _rpc_error(r)
214 assert not _is_error(r), _text(r)
215
216
217 @pytest.mark.anyio
218 async def test_mcp_list_domains(mcp_ctx: _McpCtx) -> None:
219 c, sid, hdrs, repo = mcp_ctx
220 r = await call(c, sid, hdrs, "musehub_list_domains", {})
221 assert _rpc_error(r) is None, _rpc_error(r)
222 assert not _is_error(r), _text(r)
223
224
225 @pytest.mark.anyio
226 async def test_mcp_list_symbols(mcp_ctx: _McpCtx) -> None:
227 c, sid, hdrs, repo = mcp_ctx
228 r = await call(c, sid, hdrs, "musehub_list_symbols", {"owner": repo.owner, "slug": repo.slug})
229 assert _rpc_error(r) is None, _rpc_error(r)
230 assert not _is_error(r), _text(r)
231
232
233 @pytest.mark.anyio
234 async def test_mcp_intel_index_status(mcp_ctx: _McpCtx) -> None:
235 c, sid, hdrs, repo = mcp_ctx
236 r = await call(c, sid, hdrs, "musehub_intel_index_status", {"owner": repo.owner, "slug": repo.slug})
237 assert _rpc_error(r) is None, _rpc_error(r)
238 assert not _is_error(r), _text(r)
239
240
241 @pytest.mark.anyio
242 async def test_mcp_intel_health_score(mcp_ctx: _McpCtx) -> None:
243 """Health score requires a built symbol index — not_ready is the expected result for a new repo."""
244 c, sid, hdrs, repo = mcp_ctx
245 r = await call(c, sid, hdrs, "musehub_intel_health_score", {"owner": repo.owner, "slug": repo.slug})
246 assert _rpc_error(r) is None, _rpc_error(r)
247 # A repo with no commits will report not_ready — that's correct behaviour, not a crash
248 text = _text(r)
249 assert "not_ready" in text or "health" in text.lower() or not _is_error(r), (
250 f"Unexpected error from health_score: {text[:200]}"
251 )
252
253
254 @pytest.mark.anyio
255 async def test_mcp_intel_hotspots(mcp_ctx: _McpCtx) -> None:
256 c, sid, hdrs, repo = mcp_ctx
257 r = await call(c, sid, hdrs, "musehub_intel_hotspots", {"owner": repo.owner, "slug": repo.slug})
258 assert _rpc_error(r) is None, _rpc_error(r)
259 assert not _is_error(r), _text(r)
260
261
262 @pytest.mark.anyio
263 async def test_mcp_intel_dead(mcp_ctx: _McpCtx) -> None:
264 c, sid, hdrs, repo = mcp_ctx
265 r = await call(c, sid, hdrs, "musehub_intel_dead", {"owner": repo.owner, "slug": repo.slug})
266 assert _rpc_error(r) is None, _rpc_error(r)
267 assert not _is_error(r), _text(r)
268
269
270 @pytest.mark.anyio
271 async def test_mcp_intel_blast_risk(mcp_ctx: _McpCtx) -> None:
272 c, sid, hdrs, repo = mcp_ctx
273 r = await call(c, sid, hdrs, "musehub_intel_blast_risk", {"owner": repo.owner, "slug": repo.slug})
274 assert _rpc_error(r) is None, _rpc_error(r)
275 assert not _is_error(r), _text(r)
276
277
278 @pytest.mark.anyio
279 async def test_mcp_coord_swarm(mcp_ctx: _McpCtx) -> None:
280 c, sid, hdrs, repo = mcp_ctx
281 r = await call(c, sid, hdrs, "musehub_coord_swarm", {"owner": repo.owner, "slug": repo.slug})
282 assert _rpc_error(r) is None, _rpc_error(r)
283 assert not _is_error(r), _text(r)
284
285
286 @pytest.mark.anyio
287 async def test_mcp_coord_reservations(mcp_ctx: _McpCtx) -> None:
288 c, sid, hdrs, repo = mcp_ctx
289 r = await call(c, sid, hdrs, "musehub_coord_reservations", {"owner": repo.owner, "slug": repo.slug})
290 assert _rpc_error(r) is None, _rpc_error(r)
291 assert not _is_error(r), _text(r)
292
293
294 @pytest.mark.anyio
295 async def test_mcp_coord_tasks(mcp_ctx: _McpCtx) -> None:
296 c, sid, hdrs, repo = mcp_ctx
297 r = await call(c, sid, hdrs, "musehub_coord_tasks", {"owner": repo.owner, "slug": repo.slug})
298 assert _rpc_error(r) is None, _rpc_error(r)
299 assert not _is_error(r), _text(r)
300
301
302 @pytest.mark.anyio
303 async def test_mcp_coord_check_conflicts(mcp_ctx: _McpCtx) -> None:
304 c, sid, hdrs, repo = mcp_ctx
305 r = await call(c, sid, hdrs, "musehub_coord_check_conflicts", {
306 "owner": repo.owner, "slug": repo.slug, "symbols": ["main.py::MyClass"]
307 })
308 assert _rpc_error(r) is None, _rpc_error(r)
309 assert not _is_error(r), _text(r)
310
311
312 @pytest.mark.anyio
313 async def test_mcp_get_prompt(mcp_ctx: _McpCtx) -> None:
314 c, sid, hdrs, repo = mcp_ctx
315 r = await call(c, sid, hdrs, "musehub_get_prompt", {"name": "musehub/orientation"})
316 assert _rpc_error(r) is None, _rpc_error(r)
317 assert not _is_error(r), _text(r)
318
319
320 @pytest.mark.anyio
321 async def test_mcp_muse_remote(mcp_ctx: _McpCtx) -> None:
322 c, sid, hdrs, repo = mcp_ctx
323 r = await call(c, sid, hdrs, "muse_remote", {"owner": repo.owner, "slug": repo.slug})
324 assert _rpc_error(r) is None, _rpc_error(r)
325 assert not _is_error(r), _text(r)
326
327
328 @pytest.mark.anyio
329 async def test_mcp_muse_config(mcp_ctx: _McpCtx) -> None:
330 c, sid, hdrs, repo = mcp_ctx
331 r = await call(c, sid, hdrs, "muse_config", {"owner": repo.owner, "slug": repo.slug})
332 assert _rpc_error(r) is None, _rpc_error(r)
333 assert not _is_error(r), _text(r)
334
335
336 @pytest.mark.anyio
337 async def test_mcp_workspace_intel(mcp_ctx: _McpCtx) -> None:
338 c, sid, hdrs, repo = mcp_ctx
339 r = await call(c, sid, hdrs, "musehub_workspace_intel", {"owner": repo.owner})
340 assert _rpc_error(r) is None, _rpc_error(r)
341 assert not _is_error(r), _text(r)
342
343
344 @pytest.mark.anyio
345 async def test_mcp_cross_repo_impact(mcp_ctx: _McpCtx) -> None:
346 """cross_repo_impact requires a built symbol index — not_found is expected for a new repo."""
347 c, sid, hdrs, repo = mcp_ctx
348 r = await call(c, sid, hdrs, "musehub_cross_repo_impact", {
349 "owner": repo.owner, "slug": repo.slug,
350 "address": "main.py::App",
351 })
352 assert _rpc_error(r) is None, _rpc_error(r)
353 # A repo with no commits has no symbol index — not_found is correct, not a crash
354 text = _text(r)
355 assert "not_found" in text or "not_ready" in text or not _is_error(r), (
356 f"Unexpected error: {text[:200]}"
357 )
358
359
360 @pytest.mark.anyio
361 async def test_mcp_muse_pull(mcp_ctx: _McpCtx) -> None:
362 c, sid, hdrs, repo = mcp_ctx
363 r = await call(c, sid, hdrs, "muse_pull", {
364 "owner": repo.owner, "slug": repo.slug, "branch": "main"
365 })
366 assert _rpc_error(r) is None, _rpc_error(r)
367 # muse_pull may return isError=true if branch has no commits — that's ok
368 # as long as there's no crash
369
370
371 # ── WRITE TOOLS ───────────────────────────────────────────────────────────────
372
373 @pytest.mark.anyio
374 async def test_mcp_create_repo(mcp_ctx: _McpCtx) -> None:
375 c, sid, hdrs, _repo = mcp_ctx
376 r = await call(c, sid, hdrs, "musehub_create_repo", {
377 "name": "smoke-new-repo", "description": "mcp smoke test", "visibility": "public"
378 })
379 assert _rpc_error(r) is None, _rpc_error(r)
380 assert not _is_error(r), _text(r)
381
382
383 @pytest.mark.anyio
384 async def test_mcp_create_issue(mcp_ctx: _McpCtx) -> None:
385 c, sid, hdrs, repo = mcp_ctx
386 r = await call(c, sid, hdrs, "musehub_create_issue", {
387 "owner": repo.owner, "slug": repo.slug,
388 "title": "Smoke test issue", "body": "created by mcp smoke test",
389 })
390 assert _rpc_error(r) is None, _rpc_error(r)
391 assert not _is_error(r), _text(r)
392 # Number is in the response
393 assert re.search(r'"number"', _text(r)) or "number" in _text(r).lower() or _text(r)
394
395
396 @pytest.mark.anyio
397 async def test_mcp_update_issue(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
398 c, sid, hdrs, repo = mcp_ctx
399 issue = await create_issue(db_session, repo_id=str(repo.repo_id), author=repo.owner)
400 r = await call(c, sid, hdrs, "musehub_update_issue", {
401 "owner": repo.owner, "slug": repo.slug, "issue_number": issue.number,
402 "state": "closed",
403 })
404 assert _rpc_error(r) is None, _rpc_error(r)
405 assert not _is_error(r), _text(r)
406
407
408 @pytest.mark.anyio
409 async def test_mcp_create_issue_comment(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
410 c, sid, hdrs, repo = mcp_ctx
411 issue = await create_issue(db_session, repo_id=str(repo.repo_id), author=repo.owner)
412 r = await call(c, sid, hdrs, "musehub_create_issue_comment", {
413 "owner": repo.owner, "slug": repo.slug, "issue_number": issue.number,
414 "body": "smoke test comment",
415 })
416 assert _rpc_error(r) is None, _rpc_error(r)
417 assert not _is_error(r), _text(r)
418
419
420 @pytest.mark.anyio
421 async def test_mcp_create_proposal(mcp_ctx: _McpCtx) -> None:
422 c, sid, hdrs, repo = mcp_ctx
423 r = await call(c, sid, hdrs, "musehub_create_proposal", {
424 "owner": repo.owner, "slug": repo.slug,
425 "title": "Smoke proposal", "body": "mcp smoke test",
426 "from_branch": "feature/smoke", "to_branch": "main",
427 })
428 assert _rpc_error(r) is None, _rpc_error(r)
429 assert not _is_error(r), _text(r)
430
431
432 @pytest.mark.anyio
433 async def test_mcp_create_proposal_comment(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
434 c, sid, hdrs, repo = mcp_ctx
435 proposal = await create_proposal(db_session, repo_id=str(repo.repo_id), author=repo.owner)
436 r = await call(c, sid, hdrs, "musehub_create_proposal_comment", {
437 "owner": repo.owner, "slug": repo.slug, "proposal_id": proposal.proposal_id,
438 "body": "smoke proposal comment",
439 })
440 assert _rpc_error(r) is None, _rpc_error(r)
441 assert not _is_error(r), _text(r)
442
443
444 @pytest.mark.anyio
445 async def test_mcp_submit_proposal_review(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
446 c, sid, hdrs, repo = mcp_ctx
447 proposal = await create_proposal(db_session, repo_id=str(repo.repo_id), author=repo.owner)
448 r = await call(c, sid, hdrs, "musehub_submit_proposal_review", {
449 "owner": repo.owner, "slug": repo.slug, "proposal_id": proposal.proposal_id,
450 "event": "comment", "body": "lgtm",
451 })
452 assert _rpc_error(r) is None, _rpc_error(r)
453 assert not _is_error(r), _text(r)
454
455
456 @pytest.mark.anyio
457 async def test_mcp_create_label(mcp_ctx: _McpCtx) -> None:
458 c, sid, hdrs, repo = mcp_ctx
459 r = await call(c, sid, hdrs, "musehub_create_label", {
460 "owner": repo.owner, "slug": repo.slug,
461 "name": "smoke-label", "color": "#ff5500",
462 })
463 assert _rpc_error(r) is None, _rpc_error(r)
464 assert not _is_error(r), _text(r)
465
466
467 @pytest.mark.anyio
468 async def test_mcp_create_release(mcp_ctx: _McpCtx) -> None:
469 c, sid, hdrs, repo = mcp_ctx
470 r = await call(c, sid, hdrs, "musehub_create_release", {
471 "owner": repo.owner, "slug": repo.slug,
472 "tag": "v0.1.0-smoke", "name": "Smoke Release", "body": "mcp smoke",
473 })
474 assert _rpc_error(r) is None, _rpc_error(r)
475 assert not _is_error(r), _text(r)
476
477
478 @pytest.mark.anyio
479 async def test_mcp_create_agent_token_deprecated(mcp_ctx: _McpCtx) -> None:
480 """musehub_create_agent_token is deprecated — should return ok=False with clear message."""
481 c, sid, hdrs, _repo = mcp_ctx
482 r = await call(c, sid, hdrs, "musehub_create_agent_token", {"agent_name": "test-bot"})
483 assert _rpc_error(r) is None, _rpc_error(r)
484 # This tool returns ok=False (isError=true) by design — verify the message is useful
485 text = _text(r)
486 assert "deprecated" in text.lower() or "Ed25519" in text or "muse auth" in text, (
487 f"Expected deprecation message, got: {text[:200]}"
488 )
489
490
491 @pytest.mark.anyio
492 async def test_mcp_coord_claim_task(mcp_ctx: _McpCtx) -> None:
493 c, sid, hdrs, repo = mcp_ctx
494 r = await call(c, sid, hdrs, "musehub_coord_claim_task", {
495 "owner": repo.owner, "slug": repo.slug,
496 "queue": "tasks", "agent_id": "smoke-agent-1",
497 })
498 assert _rpc_error(r) is None, _rpc_error(r)
499 # No tasks in queue — ok=False is expected here
500 assert r.get("result") is not None
501
502
503 @pytest.mark.anyio
504 async def test_mcp_merge_proposal_no_commits(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
505 """merge_proposal on a proposal with no commits should fail gracefully (not 500)."""
506 c, sid, hdrs, repo = mcp_ctx
507 proposal = await create_proposal(db_session, repo_id=str(repo.repo_id), author=repo.owner)
508 r = await call(c, sid, hdrs, "musehub_merge_proposal", {
509 "owner": repo.owner, "slug": repo.slug, "proposal_id": proposal.proposal_id,
510 })
511 assert _rpc_error(r) is None, _rpc_error(r)
512 # Merge with no commits → isError=True is acceptable; crash is not
513
514
515 @pytest.mark.anyio
516 async def test_mcp_publish_domain_no_manifest(mcp_ctx: _McpCtx) -> None:
517 """publish_domain with missing manifest should fail gracefully."""
518 c, sid, hdrs, repo = mcp_ctx
519 r = await call(c, sid, hdrs, "musehub_publish_domain", {
520 "owner": repo.owner, "slug": repo.slug,
521 "domain_name": "smoke-domain",
522 "manifest": {"name": "smoke", "version": "0.1.0"},
523 })
524 assert _rpc_error(r) is None, _rpc_error(r)
525
526
527 # ── MULTI-STEP: read-back after write ────────────────────────────────────────
528
529 @pytest.mark.anyio
530 async def test_mcp_issue_roundtrip(mcp_ctx: _McpCtx) -> None:
531 """Create an issue via MCP, then read it back."""
532 c, sid, hdrs, repo = mcp_ctx
533
534 # Create
535 cr = await call(c, sid, hdrs, "musehub_create_issue", {
536 "owner": repo.owner, "slug": repo.slug,
537 "title": "Roundtrip issue", "body": "created and read back",
538 })
539 assert not _is_error(cr), _text(cr)
540 m = re.search(r'"number":\s*(\d+)', _text(cr))
541 assert m, f"No issue number in response: {_text(cr)[:300]}"
542 number = int(m.group(1))
543
544 # Read back
545 gr = await call(c, sid, hdrs, "musehub_get_issue", {
546 "owner": repo.owner, "slug": repo.slug, "issue_number": number,
547 })
548 assert not _is_error(gr), _text(gr)
549 assert "Roundtrip issue" in _text(gr), f"Issue title missing: {_text(gr)[:300]}"
550
551
552 @pytest.mark.anyio
553 async def test_mcp_label_then_list(mcp_ctx: _McpCtx) -> None:
554 """Create a label, then verify list_issues still works (no crash on label join)."""
555 c, sid, hdrs, repo = mcp_ctx
556 await call(c, sid, hdrs, "musehub_create_label", {
557 "owner": repo.owner, "slug": repo.slug, "name": "bug", "color": "#d73a4a",
558 })
559 r = await call(c, sid, hdrs, "musehub_list_issues", {"owner": repo.owner, "slug": repo.slug})
560 assert _rpc_error(r) is None, _rpc_error(r)
561 assert not _is_error(r), _text(r)
562
563
564 # ── SECURITY: write re-verification ──────────────────────────────────────────
565
566 @pytest.mark.anyio
567 async def test_mcp_write_without_msign_rejected(
568 client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict, test_user: MusehubIdentity,
569 ) -> None:
570 """Write tools must be rejected with 401 when no MSign is present on the request.
571
572 Simulates a stolen session ID: the session was created with auth (initialize
573 carries MSign), but subsequent write calls arrive with no Authorization header.
574 Read tools on the same unauthenticated session must still succeed.
575
576 The auth_headers fixture sets a global DI override that bypasses real MSign
577 verification. We temporarily lift it for the anon calls so the real
578 optional_signed_request runs (returning None for headerless requests).
579 """
580 from musehub.main import app
581 from musehub.auth.request_signing import optional_signed_request, require_signed_request
582
583 repo = await create_repo(db_session, owner=test_user.handle, visibility="public")
584
585 # Initialize with auth (normal flow — DI override active).
586 sid = await _init_session(client, auth_headers)
587
588 # Headers that carry the session ID but NO Authorization header.
589 anon_headers = {"Mcp-Session-Id": sid, "MCP-Protocol-Version": "2025-11-25"}
590
591 # Lift the DI overrides so real optional_signed_request runs on the next calls.
592 saved_opt = app.dependency_overrides.pop(optional_signed_request, None)
593 saved_req = app.dependency_overrides.pop(require_signed_request, None)
594 try:
595 # Read tool — must succeed anonymously (no MSign needed for reads).
596 r_read = await client.post(
597 "/mcp",
598 json={
599 "jsonrpc": "2.0", "id": 10, "method": "tools/call",
600 "params": {"name": "musehub_search_repos", "arguments": {"query": "test"}},
601 },
602 headers=anon_headers,
603 )
604 assert r_read.status_code == 200, f"Read tool should pass: {r_read.text[:200]}"
605 read_body = r_read.json()
606 assert "error" not in read_body or read_body.get("error") is None, (
607 f"Read tool returned RPC error: {read_body}"
608 )
609
610 # Write tool — must be rejected with 401.
611 r_write = await client.post(
612 "/mcp",
613 json={
614 "jsonrpc": "2.0", "id": 11, "method": "tools/call",
615 "params": {
616 "name": "musehub_create_issue",
617 "arguments": {
618 "owner": repo.owner, "slug": repo.slug,
619 "title": "Should be blocked", "body": "",
620 },
621 },
622 },
623 headers=anon_headers,
624 )
625 assert r_write.status_code == 401, (
626 f"Write tool without MSign should return 401, got {r_write.status_code}: {r_write.text[:200]}"
627 )
628 finally:
629 if saved_opt is not None:
630 app.dependency_overrides[optional_signed_request] = saved_opt
631 if saved_req is not None:
632 app.dependency_overrides[require_signed_request] = saved_req
633
634
635 # ── SESSION CONTEXT ───────────────────────────────────────────────────────────
636
637 @pytest.mark.anyio
638 async def test_mcp_set_context(mcp_ctx: _McpCtx) -> None:
639 """musehub_set_context stores session focus and returns confirmation."""
640 c, sid, hdrs, repo = mcp_ctx
641 r = await call(c, sid, hdrs, "musehub_set_context", {
642 "owner": repo.owner, "slug": repo.slug,
643 })
644 assert _rpc_error(r) is None, _rpc_error(r)
645 assert not _is_error(r), _text(r)
646 assert repo.slug in _text(r), f"slug missing from confirmation: {_text(r)[:300]}"
647
648
649 @pytest.mark.anyio
650 async def test_mcp_context_inheritance(mcp_ctx: _McpCtx) -> None:
651 """After set_context, tool calls with no owner/slug use session focus."""
652 c, sid, hdrs, repo = mcp_ctx
653
654 # Set session focus.
655 sr = await call(c, sid, hdrs, "musehub_set_context", {
656 "owner": repo.owner, "slug": repo.slug,
657 })
658 assert not _is_error(sr), _text(sr)
659
660 # list_branches with NO owner/slug — should resolve via session focus.
661 r = await call(c, sid, hdrs, "musehub_list_branches", {})
662 assert _rpc_error(r) is None, _rpc_error(r)
663 assert not _is_error(r), f"list_branches without args failed: {_text(r)[:300]}"
664
665 # list_issues with NO owner/slug.
666 ri = await call(c, sid, hdrs, "musehub_list_issues", {})
667 assert _rpc_error(ri) is None, _rpc_error(ri)
668 assert not _is_error(ri), f"list_issues without args failed: {_text(ri)[:300]}"
669
670
671 # ── COORD TOOLS ───────────────────────────────────────────────────────────────
672
673 @pytest.mark.anyio
674 async def test_mcp_coord_reserve_and_release(mcp_ctx: _McpCtx) -> None:
675 """Reserve symbols, then release them."""
676 c, sid, hdrs, repo = mcp_ctx
677
678 # Reserve
679 rr = await call(c, sid, hdrs, "musehub_coord_reserve", {
680 "owner": repo.owner, "slug": repo.slug,
681 "addresses": ["src/engine.py::AudioEngine"],
682 "agent_id": "smoke-agent-1",
683 "ttl_s": 60,
684 })
685 assert _rpc_error(rr) is None, _rpc_error(rr)
686 assert not _is_error(rr), _text(rr)
687 assert "reservation_id" in _text(rr), f"No reservation_id: {_text(rr)[:300]}"
688
689 import re
690 match = re.search(r'"reservation_id":\s*"([^"]+)"', _text(rr))
691 assert match, f"Could not parse reservation_id: {_text(rr)[:300]}"
692 reservation_id = match.group(1)
693
694 # Release
695 rl = await call(c, sid, hdrs, "musehub_coord_release", {
696 "owner": repo.owner, "slug": repo.slug,
697 "reservation_id": reservation_id,
698 "agent_id": "smoke-agent-1",
699 })
700 assert _rpc_error(rl) is None, _rpc_error(rl)
701 assert not _is_error(rl), _text(rl)
702
703
704 @pytest.mark.anyio
705 async def test_mcp_coord_enqueue_and_claim(mcp_ctx: _McpCtx) -> None:
706 """Enqueue a task, then claim it."""
707 c, sid, hdrs, repo = mcp_ctx
708
709 # Enqueue
710 eq = await call(c, sid, hdrs, "musehub_coord_enqueue", {
711 "owner": repo.owner, "slug": repo.slug,
712 "queue": "smoke-queue",
713 "payload": {"action": "analyse", "target": "main.py"},
714 "agent_id": "orchestrator",
715 "priority": 75,
716 })
717 assert _rpc_error(eq) is None, _rpc_error(eq)
718 assert not _is_error(eq), _text(eq)
719
720 import re
721 match = re.search(r'"task_id":\s*"([^"]+)"', _text(eq))
722 assert match, f"No task_id in response: {_text(eq)[:300]}"
723 task_id = match.group(1)
724
725 # Claim
726 cl = await call(c, sid, hdrs, "musehub_coord_claim_task", {
727 "owner": repo.owner, "slug": repo.slug,
728 "task_id": task_id,
729 "agent_id": "worker-1",
730 })
731 assert _rpc_error(cl) is None, _rpc_error(cl)
732 assert not _is_error(cl), _text(cl)
733
734
735 @pytest.mark.anyio
736 async def test_mcp_coord_check_conflicts_clear(mcp_ctx: _McpCtx) -> None:
737 """check_conflicts returns no conflicts for symbols with no reservations."""
738 c, sid, hdrs, repo = mcp_ctx
739 r = await call(c, sid, hdrs, "musehub_coord_check_conflicts", {
740 "owner": repo.owner, "slug": repo.slug,
741 "addresses": ["src/free_symbol.py::FreeClass"],
742 })
743 assert _rpc_error(r) is None, _rpc_error(r)
744 assert not _is_error(r), _text(r)
745 assert '"has_conflicts": false' in _text(r) or "has_conflicts" in _text(r)
746
747
748 # ── AGENT-TO-AGENT SIGNALING ──────────────────────────────────────────────────
749
750 @pytest.mark.anyio
751 async def test_mcp_agent_notify_no_target_session(mcp_ctx: _McpCtx) -> None:
752 """notify returns not_ready when target has no active sessions."""
753 c, sid, hdrs, repo = mcp_ctx
754 r = await call(c, sid, hdrs, "musehub_agent_notify", {
755 "target_handle": "ghost-agent-that-does-not-exist",
756 "event": "ping",
757 "payload": {"msg": "hello"},
758 })
759 assert _rpc_error(r) is None, _rpc_error(r)
760 # Target has no sessions — isError=True with not_ready is correct
761 assert _is_error(r), f"Expected isError=True for unknown target: {_text(r)[:300]}"
762 assert "not_ready" in _text(r), f"Expected not_ready: {_text(r)[:300]}"
763
764
765 @pytest.mark.anyio
766 async def test_mcp_agent_broadcast_no_focus(mcp_ctx: _McpCtx) -> None:
767 """broadcast without set_context returns missing_args."""
768 c, sid, hdrs, repo = mcp_ctx
769 # Fresh session with no repo focus
770 new_sid = await _init_session(c, hdrs)
771 anon_hdrs = {**hdrs, "Mcp-Session-Id": new_sid, "MCP-Protocol-Version": "2025-11-25"}
772 # Remove Mcp-Session-Id from hdrs and use new_sid
773 call_hdrs = {k: v for k, v in hdrs.items() if k != "Mcp-Session-Id"}
774 call_hdrs["Mcp-Session-Id"] = new_sid
775 r = await call(c, new_sid, call_hdrs, "musehub_agent_broadcast", {
776 "event": "ping",
777 "payload": {},
778 })
779 assert _rpc_error(r) is None, _rpc_error(r)
780 assert _is_error(r), f"Expected isError=True without focus: {_text(r)[:300]}"
781 assert "missing_args" in _text(r), f"Expected missing_args: {_text(r)[:300]}"
782
783
784 @pytest.mark.anyio
785 async def test_mcp_agent_broadcast_with_focus_no_peers(mcp_ctx: _McpCtx) -> None:
786 """broadcast with set_context succeeds with 0 peers (no error, sessions_reached=0)."""
787 c, sid, hdrs, repo = mcp_ctx
788 # Set context first
789 sc = await call(c, sid, hdrs, "musehub_set_context", {
790 "owner": repo.owner, "slug": repo.slug,
791 })
792 assert not _is_error(sc), _text(sc)
793
794 r = await call(c, sid, hdrs, "musehub_agent_broadcast", {
795 "event": "phase_complete",
796 "payload": {"phase": 1, "result": "ok"},
797 })
798 assert _rpc_error(r) is None, _rpc_error(r)
799 assert not _is_error(r), f"broadcast should succeed even with 0 peers: {_text(r)[:300]}"
800 assert "sessions_reached" in _text(r), f"Missing sessions_reached: {_text(r)[:300]}"
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago