gabriel / musehub public
test_mcp_musehub.py python
682 lines 25.3 KB
7f1d07e8 feat: domains, MCP expansion, MIDI player, and production hardening (#8) Gabriel Cardona <cgcardona@gmail.com> 4d ago
1 """Tests for MuseHub MCP tools — .
2
3 Covers all acceptance criteria:
4 - musehub_browse_repo returns repo stats, branches, recent commits
5 - musehub_read_file returns file metadata with MIME type
6 - musehub_list_commits returns paginated commit list
7 - musehub_get_analysis returns analysis for overview/commits/objects dimensions
8 - musehub_search supports path and commit modes
9 - musehub_get_context returns full AI context document
10 - All tools registered in MCP server with proper schemas
11 - Tools handle errors gracefully (not_found, invalid dimension/mode)
12
13 Tests use conftest db_session (in-memory SQLite) and mock the executor's
14 AsyncSessionLocal to use the test session so no live DB is required.
15 """
16 from __future__ import annotations
17
18 import json
19 from datetime import datetime, timezone
20 from unittest.mock import AsyncMock, patch, MagicMock
21
22 import pytest
23 import pytest_asyncio
24 from sqlalchemy.ext.asyncio import AsyncSession
25
26 from musehub.db.musehub_models import (
27 MusehubBranch,
28 MusehubCommit,
29 MusehubObject,
30 MusehubRepo,
31 )
32 from musehub.mcp.server import MuseMCPServer, ToolCallResult
33 from musehub.mcp.tools import MCP_TOOLS, MUSEHUB_TOOL_NAMES, TOOL_CATEGORIES
34 from musehub.mcp.tools.musehub import MUSEHUB_TOOLS
35 from musehub.services import musehub_mcp_executor as executor
36 from musehub.services.musehub_mcp_executor import MusehubToolResult
37
38
39 # ---------------------------------------------------------------------------
40 # Helpers
41 # ---------------------------------------------------------------------------
42
43
44 def _utc(year: int = 2024, month: int = 1, day: int = 1) -> datetime:
45 return datetime(year, month, day, tzinfo=timezone.utc)
46
47
48 async def _seed_repo(session: AsyncSession) -> MusehubRepo:
49 """Insert a minimal repo, branch, commit, and object for tests."""
50 repo = MusehubRepo(
51 repo_id="repo-test-001",
52 name="jazz-sessions",
53 owner="testuser",
54 slug="jazz-sessions",
55 visibility="public",
56 owner_user_id="user-001",
57 created_at=_utc(),
58 )
59 session.add(repo)
60
61 branch = MusehubBranch(
62 branch_id="branch-001",
63 repo_id="repo-test-001",
64 name="main",
65 head_commit_id="commit-001",
66 )
67 session.add(branch)
68
69 commit = MusehubCommit(
70 commit_id="commit-001",
71 repo_id="repo-test-001",
72 branch="main",
73 parent_ids=[],
74 message="add bass track",
75 author="alice",
76 timestamp=_utc(2024, 6, 15),
77 snapshot_id="snap-001",
78 )
79 session.add(commit)
80
81 obj = MusehubObject(
82 object_id="sha256:abc123",
83 repo_id="repo-test-001",
84 path="tracks/bass.mid",
85 size_bytes=2048,
86 disk_path="/tmp/bass.mid",
87 created_at=_utc(),
88 )
89 session.add(obj)
90
91 await session.commit()
92 return repo
93
94
95 # ---------------------------------------------------------------------------
96 # Registry tests
97 # ---------------------------------------------------------------------------
98
99
100 class TestMusehubToolsRegistered:
101 """Verify that all musehub_* tools appear in the combined MCP registry."""
102
103 def test_musehub_tools_in_mcp_tools(self) -> None:
104 """All MUSEHUB_TOOLS appear in the combined MCP_TOOLS list."""
105 registered_names = {t["name"] for t in MCP_TOOLS}
106 for tool in MUSEHUB_TOOLS:
107 assert tool["name"] in registered_names, (
108 f"MuseHub tool '{tool['name']}' missing from MCP_TOOLS"
109 )
110
111 def test_musehub_tool_names_set_correct(self) -> None:
112 """MUSEHUB_TOOL_NAMES matches the names declared in MUSEHUB_TOOLS."""
113 expected = {t["name"] for t in MUSEHUB_TOOLS}
114 assert MUSEHUB_TOOL_NAMES == expected
115
116 def test_musehub_tools_in_categories(self) -> None:
117 """Every musehub_* tool has an entry in TOOL_CATEGORIES."""
118 from musehub.mcp.tools import MUSEHUB_ELICITATION_TOOL_NAMES, MUSEHUB_WRITE_TOOL_NAMES
119
120 for name in MUSEHUB_TOOL_NAMES:
121 assert name in TOOL_CATEGORIES, f"Tool '{name}' missing from TOOL_CATEGORIES"
122 if name in MUSEHUB_ELICITATION_TOOL_NAMES:
123 expected_category = "musehub-elicitation"
124 elif name in MUSEHUB_WRITE_TOOL_NAMES:
125 expected_category = "musehub-write"
126 else:
127 expected_category = "musehub-read"
128 assert TOOL_CATEGORIES[name] == expected_category, (
129 f"Tool '{name}' has category '{TOOL_CATEGORIES[name]}', expected '{expected_category}'"
130 )
131
132 def test_musehub_tools_have_required_fields(self) -> None:
133 """Every tool has name, description, and inputSchema. Names start with musehub_ or muse_."""
134 for tool in MUSEHUB_TOOLS:
135 assert "name" in tool
136 assert "description" in tool
137 assert "inputSchema" in tool
138 assert tool["name"].startswith("musehub_") or tool["name"].startswith("muse_"), (
139 f"Tool name {tool['name']!r} must start with 'musehub_' or 'muse_'"
140 )
141
142 def test_musehub_tools_are_server_side(self) -> None:
143 """Every musehub_* tool is marked server_side=True."""
144 for tool in MUSEHUB_TOOLS:
145 assert tool.get("server_side") is True, (
146 f"Tool '{tool['name']}' must be server_side=True"
147 )
148
149 def test_all_tools_defined(self) -> None:
150 """All 43 MuseHub tools (23 read + 15 write + 5 elicitation) are defined."""
151 expected_read = {
152 "musehub_browse_repo",
153 "musehub_list_branches",
154 "musehub_list_commits",
155 "musehub_read_file",
156 "musehub_get_analysis",
157 "musehub_search",
158 "musehub_get_context",
159 "musehub_get_commit",
160 "musehub_compare",
161 "musehub_list_issues",
162 "musehub_get_issue",
163 "musehub_list_prs",
164 "musehub_get_pr",
165 "musehub_list_releases",
166 "musehub_search_repos",
167 # Domain tools
168 "musehub_get_domain",
169 "musehub_get_domain_insights",
170 "musehub_get_view",
171 "musehub_list_domains",
172 # Muse CLI + identity
173 "musehub_whoami",
174 "muse_clone",
175 "muse_pull",
176 "muse_remote",
177 }
178 expected_write = {
179 "musehub_create_repo",
180 "musehub_fork_repo",
181 "musehub_create_issue",
182 "musehub_update_issue",
183 "musehub_create_issue_comment",
184 "musehub_create_pr",
185 "musehub_merge_pr",
186 "musehub_create_pr_comment",
187 "musehub_submit_pr_review",
188 "musehub_create_release",
189 "musehub_star_repo",
190 "musehub_create_label",
191 # Auth + push
192 "musehub_create_agent_token",
193 "muse_push",
194 "muse_config",
195 }
196 expected_elicitation = {
197 "musehub_compose_with_preferences",
198 "musehub_review_pr_interactive",
199 "musehub_connect_streaming_platform",
200 "musehub_connect_daw_cloud",
201 "musehub_create_release_interactive",
202 }
203 assert MUSEHUB_TOOL_NAMES == expected_read | expected_write | expected_elicitation
204
205
206 # ---------------------------------------------------------------------------
207 # Executor unit tests (using db_session fixture from conftest)
208 # ---------------------------------------------------------------------------
209
210
211 class TestMusehubExecutors:
212 """Unit tests for each executor function using the in-memory test DB."""
213
214 @pytest.mark.anyio
215 async def test_browse_repo_returns_db_unavailable_when_not_initialised(self) -> None:
216 """_check_db_available returns db_unavailable when session factory is None."""
217 from musehub.db import database
218 from musehub.services.musehub_mcp_executor import _check_db_available
219
220 original = database._async_session_factory
221 database._async_session_factory = None
222 try:
223 result = _check_db_available()
224 assert result is not None
225 assert result.ok is False
226 assert result.error_code == "db_unavailable"
227 finally:
228 database._async_session_factory = original
229
230 @pytest.mark.anyio
231 async def test_mcp_browse_repo_returns_repo_data(
232 self, db_session: AsyncSession
233 ) -> None:
234 """musehub_browse_repo returns repo, branches, and recent commits."""
235 await _seed_repo(db_session)
236
237 with patch(
238 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
239 return_value=db_session,
240 ):
241 result = await executor.execute_browse_repo("repo-test-001")
242
243 assert result.ok is True
244 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
245 assert result.data["repo"]["name"] == "jazz-sessions" # type: ignore[index, call-overload]
246 assert result.data["branch_count"] == 1
247 assert len(result.data["branches"]) == 1 # type: ignore[arg-type]
248 assert len(result.data["recent_commits"]) == 1 # type: ignore[arg-type]
249 assert result.data["total_commits"] == 1
250
251 @pytest.mark.anyio
252 async def test_mcp_browse_repo_not_found(self, db_session: AsyncSession) -> None:
253 """musehub_browse_repo returns error for unknown repo."""
254 with patch(
255 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
256 return_value=db_session,
257 ):
258 result = await executor.execute_browse_repo("nonexistent-repo")
259
260 assert result.ok is False
261 assert result.error_code == "not_found"
262
263 @pytest.mark.anyio
264 async def test_mcp_list_branches_returns_branches(
265 self, db_session: AsyncSession
266 ) -> None:
267 """musehub_list_branches returns all branches with head commit IDs."""
268 await _seed_repo(db_session)
269
270 with patch(
271 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
272 return_value=db_session,
273 ):
274 result = await executor.execute_list_branches("repo-test-001")
275
276 assert result.ok is True
277 branches = result.data["branches"]
278 assert isinstance(branches, list)
279 assert len(branches) == 1
280 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
281 assert branches[0]["name"] == "main" # type: ignore[index, call-overload]
282 assert branches[0]["head_commit_id"] == "commit-001" # type: ignore[index, call-overload]
283
284 @pytest.mark.anyio
285 async def test_mcp_list_commits_returns_paginated_list(
286 self, db_session: AsyncSession
287 ) -> None:
288 """musehub_list_commits returns commits with total count."""
289 await _seed_repo(db_session)
290
291 with patch(
292 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
293 return_value=db_session,
294 ):
295 result = await executor.execute_list_commits(
296 "repo-test-001", branch="main", limit=10
297 )
298
299 assert result.ok is True
300 assert result.data["total"] == 1
301 commits = result.data["commits"]
302 assert isinstance(commits, list)
303 assert len(commits) == 1
304 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
305 assert commits[0]["message"] == "add bass track" # type: ignore[index, call-overload]
306 assert commits[0]["author"] == "alice" # type: ignore[index, call-overload]
307
308 @pytest.mark.anyio
309 async def test_mcp_list_commits_limit_clamped(
310 self, db_session: AsyncSession
311 ) -> None:
312 """musehub_list_commits clamps limit to [1, 100]."""
313 await _seed_repo(db_session)
314
315 with patch(
316 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
317 return_value=db_session,
318 ):
319 # limit=0 should be clamped to 1
320 result = await executor.execute_list_commits("repo-test-001", limit=0)
321
322 assert result.ok is True
323
324 @pytest.mark.anyio
325 async def test_mcp_read_file_returns_metadata(
326 self, db_session: AsyncSession
327 ) -> None:
328 """musehub_read_file returns path, size_bytes, and mime_type."""
329 await _seed_repo(db_session)
330
331 with patch(
332 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
333 return_value=db_session,
334 ):
335 result = await executor.execute_read_file("repo-test-001", "sha256:abc123")
336
337 assert result.ok is True
338 assert result.data["path"] == "tracks/bass.mid"
339 assert result.data["size_bytes"] == 2048
340 assert result.data["mime_type"] == "audio/midi"
341 assert result.data["object_id"] == "sha256:abc123"
342
343 @pytest.mark.anyio
344 async def test_mcp_read_file_not_found(self, db_session: AsyncSession) -> None:
345 """musehub_read_file returns not_found for unknown object."""
346 await _seed_repo(db_session)
347
348 with patch(
349 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
350 return_value=db_session,
351 ):
352 result = await executor.execute_read_file("repo-test-001", "sha256:missing")
353
354 assert result.ok is False
355 assert result.error_code == "not_found"
356
357 @pytest.mark.anyio
358 async def test_mcp_get_analysis_overview(self, db_session: AsyncSession) -> None:
359 """musehub_get_analysis overview dimension returns repo stats."""
360 await _seed_repo(db_session)
361
362 with patch(
363 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
364 return_value=db_session,
365 ):
366 result = await executor.execute_get_analysis(
367 "repo-test-001", dimension="overview"
368 )
369
370 assert result.ok is True
371 assert result.data["dimension"] == "overview"
372 assert result.data["branch_count"] == 1
373 assert result.data["commit_count"] == 1
374 assert result.data["object_count"] == 1
375 assert result.data["midi_analysis"] is None
376
377 @pytest.mark.anyio
378 async def test_mcp_get_analysis_commits(self, db_session: AsyncSession) -> None:
379 """musehub_get_analysis commits dimension returns commit activity summary."""
380 await _seed_repo(db_session)
381
382 with patch(
383 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
384 return_value=db_session,
385 ):
386 result = await executor.execute_get_analysis(
387 "repo-test-001", dimension="commits"
388 )
389
390 assert result.ok is True
391 assert result.data["dimension"] == "commits"
392 assert result.data["total_commits"] == 1
393 by_author = result.data["by_author"]
394 assert isinstance(by_author, dict)
395 assert by_author.get("alice") == 1
396
397 @pytest.mark.anyio
398 async def test_mcp_get_analysis_objects(self, db_session: AsyncSession) -> None:
399 """musehub_get_analysis objects dimension returns artifact inventory."""
400 await _seed_repo(db_session)
401
402 with patch(
403 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
404 return_value=db_session,
405 ):
406 result = await executor.execute_get_analysis(
407 "repo-test-001", dimension="objects"
408 )
409
410 assert result.ok is True
411 assert result.data["dimension"] == "objects"
412 assert result.data["total_objects"] == 1
413 assert result.data["total_size_bytes"] == 2048
414
415 @pytest.mark.anyio
416 async def test_mcp_get_analysis_invalid_dimension(
417 self, db_session: AsyncSession
418 ) -> None:
419 """musehub_get_analysis returns error for unknown dimension."""
420 await _seed_repo(db_session)
421
422 with patch(
423 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
424 return_value=db_session,
425 ):
426 result = await executor.execute_get_analysis(
427 "repo-test-001", dimension="harmonics"
428 )
429
430 assert result.ok is False
431 assert result.error_code == "invalid_dimension"
432
433 @pytest.mark.anyio
434 async def test_mcp_search_by_path(self, db_session: AsyncSession) -> None:
435 """musehub_search path mode returns matching artifacts."""
436 await _seed_repo(db_session)
437
438 with patch(
439 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
440 return_value=db_session,
441 ):
442 result = await executor.execute_search("repo-test-001", "bass", mode="path")
443
444 assert result.ok is True
445 assert result.data["mode"] == "path"
446 assert result.data["result_count"] == 1
447 results = result.data["results"]
448 assert isinstance(results, list)
449 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
450 assert results[0]["path"] == "tracks/bass.mid" # type: ignore[index, call-overload]
451
452 @pytest.mark.anyio
453 async def test_mcp_search_by_path_no_match(
454 self, db_session: AsyncSession
455 ) -> None:
456 """musehub_search returns empty results when nothing matches."""
457 await _seed_repo(db_session)
458
459 with patch(
460 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
461 return_value=db_session,
462 ):
463 result = await executor.execute_search(
464 "repo-test-001", "drums", mode="path"
465 )
466
467 assert result.ok is True
468 assert result.data["result_count"] == 0
469
470 @pytest.mark.anyio
471 async def test_mcp_search_by_commit(self, db_session: AsyncSession) -> None:
472 """musehub_search commit mode searches commit messages."""
473 await _seed_repo(db_session)
474
475 with patch(
476 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
477 return_value=db_session,
478 ):
479 result = await executor.execute_search(
480 "repo-test-001", "bass", mode="commit"
481 )
482
483 assert result.ok is True
484 assert result.data["mode"] == "commit"
485 assert result.data["result_count"] == 1
486 results = result.data["results"]
487 assert isinstance(results, list)
488 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
489 assert results[0]["message"] == "add bass track" # type: ignore[index, call-overload]
490
491 @pytest.mark.anyio
492 async def test_mcp_search_invalid_mode(self, db_session: AsyncSession) -> None:
493 """musehub_search returns error for unknown mode."""
494 await _seed_repo(db_session)
495
496 with patch(
497 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
498 return_value=db_session,
499 ):
500 result = await executor.execute_search(
501 "repo-test-001", "bass", mode="fuzzy"
502 )
503
504 assert result.ok is False
505 assert result.error_code == "invalid_mode"
506
507 @pytest.mark.anyio
508 async def test_mcp_search_case_insensitive(self, db_session: AsyncSession) -> None:
509 """musehub_search is case-insensitive."""
510 await _seed_repo(db_session)
511
512 with patch(
513 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
514 return_value=db_session,
515 ):
516 result = await executor.execute_search(
517 "repo-test-001", "BASS", mode="path"
518 )
519
520 assert result.ok is True
521 assert result.data["result_count"] == 1
522
523 @pytest.mark.anyio
524 async def test_mcp_get_context_returns_ai_context(
525 self, db_session: AsyncSession
526 ) -> None:
527 """musehub_get_context returns full AI context document."""
528 await _seed_repo(db_session)
529
530 with patch(
531 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
532 return_value=db_session,
533 ):
534 result = await executor.execute_get_context("repo-test-001")
535
536 assert result.ok is True
537 ctx = result.data["context"]
538 assert isinstance(ctx, dict)
539 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
540 assert ctx["repo"]["name"] == "jazz-sessions" # type: ignore[index, call-overload]
541 assert len(ctx["branches"]) == 1 # type: ignore[arg-type]
542 assert len(ctx["recent_commits"]) == 1 # type: ignore[arg-type]
543 assert ctx["artifacts"]["total_count"] == 1 # type: ignore[index, call-overload]
544 assert ctx["musical_analysis"]["key"] is None # type: ignore[index, call-overload]
545
546 @pytest.mark.anyio
547 async def test_mcp_get_context_not_found(self, db_session: AsyncSession) -> None:
548 """musehub_get_context returns not_found for unknown repo."""
549 with patch(
550 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
551 return_value=db_session,
552 ):
553 result = await executor.execute_get_context("ghost-repo")
554
555 assert result.ok is False
556 assert result.error_code == "not_found"
557
558
559 # ---------------------------------------------------------------------------
560 # MCP server routing tests
561 # ---------------------------------------------------------------------------
562
563
564 class TestMusehubMcpServerRouting:
565 """Verify that the MCP server correctly routes musehub_* calls."""
566
567 @pytest.fixture
568 def server(self) -> MuseMCPServer:
569 with patch("musehub.config.get_settings") as mock_settings:
570 mock_settings.return_value = MagicMock(app_version="0.0.0-test")
571 return MuseMCPServer()
572
573 @pytest.mark.anyio
574 async def test_musehub_browse_repo_routed_to_executor(
575 self, server: MuseMCPServer
576 ) -> None:
577 """call_tool routes musehub_browse_repo to the MuseHub executor."""
578 mock_result = MusehubToolResult(
579 ok=True,
580 data={"repo": {"name": "test", "owner": "testuser"}, "branches": [], "recent_commits": [], "total_commits": 0, "branch_count": 0},
581 )
582 with patch(
583 "musehub.services.musehub_mcp_executor.execute_browse_repo",
584 new_callable=AsyncMock,
585 return_value=mock_result,
586 ):
587 result = await server.call_tool(
588 "musehub_browse_repo", {"repo_id": "repo-001"}
589 )
590
591 assert result.success is True
592 assert result.is_error is False
593
594 @pytest.mark.anyio
595 async def test_musehub_tool_not_found_returns_error(
596 self, server: MuseMCPServer
597 ) -> None:
598 """musehub_browse_repo propagates not_found as an error response."""
599 mock_result = MusehubToolResult(
600 ok=False,
601 error_code="not_found",
602 error_message="Repository 'bad-id' not found.",
603 )
604 with patch(
605 "musehub.services.musehub_mcp_executor.execute_browse_repo",
606 new_callable=AsyncMock,
607 return_value=mock_result,
608 ):
609 result = await server.call_tool(
610 "musehub_browse_repo", {"repo_id": "bad-id"}
611 )
612
613 assert result.success is False
614 assert result.is_error is True
615
616 @pytest.mark.anyio
617 async def test_musehub_invalid_dimension_is_bad_request(
618 self, server: MuseMCPServer
619 ) -> None:
620 """invalid_dimension error is surfaced as bad_request=True."""
621 mock_result = MusehubToolResult(
622 ok=False,
623 error_code="invalid_dimension",
624 error_message="Unknown dimension 'xyz'.",
625 )
626 with patch(
627 "musehub.services.musehub_mcp_executor.execute_get_analysis",
628 new_callable=AsyncMock,
629 return_value=mock_result,
630 ):
631 result = await server.call_tool(
632 "musehub_get_analysis", {"repo_id": "r", "dimension": "xyz"}
633 )
634
635 assert result.bad_request is True
636
637 @pytest.mark.anyio
638 async def test_musehub_browse_repo_db_unavailable_returns_error(
639 self, server: MuseMCPServer
640 ) -> None:
641 """musehub tools return db_unavailable when session factory is not initialised."""
642 mock_result = MusehubToolResult(
643 ok=False,
644 error_code="db_unavailable",
645 error_message="Database session factory is not initialised.",
646 )
647 with patch(
648 "musehub.services.musehub_mcp_executor.execute_browse_repo",
649 new_callable=AsyncMock,
650 return_value=mock_result,
651 ):
652 result = await server.call_tool(
653 "musehub_browse_repo", {"repo_id": "any-id"}
654 )
655
656 assert result.success is False
657 assert result.is_error is True
658 assert "not initialised" in result.content[0]["text"]
659
660 @pytest.mark.anyio
661 async def test_musehub_get_context_response_is_json(
662 self, server: MuseMCPServer
663 ) -> None:
664 """musehub_get_context response content is valid JSON."""
665 mock_result = MusehubToolResult(
666 ok=True,
667 data={"repo_id": "r", "context": {"repo": {}, "branches": [], "recent_commits": [], "commit_stats": {"total": 0, "shown": 0}, "artifacts": {"total_count": 0, "by_mime_type": {}, "paths": []}, "musical_analysis": {"key": None, "tempo": None, "time_signature": None, "note": ""}}},
668 )
669 with patch(
670 "musehub.services.musehub_mcp_executor.execute_get_context",
671 new_callable=AsyncMock,
672 return_value=mock_result,
673 ):
674 result = await server.call_tool(
675 "musehub_get_context", {"repo_id": "r"}
676 )
677
678 assert result.success is True
679 # Content must be parseable JSON
680 text = result.content[0]["text"]
681 parsed = json.loads(text)
682 assert "context" in parsed