Files
beads/integrations/beads-mcp/src/beads_mcp/bd_client.py
Steve Yegge b40de9bc41 Implement daemon RPC with per-request context routing (bd-115)
- Added per-request storage routing in daemon server
  - Server now supports Cwd field in requests for database discovery
  - Tree-walking to find .beads/*.db from any working directory
  - Storage caching for performance across requests

- Created Python daemon client (bd_daemon_client.py)
  - RPC over Unix socket communication
  - Implements full BdClientBase interface
  - Auto-discovery of daemon socket from working directory

- Refactored bd_client.py with abstract interface
  - BdClientBase abstract class for common interface
  - BdCliClient for CLI-based operations (renamed from BdClient)
  - create_bd_client() factory with daemon/CLI fallback
  - Backwards-compatible BdClient alias

Next: Update MCP server to use daemon client when available
2025-10-17 16:28:29 -07:00

666 lines
21 KiB
Python

"""Client for interacting with bd (beads) CLI and daemon."""
import asyncio
import json
import os
import re
from abc import ABC, abstractmethod
from typing import List, Optional
from .config import load_config
from .models import (
AddDependencyParams,
BlockedIssue,
CloseIssueParams,
CreateIssueParams,
InitParams,
Issue,
ListIssuesParams,
ReadyWorkParams,
ReopenIssueParams,
ShowIssueParams,
Stats,
UpdateIssueParams,
)
class BdError(Exception):
"""Base exception for bd CLI errors."""
pass
class BdNotFoundError(BdError):
"""Raised when bd command is not found."""
@staticmethod
def installation_message(attempted_path: str) -> str:
"""Get helpful installation message.
Args:
attempted_path: Path where we tried to find bd
Returns:
Formatted error message with installation instructions
"""
return (
f"bd CLI not found at: {attempted_path}\n\n"
"The beads Claude Code plugin requires the bd CLI to be installed separately.\n\n"
"Install bd CLI:\n"
" curl -fsSL https://raw.githubusercontent.com/steveyegge/beads/main/install.sh | bash\n\n"
"Or visit: https://github.com/steveyegge/beads#installation\n\n"
"After installation, restart Claude Code to reload the MCP server."
)
class BdCommandError(BdError):
"""Raised when bd command fails."""
stderr: str
returncode: int
def __init__(self, message: str, stderr: str = "", returncode: int = 1):
super().__init__(message)
self.stderr = stderr
self.returncode = returncode
class BdVersionError(BdError):
"""Raised when bd version is incompatible with MCP server."""
pass
class BdClientBase(ABC):
"""Abstract base class for bd clients (CLI or daemon)."""
@abstractmethod
async def ready(self, params: Optional[ReadyWorkParams] = None) -> List[Issue]:
"""Get ready work (issues with no blockers)."""
pass
@abstractmethod
async def list_issues(self, params: Optional[ListIssuesParams] = None) -> List[Issue]:
"""List issues with optional filters."""
pass
@abstractmethod
async def show(self, params: ShowIssueParams) -> Issue:
"""Show detailed issue information."""
pass
@abstractmethod
async def create(self, params: CreateIssueParams) -> Issue:
"""Create a new issue."""
pass
@abstractmethod
async def update(self, params: UpdateIssueParams) -> Issue:
"""Update an existing issue."""
pass
@abstractmethod
async def close(self, params: CloseIssueParams) -> List[Issue]:
"""Close one or more issues."""
pass
@abstractmethod
async def reopen(self, params: ReopenIssueParams) -> List[Issue]:
"""Reopen one or more closed issues."""
pass
@abstractmethod
async def add_dependency(self, params: AddDependencyParams) -> None:
"""Add a dependency between issues."""
pass
@abstractmethod
async def stats(self) -> Stats:
"""Get repository statistics."""
pass
@abstractmethod
async def blocked(self) -> List[BlockedIssue]:
"""Get blocked issues."""
pass
@abstractmethod
async def init(self, params: Optional[InitParams] = None) -> str:
"""Initialize a new beads database."""
pass
class BdCliClient(BdClientBase):
"""Client for calling bd CLI commands and parsing JSON output."""
bd_path: str
beads_db: str | None
actor: str | None
no_auto_flush: bool
no_auto_import: bool
working_dir: str | None
def __init__(
self,
bd_path: str | None = None,
beads_db: str | None = None,
actor: str | None = None,
no_auto_flush: bool | None = None,
no_auto_import: bool | None = None,
working_dir: str | None = None,
):
"""Initialize bd client.
Args:
bd_path: Path to bd executable (optional, loads from config if not provided)
beads_db: Path to beads database file (optional, loads from config if not provided)
actor: Actor name for audit trail (optional, loads from config if not provided)
no_auto_flush: Disable automatic JSONL sync (optional, loads from config if not provided)
no_auto_import: Disable automatic JSONL import (optional, loads from config if not provided)
working_dir: Working directory for bd commands (optional, loads from config/env if not provided)
"""
config = load_config()
self.bd_path = bd_path if bd_path is not None else config.beads_path
self.beads_db = beads_db if beads_db is not None else config.beads_db
self.actor = actor if actor is not None else config.beads_actor
self.no_auto_flush = no_auto_flush if no_auto_flush is not None else config.beads_no_auto_flush
self.no_auto_import = no_auto_import if no_auto_import is not None else config.beads_no_auto_import
self.working_dir = working_dir if working_dir is not None else config.beads_working_dir
def _get_working_dir(self) -> str:
"""Get working directory for bd commands.
Returns:
Working directory path, falls back to current directory if not configured
"""
if self.working_dir:
return self.working_dir
# Use process working directory (set by MCP client at spawn time)
return os.getcwd()
def _global_flags(self) -> list[str]:
"""Build list of global flags for bd commands.
Returns:
List of global flag arguments
"""
flags = []
if self.beads_db:
flags.extend(["--db", self.beads_db])
if self.actor:
flags.extend(["--actor", self.actor])
if self.no_auto_flush:
flags.append("--no-auto-flush")
if self.no_auto_import:
flags.append("--no-auto-import")
return flags
async def _run_command(self, *args: str, cwd: str | None = None) -> object:
"""Run bd command and parse JSON output.
Args:
*args: Command arguments to pass to bd
cwd: Optional working directory override for this command
Returns:
Parsed JSON output (dict or list)
Raises:
BdNotFoundError: If bd command not found
BdCommandError: If bd command fails
"""
cmd = [self.bd_path, *args, *self._global_flags(), "--json"]
working_dir = cwd if cwd is not None else self._get_working_dir()
# Log database routing for debugging
import sys
working_dir = self._get_working_dir()
db_info = self.beads_db if self.beads_db else "auto-discover"
print(f"[beads-mcp] Running bd command: {' '.join(args)}", file=sys.stderr)
print(f"[beads-mcp] Database: {db_info}", file=sys.stderr)
print(f"[beads-mcp] Working dir: {working_dir}", file=sys.stderr)
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=working_dir,
)
stdout, stderr = await process.communicate()
except FileNotFoundError as e:
raise BdNotFoundError(BdNotFoundError.installation_message(self.bd_path)) from e
if process.returncode != 0:
raise BdCommandError(
f"bd command failed: {stderr.decode()}",
stderr=stderr.decode(),
returncode=process.returncode or 1,
)
stdout_str = stdout.decode().strip()
if not stdout_str:
return {}
try:
result: object = json.loads(stdout_str)
return result
except json.JSONDecodeError as e:
raise BdCommandError(
f"Failed to parse bd JSON output: {e}",
stderr=stdout_str,
) from e
async def _check_version(self) -> None:
"""Check that bd CLI version meets minimum requirements.
Raises:
BdVersionError: If bd version is incompatible
BdNotFoundError: If bd command not found
"""
# Minimum required version
min_version = (0, 9, 0)
try:
process = await asyncio.create_subprocess_exec(
self.bd_path,
"version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self._get_working_dir(),
)
stdout, stderr = await process.communicate()
except FileNotFoundError as e:
raise BdNotFoundError(BdNotFoundError.installation_message(self.bd_path)) from e
if process.returncode != 0:
raise BdCommandError(
f"bd version failed: {stderr.decode()}",
stderr=stderr.decode(),
returncode=process.returncode or 1,
)
# Parse version from output like "bd version 0.9.2"
version_output = stdout.decode().strip()
match = re.search(r"(\d+)\.(\d+)\.(\d+)", version_output)
if not match:
raise BdVersionError(f"Could not parse bd version from: {version_output}")
version = tuple(int(x) for x in match.groups())
if version < min_version:
min_ver_str = ".".join(str(x) for x in min_version)
cur_ver_str = ".".join(str(x) for x in version)
install_cmd = "curl -fsSL https://raw.githubusercontent.com/steveyegge/beads/main/install.sh | bash"
raise BdVersionError(
f"bd version {cur_ver_str} is too old. "
f"This MCP server requires bd >= {min_ver_str}. "
f"Update with: {install_cmd}"
)
async def ready(self, params: ReadyWorkParams | None = None) -> list[Issue]:
"""Get ready work (issues with no blocking dependencies).
Args:
params: Query parameters
Returns:
List of ready issues
"""
params = params or ReadyWorkParams()
args = ["ready", "--limit", str(params.limit)]
if params.priority is not None:
args.extend(["--priority", str(params.priority)])
if params.assignee:
args.extend(["--assignee", params.assignee])
data = await self._run_command(*args)
if not isinstance(data, list):
return []
return [Issue.model_validate(issue) for issue in data]
async def list_issues(self, params: ListIssuesParams | None = None) -> list[Issue]:
"""List issues with optional filters.
Args:
params: Query parameters
Returns:
List of issues
"""
params = params or ListIssuesParams()
args = ["list"]
if params.status:
args.extend(["--status", params.status])
if params.priority is not None:
args.extend(["--priority", str(params.priority)])
if params.issue_type:
args.extend(["--type", params.issue_type])
if params.assignee:
args.extend(["--assignee", params.assignee])
if params.limit:
args.extend(["--limit", str(params.limit)])
data = await self._run_command(*args)
if not isinstance(data, list):
return []
return [Issue.model_validate(issue) for issue in data]
async def show(self, params: ShowIssueParams) -> Issue:
"""Show issue details.
Args:
params: Issue ID to show
Returns:
Issue details
Raises:
BdCommandError: If issue not found
"""
data = await self._run_command("show", params.issue_id)
if not isinstance(data, dict):
raise BdCommandError(f"Invalid response for show {params.issue_id}")
return Issue.model_validate(data)
async def create(self, params: CreateIssueParams) -> Issue:
"""Create a new issue.
Args:
params: Issue creation parameters
Returns:
Created issue
"""
args = ["create", params.title, "-p", str(params.priority), "-t", params.issue_type]
if params.description:
args.extend(["-d", params.description])
if params.design:
args.extend(["--design", params.design])
if params.acceptance:
args.extend(["--acceptance", params.acceptance])
if params.external_ref:
args.extend(["--external-ref", params.external_ref])
if params.assignee:
args.extend(["--assignee", params.assignee])
if params.id:
args.extend(["--id", params.id])
for label in params.labels:
args.extend(["-l", label])
if params.deps:
args.extend(["--deps", ",".join(params.deps)])
data = await self._run_command(*args)
if not isinstance(data, dict):
raise BdCommandError("Invalid response for create")
return Issue.model_validate(data)
async def update(self, params: UpdateIssueParams) -> Issue:
"""Update an issue.
Args:
params: Issue update parameters
Returns:
Updated issue
"""
args = ["update", params.issue_id]
if params.status:
args.extend(["--status", params.status])
if params.priority is not None:
args.extend(["--priority", str(params.priority)])
if params.assignee:
args.extend(["--assignee", params.assignee])
if params.title:
args.extend(["--title", params.title])
if params.design:
args.extend(["--design", params.design])
if params.acceptance_criteria:
args.extend(["--acceptance-criteria", params.acceptance_criteria])
if params.notes:
args.extend(["--notes", params.notes])
if params.external_ref:
args.extend(["--external-ref", params.external_ref])
data = await self._run_command(*args)
if not isinstance(data, dict):
raise BdCommandError(f"Invalid response for update {params.issue_id}")
return Issue.model_validate(data)
async def close(self, params: CloseIssueParams) -> list[Issue]:
"""Close an issue.
Args:
params: Close parameters
Returns:
List containing closed issue
"""
args = ["close", params.issue_id, "--reason", params.reason]
data = await self._run_command(*args)
if not isinstance(data, list):
raise BdCommandError(f"Invalid response for close {params.issue_id}")
return [Issue.model_validate(issue) for issue in data]
async def reopen(self, params: ReopenIssueParams) -> list[Issue]:
"""Reopen one or more closed issues.
Args:
params: Reopen parameters
Returns:
List of reopened issues
"""
args = ["reopen", *params.issue_ids]
if params.reason:
args.extend(["--reason", params.reason])
data = await self._run_command(*args)
if not isinstance(data, list):
raise BdCommandError(f"Invalid response for reopen {params.issue_ids}")
return [Issue.model_validate(issue) for issue in data]
async def add_dependency(self, params: AddDependencyParams) -> None:
"""Add a dependency between issues.
Args:
params: Dependency parameters
"""
# bd dep add doesn't return JSON, just prints confirmation
cmd = [
self.bd_path,
"dep",
"add",
params.from_id,
params.to_id,
"--type",
params.dep_type,
*self._global_flags(),
]
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self._get_working_dir(),
)
_stdout, stderr = await process.communicate()
except FileNotFoundError as e:
raise BdNotFoundError(BdNotFoundError.installation_message(self.bd_path)) from e
if process.returncode != 0:
raise BdCommandError(
f"bd dep add failed: {stderr.decode()}",
stderr=stderr.decode(),
returncode=process.returncode or 1,
)
async def quickstart(self) -> str:
"""Get bd quickstart guide.
Returns:
Quickstart guide text
"""
cmd = [self.bd_path, "quickstart"]
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self._get_working_dir(),
)
stdout, stderr = await process.communicate()
except FileNotFoundError as e:
raise BdNotFoundError(BdNotFoundError.installation_message(self.bd_path)) from e
if process.returncode != 0:
raise BdCommandError(
f"bd quickstart failed: {stderr.decode()}",
stderr=stderr.decode(),
returncode=process.returncode or 1,
)
return stdout.decode()
async def stats(self) -> Stats:
"""Get statistics about issues.
Returns:
Statistics object
"""
data = await self._run_command("stats")
if not isinstance(data, dict):
raise BdCommandError("Invalid response for stats")
return Stats.model_validate(data)
async def blocked(self) -> list[BlockedIssue]:
"""Get blocked issues.
Returns:
List of blocked issues with blocking information
"""
data = await self._run_command("blocked")
if not isinstance(data, list):
return []
return [BlockedIssue.model_validate(issue) for issue in data]
async def init(self, params: InitParams | None = None) -> str:
"""Initialize bd in current directory.
Args:
params: Initialization parameters
Returns:
Initialization output message
"""
params = params or InitParams()
cmd = [self.bd_path, "init"]
if params.prefix:
cmd.extend(["--prefix", params.prefix])
# NOTE: Do NOT add --db flag for init!
# init creates a NEW database in the current directory.
# Only add actor-related flags.
if self.actor:
cmd.extend(["--actor", self.actor])
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self._get_working_dir(),
)
stdout, stderr = await process.communicate()
except FileNotFoundError as e:
raise BdNotFoundError(BdNotFoundError.installation_message(self.bd_path)) from e
if process.returncode != 0:
raise BdCommandError(
f"bd init failed: {stderr.decode()}",
stderr=stderr.decode(),
returncode=process.returncode or 1,
)
return stdout.decode()
# Backwards compatibility alias
BdClient = BdCliClient
def create_bd_client(
prefer_daemon: bool = False,
bd_path: Optional[str] = None,
beads_db: Optional[str] = None,
actor: Optional[str] = None,
no_auto_flush: Optional[bool] = None,
no_auto_import: Optional[bool] = None,
working_dir: Optional[str] = None,
) -> BdClientBase:
"""Create a bd client (daemon or CLI-based).
Args:
prefer_daemon: If True, attempt to use daemon client first, fall back to CLI
bd_path: Path to bd executable (for CLI client)
beads_db: Path to beads database (for CLI client)
actor: Actor name for audit trail
no_auto_flush: Disable auto-flush (CLI only)
no_auto_import: Disable auto-import (CLI only)
working_dir: Working directory for database discovery
Returns:
BdClientBase implementation (daemon or CLI)
Note:
If prefer_daemon is True and daemon is not running, falls back to CLI client.
To check if daemon is running without falling back, use BdDaemonClient directly.
"""
if prefer_daemon:
try:
from .bd_daemon_client import BdDaemonClient
# Create daemon client with working_dir for context
client = BdDaemonClient(
working_dir=working_dir,
actor=actor,
)
# Try to ping - if this works, use daemon
# Note: This is sync check, actual usage is async
# The caller will need to handle daemon not running at call time
return client
except ImportError:
# Daemon client not available (shouldn't happen but be defensive)
pass
except Exception:
# If daemon setup fails for any reason, fall back to CLI
pass
# Use CLI client
return BdCliClient(
bd_path=bd_path,
beads_db=beads_db,
actor=actor,
no_auto_flush=no_auto_flush,
no_auto_import=no_auto_import,
working_dir=working_dir,
)