From e1d40733c63261cb8acef2a47c960356d2a55a25 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 19 Dec 2025 23:30:22 -0800 Subject: [PATCH] feat(mcp): Consolidate context tools into unified 'context' tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge set_context, where_am_i, and init into a single 'context' tool with action parameter (set, show, init). - set: Set the workspace root directory (default when workspace_root provided) - show: Show current workspace context (default when no args) - init: Initialize beads in the current workspace directory This reduces tool count from 3 to 1 while maintaining all functionality. Closes beads-eub 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../beads-mcp/src/beads_mcp/server.py | 105 ++++++++++++------ integrations/beads-mcp/src/beads_mcp/tools.py | 2 +- .../tests/test_mcp_server_integration.py | 74 +++++++++--- .../tests/test_workspace_auto_detect.py | 2 +- .../tests/test_worktree_separate_dbs.py | 2 +- 5 files changed, 136 insertions(+), 49 deletions(-) diff --git a/integrations/beads-mcp/src/beads_mcp/server.py b/integrations/beads-mcp/src/beads_mcp/server.py index cf179ce0..256e81a0 100644 --- a/integrations/beads-mcp/src/beads_mcp/server.py +++ b/integrations/beads-mcp/src/beads_mcp/server.py @@ -120,7 +120,7 @@ Check the resource beads://quickstart to see how. CONTEXT OPTIMIZATION: Use discover_tools() to see available tools (names only), then get_tool_info(tool_name) for specific tool details. This saves context. -IMPORTANT: Call set_context with your workspace root before any write operations. +IMPORTANT: Call context(workspace_root='...') to set your workspace before any write operations. """, ) @@ -213,7 +213,7 @@ def require_context(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitabl Passes if either: - workspace_root was provided on tool call (via ContextVar), OR - - BEADS_WORKING_DIR is set (from set_context) + - BEADS_WORKING_DIR is set (from context tool) Only enforces if BEADS_REQUIRE_CONTEXT=1 is set in environment. This allows backward compatibility while adding safety for multi-repo setups. @@ -226,7 +226,7 @@ def require_context(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitabl workspace = current_workspace.get() or os.environ.get("BEADS_WORKING_DIR") if not workspace: raise ValueError( - "Context not set. Either provide workspace_root parameter or call set_context() first." + "Context not set. Either provide workspace_root parameter or call context(workspace_root='...') first." ) return await func(*args, **kwargs) return wrapper @@ -316,9 +316,7 @@ _TOOL_CATALOG = { "dep": "Add dependency between issues", "stats": "Get issue statistics", "blocked": "Show blocked issues and what blocks them", - "init": "Initialize beads in a directory", - "set_context": "Set workspace root for operations", - "where_am_i": "Show current workspace context", + "context": "Manage workspace context (set, show, init)", "admin": "Administrative/diagnostic operations (validate, repair, schema, debug, migration, pollution)", "discover_tools": "List available tools (names only)", "get_tool_info": "Get detailed info for a specific tool", @@ -487,8 +485,19 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]: "returns": "Dict with operation results (or string for debug)", "example": "admin(action='validate', checks='orphans')" }, + "context": { + "name": "context", + "description": "Manage workspace context for beads operations", + "parameters": { + "action": "str (optional) - set|show|init (default: show if no args, set if workspace_root provided)", + "workspace_root": "str (optional) - Workspace path for set/init actions", + "prefix": "str (optional) - Issue ID prefix for init action" + }, + "returns": "String with context information or confirmation", + "example": "context(action='set', workspace_root='/path/to/project')" + }, } - + if tool_name not in tool_details: available = list(tool_details.keys()) return { @@ -500,20 +509,66 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]: return tool_details[tool_name] -# Context management tools +# Context management tool - unified set_context, where_am_i, and init @mcp.tool( - name="set_context", - description="Set the workspace root directory for all bd operations. Call this first!", + name="context", + description="""Manage workspace context for beads operations. +Actions: +- set: Set the workspace root directory (default when workspace_root provided) +- show: Show current workspace context and database path (default when no args) +- init: Initialize beads in the current workspace directory""", ) -async def set_context(workspace_root: str) -> str: - """Set workspace root directory and discover the beads database. +async def context( + action: str | None = None, + workspace_root: str | None = None, + prefix: str | None = None, +) -> str: + """Manage workspace context for beads operations. Args: - workspace_root: Absolute path to workspace/project root directory + action: Action to perform - set, show, or init (inferred if not provided) + workspace_root: Workspace path for set/init actions + prefix: Issue ID prefix for init action Returns: - Confirmation message with resolved paths + Context information or confirmation message """ + # Infer action if not explicitly provided + if action is None: + if workspace_root is not None: + action = "set" + else: + action = "show" + + action = action.lower() + + if action == "set": + if workspace_root is None: + return "Error: workspace_root is required for 'set' action" + return await _context_set(workspace_root) + + elif action == "show": + return _context_show() + + elif action == "init": + # For init, we need context to be set first + context_set = ( + _workspace_context.get("BEADS_CONTEXT_SET") + or os.environ.get("BEADS_CONTEXT_SET") + ) + if not context_set: + return ( + "Error: Context must be set before init.\n" + "Use context(action='set', workspace_root='/path/to/project') first." + ) + return await beads_init(prefix=prefix) + + else: + return f"Error: Unknown action '{action}'. Valid actions: set, show, init" + + +async def _context_set(workspace_root: str) -> str: + """Set workspace root directory and discover the beads database.""" # Resolve to git repo root if possible (run in thread to avoid blocking event loop) try: resolved_root = await asyncio.wait_for( @@ -547,7 +602,7 @@ async def set_context(workspace_root: str) -> str: return ( f"Context set successfully:\n" f" Workspace root: {resolved_root}\n" - f" Database: Not found (run 'bd init' to create)" + f" Database: Not found (run context(action='init') to create)" ) # Set database path in both persistent context and os.environ @@ -561,11 +616,7 @@ async def set_context(workspace_root: str) -> str: ) -@mcp.tool( - name="where_am_i", - description="Show current workspace context and database path", -) -async def where_am_i(workspace_root: str | None = None) -> str: +def _context_show() -> str: """Show current workspace context for debugging.""" context_set = ( _workspace_context.get("BEADS_CONTEXT_SET") @@ -574,7 +625,7 @@ async def where_am_i(workspace_root: str | None = None) -> str: if not context_set: return ( - "Context not set. Call set_context with your workspace root first.\n" + "Context not set. Use context(action='set', workspace_root='...') first.\n" f"Current process CWD: {os.getcwd()}\n" f"BEADS_WORKING_DIR (persistent): {_workspace_context.get('BEADS_WORKING_DIR', 'NOT SET')}\n" f"BEADS_WORKING_DIR (env): {os.environ.get('BEADS_WORKING_DIR', 'NOT SET')}\n" @@ -845,18 +896,6 @@ async def blocked(workspace_root: str | None = None) -> list[BlockedIssue]: return await beads_blocked() -@mcp.tool( - name="init", - description="""Initialize bd in current directory. Creates .beads/ directory and -database with optional custom prefix for issue IDs.""", -) -@with_workspace -@require_context -async def init(prefix: str | None = None, workspace_root: str | None = None) -> str: - """Initialize bd in current directory.""" - return await beads_init(prefix=prefix) - - @mcp.tool( name="admin", description="""Administrative and diagnostic operations. diff --git a/integrations/beads-mcp/src/beads_mcp/tools.py b/integrations/beads-mcp/src/beads_mcp/tools.py index 930249e1..65cb5611 100644 --- a/integrations/beads-mcp/src/beads_mcp/tools.py +++ b/integrations/beads-mcp/src/beads_mcp/tools.py @@ -255,7 +255,7 @@ async def _get_client() -> BdClientBase: if not workspace: raise BdError( "No beads workspace found. Either:\n" - " 1. Call set_context(workspace_root=\"/path/to/project\"), OR\n" + " 1. Call context(workspace_root=\"/path/to/project\"), OR\n" " 2. Run from a directory containing .beads/, OR\n" " 3. Set BEADS_WORKING_DIR environment variable" ) diff --git a/integrations/beads-mcp/tests/test_mcp_server_integration.py b/integrations/beads-mcp/tests/test_mcp_server_integration.py index e336b5f0..e0a6ba72 100644 --- a/integrations/beads-mcp/tests/test_mcp_server_integration.py +++ b/integrations/beads-mcp/tests/test_mcp_server_integration.py @@ -85,7 +85,7 @@ async def mcp_client(bd_executable, temp_db, monkeypatch): # Create test client async with Client(mcp) as client: # Automatically set context for the tests - await client.call_tool("set_context", {"workspace_root": workspace_root}) + await client.call_tool("context", {"workspace_root": workspace_root}) yield client # Reset connection pool and context after test @@ -598,21 +598,69 @@ async def test_blocked_tool(mcp_client): @pytest.mark.asyncio -async def test_init_tool(mcp_client, bd_executable): - """Test init tool. - - Note: This test validates that init can be called successfully via MCP. - The actual database is created in the workspace from mcp_client fixture, - not in a separate temp directory, because the init tool uses the connection - pool which is keyed by workspace path. +async def test_context_init_action(bd_executable): + """Test context tool with init action. + + Note: This test validates that context(action='init') can be called successfully via MCP. + Uses a fresh temp directory without an existing database. """ import os import tempfile + import shutil + from beads_mcp import tools + from beads_mcp.server import mcp - # Call init tool (will init in the current workspace from mcp_client fixture) - result = await mcp_client.call_tool("init", {"prefix": "test-init"}) + # Reset connection pool and context + tools._connection_pool.clear() + os.environ.pop("BEADS_CONTEXT_SET", None) + os.environ.pop("BEADS_WORKING_DIR", None) + os.environ.pop("BEADS_DB", None) + os.environ.pop("BEADS_DIR", None) + os.environ["BEADS_NO_DAEMON"] = "1" + + # Create a fresh temp directory without any beads database + temp_dir = tempfile.mkdtemp(prefix="beads_init_test_") + try: + async with Client(mcp) as client: + # First set context to the fresh directory + await client.call_tool("context", {"workspace_root": temp_dir}) + + # Call context tool with init action + result = await client.call_tool("context", {"action": "init", "prefix": "test-init"}) + output = result.content[0].text + + # Verify output contains success message + assert "bd initialized successfully!" in output + assert "test-init" in output + finally: + tools._connection_pool.clear() + shutil.rmtree(temp_dir, ignore_errors=True) + os.environ.pop("BEADS_CONTEXT_SET", None) + os.environ.pop("BEADS_WORKING_DIR", None) + + +@pytest.mark.asyncio +async def test_context_show_action(mcp_client, temp_db): + """Test context tool with show action. + + Verifies that context(action='show') returns workspace information. + """ + # Call context tool with show action (default when no args) + result = await mcp_client.call_tool("context", {"action": "show"}) output = result.content[0].text - # Verify output contains success message - assert "bd initialized successfully!" in output - assert "test-init" in output + # Verify output contains workspace info + assert "Workspace root:" in output + assert "Database:" in output + + +@pytest.mark.asyncio +async def test_context_default_show(mcp_client, temp_db): + """Test context tool defaults to show when no args provided.""" + # Call context tool with no args - should default to show + result = await mcp_client.call_tool("context", {}) + output = result.content[0].text + + # Verify output contains workspace info (same as show action) + assert "Workspace root:" in output + assert "Database:" in output diff --git a/integrations/beads-mcp/tests/test_workspace_auto_detect.py b/integrations/beads-mcp/tests/test_workspace_auto_detect.py index 2a5e4c03..a6299de1 100644 --- a/integrations/beads-mcp/tests/test_workspace_auto_detect.py +++ b/integrations/beads-mcp/tests/test_workspace_auto_detect.py @@ -107,7 +107,7 @@ async def test_get_client_no_workspace_found(): # 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 "context" in error_msg assert ".beads/" in error_msg finally: current_workspace.reset(token) diff --git a/integrations/beads-mcp/tests/test_worktree_separate_dbs.py b/integrations/beads-mcp/tests/test_worktree_separate_dbs.py index 391b269e..8533d4be 100644 --- a/integrations/beads-mcp/tests/test_worktree_separate_dbs.py +++ b/integrations/beads-mcp/tests/test_worktree_separate_dbs.py @@ -295,7 +295,7 @@ async def test_mcp_works_with_separate_databases(git_worktree_with_separate_dbs, # Create MCP client async with Client(mcp) as client: # Set context to worktree - await client.call_tool("set_context", {"workspace_root": str(worktree)}) + await client.call_tool("context", {"workspace_root": str(worktree)}) # Create issue via MCP result = await client.call_tool(