diff --git a/integrations/beads-mcp/src/beads_mcp/server.py b/integrations/beads-mcp/src/beads_mcp/server.py index 657c95e4cc..de6916807d 100644 --- a/integrations/beads-mcp/src/beads_mcp/server.py +++ b/integrations/beads-mcp/src/beads_mcp/server.py @@ -225,17 +225,19 @@ async def wrapper(*args: Any, **kwargs: Any) -> T: def _find_beads_db(workspace_root: str) -> str | None: - """Find .beads/*.db by walking up from workspace_root. - + """Find a SQLite .beads/*.db by walking up from workspace_root. + Args: workspace_root: Starting directory to search from - + Returns: - Absolute path to first .db file found in .beads/, None otherwise + Absolute path to first .db file found in .beads/, None otherwise. + Returns None for Dolt-backed projects (which have no single .db file); + callers should use _find_beads_project() to detect those. """ import glob current = os.path.abspath(workspace_root) - + while True: beads_dir = os.path.join(current, ".beads") if os.path.isdir(beads_dir): @@ -243,15 +245,69 @@ def _find_beads_db(workspace_root: str) -> str | None: db_files = glob.glob(os.path.join(beads_dir, "*.db")) if db_files: return db_files[0] # Return first .db file found - + parent = os.path.dirname(current) if parent == current: # Reached root break current = parent - + return None +def _find_beads_project(workspace_root: str) -> tuple[str, str] | None: + """Find a .beads project by walking up from workspace_root. + + Delegates to ``_find_beads_db_in_tree`` so that ``.beads/redirect`` files, + symlinks, and all backend types are handled identically to the rest of the + MCP server. + + Returns: + (project_root, backend) where backend is "sqlite", "dolt-embedded", + "dolt-server", or "unknown". None if no .beads project is found. + """ + from beads_mcp.tools import _find_beads_db_in_tree + + project_root = _find_beads_db_in_tree(workspace_root) + if project_root is None: + return None + backend = _detect_backend(os.path.join(project_root, ".beads")) + return (project_root, backend) + + +def _detect_backend(beads_dir: str) -> str: + """Identify the storage backend in a .beads directory.""" + import glob + import json + + metadata_path = os.path.join(beads_dir, "metadata.json") + if os.path.isfile(metadata_path): + try: + with open(metadata_path) as f: + meta = json.load(f) + if isinstance(meta, dict): + backend = (meta.get("backend") or meta.get("database") or "").lower() + if backend == "dolt": + if (meta.get("dolt_mode") or "").lower() == "embedded": + return "dolt-embedded" + return "dolt-server" + if backend == "sqlite": + return "sqlite" + except Exception: + pass + + if os.path.isdir(os.path.join(beads_dir, "embeddeddolt")): + return "dolt-embedded" + if os.path.isdir(os.path.join(beads_dir, "dolt")): + return "dolt-server" + + for match in glob.glob(os.path.join(beads_dir, "*.db")): + base = os.path.basename(match) + if ".backup" not in base and base != "vc.db": + return "sqlite" + + return "unknown" + + def _resolve_workspace_root(path: str) -> str: """Resolve workspace root to git repo root if inside a git repo. @@ -623,10 +679,10 @@ async def _context_set(workspace_root: str) -> str: os.environ["BEADS_WORKING_DIR"] = resolved_root os.environ["BEADS_CONTEXT_SET"] = "1" - # Find beads database - db_path = _find_beads_db(resolved_root) + # Locate the beads project (handles SQLite and Dolt backends) + project = _find_beads_project(resolved_root) - if db_path is None: + if project is None: # Clear any stale DB path _workspace_context.pop("BEADS_DB", None) os.environ.pop("BEADS_DB", None) @@ -636,14 +692,36 @@ async def _context_set(workspace_root: str) -> str: f" Database: Not found (run context(action='init') to create)" ) - # Set database path in both persistent context and os.environ - _workspace_context["BEADS_DB"] = db_path - os.environ["BEADS_DB"] = db_path + project_root, backend = project + + # BEADS_DB only applies to SQLite. Dolt backends use metadata.json, + # which the bd CLI reads directly. + if backend == "sqlite": + db_path = _find_beads_db(project_root) + if db_path: + _workspace_context["BEADS_DB"] = db_path + os.environ["BEADS_DB"] = db_path + return ( + f"Context set successfully:\n" + f" Workspace root: {resolved_root}\n" + f" Database: {db_path}" + ) + else: + _workspace_context.pop("BEADS_DB", None) + os.environ.pop("BEADS_DB", None) + return ( + f"Context set successfully:\n" + f" Workspace root: {resolved_root}\n" + f" Database: Not found (run context(action='init') to create)" + ) + # Dolt or unknown — clear any stale BEADS_DB and report the project root. + _workspace_context.pop("BEADS_DB", None) + os.environ.pop("BEADS_DB", None) return ( f"Context set successfully:\n" f" Workspace root: {resolved_root}\n" - f" Database: {db_path}" + f" Project: {os.path.join(project_root, '.beads')} (backend: {backend})" ) diff --git a/integrations/beads-mcp/src/beads_mcp/tools.py b/integrations/beads-mcp/src/beads_mcp/tools.py index aeda49d015..f551471618 100644 --- a/integrations/beads-mcp/src/beads_mcp/tools.py +++ b/integrations/beads-mcp/src/beads_mcp/tools.py @@ -65,6 +65,32 @@ def _register_client_for_cleanup(client: BdClientBase) -> None: pass +def _has_beads_project_files(beads_dir: str) -> bool: + """Check if a .beads directory contains actual project files. + + Mirrors hasBeadsProjectFiles in internal/beads/beads.go. Returns True when + any of these are present: metadata.json, config.yaml, dolt/, embeddeddolt/, + or a non-backup *.db file (excluding vc.db). + """ + import glob + + if os.path.isfile(os.path.join(beads_dir, "metadata.json")): + return True + if os.path.isfile(os.path.join(beads_dir, "config.yaml")): + return True + if os.path.isdir(os.path.join(beads_dir, "dolt")): + return True + if os.path.isdir(os.path.join(beads_dir, "embeddeddolt")): + return True + + for match in glob.glob(os.path.join(beads_dir, "*.db")): + base = os.path.basename(match) + if ".backup" not in base and base != "vc.db": + return True + + return False + + def _resolve_beads_redirect(beads_dir: str, workspace_root: str) -> str | None: """Follow a .beads/redirect file to the actual beads directory. @@ -75,8 +101,6 @@ def _resolve_beads_redirect(beads_dir: str, workspace_root: str) -> str | None: Returns: Resolved workspace root if redirect is valid, None otherwise """ - import glob - redirect_path = os.path.join(beads_dir, "redirect") if not os.path.isfile(redirect_path): return None @@ -98,12 +122,10 @@ def _resolve_beads_redirect(beads_dir: str, workspace_root: str) -> str | None: logger.debug(f"Redirect target {resolved} does not exist") return None - # Verify the redirected location has a valid database - db_files = glob.glob(os.path.join(resolved, "*.db")) - valid_dbs = [f for f in db_files if ".backup" not in os.path.basename(f)] - - if not valid_dbs: - logger.debug(f"Redirect target {resolved} has no valid .db files") + # Verify the redirected location has a valid beads project + # (SQLite *.db, embedded Dolt, server Dolt, or just metadata/config) + if not _has_beads_project_files(resolved): + logger.debug(f"Redirect target {resolved} has no valid beads project files") return None # Return the workspace root of the redirected location (parent of .beads) @@ -124,10 +146,10 @@ def _find_beads_db_in_tree(start_dir: str | None = None) -> str | None: start_dir: Starting directory (default: current working directory) Returns: - Absolute path to workspace root containing .beads/*.db, or None if not found + Absolute path to workspace root containing a valid .beads project, + or None if not found. Detects SQLite (*.db), embedded Dolt + (embeddeddolt/), server Dolt (dolt/), and metadata-only projects. """ - import glob - try: current = os.path.abspath(start_dir or os.getcwd()) @@ -147,12 +169,9 @@ def _find_beads_db_in_tree(start_dir: str | None = None) -> str | None: logger.debug(f"Followed redirect from {current} to {redirected}") return redirected - # No redirect, check for local .db files - db_files = glob.glob(os.path.join(beads_dir, "*.db")) - valid_dbs = [f for f in db_files if ".backup" not in os.path.basename(f)] - - if valid_dbs: - # Return workspace root (parent of .beads), not the db path + # No redirect — check for any valid beads project files + # (matches Go's hasBeadsProjectFiles) + if _has_beads_project_files(beads_dir): return current parent = os.path.dirname(current) diff --git a/integrations/beads-mcp/tests/test_workspace_auto_detect.py b/integrations/beads-mcp/tests/test_workspace_auto_detect.py index 641d5426f3..64272e14b1 100644 --- a/integrations/beads-mcp/tests/test_workspace_auto_detect.py +++ b/integrations/beads-mcp/tests/test_workspace_auto_detect.py @@ -6,7 +6,12 @@ from pathlib import Path from unittest.mock import AsyncMock, patch -from beads_mcp.tools import _find_beads_db_in_tree, _get_client, current_workspace +from beads_mcp.tools import ( + _find_beads_db_in_tree, + _get_client, + _has_beads_project_files, + current_workspace, +) from beads_mcp.bd_client import BdError @@ -272,3 +277,78 @@ def test_find_beads_db_prefers_redirect_over_parent(): # Should follow redirect (to remote), not walk up to parent result = _find_beads_db_in_tree(str(child_dir)) assert result == os.path.realpath(str(remote_dir)) + + +# --- GH#2997: embedded Dolt and other backend detection --- + +def test_find_beads_db_embedded_dolt(): + """Embedded Dolt projects have no *.db file; detect via metadata.json.""" + with tempfile.TemporaryDirectory() as tmpdir: + beads_dir = Path(tmpdir) / ".beads" + beads_dir.mkdir() + (beads_dir / "metadata.json").write_text( + '{"backend":"dolt","dolt_mode":"embedded","dolt_database":"therm"}' + ) + (beads_dir / "embeddeddolt").mkdir() + + result = _find_beads_db_in_tree(tmpdir) + assert result == os.path.realpath(tmpdir) + + +def test_find_beads_db_dolt_server(): + """Server-mode Dolt: detect via metadata.json + dolt/ dir.""" + with tempfile.TemporaryDirectory() as tmpdir: + beads_dir = Path(tmpdir) / ".beads" + beads_dir.mkdir() + (beads_dir / "metadata.json").write_text('{"backend":"dolt"}') + (beads_dir / "dolt").mkdir() + + result = _find_beads_db_in_tree(tmpdir) + assert result == os.path.realpath(tmpdir) + + +def test_find_beads_db_metadata_only(): + """metadata.json alone is sufficient evidence of a beads project.""" + with tempfile.TemporaryDirectory() as tmpdir: + beads_dir = Path(tmpdir) / ".beads" + beads_dir.mkdir() + (beads_dir / "metadata.json").write_text('{"backend":"sqlite"}') + + result = _find_beads_db_in_tree(tmpdir) + assert result == os.path.realpath(tmpdir) + + +def test_find_beads_db_redirect_to_dolt(): + """Redirect to an embedded Dolt project should be accepted.""" + with tempfile.TemporaryDirectory() as tmpdir: + main_dir = Path(tmpdir) / "main" + main_dir.mkdir() + main_beads = main_dir / ".beads" + main_beads.mkdir() + (main_beads / "metadata.json").write_text( + '{"backend":"dolt","dolt_mode":"embedded"}' + ) + (main_beads / "embeddeddolt").mkdir() + + worker = Path(tmpdir) / "worker" + worker.mkdir() + worker_beads = worker / ".beads" + worker_beads.mkdir() + (worker_beads / "redirect").write_text("../main/.beads") + + result = _find_beads_db_in_tree(str(worker)) + assert result == os.path.realpath(str(main_dir)) + + +def test_has_beads_project_files_excludes_vc_db(): + """vc.db alone doesn't count as a beads project.""" + with tempfile.TemporaryDirectory() as tmpdir: + beads_dir = Path(tmpdir) / ".beads" + beads_dir.mkdir() + (beads_dir / "vc.db").touch() + (beads_dir / "beads.db.backup").touch() + + assert _has_beads_project_files(str(beads_dir)) is False + + (beads_dir / "beads.db").touch() + assert _has_beads_project_files(str(beads_dir)) is True