Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 92 additions & 14 deletions integrations/beads-mcp/src/beads_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,33 +225,89 @@ 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):
# Find any .db file in .beads/
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.

Expand Down Expand Up @@ -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)
Expand All @@ -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})"
)


Expand Down
53 changes: 36 additions & 17 deletions integrations/beads-mcp/src/beads_mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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())

Expand All @@ -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)
Expand Down
82 changes: 81 additions & 1 deletion integrations/beads-mcp/tests/test_workspace_auto_detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Loading