gabriel / musehub public
test_mcp_musehub.py python
666 lines 24.7 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d 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 musehub_* tool has name, description, and inputSchema."""
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_")
139
140 def test_musehub_tools_are_server_side(self) -> None:
141 """Every musehub_* tool is marked server_side=True."""
142 for tool in MUSEHUB_TOOLS:
143 assert tool.get("server_side") is True, (
144 f"Tool '{tool['name']}' must be server_side=True"
145 )
146
147 def test_all_tools_defined(self) -> None:
148 """All 32 MuseHub tools (15 read + 12 write + 5 elicitation) are defined."""
149 expected_read = {
150 "musehub_browse_repo",
151 "musehub_list_branches",
152 "musehub_list_commits",
153 "musehub_read_file",
154 "musehub_get_analysis",
155 "musehub_search",
156 "musehub_get_context",
157 "musehub_get_commit",
158 "musehub_compare",
159 "musehub_list_issues",
160 "musehub_get_issue",
161 "musehub_list_prs",
162 "musehub_get_pr",
163 "musehub_list_releases",
164 "musehub_search_repos",
165 }
166 expected_write = {
167 "musehub_create_repo",
168 "musehub_fork_repo",
169 "musehub_create_issue",
170 "musehub_update_issue",
171 "musehub_create_issue_comment",
172 "musehub_create_pr",
173 "musehub_merge_pr",
174 "musehub_create_pr_comment",
175 "musehub_submit_pr_review",
176 "musehub_create_release",
177 "musehub_star_repo",
178 "musehub_create_label",
179 }
180 expected_elicitation = {
181 "musehub_compose_with_preferences",
182 "musehub_review_pr_interactive",
183 "musehub_connect_streaming_platform",
184 "musehub_connect_daw_cloud",
185 "musehub_create_release_interactive",
186 }
187 assert MUSEHUB_TOOL_NAMES == expected_read | expected_write | expected_elicitation
188
189
190 # ---------------------------------------------------------------------------
191 # Executor unit tests (using db_session fixture from conftest)
192 # ---------------------------------------------------------------------------
193
194
195 class TestMusehubExecutors:
196 """Unit tests for each executor function using the in-memory test DB."""
197
198 @pytest.mark.anyio
199 async def test_browse_repo_returns_db_unavailable_when_not_initialised(self) -> None:
200 """_check_db_available returns db_unavailable when session factory is None."""
201 from musehub.db import database
202 from musehub.services.musehub_mcp_executor import _check_db_available
203
204 original = database._async_session_factory
205 database._async_session_factory = None
206 try:
207 result = _check_db_available()
208 assert result is not None
209 assert result.ok is False
210 assert result.error_code == "db_unavailable"
211 finally:
212 database._async_session_factory = original
213
214 @pytest.mark.anyio
215 async def test_mcp_browse_repo_returns_repo_data(
216 self, db_session: AsyncSession
217 ) -> None:
218 """musehub_browse_repo returns repo, branches, and recent commits."""
219 await _seed_repo(db_session)
220
221 with patch(
222 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
223 return_value=db_session,
224 ):
225 result = await executor.execute_browse_repo("repo-test-001")
226
227 assert result.ok is True
228 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
229 assert result.data["repo"]["name"] == "jazz-sessions" # type: ignore[index, call-overload]
230 assert result.data["branch_count"] == 1
231 assert len(result.data["branches"]) == 1 # type: ignore[arg-type]
232 assert len(result.data["recent_commits"]) == 1 # type: ignore[arg-type]
233 assert result.data["total_commits"] == 1
234
235 @pytest.mark.anyio
236 async def test_mcp_browse_repo_not_found(self, db_session: AsyncSession) -> None:
237 """musehub_browse_repo returns error for unknown repo."""
238 with patch(
239 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
240 return_value=db_session,
241 ):
242 result = await executor.execute_browse_repo("nonexistent-repo")
243
244 assert result.ok is False
245 assert result.error_code == "not_found"
246
247 @pytest.mark.anyio
248 async def test_mcp_list_branches_returns_branches(
249 self, db_session: AsyncSession
250 ) -> None:
251 """musehub_list_branches returns all branches with head commit IDs."""
252 await _seed_repo(db_session)
253
254 with patch(
255 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
256 return_value=db_session,
257 ):
258 result = await executor.execute_list_branches("repo-test-001")
259
260 assert result.ok is True
261 branches = result.data["branches"]
262 assert isinstance(branches, list)
263 assert len(branches) == 1
264 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
265 assert branches[0]["name"] == "main" # type: ignore[index, call-overload]
266 assert branches[0]["head_commit_id"] == "commit-001" # type: ignore[index, call-overload]
267
268 @pytest.mark.anyio
269 async def test_mcp_list_commits_returns_paginated_list(
270 self, db_session: AsyncSession
271 ) -> None:
272 """musehub_list_commits returns commits with total count."""
273 await _seed_repo(db_session)
274
275 with patch(
276 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
277 return_value=db_session,
278 ):
279 result = await executor.execute_list_commits(
280 "repo-test-001", branch="main", limit=10
281 )
282
283 assert result.ok is True
284 assert result.data["total"] == 1
285 commits = result.data["commits"]
286 assert isinstance(commits, list)
287 assert len(commits) == 1
288 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
289 assert commits[0]["message"] == "add bass track" # type: ignore[index, call-overload]
290 assert commits[0]["author"] == "alice" # type: ignore[index, call-overload]
291
292 @pytest.mark.anyio
293 async def test_mcp_list_commits_limit_clamped(
294 self, db_session: AsyncSession
295 ) -> None:
296 """musehub_list_commits clamps limit to [1, 100]."""
297 await _seed_repo(db_session)
298
299 with patch(
300 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
301 return_value=db_session,
302 ):
303 # limit=0 should be clamped to 1
304 result = await executor.execute_list_commits("repo-test-001", limit=0)
305
306 assert result.ok is True
307
308 @pytest.mark.anyio
309 async def test_mcp_read_file_returns_metadata(
310 self, db_session: AsyncSession
311 ) -> None:
312 """musehub_read_file returns path, size_bytes, and mime_type."""
313 await _seed_repo(db_session)
314
315 with patch(
316 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
317 return_value=db_session,
318 ):
319 result = await executor.execute_read_file("repo-test-001", "sha256:abc123")
320
321 assert result.ok is True
322 assert result.data["path"] == "tracks/bass.mid"
323 assert result.data["size_bytes"] == 2048
324 assert result.data["mime_type"] == "audio/midi"
325 assert result.data["object_id"] == "sha256:abc123"
326
327 @pytest.mark.anyio
328 async def test_mcp_read_file_not_found(self, db_session: AsyncSession) -> None:
329 """musehub_read_file returns not_found for unknown object."""
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:missing")
337
338 assert result.ok is False
339 assert result.error_code == "not_found"
340
341 @pytest.mark.anyio
342 async def test_mcp_get_analysis_overview(self, db_session: AsyncSession) -> None:
343 """musehub_get_analysis overview dimension returns repo stats."""
344 await _seed_repo(db_session)
345
346 with patch(
347 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
348 return_value=db_session,
349 ):
350 result = await executor.execute_get_analysis(
351 "repo-test-001", dimension="overview"
352 )
353
354 assert result.ok is True
355 assert result.data["dimension"] == "overview"
356 assert result.data["branch_count"] == 1
357 assert result.data["commit_count"] == 1
358 assert result.data["object_count"] == 1
359 assert result.data["midi_analysis"] is None
360
361 @pytest.mark.anyio
362 async def test_mcp_get_analysis_commits(self, db_session: AsyncSession) -> None:
363 """musehub_get_analysis commits dimension returns commit activity summary."""
364 await _seed_repo(db_session)
365
366 with patch(
367 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
368 return_value=db_session,
369 ):
370 result = await executor.execute_get_analysis(
371 "repo-test-001", dimension="commits"
372 )
373
374 assert result.ok is True
375 assert result.data["dimension"] == "commits"
376 assert result.data["total_commits"] == 1
377 by_author = result.data["by_author"]
378 assert isinstance(by_author, dict)
379 assert by_author.get("alice") == 1
380
381 @pytest.mark.anyio
382 async def test_mcp_get_analysis_objects(self, db_session: AsyncSession) -> None:
383 """musehub_get_analysis objects dimension returns artifact inventory."""
384 await _seed_repo(db_session)
385
386 with patch(
387 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
388 return_value=db_session,
389 ):
390 result = await executor.execute_get_analysis(
391 "repo-test-001", dimension="objects"
392 )
393
394 assert result.ok is True
395 assert result.data["dimension"] == "objects"
396 assert result.data["total_objects"] == 1
397 assert result.data["total_size_bytes"] == 2048
398
399 @pytest.mark.anyio
400 async def test_mcp_get_analysis_invalid_dimension(
401 self, db_session: AsyncSession
402 ) -> None:
403 """musehub_get_analysis returns error for unknown dimension."""
404 await _seed_repo(db_session)
405
406 with patch(
407 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
408 return_value=db_session,
409 ):
410 result = await executor.execute_get_analysis(
411 "repo-test-001", dimension="harmonics"
412 )
413
414 assert result.ok is False
415 assert result.error_code == "invalid_dimension"
416
417 @pytest.mark.anyio
418 async def test_mcp_search_by_path(self, db_session: AsyncSession) -> None:
419 """musehub_search path mode returns matching artifacts."""
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_search("repo-test-001", "bass", mode="path")
427
428 assert result.ok is True
429 assert result.data["mode"] == "path"
430 assert result.data["result_count"] == 1
431 results = result.data["results"]
432 assert isinstance(results, list)
433 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
434 assert results[0]["path"] == "tracks/bass.mid" # type: ignore[index, call-overload]
435
436 @pytest.mark.anyio
437 async def test_mcp_search_by_path_no_match(
438 self, db_session: AsyncSession
439 ) -> None:
440 """musehub_search returns empty results when nothing matches."""
441 await _seed_repo(db_session)
442
443 with patch(
444 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
445 return_value=db_session,
446 ):
447 result = await executor.execute_search(
448 "repo-test-001", "drums", mode="path"
449 )
450
451 assert result.ok is True
452 assert result.data["result_count"] == 0
453
454 @pytest.mark.anyio
455 async def test_mcp_search_by_commit(self, db_session: AsyncSession) -> None:
456 """musehub_search commit mode searches commit messages."""
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", "bass", mode="commit"
465 )
466
467 assert result.ok is True
468 assert result.data["mode"] == "commit"
469 assert result.data["result_count"] == 1
470 results = result.data["results"]
471 assert isinstance(results, list)
472 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
473 assert results[0]["message"] == "add bass track" # type: ignore[index, call-overload]
474
475 @pytest.mark.anyio
476 async def test_mcp_search_invalid_mode(self, db_session: AsyncSession) -> None:
477 """musehub_search returns error for unknown mode."""
478 await _seed_repo(db_session)
479
480 with patch(
481 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
482 return_value=db_session,
483 ):
484 result = await executor.execute_search(
485 "repo-test-001", "bass", mode="fuzzy"
486 )
487
488 assert result.ok is False
489 assert result.error_code == "invalid_mode"
490
491 @pytest.mark.anyio
492 async def test_mcp_search_case_insensitive(self, db_session: AsyncSession) -> None:
493 """musehub_search is case-insensitive."""
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="path"
502 )
503
504 assert result.ok is True
505 assert result.data["result_count"] == 1
506
507 @pytest.mark.anyio
508 async def test_mcp_get_context_returns_ai_context(
509 self, db_session: AsyncSession
510 ) -> None:
511 """musehub_get_context returns full AI context document."""
512 await _seed_repo(db_session)
513
514 with patch(
515 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
516 return_value=db_session,
517 ):
518 result = await executor.execute_get_context("repo-test-001")
519
520 assert result.ok is True
521 ctx = result.data["context"]
522 assert isinstance(ctx, dict)
523 # JSONValue union includes list[JSONValue] which rejects str keys — narrow at test boundary.
524 assert ctx["repo"]["name"] == "jazz-sessions" # type: ignore[index, call-overload]
525 assert len(ctx["branches"]) == 1 # type: ignore[arg-type]
526 assert len(ctx["recent_commits"]) == 1 # type: ignore[arg-type]
527 assert ctx["artifacts"]["total_count"] == 1 # type: ignore[index, call-overload]
528 assert ctx["musical_analysis"]["key"] is None # type: ignore[index, call-overload]
529
530 @pytest.mark.anyio
531 async def test_mcp_get_context_not_found(self, db_session: AsyncSession) -> None:
532 """musehub_get_context returns not_found for unknown repo."""
533 with patch(
534 "musehub.services.musehub_mcp_executor.AsyncSessionLocal",
535 return_value=db_session,
536 ):
537 result = await executor.execute_get_context("ghost-repo")
538
539 assert result.ok is False
540 assert result.error_code == "not_found"
541
542
543 # ---------------------------------------------------------------------------
544 # MCP server routing tests
545 # ---------------------------------------------------------------------------
546
547
548 class TestMusehubMcpServerRouting:
549 """Verify that the MCP server correctly routes musehub_* calls."""
550
551 @pytest.fixture
552 def server(self) -> MuseMCPServer:
553 with patch("musehub.config.get_settings") as mock_settings:
554 mock_settings.return_value = MagicMock(app_version="0.0.0-test")
555 return MuseMCPServer()
556
557 @pytest.mark.anyio
558 async def test_musehub_browse_repo_routed_to_executor(
559 self, server: MuseMCPServer
560 ) -> None:
561 """call_tool routes musehub_browse_repo to the MuseHub executor."""
562 mock_result = MusehubToolResult(
563 ok=True,
564 data={"repo": {"name": "test", "owner": "testuser"}, "branches": [], "recent_commits": [], "total_commits": 0, "branch_count": 0},
565 )
566 with patch(
567 "musehub.services.musehub_mcp_executor.execute_browse_repo",
568 new_callable=AsyncMock,
569 return_value=mock_result,
570 ):
571 result = await server.call_tool(
572 "musehub_browse_repo", {"repo_id": "repo-001"}
573 )
574
575 assert result.success is True
576 assert result.is_error is False
577
578 @pytest.mark.anyio
579 async def test_musehub_tool_not_found_returns_error(
580 self, server: MuseMCPServer
581 ) -> None:
582 """musehub_browse_repo propagates not_found as an error response."""
583 mock_result = MusehubToolResult(
584 ok=False,
585 error_code="not_found",
586 error_message="Repository 'bad-id' not found.",
587 )
588 with patch(
589 "musehub.services.musehub_mcp_executor.execute_browse_repo",
590 new_callable=AsyncMock,
591 return_value=mock_result,
592 ):
593 result = await server.call_tool(
594 "musehub_browse_repo", {"repo_id": "bad-id"}
595 )
596
597 assert result.success is False
598 assert result.is_error is True
599
600 @pytest.mark.anyio
601 async def test_musehub_invalid_dimension_is_bad_request(
602 self, server: MuseMCPServer
603 ) -> None:
604 """invalid_dimension error is surfaced as bad_request=True."""
605 mock_result = MusehubToolResult(
606 ok=False,
607 error_code="invalid_dimension",
608 error_message="Unknown dimension 'xyz'.",
609 )
610 with patch(
611 "musehub.services.musehub_mcp_executor.execute_get_analysis",
612 new_callable=AsyncMock,
613 return_value=mock_result,
614 ):
615 result = await server.call_tool(
616 "musehub_get_analysis", {"repo_id": "r", "dimension": "xyz"}
617 )
618
619 assert result.bad_request is True
620
621 @pytest.mark.anyio
622 async def test_musehub_browse_repo_db_unavailable_returns_error(
623 self, server: MuseMCPServer
624 ) -> None:
625 """musehub tools return db_unavailable when session factory is not initialised."""
626 mock_result = MusehubToolResult(
627 ok=False,
628 error_code="db_unavailable",
629 error_message="Database session factory is not initialised.",
630 )
631 with patch(
632 "musehub.services.musehub_mcp_executor.execute_browse_repo",
633 new_callable=AsyncMock,
634 return_value=mock_result,
635 ):
636 result = await server.call_tool(
637 "musehub_browse_repo", {"repo_id": "any-id"}
638 )
639
640 assert result.success is False
641 assert result.is_error is True
642 assert "not initialised" in result.content[0]["text"]
643
644 @pytest.mark.anyio
645 async def test_musehub_get_context_response_is_json(
646 self, server: MuseMCPServer
647 ) -> None:
648 """musehub_get_context response content is valid JSON."""
649 mock_result = MusehubToolResult(
650 ok=True,
651 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": ""}}},
652 )
653 with patch(
654 "musehub.services.musehub_mcp_executor.execute_get_context",
655 new_callable=AsyncMock,
656 return_value=mock_result,
657 ):
658 result = await server.call_tool(
659 "musehub_get_context", {"repo_id": "r"}
660 )
661
662 assert result.success is True
663 # Content must be parseable JSON
664 text = result.content[0]["text"]
665 parsed = json.loads(text)
666 assert "context" in parsed