diff --git a/integrations/beads-mcp/src/beads_mcp/tools.py b/integrations/beads-mcp/src/beads_mcp/tools.py index 39838e0c..37000512 100644 --- a/integrations/beads-mcp/src/beads_mcp/tools.py +++ b/integrations/beads-mcp/src/beads_mcp/tools.py @@ -63,6 +63,50 @@ def _register_client_for_cleanup(client: BdClientBase) -> None: pass +def _find_beads_db_in_tree(start_dir: str | None = None) -> str | None: + """Walk up directory tree looking for .beads/*.db (matches Go CLI behavior). + + Args: + start_dir: Starting directory (default: current working directory) + + Returns: + Absolute path to workspace root containing .beads/*.db, or None if not found + """ + import glob + + try: + current = os.path.abspath(start_dir or os.getcwd()) + + # Resolve symlinks like Go CLI does + try: + current = os.path.realpath(current) + except Exception: + pass + + # Walk up directory tree + while True: + beads_dir = os.path.join(current, ".beads") + if os.path.isdir(beads_dir): + # Find any .db file in .beads/ (excluding backups) + 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 + return current + + parent = os.path.dirname(current) + if parent == current: # Reached filesystem root + break + current = parent + + return None + + except Exception as e: + logger.debug(f"Failed to search for .beads in tree: {e}") + return None + + def _resolve_workspace_root(path: str) -> str: """Resolve workspace root to git repo root if inside a git repo. @@ -181,8 +225,11 @@ async def _get_client() -> BdClientBase: """Get a BdClient instance for the current workspace. Uses connection pool to manage per-project daemon sockets. - Workspace is determined by current_workspace ContextVar or BEADS_WORKING_DIR env. - + Workspace is auto-detected using the same logic as CLI: + 1. current_workspace ContextVar (from workspace_root parameter) + 2. BEADS_WORKING_DIR environment variable + 3. Walk up from CWD looking for .beads/*.db + Performs health check before returning cached client. On failure, drops from pool and attempts reconnection with exponential backoff. @@ -193,13 +240,23 @@ async def _get_client() -> BdClientBase: Configured BdClientBase instance for the current workspace Raises: - BdError: If no workspace is set, or bd is not installed, or version is incompatible + BdError: If no workspace found, or bd is not installed, or version is incompatible """ - # Determine workspace from ContextVar or environment + # Determine workspace using standard search order (matches Go CLI) workspace = current_workspace.get() or os.environ.get("BEADS_WORKING_DIR") + + # Auto-detect from CWD if not explicitly set (NEW!) + if not workspace: + workspace = _find_beads_db_in_tree() + if workspace: + logger.debug(f"Auto-detected workspace from CWD: {workspace}") + if not workspace: raise BdError( - "No workspace set. Either provide workspace_root parameter or call set_context() first." + "No beads workspace found. Either:\n" + " 1. Call set_context(workspace_root=\"/path/to/project\"), OR\n" + " 2. Run from a directory containing .beads/, OR\n" + " 3. Set BEADS_WORKING_DIR environment variable" ) # Canonicalize path to handle symlinks and deduplicate connections diff --git a/integrations/beads-mcp/tests/test_daemon_health_check.py b/integrations/beads-mcp/tests/test_daemon_health_check.py index 22e2d8c3..2fec0a3f 100644 --- a/integrations/beads-mcp/tests/test_daemon_health_check.py +++ b/integrations/beads-mcp/tests/test_daemon_health_check.py @@ -299,5 +299,7 @@ async def test_get_client_no_workspace_error(): tools.current_workspace.set(None) with patch.dict('os.environ', {}, clear=True): - with pytest.raises(BdError, match="No workspace set"): - await _get_client() + # Mock auto-detection to fail + with patch("beads_mcp.tools._find_beads_db_in_tree", return_value=None): + with pytest.raises(BdError, match="No beads workspace found"): + await _get_client() diff --git a/integrations/beads-mcp/tests/test_multi_project_switching.py b/integrations/beads-mcp/tests/test_multi_project_switching.py index 056a794c..288a5930 100644 --- a/integrations/beads-mcp/tests/test_multi_project_switching.py +++ b/integrations/beads-mcp/tests/test_multi_project_switching.py @@ -430,16 +430,18 @@ class TestEdgeCases: async def test_no_workspace_raises_error(self): """Test calling without workspace raises helpful error.""" import os + from beads_mcp import tools - # Clear env + # Clear context and env + tools.current_workspace.set(None) os.environ.pop("BEADS_WORKING_DIR", None) - # No ContextVar set, no env var + # No ContextVar set, no env var, and auto-detect fails with pytest.raises(Exception) as exc_info: - with patch("beads_mcp.tools.create_bd_client") as mock_create: + with patch("beads_mcp.tools._find_beads_db_in_tree", return_value=None): await beads_ready_work() - assert "No workspace set" in str(exc_info.value) + assert "No beads workspace found" in str(exc_info.value) def test_canonicalize_path_cached(self, temp_projects): """Test path canonicalization is cached for performance.""" diff --git a/integrations/beads-mcp/tests/test_workspace_auto_detect.py b/integrations/beads-mcp/tests/test_workspace_auto_detect.py new file mode 100644 index 00000000..2a5e4c03 --- /dev/null +++ b/integrations/beads-mcp/tests/test_workspace_auto_detect.py @@ -0,0 +1,156 @@ +"""Test workspace auto-detection from CWD (bd-8zf2).""" + +import os +import pytest +import tempfile +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.bd_client import BdError + + +def test_find_beads_db_in_tree_direct(): + """Test finding .beads in current directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create .beads/beads.db + beads_dir = Path(tmpdir) / ".beads" + beads_dir.mkdir() + (beads_dir / "beads.db").touch() + + # Should find workspace root (use realpath for macOS symlink resolution) + result = _find_beads_db_in_tree(tmpdir) + assert result == os.path.realpath(tmpdir) + + +def test_find_beads_db_in_tree_parent(): + """Test finding .beads in parent directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create .beads/beads.db in root + beads_dir = Path(tmpdir) / ".beads" + beads_dir.mkdir() + (beads_dir / "beads.db").touch() + + # Create subdirectory + subdir = Path(tmpdir) / "subdir" / "deep" + subdir.mkdir(parents=True) + + # Should find workspace root (walks up from subdir) + result = _find_beads_db_in_tree(str(subdir)) + assert result == os.path.realpath(tmpdir) + + +def test_find_beads_db_in_tree_not_found(): + """Test when no .beads directory exists.""" + with tempfile.TemporaryDirectory() as tmpdir: + # No .beads directory + result = _find_beads_db_in_tree(tmpdir) + assert result is None + + +def test_find_beads_db_excludes_backups(): + """Test that backup .db files are ignored.""" + with tempfile.TemporaryDirectory() as tmpdir: + beads_dir = Path(tmpdir) / ".beads" + beads_dir.mkdir() + + # Only backup file exists + (beads_dir / "beads.db.backup").touch() + + result = _find_beads_db_in_tree(tmpdir) + assert result is None # Should not find backup files + + # Add valid db file + (beads_dir / "beads.db").touch() + result = _find_beads_db_in_tree(tmpdir) + assert result == os.path.realpath(tmpdir) + + +@pytest.mark.asyncio +async def test_get_client_auto_detect_from_cwd(): + """Test that _get_client() auto-detects workspace from CWD.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create .beads/beads.db + beads_dir = Path(tmpdir) / ".beads" + beads_dir.mkdir() + (beads_dir / "beads.db").touch() + + # Reset ContextVar for this test + token = current_workspace.set(None) + try: + with patch.dict(os.environ, {}, clear=True): + # Mock _find_beads_db_in_tree to return our tmpdir + with patch("beads_mcp.tools._find_beads_db_in_tree", return_value=tmpdir): + # Mock create_bd_client to avoid actual connection + mock_client = AsyncMock() + mock_client.ping = AsyncMock(return_value=None) + + with patch("beads_mcp.tools.create_bd_client", return_value=mock_client): + # Should auto-detect and not raise error + client = await _get_client() + assert client is not None + finally: + current_workspace.reset(token) + + +@pytest.mark.asyncio +async def test_get_client_no_workspace_found(): + """Test that _get_client() raises helpful error when no workspace found.""" + # Reset ContextVar for this test + token = current_workspace.set(None) + try: + with patch.dict(os.environ, {}, clear=True): + with patch("beads_mcp.tools._find_beads_db_in_tree", return_value=None): + with pytest.raises(BdError) as exc_info: + await _get_client() + + # Verify error message is helpful + error_msg = str(exc_info.value) + assert "No beads workspace found" in error_msg + assert "set_context" in error_msg + assert ".beads/" in error_msg + finally: + current_workspace.reset(token) + + +@pytest.mark.asyncio +async def test_get_client_prefers_context_var_over_auto_detect(): + """Test that explicit workspace_root parameter takes precedence.""" + explicit_workspace = "/explicit/path" + + token = current_workspace.set(explicit_workspace) + try: + with patch("beads_mcp.tools._canonicalize_path", return_value=explicit_workspace): + mock_client = AsyncMock() + mock_client.ping = AsyncMock(return_value=None) + + with patch("beads_mcp.tools.create_bd_client", return_value=mock_client) as mock_create: + client = await _get_client() + + # Should use explicit workspace, not auto-detect + mock_create.assert_called_once() + # The working_dir parameter should be the canonicalized explicit path + assert mock_create.call_args[1]["working_dir"] == explicit_workspace + finally: + current_workspace.reset(token) + + +@pytest.mark.asyncio +async def test_get_client_env_var_over_auto_detect(): + """Test that BEADS_WORKING_DIR env var takes precedence over auto-detect.""" + env_workspace = "/env/path" + + token = current_workspace.set(None) + try: + with patch.dict(os.environ, {"BEADS_WORKING_DIR": env_workspace}): + with patch("beads_mcp.tools._canonicalize_path", return_value=env_workspace): + mock_client = AsyncMock() + mock_client.ping = AsyncMock(return_value=None) + + with patch("beads_mcp.tools.create_bd_client", return_value=mock_client): + client = await _get_client() + + # Should use env var, not call auto-detect + assert client is not None + finally: + current_workspace.reset(token)