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