gabriel / musehub public
test_mcp_smoke.py python
764 lines 31.9 KB
Raw
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 2 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.musehub_identity_models import MusehubIdentity
22 from musehub.db.musehub_repo_models import MusehubRepo
23 from tests.factories import create_issue, create_proposal, create_repo
24 from musehub.types.json_types import JSONObject, StrDict
25
26 type _McpCtx = tuple[AsyncClient, str, StrDict, 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 async def test_mcp_whoami(mcp_ctx: _McpCtx) -> None:
109 c, sid, hdrs, repo = mcp_ctx
110 r = await call(c, sid, hdrs, "musehub_whoami", {})
111 assert _rpc_error(r) is None, _rpc_error(r)
112 assert not _is_error(r), _text(r)
113
114
115 async def test_mcp_search_repos(mcp_ctx: _McpCtx) -> None:
116 c, sid, hdrs, repo = mcp_ctx
117 r = await call(c, sid, hdrs, "musehub_search_repos", {"query": repo.slug})
118 assert _rpc_error(r) is None, _rpc_error(r)
119 assert not _is_error(r), _text(r)
120
121
122 async def test_mcp_get_context(mcp_ctx: _McpCtx) -> None:
123 c, sid, hdrs, repo = mcp_ctx
124 r = await call(c, sid, hdrs, "musehub_read_context", {"owner": repo.owner, "slug": repo.slug})
125 assert _rpc_error(r) is None, _rpc_error(r)
126 assert not _is_error(r), _text(r)
127
128
129 async def test_mcp_list_branches(mcp_ctx: _McpCtx) -> None:
130 c, sid, hdrs, repo = mcp_ctx
131 r = await call(c, sid, hdrs, "musehub_list_branches", {"owner": repo.owner, "slug": repo.slug})
132 assert _rpc_error(r) is None, _rpc_error(r)
133 assert not _is_error(r), _text(r)
134
135
136 async def test_mcp_list_commits(mcp_ctx: _McpCtx) -> None:
137 c, sid, hdrs, repo = mcp_ctx
138 r = await call(c, sid, hdrs, "musehub_list_commits", {"owner": repo.owner, "slug": repo.slug})
139 assert _rpc_error(r) is None, _rpc_error(r)
140 assert not _is_error(r), _text(r)
141
142
143 async def test_mcp_list_issues(mcp_ctx: _McpCtx) -> None:
144 c, sid, hdrs, repo = mcp_ctx
145 r = await call(c, sid, hdrs, "musehub_list_issues", {"owner": repo.owner, "slug": repo.slug})
146 assert _rpc_error(r) is None, _rpc_error(r)
147 assert not _is_error(r), _text(r)
148
149
150 async def test_mcp_read_issue(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
151 c, sid, hdrs, repo = mcp_ctx
152 issue = await create_issue(db_session, repo_id=str(repo.repo_id), author=repo.owner)
153 r = await call(c, sid, hdrs, "musehub_read_issue", {"owner": repo.owner, "slug": repo.slug, "issue_number": issue.number})
154 assert _rpc_error(r) is None, _rpc_error(r)
155 assert not _is_error(r), _text(r)
156
157
158 async def test_mcp_list_proposals(mcp_ctx: _McpCtx) -> None:
159 c, sid, hdrs, repo = mcp_ctx
160 r = await call(c, sid, hdrs, "musehub_list_proposals", {"owner": repo.owner, "slug": repo.slug})
161 assert _rpc_error(r) is None, _rpc_error(r)
162 assert not _is_error(r), _text(r)
163
164
165 async def test_mcp_read_proposal(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
166 c, sid, hdrs, repo = mcp_ctx
167 proposal = await create_proposal(db_session, repo_id=str(repo.repo_id), author=repo.owner)
168 r = await call(c, sid, hdrs, "musehub_read_proposal", {"owner": repo.owner, "slug": repo.slug, "proposal_id": proposal.proposal_id})
169 assert _rpc_error(r) is None, _rpc_error(r)
170 assert not _is_error(r), _text(r)
171
172
173 async def test_mcp_proposal_risk(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
174 c, sid, hdrs, repo = mcp_ctx
175 proposal = await create_proposal(db_session, repo_id=str(repo.repo_id), author=repo.owner)
176 r = await call(c, sid, hdrs, "musehub_read_proposal_risk", {"owner": repo.owner, "slug": repo.slug, "proposal_id": proposal.proposal_id})
177 assert _rpc_error(r) is None, _rpc_error(r)
178 assert not _is_error(r), _text(r)
179
180
181 async def test_mcp_proposal_symbol_diff(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
182 c, sid, hdrs, repo = mcp_ctx
183 proposal = await create_proposal(db_session, repo_id=str(repo.repo_id), author=repo.owner)
184 r = await call(c, sid, hdrs, "musehub_read_proposal_diff", {"owner": repo.owner, "slug": repo.slug, "proposal_id": proposal.proposal_id})
185 assert _rpc_error(r) is None, _rpc_error(r)
186 assert not _is_error(r), _text(r)
187
188
189 async def test_mcp_proposal_breakage(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
190 c, sid, hdrs, repo = mcp_ctx
191 proposal = await create_proposal(db_session, repo_id=str(repo.repo_id), author=repo.owner)
192 r = await call(c, sid, hdrs, "musehub_read_proposal_breakage", {"owner": repo.owner, "slug": repo.slug, "proposal_id": proposal.proposal_id})
193 assert _rpc_error(r) is None, _rpc_error(r)
194 assert not _is_error(r), _text(r)
195
196
197 async def test_mcp_list_releases(mcp_ctx: _McpCtx) -> None:
198 c, sid, hdrs, repo = mcp_ctx
199 r = await call(c, sid, hdrs, "musehub_list_releases", {"owner": repo.owner, "slug": repo.slug})
200 assert _rpc_error(r) is None, _rpc_error(r)
201 assert not _is_error(r), _text(r)
202
203
204 async def test_mcp_list_domains(mcp_ctx: _McpCtx) -> None:
205 """musehub_list_domains accepts cursor (not offset) for pagination."""
206 c, sid, hdrs, repo = mcp_ctx
207 r = await call(c, sid, hdrs, "musehub_list_domains", {})
208 assert _rpc_error(r) is None, _rpc_error(r)
209 assert not _is_error(r), _text(r)
210
211
212 async def test_mcp_list_symbols(mcp_ctx: _McpCtx) -> None:
213 """musehub_list_symbols returns next_cursor (not total with offset) for pagination."""
214 c, sid, hdrs, repo = mcp_ctx
215 r = await call(c, sid, hdrs, "musehub_list_symbols", {"owner": repo.owner, "slug": repo.slug})
216 assert _rpc_error(r) is None, _rpc_error(r)
217 assert not _is_error(r), _text(r)
218 # Response data must include next_cursor (may be null on last page).
219 text = _text(r)
220 assert "next_cursor" in text, f"musehub_list_symbols must include next_cursor in response; got: {text[:200]}"
221
222
223 async def test_mcp_list_domains_schema_has_cursor_not_offset(mcp_ctx: _McpCtx) -> None:
224 """musehub_list_domains tool schema must use cursor, not offset."""
225 c, sid, hdrs, _ = mcp_ctx
226 r = await c.post(
227 "/mcp",
228 json={
229 "jsonrpc": "2.0",
230 "id": 99,
231 "method": "tools/list",
232 "params": {},
233 },
234 headers={**hdrs, "Mcp-Session-Id": await _init_session(c, hdrs)},
235 )
236 assert r.status_code == 200
237 tools = r.json().get("result", {}).get("tools", [])
238 list_domains = next((t for t in tools if t["name"] == "musehub_list_domains"), None)
239 assert list_domains is not None, "musehub_list_domains not found in tools/list"
240 props = list_domains.get("inputSchema", {}).get("properties", {})
241 assert "offset" not in props, "musehub_list_domains schema must not expose offset"
242 list_symbols = next((t for t in tools if t["name"] == "musehub_list_symbols"), None)
243 assert list_symbols is not None, "musehub_list_symbols not found in tools/list"
244 sym_props = list_symbols.get("inputSchema", {}).get("properties", {})
245 assert "offset" not in sym_props, "musehub_list_symbols schema must not expose offset"
246
247
248 async def test_mcp_intel_index_status(mcp_ctx: _McpCtx) -> None:
249 c, sid, hdrs, repo = mcp_ctx
250 r = await call(c, sid, hdrs, "musehub_read_intel_index_status", {"owner": repo.owner, "slug": repo.slug})
251 assert _rpc_error(r) is None, _rpc_error(r)
252 assert not _is_error(r), _text(r)
253
254
255 async def test_mcp_intel_health_score(mcp_ctx: _McpCtx) -> None:
256 """Health score requires a built symbol index — not_ready is the expected result for a new repo."""
257 c, sid, hdrs, repo = mcp_ctx
258 r = await call(c, sid, hdrs, "musehub_read_intel_health_score", {"owner": repo.owner, "slug": repo.slug})
259 assert _rpc_error(r) is None, _rpc_error(r)
260 # A repo with no commits will report not_ready — that's correct behaviour, not a crash
261 text = _text(r)
262 assert "not_ready" in text or "health" in text.lower() or not _is_error(r), (
263 f"Unexpected error from health_score: {text[:200]}"
264 )
265
266
267 async def test_mcp_intel_hotspots(mcp_ctx: _McpCtx) -> None:
268 c, sid, hdrs, repo = mcp_ctx
269 r = await call(c, sid, hdrs, "musehub_read_intel_hotspots", {"owner": repo.owner, "slug": repo.slug})
270 assert _rpc_error(r) is None, _rpc_error(r)
271 assert not _is_error(r), _text(r)
272
273
274 async def test_mcp_intel_dead(mcp_ctx: _McpCtx) -> None:
275 c, sid, hdrs, repo = mcp_ctx
276 r = await call(c, sid, hdrs, "musehub_read_intel_dead", {"owner": repo.owner, "slug": repo.slug})
277 assert _rpc_error(r) is None, _rpc_error(r)
278 assert not _is_error(r), _text(r)
279
280
281 async def test_mcp_intel_blast_risk(mcp_ctx: _McpCtx) -> None:
282 c, sid, hdrs, repo = mcp_ctx
283 r = await call(c, sid, hdrs, "musehub_read_intel_blast_risk", {"owner": repo.owner, "slug": repo.slug})
284 assert _rpc_error(r) is None, _rpc_error(r)
285 assert not _is_error(r), _text(r)
286
287
288 async def test_mcp_coord_swarm(mcp_ctx: _McpCtx) -> None:
289 c, sid, hdrs, repo = mcp_ctx
290 r = await call(c, sid, hdrs, "musehub_read_coord_swarm", {"owner": repo.owner, "slug": repo.slug})
291 assert _rpc_error(r) is None, _rpc_error(r)
292 assert not _is_error(r), _text(r)
293
294
295 async def test_mcp_coord_reservations(mcp_ctx: _McpCtx) -> None:
296 c, sid, hdrs, repo = mcp_ctx
297 r = await call(c, sid, hdrs, "musehub_list_coord_reservations", {"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 async def test_mcp_coord_tasks(mcp_ctx: _McpCtx) -> None:
303 c, sid, hdrs, repo = mcp_ctx
304 r = await call(c, sid, hdrs, "musehub_list_coord_tasks", {"owner": repo.owner, "slug": repo.slug})
305 assert _rpc_error(r) is None, _rpc_error(r)
306 assert not _is_error(r), _text(r)
307
308
309 async def test_mcp_coord_check_conflicts(mcp_ctx: _McpCtx) -> None:
310 c, sid, hdrs, repo = mcp_ctx
311 r = await call(c, sid, hdrs, "musehub_read_coord_conflicts", {
312 "owner": repo.owner, "slug": repo.slug, "symbols": ["main.py::MyClass"]
313 })
314 assert _rpc_error(r) is None, _rpc_error(r)
315 assert not _is_error(r), _text(r)
316
317
318 async def test_mcp_get_prompt(mcp_ctx: _McpCtx) -> None:
319 c, sid, hdrs, repo = mcp_ctx
320 r = await call(c, sid, hdrs, "musehub_read_prompt", {"name": "musehub/orientation"})
321 assert _rpc_error(r) is None, _rpc_error(r)
322 assert not _is_error(r), _text(r)
323
324
325 async def test_mcp_muse_remote(mcp_ctx: _McpCtx) -> None:
326 c, sid, hdrs, repo = mcp_ctx
327 r = await call(c, sid, hdrs, "muse_remote", {"owner": repo.owner, "slug": repo.slug})
328 assert _rpc_error(r) is None, _rpc_error(r)
329 assert not _is_error(r), _text(r)
330
331
332 async def test_mcp_muse_config(mcp_ctx: _McpCtx) -> None:
333 c, sid, hdrs, repo = mcp_ctx
334 r = await call(c, sid, hdrs, "muse_config", {"owner": repo.owner, "slug": repo.slug})
335 assert _rpc_error(r) is None, _rpc_error(r)
336 assert not _is_error(r), _text(r)
337
338
339 async def test_mcp_workspace_intel(mcp_ctx: _McpCtx) -> None:
340 c, sid, hdrs, repo = mcp_ctx
341 r = await call(c, sid, hdrs, "musehub_read_workspace_intel", {"owner": repo.owner})
342 assert _rpc_error(r) is None, _rpc_error(r)
343 assert not _is_error(r), _text(r)
344
345
346 async def test_mcp_cross_repo_impact(mcp_ctx: _McpCtx) -> None:
347 """cross_repo_impact requires a built symbol index — not_found is expected for a new repo."""
348 c, sid, hdrs, repo = mcp_ctx
349 r = await call(c, sid, hdrs, "musehub_read_cross_repo_impact", {
350 "owner": repo.owner, "slug": repo.slug,
351 "address": "main.py::App",
352 })
353 assert _rpc_error(r) is None, _rpc_error(r)
354 # A repo with no commits has no symbol index — not_found is correct, not a crash
355 text = _text(r)
356 assert "not_found" in text or "not_ready" in text or not _is_error(r), (
357 f"Unexpected error: {text[:200]}"
358 )
359
360
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 async def test_mcp_create_repo(mcp_ctx: _McpCtx) -> None:
374 c, sid, hdrs, _repo = mcp_ctx
375 r = await call(c, sid, hdrs, "musehub_create_repo", {
376 "name": "smoke-new-repo", "description": "mcp smoke test", "visibility": "public"
377 })
378 assert _rpc_error(r) is None, _rpc_error(r)
379 assert not _is_error(r), _text(r)
380
381
382 async def test_mcp_create_issue(mcp_ctx: _McpCtx) -> None:
383 c, sid, hdrs, repo = mcp_ctx
384 r = await call(c, sid, hdrs, "musehub_create_issue", {
385 "owner": repo.owner, "slug": repo.slug,
386 "title": "Smoke test issue", "body": "created by mcp smoke test",
387 })
388 assert _rpc_error(r) is None, _rpc_error(r)
389 assert not _is_error(r), _text(r)
390 # Number is in the response
391 assert re.search(r'"number"', _text(r)) or "number" in _text(r).lower() or _text(r)
392
393
394 async def test_mcp_update_issue(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
395 c, sid, hdrs, repo = mcp_ctx
396 issue = await create_issue(db_session, repo_id=str(repo.repo_id), author=repo.owner)
397 r = await call(c, sid, hdrs, "musehub_update_issue", {
398 "owner": repo.owner, "slug": repo.slug, "issue_number": issue.number,
399 "state": "closed",
400 })
401 assert _rpc_error(r) is None, _rpc_error(r)
402 assert not _is_error(r), _text(r)
403
404
405 async def test_mcp_create_issue_comment(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
406 c, sid, hdrs, repo = mcp_ctx
407 issue = await create_issue(db_session, repo_id=str(repo.repo_id), author=repo.owner)
408 r = await call(c, sid, hdrs, "musehub_create_issue_comment", {
409 "owner": repo.owner, "slug": repo.slug, "issue_number": issue.number,
410 "body": "smoke test comment",
411 })
412 assert _rpc_error(r) is None, _rpc_error(r)
413 assert not _is_error(r), _text(r)
414
415
416 async def test_mcp_create_proposal(mcp_ctx: _McpCtx) -> None:
417 c, sid, hdrs, repo = mcp_ctx
418 r = await call(c, sid, hdrs, "musehub_create_proposal", {
419 "owner": repo.owner, "slug": repo.slug,
420 "title": "Smoke proposal", "body": "mcp smoke test",
421 "from_branch": "feature/smoke", "to_branch": "main",
422 })
423 assert _rpc_error(r) is None, _rpc_error(r)
424 assert not _is_error(r), _text(r)
425
426
427 async def test_mcp_create_proposal_comment(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
428 c, sid, hdrs, repo = mcp_ctx
429 proposal = await create_proposal(db_session, repo_id=str(repo.repo_id), author=repo.owner)
430 r = await call(c, sid, hdrs, "musehub_create_proposal_comment", {
431 "owner": repo.owner, "slug": repo.slug, "proposal_id": proposal.proposal_id,
432 "body": "smoke proposal comment",
433 })
434 assert _rpc_error(r) is None, _rpc_error(r)
435 assert not _is_error(r), _text(r)
436
437
438 async def test_mcp_submit_proposal_review(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
439 c, sid, hdrs, repo = mcp_ctx
440 proposal = await create_proposal(db_session, repo_id=str(repo.repo_id), author=repo.owner)
441 r = await call(c, sid, hdrs, "musehub_create_proposal_review", {
442 "owner": repo.owner, "slug": repo.slug, "proposal_id": proposal.proposal_id,
443 "verdict": "approve", "body": "lgtm",
444 })
445 assert _rpc_error(r) is None, _rpc_error(r)
446 assert not _is_error(r), _text(r)
447
448
449 async def test_mcp_create_label(mcp_ctx: _McpCtx) -> None:
450 c, sid, hdrs, repo = mcp_ctx
451 r = await call(c, sid, hdrs, "musehub_create_label", {
452 "owner": repo.owner, "slug": repo.slug,
453 "name": "smoke-label", "color": "#ff5500",
454 })
455 assert _rpc_error(r) is None, _rpc_error(r)
456 assert not _is_error(r), _text(r)
457
458
459 async def test_mcp_create_release(mcp_ctx: _McpCtx) -> None:
460 c, sid, hdrs, repo = mcp_ctx
461 r = await call(c, sid, hdrs, "musehub_create_release", {
462 "owner": repo.owner, "slug": repo.slug,
463 "tag": "v0.1.0-smoke", "name": "Smoke Release", "body": "mcp smoke",
464 })
465 assert _rpc_error(r) is None, _rpc_error(r)
466 assert not _is_error(r), _text(r)
467
468
469 async def test_mcp_coord_claim_task(mcp_ctx: _McpCtx) -> None:
470 c, sid, hdrs, repo = mcp_ctx
471 r = await call(c, sid, hdrs, "musehub_claim_coord_task", {
472 "owner": repo.owner, "slug": repo.slug,
473 "queue": "tasks", "agent_id": "smoke-agent-1",
474 })
475 assert _rpc_error(r) is None, _rpc_error(r)
476 # No tasks in queue — ok=False is expected here
477 assert r.get("result") is not None
478
479
480 async def test_mcp_merge_proposal_no_commits(mcp_ctx: _McpCtx, db_session: AsyncSession) -> None:
481 """merge_proposal on a proposal with no commits should fail gracefully (not 500)."""
482 c, sid, hdrs, repo = mcp_ctx
483 proposal = await create_proposal(db_session, repo_id=str(repo.repo_id), author=repo.owner)
484 r = await call(c, sid, hdrs, "musehub_merge_proposal", {
485 "owner": repo.owner, "slug": repo.slug, "proposal_id": proposal.proposal_id,
486 })
487 assert _rpc_error(r) is None, _rpc_error(r)
488 # Merge with no commits → isError=True is acceptable; crash is not
489
490
491 async def test_mcp_publish_domain_no_manifest(mcp_ctx: _McpCtx) -> None:
492 """publish_domain with missing manifest should fail gracefully."""
493 c, sid, hdrs, repo = mcp_ctx
494 r = await call(c, sid, hdrs, "musehub_publish_domain", {
495 "owner": repo.owner, "slug": repo.slug,
496 "domain_name": "smoke-domain",
497 "manifest": {"name": "smoke", "version": "0.1.0"},
498 })
499 assert _rpc_error(r) is None, _rpc_error(r)
500
501
502 # ── MULTI-STEP: read-back after write ────────────────────────────────────────
503
504 async def test_mcp_issue_roundtrip(mcp_ctx: _McpCtx) -> None:
505 """Create an issue via MCP, then read it back."""
506 c, sid, hdrs, repo = mcp_ctx
507
508 # Create
509 cr = await call(c, sid, hdrs, "musehub_create_issue", {
510 "owner": repo.owner, "slug": repo.slug,
511 "title": "Roundtrip issue", "body": "created and read back",
512 })
513 assert not _is_error(cr), _text(cr)
514 m = re.search(r'"number":\s*(\d+)', _text(cr))
515 assert m, f"No issue number in response: {_text(cr)[:300]}"
516 number = int(m.group(1))
517
518 # Read back
519 gr = await call(c, sid, hdrs, "musehub_read_issue", {
520 "owner": repo.owner, "slug": repo.slug, "issue_number": number,
521 })
522 assert not _is_error(gr), _text(gr)
523 assert "Roundtrip issue" in _text(gr), f"Issue title missing: {_text(gr)[:300]}"
524
525
526 async def test_mcp_label_then_list(mcp_ctx: _McpCtx) -> None:
527 """Create a label, then verify list_issues still works (no crash on label join)."""
528 c, sid, hdrs, repo = mcp_ctx
529 await call(c, sid, hdrs, "musehub_create_label", {
530 "owner": repo.owner, "slug": repo.slug, "name": "bug", "color": "#d73a4a",
531 })
532 r = await call(c, sid, hdrs, "musehub_list_issues", {"owner": repo.owner, "slug": repo.slug})
533 assert _rpc_error(r) is None, _rpc_error(r)
534 assert not _is_error(r), _text(r)
535
536
537 # ── SECURITY: write re-verification ──────────────────────────────────────────
538
539 async def test_mcp_write_without_msign_rejected(
540 client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict, test_user: MusehubIdentity,
541 ) -> None:
542 """Write tools must be rejected with 401 when no MSign is present on the request.
543
544 Simulates a stolen session ID: the session was created with auth (initialize
545 carries MSign), but subsequent write calls arrive with no Authorization header.
546 Read tools on the same unauthenticated session must still succeed.
547
548 The auth_headers fixture sets a global DI override that bypasses real MSign
549 verification. We temporarily lift it for the anon calls so the real
550 optional_signed_request runs (returning None for headerless requests).
551 """
552 from musehub.main import app
553 from musehub.auth.request_signing import optional_signed_request, require_signed_request
554
555 repo = await create_repo(db_session, owner=test_user.handle, visibility="public")
556
557 # Initialize with auth (normal flow — DI override active).
558 sid = await _init_session(client, auth_headers)
559
560 # Headers that carry the session ID but NO Authorization header.
561 anon_headers = {"Mcp-Session-Id": sid, "MCP-Protocol-Version": "2025-11-25"}
562
563 # Lift the DI overrides so real optional_signed_request runs on the next calls.
564 saved_opt = app.dependency_overrides.pop(optional_signed_request, None)
565 saved_req = app.dependency_overrides.pop(require_signed_request, None)
566 try:
567 # Read tool — must succeed anonymously (no MSign needed for reads).
568 r_read = await client.post(
569 "/mcp",
570 json={
571 "jsonrpc": "2.0", "id": 10, "method": "tools/call",
572 "params": {"name": "musehub_search_repos", "arguments": {"query": "test"}},
573 },
574 headers=anon_headers,
575 )
576 assert r_read.status_code == 200, f"Read tool should pass: {r_read.text[:200]}"
577 read_body = r_read.json()
578 assert "error" not in read_body or read_body.get("error") is None, (
579 f"Read tool returned RPC error: {read_body}"
580 )
581
582 # Write tool — must be rejected with 401.
583 r_write = await client.post(
584 "/mcp",
585 json={
586 "jsonrpc": "2.0", "id": 11, "method": "tools/call",
587 "params": {
588 "name": "musehub_create_issue",
589 "arguments": {
590 "owner": repo.owner, "slug": repo.slug,
591 "title": "Should be blocked", "body": "",
592 },
593 },
594 },
595 headers=anon_headers,
596 )
597 assert r_write.status_code == 401, (
598 f"Write tool without MSign should return 401, got {r_write.status_code}: {r_write.text[:200]}"
599 )
600 finally:
601 if saved_opt is not None:
602 app.dependency_overrides[optional_signed_request] = saved_opt
603 if saved_req is not None:
604 app.dependency_overrides[require_signed_request] = saved_req
605
606
607 # ── SESSION CONTEXT ───────────────────────────────────────────────────────────
608
609 async def test_mcp_set_context(mcp_ctx: _McpCtx) -> None:
610 """musehub_set_context stores session focus and returns confirmation."""
611 c, sid, hdrs, repo = mcp_ctx
612 r = await call(c, sid, hdrs, "musehub_set_context", {
613 "owner": repo.owner, "slug": repo.slug,
614 })
615 assert _rpc_error(r) is None, _rpc_error(r)
616 assert not _is_error(r), _text(r)
617 assert repo.slug in _text(r), f"slug missing from confirmation: {_text(r)[:300]}"
618
619
620 async def test_mcp_context_inheritance(mcp_ctx: _McpCtx) -> None:
621 """After set_context, tool calls with no owner/slug use session focus."""
622 c, sid, hdrs, repo = mcp_ctx
623
624 # Set session focus.
625 sr = await call(c, sid, hdrs, "musehub_set_context", {
626 "owner": repo.owner, "slug": repo.slug,
627 })
628 assert not _is_error(sr), _text(sr)
629
630 # list_branches with NO owner/slug — should resolve via session focus.
631 r = await call(c, sid, hdrs, "musehub_list_branches", {})
632 assert _rpc_error(r) is None, _rpc_error(r)
633 assert not _is_error(r), f"list_branches without args failed: {_text(r)[:300]}"
634
635 # list_issues with NO owner/slug.
636 ri = await call(c, sid, hdrs, "musehub_list_issues", {})
637 assert _rpc_error(ri) is None, _rpc_error(ri)
638 assert not _is_error(ri), f"list_issues without args failed: {_text(ri)[:300]}"
639
640
641 # ── COORD TOOLS ───────────────────────────────────────────────────────────────
642
643 async def test_mcp_coord_reserve_and_release(mcp_ctx: _McpCtx) -> None:
644 """Reserve symbols, then release them."""
645 c, sid, hdrs, repo = mcp_ctx
646
647 # Reserve
648 rr = await call(c, sid, hdrs, "musehub_create_coord_reservation", {
649 "owner": repo.owner, "slug": repo.slug,
650 "addresses": ["src/engine.py::AudioEngine"],
651 "agent_id": "smoke-agent-1",
652 "ttl_s": 60,
653 })
654 assert _rpc_error(rr) is None, _rpc_error(rr)
655 assert not _is_error(rr), _text(rr)
656 assert "reservation_id" in _text(rr), f"No reservation_id: {_text(rr)[:300]}"
657
658 import re
659 match = re.search(r'"reservation_id":\s*"([^"]+)"', _text(rr))
660 assert match, f"Could not parse reservation_id: {_text(rr)[:300]}"
661 reservation_id = match.group(1)
662
663 # Release
664 rl = await call(c, sid, hdrs, "musehub_delete_coord_reservation", {
665 "owner": repo.owner, "slug": repo.slug,
666 "reservation_id": reservation_id,
667 "agent_id": "smoke-agent-1",
668 })
669 assert _rpc_error(rl) is None, _rpc_error(rl)
670 assert not _is_error(rl), _text(rl)
671
672
673 async def test_mcp_coord_enqueue_and_claim(mcp_ctx: _McpCtx) -> None:
674 """Enqueue a task, then claim it."""
675 c, sid, hdrs, repo = mcp_ctx
676
677 # Enqueue
678 eq = await call(c, sid, hdrs, "musehub_enqueue_coord_task", {
679 "owner": repo.owner, "slug": repo.slug,
680 "queue": "smoke-queue",
681 "payload": {"action": "analyse", "target": "main.py"},
682 "agent_id": "orchestrator",
683 "priority": 75,
684 })
685 assert _rpc_error(eq) is None, _rpc_error(eq)
686 assert not _is_error(eq), _text(eq)
687
688 import re
689 match = re.search(r'"task_id":\s*"([^"]+)"', _text(eq))
690 assert match, f"No task_id in response: {_text(eq)[:300]}"
691 task_id = match.group(1)
692
693 # Claim
694 cl = await call(c, sid, hdrs, "musehub_claim_coord_task", {
695 "owner": repo.owner, "slug": repo.slug,
696 "task_id": task_id,
697 "agent_id": "worker-1",
698 })
699 assert _rpc_error(cl) is None, _rpc_error(cl)
700 assert not _is_error(cl), _text(cl)
701
702
703 async def test_mcp_coord_check_conflicts_clear(mcp_ctx: _McpCtx) -> None:
704 """check_conflicts returns no conflicts for symbols with no reservations."""
705 c, sid, hdrs, repo = mcp_ctx
706 r = await call(c, sid, hdrs, "musehub_read_coord_conflicts", {
707 "owner": repo.owner, "slug": repo.slug,
708 "addresses": ["src/free_symbol.py::FreeClass"],
709 })
710 assert _rpc_error(r) is None, _rpc_error(r)
711 assert not _is_error(r), _text(r)
712 assert '"has_conflicts": false' in _text(r) or "has_conflicts" in _text(r)
713
714
715 # ── AGENT-TO-AGENT SIGNALING ──────────────────────────────────────────────────
716
717 async def test_mcp_agent_notify_no_target_session(mcp_ctx: _McpCtx) -> None:
718 """notify returns not_ready when target has no active sessions."""
719 c, sid, hdrs, repo = mcp_ctx
720 r = await call(c, sid, hdrs, "musehub_agent_notify", {
721 "target_handle": "ghost-agent-that-does-not-exist",
722 "event": "ping",
723 "payload": {"msg": "hello"},
724 })
725 assert _rpc_error(r) is None, _rpc_error(r)
726 # Target has no sessions — isError=True with not_ready is correct
727 assert _is_error(r), f"Expected isError=True for unknown target: {_text(r)[:300]}"
728 assert "not_ready" in _text(r), f"Expected not_ready: {_text(r)[:300]}"
729
730
731 async def test_mcp_agent_broadcast_no_focus(mcp_ctx: _McpCtx) -> None:
732 """broadcast without set_context returns missing_args."""
733 c, sid, hdrs, repo = mcp_ctx
734 # Fresh session with no repo focus
735 new_sid = await _init_session(c, hdrs)
736 anon_hdrs = {**hdrs, "Mcp-Session-Id": new_sid, "MCP-Protocol-Version": "2025-11-25"}
737 # Remove Mcp-Session-Id from hdrs and use new_sid
738 call_hdrs = {k: v for k, v in hdrs.items() if k != "Mcp-Session-Id"}
739 call_hdrs["Mcp-Session-Id"] = new_sid
740 r = await call(c, new_sid, call_hdrs, "musehub_agent_broadcast", {
741 "event": "ping",
742 "payload": {},
743 })
744 assert _rpc_error(r) is None, _rpc_error(r)
745 assert _is_error(r), f"Expected isError=True without focus: {_text(r)[:300]}"
746 assert "missing_args" in _text(r), f"Expected missing_args: {_text(r)[:300]}"
747
748
749 async def test_mcp_agent_broadcast_with_focus_no_peers(mcp_ctx: _McpCtx) -> None:
750 """broadcast with set_context succeeds with 0 peers (no error, sessions_reached=0)."""
751 c, sid, hdrs, repo = mcp_ctx
752 # Set context first
753 sc = await call(c, sid, hdrs, "musehub_set_context", {
754 "owner": repo.owner, "slug": repo.slug,
755 })
756 assert not _is_error(sc), _text(sc)
757
758 r = await call(c, sid, hdrs, "musehub_agent_broadcast", {
759 "event": "phase_complete",
760 "payload": {"phase": 1, "result": "ok"},
761 })
762 assert _rpc_error(r) is None, _rpc_error(r)
763 assert not _is_error(r), f"broadcast should succeed even with 0 peers: {_text(r)[:300]}"
764 assert "sessions_reached" in _text(r), f"Missing sessions_reached: {_text(r)[:300]}"
File History 3 commits
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 2 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 11 days ago
sha256:4aed3d8601c8dd3ed37074de35f11f4a9699a0a4b99d43727048fd3f8e6fd13d chore: doc sweep, ignore wrangler build state, misc fixes Sonnet 4.6 minor 13 days ago