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