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:
@@ -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,6 +485,17 @@ 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:
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user