feat(mcp): Consolidate context tools into unified 'context' tool

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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-19 23:30:22 -08:00
parent 2dc0c9b16e
commit e1d40733c6
5 changed files with 136 additions and 49 deletions

View File

@@ -120,7 +120,7 @@ Check the resource beads://quickstart to see how.
CONTEXT OPTIMIZATION: Use discover_tools() to see available tools (names only), CONTEXT OPTIMIZATION: Use discover_tools() to see available tools (names only),
then get_tool_info(tool_name) for specific tool details. This saves context. 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: Passes if either:
- workspace_root was provided on tool call (via ContextVar), OR - 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. Only enforces if BEADS_REQUIRE_CONTEXT=1 is set in environment.
This allows backward compatibility while adding safety for multi-repo setups. 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") workspace = current_workspace.get() or os.environ.get("BEADS_WORKING_DIR")
if not workspace: if not workspace:
raise ValueError( 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 await func(*args, **kwargs)
return wrapper return wrapper
@@ -316,9 +316,7 @@ _TOOL_CATALOG = {
"dep": "Add dependency between issues", "dep": "Add dependency between issues",
"stats": "Get issue statistics", "stats": "Get issue statistics",
"blocked": "Show blocked issues and what blocks them", "blocked": "Show blocked issues and what blocks them",
"init": "Initialize beads in a directory", "context": "Manage workspace context (set, show, init)",
"set_context": "Set workspace root for operations",
"where_am_i": "Show current workspace context",
"admin": "Administrative/diagnostic operations (validate, repair, schema, debug, migration, pollution)", "admin": "Administrative/diagnostic operations (validate, repair, schema, debug, migration, pollution)",
"discover_tools": "List available tools (names only)", "discover_tools": "List available tools (names only)",
"get_tool_info": "Get detailed info for a specific tool", "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)", "returns": "Dict with operation results (or string for debug)",
"example": "admin(action='validate', checks='orphans')" "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: if tool_name not in tool_details:
available = list(tool_details.keys()) available = list(tool_details.keys())
return { return {
@@ -500,20 +509,66 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]:
return tool_details[tool_name] return tool_details[tool_name]
# Context management tools # Context management tool - unified set_context, where_am_i, and init
@mcp.tool( @mcp.tool(
name="set_context", name="context",
description="Set the workspace root directory for all bd operations. Call this first!", 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: async def context(
"""Set workspace root directory and discover the beads database. action: str | None = None,
workspace_root: str | None = None,
prefix: str | None = None,
) -> str:
"""Manage workspace context for beads operations.
Args: 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: 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) # Resolve to git repo root if possible (run in thread to avoid blocking event loop)
try: try:
resolved_root = await asyncio.wait_for( resolved_root = await asyncio.wait_for(
@@ -547,7 +602,7 @@ async def set_context(workspace_root: str) -> str:
return ( return (
f"Context set successfully:\n" f"Context set successfully:\n"
f" Workspace root: {resolved_root}\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 # Set database path in both persistent context and os.environ
@@ -561,11 +616,7 @@ async def set_context(workspace_root: str) -> str:
) )
@mcp.tool( def _context_show() -> str:
name="where_am_i",
description="Show current workspace context and database path",
)
async def where_am_i(workspace_root: str | None = None) -> str:
"""Show current workspace context for debugging.""" """Show current workspace context for debugging."""
context_set = ( context_set = (
_workspace_context.get("BEADS_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: if not context_set:
return ( 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"Current process CWD: {os.getcwd()}\n"
f"BEADS_WORKING_DIR (persistent): {_workspace_context.get('BEADS_WORKING_DIR', 'NOT SET')}\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" 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() 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( @mcp.tool(
name="admin", name="admin",
description="""Administrative and diagnostic operations. description="""Administrative and diagnostic operations.

View File

@@ -255,7 +255,7 @@ async def _get_client() -> BdClientBase:
if not workspace: if not workspace:
raise BdError( raise BdError(
"No beads workspace found. Either:\n" "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" " 2. Run from a directory containing .beads/, OR\n"
" 3. Set BEADS_WORKING_DIR environment variable" " 3. Set BEADS_WORKING_DIR environment variable"
) )

View File

@@ -85,7 +85,7 @@ async def mcp_client(bd_executable, temp_db, monkeypatch):
# Create test client # Create test client
async with Client(mcp) as client: async with Client(mcp) as client:
# Automatically set context for the tests # 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 yield client
# Reset connection pool and context after test # Reset connection pool and context after test
@@ -598,21 +598,69 @@ async def test_blocked_tool(mcp_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_init_tool(mcp_client, bd_executable): async def test_context_init_action(bd_executable):
"""Test init tool. """Test context tool with init action.
Note: This test validates that init can be called successfully via MCP. Note: This test validates that context(action='init') can be called successfully via MCP.
The actual database is created in the workspace from mcp_client fixture, Uses a fresh temp directory without an existing database.
not in a separate temp directory, because the init tool uses the connection
pool which is keyed by workspace path.
""" """
import os import os
import tempfile 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) # Reset connection pool and context
result = await mcp_client.call_tool("init", {"prefix": "test-init"}) 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 output = result.content[0].text
# Verify output contains success message # Verify output contains workspace info
assert "bd initialized successfully!" in output assert "Workspace root:" in output
assert "test-init" 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

View File

@@ -107,7 +107,7 @@ async def test_get_client_no_workspace_found():
# Verify error message is helpful # Verify error message is helpful
error_msg = str(exc_info.value) error_msg = str(exc_info.value)
assert "No beads workspace found" in error_msg assert "No beads workspace found" in error_msg
assert "set_context" in error_msg assert "context" in error_msg
assert ".beads/" in error_msg assert ".beads/" in error_msg
finally: finally:
current_workspace.reset(token) current_workspace.reset(token)

View File

@@ -295,7 +295,7 @@ async def test_mcp_works_with_separate_databases(git_worktree_with_separate_dbs,
# Create MCP client # Create MCP client
async with Client(mcp) as client: async with Client(mcp) as client:
# Set context to worktree # 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 # Create issue via MCP
result = await client.call_tool( result = await client.call_tool(