From ffdd4b8c664fbf3561281def9eeb43724903f058 Mon Sep 17 00:00:00 2001 From: matt wilkie Date: Sat, 11 Apr 2026 21:12:34 -0700 Subject: [PATCH 1/2] fix(mcp): detect Dolt-backed projects in workspace discovery (GH#2997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP `context()` tool reported "Database: Not found" for embedded Dolt projects because `_find_beads_db()` and `_find_beads_db_in_tree()` only globbed for `*.db` files. Embedded Dolt projects keep their data under `.beads/embeddeddolt/` and declare the backend in `.beads/metadata.json` — there is no `*.db` file to find. Adds `_has_beads_project_files()` mirroring Go's `hasBeadsProjectFiles` (metadata.json, config.yaml, dolt/, embeddeddolt/, or non-backup *.db). Both Python discovery functions (and `.beads/redirect` validation) now use this check, so SQLite, embedded Dolt, and server Dolt projects are all recognized. `context()` now reports the project root and backend when no SQLite db is present, instead of the misleading "Not found". --- .../beads-mcp/src/beads_mcp/server.py | 102 +++++++++++++++--- integrations/beads-mcp/src/beads_mcp/tools.py | 53 ++++++--- .../tests/test_workspace_auto_detect.py | 82 +++++++++++++- 3 files changed, 205 insertions(+), 32 deletions(-) diff --git a/integrations/beads-mcp/src/beads_mcp/server.py b/integrations/beads-mcp/src/beads_mcp/server.py index 657c95e4cc..0528d11eaa 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,73 @@ 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. + + Detects SQLite, embedded Dolt, and server Dolt backends. See + `_has_beads_project_files` for the detection rules. + + Returns: + (workspace_root, backend) where backend is "sqlite", "dolt-embedded", + "dolt-server", or "unknown". None if no .beads project is found. + """ + from beads_mcp.tools import _has_beads_project_files + + current = os.path.abspath(workspace_root) + while True: + beads_dir = os.path.join(current, ".beads") + if os.path.isdir(beads_dir) and _has_beads_project_files(beads_dir): + backend = _detect_backend(beads_dir) + return (current, backend) + parent = os.path.dirname(current) + if parent == current: + break + current = parent return None +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) + 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 (OSError, ValueError, json.JSONDecodeError): + 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 +683,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 +696,28 @@ 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}" + ) + # 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 From 2b0e15f20e686b488ce97596d49ca99802a079f8 Mon Sep 17 00:00:00 2001 From: matt wilkie Date: Sun, 12 Apr 2026 20:34:38 -0700 Subject: [PATCH 2/2] fix: delegate _find_beads_project to _find_beads_db_in_tree for redirect support Addresses review item #2: _find_beads_project duplicated the upward directory walk but skipped .beads/redirect handling. Now delegates to _find_beads_db_in_tree, inheriting redirect, symlink, and all backend detection for free. Amp-Thread-ID: https://ampcode.com/threads/T-019d84e2-3a96-7263-a399-c3b2cc0ba6bb Co-authored-by: Amp --- .../beads-mcp/src/beads_mcp/server.py | 50 ++++++++++--------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/integrations/beads-mcp/src/beads_mcp/server.py b/integrations/beads-mcp/src/beads_mcp/server.py index 0528d11eaa..de6916807d 100644 --- a/integrations/beads-mcp/src/beads_mcp/server.py +++ b/integrations/beads-mcp/src/beads_mcp/server.py @@ -257,26 +257,21 @@ def _find_beads_db(workspace_root: str) -> str | None: def _find_beads_project(workspace_root: str) -> tuple[str, str] | None: """Find a .beads project by walking up from workspace_root. - Detects SQLite, embedded Dolt, and server Dolt backends. See - `_has_beads_project_files` for the detection rules. + 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: - (workspace_root, backend) where backend is "sqlite", "dolt-embedded", + (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 _has_beads_project_files + from beads_mcp.tools import _find_beads_db_in_tree - current = os.path.abspath(workspace_root) - while True: - beads_dir = os.path.join(current, ".beads") - if os.path.isdir(beads_dir) and _has_beads_project_files(beads_dir): - backend = _detect_backend(beads_dir) - return (current, backend) - parent = os.path.dirname(current) - if parent == current: - break - current = parent - return None + 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: @@ -289,14 +284,15 @@ def _detect_backend(beads_dir: str) -> str: try: with open(metadata_path) as f: meta = json.load(f) - 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 (OSError, ValueError, json.JSONDecodeError): + 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")): @@ -710,6 +706,14 @@ async def _context_set(workspace_root: str) -> str: 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)