gabriel / musehub public
test_mcp_musehub.py python
685 lines 25.4 KB
58180686 dev → main: wire protocol fixes + musehub_publish_domain MCP tool (#18) 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 )
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 41 MuseHub tools (20 read + 16 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 # Domain marketplace
197 "musehub_publish_domain",
198 }
199 expected_elicitation = {
200 "musehub_create_with_preferences",
201 "musehub_review_pr_interactive",
202 "musehub_connect_streaming_platform",
203 "musehub_connect_daw_cloud",
204 "musehub_create_release_interactive",
205 }
206 assert MUSEHUB_TOOL_NAMES == expected_read | expected_write | expected_elicitation
207
208
209 # ---------------------------------------------------------------------------
210 # Executor unit tests (using db_session fixture from conftest)
211 # ---------------------------------------------------------------------------
212
213
214 class TestMusehubExecutors:
215 """Unit tests for each executor function using the in-memory test DB."""
216
217 @pytest.mark.anyio
218 async def test_browse_repo_returns_db_unavailable_when_not_initialised(self) -> None:
219 """_check_db_available returns db_unavailable when session factory is None."""
220 from musehub.db import database
221 from musehub.services.musehub_mcp_executor import _check_db_available
222
223 original = database._async_session_factory
224 database._async_session_factory = None
225 try:
226 result = _check_db_available()
227 assert result is not None
228 assert result.ok is False
229 assert result.error_code == "db_unavailable"
230 finally:
231 database._async_session_factory = original
232
233 @pytest.mark.anyio
234 async def test_execute_browse_repo_returns_repo_data(
235 self, db_session: AsyncSession
236 ) -> None:
237 """execute_browse_repo (internal executor) returns repo, branches, and commits."""
238 await _seed_repo(db_session)
239
240 with patch(
241 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
242 return_value=db_session,
243 ):
244 result = await executor.execute_browse_repo("repo-test-001")
245
246 assert result.ok is True
247 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
248 assert result.data["repo"]["name"] == "jazz-sessions" # type: ignore[index, call-overload]
249 assert result.data["branch_count"] == 1
250 assert len(result.data["branches"]) == 1 # type: ignore[arg-type]
251 assert len(result.data["recent_commits"]) == 1 # type: ignore[arg-type]
252 assert result.data["total_commits"] == 1
253
254 @pytest.mark.anyio
255 async def test_execute_browse_repo_not_found(self, db_session: AsyncSession) -> None:
256 """execute_browse_repo (internal executor) returns error for unknown repo."""
257 with patch(
258 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
259 return_value=db_session,
260 ):
261 result = await executor.execute_browse_repo("nonexistent-repo")
262
263 assert result.ok is False
264 assert result.error_code == "not_found"
265
266 @pytest.mark.anyio
267 async def test_mcp_list_branches_returns_branches(
268 self, db_session: AsyncSession
269 ) -> None:
270 """musehub_list_branches returns all branches with head commit IDs."""
271 await _seed_repo(db_session)
272
273 with patch(
274 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
275 return_value=db_session,
276 ):
277 result = await executor.execute_list_branches("repo-test-001")
278
279 assert result.ok is True
280 branches = result.data["branches"]
281 assert isinstance(branches, list)
282 assert len(branches) == 1
283 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
284 assert branches[0]["name"] == "main" # type: ignore[index, call-overload]
285 assert branches[0]["head_commit_id"] == "commit-001" # type: ignore[index, call-overload]
286
287 @pytest.mark.anyio
288 async def test_mcp_list_commits_returns_paginated_list(
289 self, db_session: AsyncSession
290 ) -> None:
291 """musehub_list_commits returns commits with total count."""
292 await _seed_repo(db_session)
293
294 with patch(
295 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
296 return_value=db_session,
297 ):
298 result = await executor.execute_list_commits(
299 "repo-test-001", branch="main", limit=10
300 )
301
302 assert result.ok is True
303 assert result.data["total"] == 1
304 commits = result.data["commits"]
305 assert isinstance(commits, list)
306 assert len(commits) == 1
307 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
308 assert commits[0]["message"] == "add bass track" # type: ignore[index, call-overload]
309 assert commits[0]["author"] == "alice" # type: ignore[index, call-overload]
310
311 @pytest.mark.anyio
312 async def test_mcp_list_commits_limit_clamped(
313 self, db_session: AsyncSession
314 ) -> None:
315 """musehub_list_commits clamps limit to [1, 100]."""
316 await _seed_repo(db_session)
317
318 with patch(
319 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
320 return_value=db_session,
321 ):
322 # limit=0 should be clamped to 1
323 result = await executor.execute_list_commits("repo-test-001", limit=0)
324
325 assert result.ok is True
326
327 @pytest.mark.anyio
328 async def test_mcp_read_file_returns_metadata(
329 self, db_session: AsyncSession
330 ) -> None:
331 """musehub_read_file returns path, size_bytes, and mime_type."""
332 await _seed_repo(db_session)
333
334 with patch(
335 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
336 return_value=db_session,
337 ):
338 result = await executor.execute_read_file("repo-test-001", "sha256:abc123")
339
340 assert result.ok is True
341 assert result.data["path"] == "tracks/bass.mid"
342 assert result.data["size_bytes"] == 2048
343 assert result.data["mime_type"] == "audio/midi"
344 assert result.data["object_id"] == "sha256:abc123"
345
346 @pytest.mark.anyio
347 async def test_mcp_read_file_not_found(self, db_session: AsyncSession) -> None:
348 """musehub_read_file returns not_found for unknown object."""
349 await _seed_repo(db_session)
350
351 with patch(
352 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
353 return_value=db_session,
354 ):
355 result = await executor.execute_read_file("repo-test-001", "sha256:missing")
356
357 assert result.ok is False
358 assert result.error_code == "not_found"
359
360 @pytest.mark.anyio
361 async def test_mcp_get_analysis_overview(self, db_session: AsyncSession) -> None:
362 """execute_get_analysis (internal executor) overview dimension returns repo stats."""
363 await _seed_repo(db_session)
364
365 with patch(
366 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
367 return_value=db_session,
368 ):
369 result = await executor.execute_get_analysis(
370 "repo-test-001", dimension="overview"
371 )
372
373 assert result.ok is True
374 assert result.data["dimension"] == "overview"
375 assert result.data["branch_count"] == 1
376 assert result.data["commit_count"] == 1
377 assert result.data["object_count"] == 1
378 assert result.data["midi_analysis"] is None
379
380 @pytest.mark.anyio
381 async def test_mcp_get_analysis_commits(self, db_session: AsyncSession) -> None:
382 """execute_get_analysis commits dimension returns commit activity summary."""
383 await _seed_repo(db_session)
384
385 with patch(
386 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
387 return_value=db_session,
388 ):
389 result = await executor.execute_get_analysis(
390 "repo-test-001", dimension="commits"
391 )
392
393 assert result.ok is True
394 assert result.data["dimension"] == "commits"
395 assert result.data["total_commits"] == 1
396 by_author = result.data["by_author"]
397 assert isinstance(by_author, dict)
398 assert by_author.get("alice") == 1
399
400 @pytest.mark.anyio
401 async def test_mcp_get_analysis_objects(self, db_session: AsyncSession) -> None:
402 """execute_get_analysis objects dimension returns artifact inventory."""
403 await _seed_repo(db_session)
404
405 with patch(
406 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
407 return_value=db_session,
408 ):
409 result = await executor.execute_get_analysis(
410 "repo-test-001", dimension="objects"
411 )
412
413 assert result.ok is True
414 assert result.data["dimension"] == "objects"
415 assert result.data["total_objects"] == 1
416 assert result.data["total_size_bytes"] == 2048
417
418 @pytest.mark.anyio
419 async def test_mcp_get_analysis_invalid_dimension(
420 self, db_session: AsyncSession
421 ) -> None:
422 """execute_get_analysis returns error for unknown dimension."""
423 await _seed_repo(db_session)
424
425 with patch(
426 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
427 return_value=db_session,
428 ):
429 result = await executor.execute_get_analysis(
430 "repo-test-001", dimension="harmonics"
431 )
432
433 assert result.ok is False
434 assert result.error_code == "invalid_dimension"
435
436 @pytest.mark.anyio
437 async def test_mcp_search_by_path(self, db_session: AsyncSession) -> None:
438 """musehub_search path mode returns matching artifacts."""
439 await _seed_repo(db_session)
440
441 with patch(
442 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
443 return_value=db_session,
444 ):
445 result = await executor.execute_search("repo-test-001", "bass", mode="path")
446
447 assert result.ok is True
448 assert result.data["mode"] == "path"
449 assert result.data["result_count"] == 1
450 results = result.data["results"]
451 assert isinstance(results, list)
452 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
453 assert results[0]["path"] == "tracks/bass.mid" # type: ignore[index, call-overload]
454
455 @pytest.mark.anyio
456 async def test_mcp_search_by_path_no_match(
457 self, db_session: AsyncSession
458 ) -> None:
459 """musehub_search returns empty results when nothing matches."""
460 await _seed_repo(db_session)
461
462 with patch(
463 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
464 return_value=db_session,
465 ):
466 result = await executor.execute_search(
467 "repo-test-001", "drums", mode="path"
468 )
469
470 assert result.ok is True
471 assert result.data["result_count"] == 0
472
473 @pytest.mark.anyio
474 async def test_mcp_search_by_commit(self, db_session: AsyncSession) -> None:
475 """musehub_search commit mode searches commit messages."""
476 await _seed_repo(db_session)
477
478 with patch(
479 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
480 return_value=db_session,
481 ):
482 result = await executor.execute_search(
483 "repo-test-001", "bass", mode="commit"
484 )
485
486 assert result.ok is True
487 assert result.data["mode"] == "commit"
488 assert result.data["result_count"] == 1
489 results = result.data["results"]
490 assert isinstance(results, list)
491 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
492 assert results[0]["message"] == "add bass track" # type: ignore[index, call-overload]
493
494 @pytest.mark.anyio
495 async def test_mcp_search_invalid_mode(self, db_session: AsyncSession) -> None:
496 """musehub_search returns error for unknown mode."""
497 await _seed_repo(db_session)
498
499 with patch(
500 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
501 return_value=db_session,
502 ):
503 result = await executor.execute_search(
504 "repo-test-001", "bass", mode="fuzzy"
505 )
506
507 assert result.ok is False
508 assert result.error_code == "invalid_mode"
509
510 @pytest.mark.anyio
511 async def test_mcp_search_case_insensitive(self, db_session: AsyncSession) -> None:
512 """musehub_search is case-insensitive."""
513 await _seed_repo(db_session)
514
515 with patch(
516 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
517 return_value=db_session,
518 ):
519 result = await executor.execute_search(
520 "repo-test-001", "BASS", mode="path"
521 )
522
523 assert result.ok is True
524 assert result.data["result_count"] == 1
525
526 @pytest.mark.anyio
527 async def test_mcp_get_context_returns_ai_context(
528 self, db_session: AsyncSession
529 ) -> None:
530 """musehub_get_context returns full AI context document."""
531 await _seed_repo(db_session)
532
533 with patch(
534 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
535 return_value=db_session,
536 ):
537 result = await executor.execute_get_context("repo-test-001")
538
539 assert result.ok is True
540 ctx = result.data["context"]
541 assert isinstance(ctx, dict)
542 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
543 assert ctx["repo"]["name"] == "jazz-sessions" # type: ignore[index, call-overload]
544 assert len(ctx["branches"]) == 1 # type: ignore[arg-type]
545 assert len(ctx["recent_commits"]) == 1 # type: ignore[arg-type]
546 assert ctx["artifacts"]["total_count"] == 1 # type: ignore[index, call-overload]
547 assert ctx["musical_analysis"]["key"] is None # type: ignore[index, call-overload]
548
549 @pytest.mark.anyio
550 async def test_mcp_get_context_not_found(self, db_session: AsyncSession) -> None:
551 """musehub_get_context returns not_found for unknown repo."""
552 with patch(
553 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
554 return_value=db_session,
555 ):
556 result = await executor.execute_get_context("ghost-repo")
557
558 assert result.ok is False
559 assert result.error_code == "not_found"
560
561
562 # ---------------------------------------------------------------------------
563 # MCP server routing tests
564 # ---------------------------------------------------------------------------
565
566
567 class TestMusehubMcpServerRouting:
568 """Verify that the MCP server correctly routes musehub_* calls."""
569
570 @pytest.fixture
571 def server(self) -> MuseMCPServer:
572 with patch("musehub.config.get_settings") as mock_settings:
573 mock_settings.return_value = MagicMock(app_version="0.0.0-test")
574 return MuseMCPServer()
575
576 @pytest.mark.anyio
577 async def test_musehub_list_branches_routed_to_executor(
578 self, server: MuseMCPServer
579 ) -> None:
580 """call_tool routes musehub_list_branches to the MuseHub executor."""
581 mock_result = MusehubToolResult(
582 ok=True,
583 data={"repo_id": "repo-001", "branches": []},
584 )
585 with patch(
586 "musehub.services.musehub_mcp_executor.execute_list_branches",
587 new_callable=AsyncMock,
588 return_value=mock_result,
589 ):
590 result = await server.call_tool(
591 "musehub_list_branches", {"repo_id": "repo-001"}
592 )
593
594 assert result.success is True
595 assert result.is_error is False
596
597 @pytest.mark.anyio
598 async def test_musehub_tool_not_found_returns_error(
599 self, server: MuseMCPServer
600 ) -> None:
601 """musehub_list_branches propagates not_found as an error response."""
602 mock_result = MusehubToolResult(
603 ok=False,
604 error_code="not_found",
605 error_message="Repository 'bad-id' not found.",
606 )
607 with patch(
608 "musehub.services.musehub_mcp_executor.execute_list_branches",
609 new_callable=AsyncMock,
610 return_value=mock_result,
611 ):
612 result = await server.call_tool(
613 "musehub_list_branches", {"repo_id": "bad-id"}
614 )
615
616 assert result.success is False
617 assert result.is_error is True
618
619 @pytest.mark.anyio
620 async def test_musehub_invalid_mode_is_bad_request(
621 self, server: MuseMCPServer
622 ) -> None:
623 """invalid_mode error is surfaced as bad_request=True."""
624 mock_result = MusehubToolResult(
625 ok=False,
626 error_code="invalid_mode",
627 error_message="Unknown mode 'fuzzy'.",
628 )
629 with patch(
630 "musehub.services.musehub_mcp_executor.execute_search",
631 new_callable=AsyncMock,
632 return_value=mock_result,
633 ):
634 result = await server.call_tool(
635 "musehub_search", {"repo_id": "r", "query": "bass", "mode": "fuzzy"}
636 )
637
638 assert result.bad_request is True
639
640 @pytest.mark.anyio
641 async def test_musehub_list_branches_db_unavailable_returns_error(
642 self, server: MuseMCPServer
643 ) -> None:
644 """musehub tools return db_unavailable when session factory is not initialised."""
645 mock_result = MusehubToolResult(
646 ok=False,
647 error_code="db_unavailable",
648 error_message="Database session factory is not initialised.",
649 )
650 with patch(
651 "musehub.services.musehub_mcp_executor.execute_list_branches",
652 new_callable=AsyncMock,
653 return_value=mock_result,
654 ):
655 result = await server.call_tool(
656 "musehub_list_branches", {"repo_id": "any-id"}
657 )
658
659 assert result.success is False
660 assert result.is_error is True
661 assert "not initialised" in result.content[0]["text"]
662
663 @pytest.mark.anyio
664 async def test_musehub_get_context_response_is_json(
665 self, server: MuseMCPServer
666 ) -> None:
667 """musehub_get_context response content is valid JSON."""
668 mock_result = MusehubToolResult(
669 ok=True,
670 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": ""}}},
671 )
672 with patch(
673 "musehub.services.musehub_mcp_executor.execute_get_context",
674 new_callable=AsyncMock,
675 return_value=mock_result,
676 ):
677 result = await server.call_tool(
678 "musehub_get_context", {"repo_id": "r"}
679 )
680
681 assert result.success is True
682 # Content must be parseable JSON
683 text = result.content[0]["text"]
684 parsed = json.loads(text)
685 assert "context" in parsed