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