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