feat: Add Beads MCP Server [bd-5]

Implements MCP server for beads issue tracker, exposing all bd CLI functionality to MCP clients like Claude Desktop.

Features:
- Complete bd command coverage (init, create, list, ready, show, update, close, dep, blocked, stats)
- Type-safe Pydantic models with validation
- Comprehensive test suite (unit + integration tests)
- Production-ready Python package structure
- Environment variable configuration support
- Quickstart resource (beads://quickstart)

Ready for PyPI publication after real-world testing.

Co-authored-by: ghoseb <baishampayan.ghose@gmail.com>
This commit is contained in:
Baishampayan Ghose
2025-10-14 23:43:52 +05:30
committed by GitHub
parent 69cff96d9d
commit 1b1380e6c3
17 changed files with 4733 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
"""MCP Server for Beads Agentic Task Tracker and Memory System
This package provides an MCP (Model Context Protocol) server that exposes
beads (bd) issue tracker functionality to MCP Clients.
"""
__version__ = "1.0.0"

View File

@@ -0,0 +1,6 @@
"""Entry point for running beads_mcp as a module."""
from beads_mcp.server import main
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,428 @@
"""Client for interacting with bd (beads) CLI."""
import asyncio
import json
from .config import load_config
from .models import (
AddDependencyParams,
BlockedIssue,
CloseIssueParams,
CreateIssueParams,
InitParams,
Issue,
ListIssuesParams,
ReadyWorkParams,
ShowIssueParams,
Stats,
UpdateIssueParams,
)
class BdError(Exception):
"""Base exception for bd CLI errors."""
pass
class BdNotFoundError(BdError):
"""Raised when bd command is not found."""
pass
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 BdClient:
"""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
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,
):
"""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)
"""
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
)
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) -> object:
"""Run bd command and parse JSON output.
Args:
*args: Command arguments to pass to bd
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"]
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
except FileNotFoundError as e:
raise BdNotFoundError(
f"bd command not found at '{self.bd_path}'. Make sure bd is installed and in 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 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 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,
)
_stdout, stderr = await process.communicate()
except FileNotFoundError as e:
raise BdNotFoundError(
f"bd command not found at '{self.bd_path}'. Make sure bd is installed and in 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,
)
stdout, stderr = await process.communicate()
except FileNotFoundError as e:
raise BdNotFoundError(
f"bd command not found at '{self.bd_path}'. Make sure bd is installed and in 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])
cmd.extend(self._global_flags())
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
except FileNotFoundError as e:
raise BdNotFoundError(
f"bd command not found at '{self.bd_path}'. Make sure bd is installed and in 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()

View File

@@ -0,0 +1,111 @@
"""Configuration for beads MCP server."""
import os
import sys
from pathlib import Path
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
def _default_beads_path() -> str:
"""Get default bd executable path.
Returns:
Default path to bd executable (~/.local/bin/bd)
"""
return str(Path.home() / ".local" / "bin" / "bd")
class Config(BaseSettings):
"""Server configuration loaded from environment variables."""
model_config = SettingsConfigDict(env_prefix="")
beads_path: str = _default_beads_path()
beads_db: str | None = None
beads_actor: str | None = None
beads_no_auto_flush: bool = False
beads_no_auto_import: bool = False
@field_validator("beads_path")
@classmethod
def validate_beads_path(cls, v: str) -> str:
"""Validate BEADS_PATH points to an executable bd binary.
Args:
v: Path to bd executable
Returns:
Validated path
Raises:
ValueError: If path is invalid or not executable
"""
path = Path(v)
if not path.exists():
raise ValueError(
f"bd executable not found at: {v}\n"
+ "Please verify BEADS_PATH points to a valid bd executable."
)
if not os.access(v, os.X_OK):
raise ValueError(
f"bd executable at {v} is not executable.\nPlease check file permissions."
)
return v
@field_validator("beads_db")
@classmethod
def validate_beads_db(cls, v: str | None) -> str | None:
"""Validate BEADS_DB points to an existing database file.
Args:
v: Path to database file or None
Returns:
Validated path or None
Raises:
ValueError: If path is set but file doesn't exist
"""
if v is None:
return v
path = Path(v)
if not path.exists():
raise ValueError(
f"BEADS_DB points to non-existent file: {v}\n"
+ "Please verify the database path is correct."
)
return v
def load_config() -> Config:
"""Load and validate configuration from environment variables.
Returns:
Validated configuration
Raises:
SystemExit: If configuration is invalid
"""
try:
return Config()
except Exception as e:
default_path = _default_beads_path()
print(
f"Configuration Error: {e}\n\n"
+ "Environment variables:\n"
+ f" BEADS_PATH - Path to bd executable (default: {default_path})\n"
+ " BEADS_DB - Optional path to beads database file\n"
+ " BEADS_ACTOR - Actor name for audit trail (default: $USER)\n"
+ " BEADS_NO_AUTO_FLUSH - Disable automatic JSONL sync (default: false)\n"
+ " BEADS_NO_AUTO_IMPORT - Disable automatic JSONL import (default: false)\n\n"
+ "Make sure bd is installed and the path is correct.",
file=sys.stderr,
)
sys.exit(1)

View File

@@ -0,0 +1,151 @@
"""Pydantic models for beads issue tracker types."""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator
# Type aliases for issue statuses, types, and dependencies
IssueStatus = Literal["open", "in_progress", "blocked", "closed"]
IssueType = Literal["bug", "feature", "task", "epic", "chore"]
DependencyType = Literal["blocks", "related", "parent-child", "discovered-from"]
class Issue(BaseModel):
"""Issue model matching bd JSON output."""
id: str
title: str
description: str = ""
design: str | None = None
acceptance_criteria: str | None = None
notes: str | None = None
external_ref: str | None = None
status: IssueStatus
priority: int = Field(ge=0, le=4)
issue_type: IssueType
created_at: datetime
updated_at: datetime
closed_at: datetime | None = None
assignee: str | None = None
labels: list[str] = Field(default_factory=list)
dependencies: list["Issue"] = Field(default_factory=list)
dependents: list["Issue"] = Field(default_factory=list)
@field_validator("priority")
@classmethod
def validate_priority(cls, v: int) -> int:
"""Validate priority is 0-4."""
if not 0 <= v <= 4:
raise ValueError("Priority must be between 0 and 4")
return v
class Dependency(BaseModel):
"""Dependency relationship model."""
from_id: str
to_id: str
dep_type: DependencyType
class CreateIssueParams(BaseModel):
"""Parameters for creating an issue."""
title: str
description: str = ""
design: str | None = None
acceptance: str | None = None
external_ref: str | None = None
priority: int = Field(default=2, ge=0, le=4)
issue_type: IssueType = "task"
assignee: str | None = None
labels: list[str] = Field(default_factory=list)
id: str | None = None
deps: list[str] = Field(default_factory=list)
class UpdateIssueParams(BaseModel):
"""Parameters for updating an issue."""
issue_id: str
status: IssueStatus | None = None
priority: int | None = Field(default=None, ge=0, le=4)
assignee: str | None = None
title: str | None = None
design: str | None = None
acceptance_criteria: str | None = None
notes: str | None = None
external_ref: str | None = None
class CloseIssueParams(BaseModel):
"""Parameters for closing an issue."""
issue_id: str
reason: str = "Completed"
class AddDependencyParams(BaseModel):
"""Parameters for adding a dependency."""
from_id: str
to_id: str
dep_type: DependencyType = "blocks"
class ReadyWorkParams(BaseModel):
"""Parameters for querying ready work."""
limit: int = Field(default=10, ge=1, le=100)
priority: int | None = Field(default=None, ge=0, le=4)
assignee: str | None = None
class ListIssuesParams(BaseModel):
"""Parameters for listing issues."""
status: IssueStatus | None = None
priority: int | None = Field(default=None, ge=0, le=4)
issue_type: IssueType | None = None
assignee: str | None = None
limit: int = Field(default=50, ge=1, le=1000)
class ShowIssueParams(BaseModel):
"""Parameters for showing issue details."""
issue_id: str
class Stats(BaseModel):
"""Beads task statistics."""
total_issues: int
open_issues: int
in_progress_issues: int
closed_issues: int
blocked_issues: int
ready_issues: int
average_lead_time_hours: float
class BlockedIssue(Issue):
"""Blocked issue with blocking information."""
blocked_by_count: int
blocked_by: list[str]
class InitParams(BaseModel):
"""Parameters for initializing bd."""
prefix: str | None = None
class InitResult(BaseModel):
"""Result from bd init command."""
database: str
prefix: str
message: str

View File

@@ -0,0 +1,206 @@
"""FastMCP server for beads issue tracker."""
from fastmcp import FastMCP
from beads_mcp.models import BlockedIssue, DependencyType, Issue, IssueStatus, IssueType, Stats
from beads_mcp.tools import (
beads_add_dependency,
beads_blocked,
beads_close_issue,
beads_create_issue,
beads_init,
beads_list_issues,
beads_quickstart,
beads_ready_work,
beads_show_issue,
beads_stats,
beads_update_issue,
)
# Create FastMCP server
mcp = FastMCP(
name="Beads",
instructions="""
We track work in Beads (bd) instead of Markdown.
Check the resource beads://quickstart to see how.
""",
)
# Register quickstart resource
@mcp.resource("beads://quickstart", name="Beads Quickstart Guide")
async def get_quickstart() -> str:
"""Get beads (bd) quickstart guide.
Read this first to understand how to use beads (bd) commands.
"""
return await beads_quickstart()
# Register all tools
@mcp.tool(name="ready", description="Find tasks that have no blockers and are ready to be worked on.")
async def ready_work(
limit: int = 10,
priority: int | None = None,
assignee: str | None = None,
) -> list[Issue]:
"""Find issues with no blocking dependencies that are ready to work on."""
return await beads_ready_work(limit=limit, priority=priority, assignee=assignee)
@mcp.tool(
name="list",
description="List all issues with optional filters (status, priority, type, assignee).",
)
async def list_issues(
status: IssueStatus | None = None,
priority: int | None = None,
issue_type: IssueType | None = None,
assignee: str | None = None,
limit: int = 50,
) -> list[Issue]:
"""List all issues with optional filters."""
return await beads_list_issues(
status=status,
priority=priority,
issue_type=issue_type,
assignee=assignee,
limit=limit,
)
@mcp.tool(
name="show",
description="Show detailed information about a specific issue including dependencies and dependents.",
)
async def show_issue(issue_id: str) -> Issue:
"""Show detailed information about a specific issue."""
return await beads_show_issue(issue_id=issue_id)
@mcp.tool(
name="create",
description="""Create a new issue (bug, feature, task, epic, or chore) with optional design,
acceptance criteria, and dependencies.""",
)
async def create_issue(
title: str,
description: str = "",
design: str | None = None,
acceptance: str | None = None,
external_ref: str | None = None,
priority: int = 2,
issue_type: IssueType = "task",
assignee: str | None = None,
labels: list[str] | None = None,
id: str | None = None,
deps: list[str] | None = None,
) -> Issue:
"""Create a new issue."""
return await beads_create_issue(
title=title,
description=description,
design=design,
acceptance=acceptance,
external_ref=external_ref,
priority=priority,
issue_type=issue_type,
assignee=assignee,
labels=labels,
id=id,
deps=deps,
)
@mcp.tool(
name="update",
description="""Update an existing issue's status, priority, assignee, design notes,
or acceptance criteria. Use this to claim work (set status=in_progress).""",
)
async def update_issue(
issue_id: str,
status: IssueStatus | None = None,
priority: int | None = None,
assignee: str | None = None,
title: str | None = None,
design: str | None = None,
acceptance_criteria: str | None = None,
notes: str | None = None,
external_ref: str | None = None,
) -> Issue:
"""Update an existing issue."""
return await beads_update_issue(
issue_id=issue_id,
status=status,
priority=priority,
assignee=assignee,
title=title,
design=design,
acceptance_criteria=acceptance_criteria,
notes=notes,
external_ref=external_ref,
)
@mcp.tool(
name="close",
description="Close (complete) an issue. Mark work as done when you've finished implementing/fixing it.",
)
async def close_issue(issue_id: str, reason: str = "Completed") -> list[Issue]:
"""Close (complete) an issue."""
return await beads_close_issue(issue_id=issue_id, reason=reason)
@mcp.tool(
name="dep",
description="""Add a dependency between issues. Types: blocks (hard blocker),
related (soft link), parent-child (epic/subtask), discovered-from (found during work).""",
)
async def add_dependency(
from_id: str,
to_id: str,
dep_type: DependencyType = "blocks",
) -> str:
"""Add a dependency relationship between two issues."""
return await beads_add_dependency(
from_id=from_id,
to_id=to_id,
dep_type=dep_type,
)
@mcp.tool(
name="stats",
description="Get statistics: total issues, open, in_progress, closed, blocked, ready, and average lead time.",
)
async def stats() -> Stats:
"""Get statistics about tasks."""
return await beads_stats()
@mcp.tool(
name="blocked",
description="Get blocked issues showing what dependencies are blocking them from being worked on.",
)
async def blocked() -> list[BlockedIssue]:
"""Get blocked issues."""
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.""",
)
async def init(prefix: str | None = None) -> str:
"""Initialize bd in current directory."""
return await beads_init(prefix=prefix)
def main() -> None:
"""Entry point for the MCP server."""
mcp.run()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,244 @@
"""MCP tools for beads issue tracker."""
from typing import Annotated
from .bd_client import BdClient, BdError
from .models import (
AddDependencyParams,
BlockedIssue,
CloseIssueParams,
CreateIssueParams,
DependencyType,
InitParams,
Issue,
IssueStatus,
IssueType,
ListIssuesParams,
ReadyWorkParams,
ShowIssueParams,
Stats,
UpdateIssueParams,
)
# Global client instance - initialized on first use
_client: BdClient | None = None
# Default constants
DEFAULT_ISSUE_TYPE: IssueType = "task"
DEFAULT_DEPENDENCY_TYPE: DependencyType = "blocks"
def _get_client() -> BdClient:
"""Get a BdClient instance, creating it on first use.
Returns:
Configured BdClient instance (config loaded automatically)
"""
global _client
if _client is None:
_client = BdClient()
return _client
async def beads_ready_work(
limit: Annotated[int, "Maximum number of issues to return (1-100)"] = 10,
priority: Annotated[int | None, "Filter by priority (0-4, 0=highest)"] = None,
assignee: Annotated[str | None, "Filter by assignee"] = None,
) -> list[Issue]:
"""Find issues with no blocking dependencies that are ready to work on.
Ready work = status is 'open' AND no blocking dependencies.
Perfect for agents to claim next work!
"""
client = _get_client()
params = ReadyWorkParams(limit=limit, priority=priority, assignee=assignee)
return await client.ready(params)
async def beads_list_issues(
status: Annotated[
IssueStatus | None, "Filter by status (open, in_progress, blocked, closed)"
] = None,
priority: Annotated[int | None, "Filter by priority (0-4, 0=highest)"] = None,
issue_type: Annotated[
IssueType | None, "Filter by type (bug, feature, task, epic, chore)"
] = None,
assignee: Annotated[str | None, "Filter by assignee"] = None,
limit: Annotated[int, "Maximum number of issues to return (1-1000)"] = 50,
) -> list[Issue]:
"""List all issues with optional filters."""
client = _get_client()
params = ListIssuesParams(
status=status,
priority=priority,
issue_type=issue_type,
assignee=assignee,
limit=limit,
)
return await client.list_issues(params)
async def beads_show_issue(
issue_id: Annotated[str, "Issue ID (e.g., bd-1)"],
) -> Issue:
"""Show detailed information about a specific issue.
Includes full description, dependencies, and dependents.
"""
client = _get_client()
params = ShowIssueParams(issue_id=issue_id)
return await client.show(params)
async def beads_create_issue(
title: Annotated[str, "Issue title"],
description: Annotated[str, "Issue description"] = "",
design: Annotated[str | None, "Design notes"] = None,
acceptance: Annotated[str | None, "Acceptance criteria"] = None,
external_ref: Annotated[str | None, "External reference (e.g., gh-9, jira-ABC)"] = None,
priority: Annotated[int, "Priority (0-4, 0=highest)"] = 2,
issue_type: Annotated[
IssueType, "Type: bug, feature, task, epic, or chore"
] = DEFAULT_ISSUE_TYPE,
assignee: Annotated[str | None, "Assignee username"] = None,
labels: Annotated[list[str] | None, "List of labels"] = None,
id: Annotated[str | None, "Explicit issue ID (e.g., bd-42)"] = None,
deps: Annotated[list[str] | None, "Dependencies (e.g., ['bd-20', 'blocks:bd-15'])"] = None,
) -> Issue:
"""Create a new issue.
Use this when you discover new work during your session.
Link it back with beads_add_dependency using 'discovered-from' type.
"""
client = _get_client()
params = CreateIssueParams(
title=title,
description=description,
design=design,
acceptance=acceptance,
external_ref=external_ref,
priority=priority,
issue_type=issue_type,
assignee=assignee,
labels=labels or [],
id=id,
deps=deps or [],
)
return await client.create(params)
async def beads_update_issue(
issue_id: Annotated[str, "Issue ID (e.g., bd-1)"],
status: Annotated[IssueStatus | None, "New status (open, in_progress, blocked, closed)"] = None,
priority: Annotated[int | None, "New priority (0-4)"] = None,
assignee: Annotated[str | None, "New assignee"] = None,
title: Annotated[str | None, "New title"] = None,
design: Annotated[str | None, "Design notes"] = None,
acceptance_criteria: Annotated[str | None, "Acceptance criteria"] = None,
notes: Annotated[str | None, "Additional notes"] = None,
external_ref: Annotated[str | None, "External reference (e.g., gh-9, jira-ABC)"] = None,
) -> Issue:
"""Update an existing issue.
Claim work by setting status to 'in_progress'.
"""
client = _get_client()
params = UpdateIssueParams(
issue_id=issue_id,
status=status,
priority=priority,
assignee=assignee,
title=title,
design=design,
acceptance_criteria=acceptance_criteria,
notes=notes,
external_ref=external_ref,
)
return await client.update(params)
async def beads_close_issue(
issue_id: Annotated[str, "Issue ID (e.g., bd-1)"],
reason: Annotated[str, "Reason for closing"] = "Completed",
) -> list[Issue]:
"""Close (complete) an issue.
Mark work as done when you've finished implementing/fixing it.
"""
client = _get_client()
params = CloseIssueParams(issue_id=issue_id, reason=reason)
return await client.close(params)
async def beads_add_dependency(
from_id: Annotated[str, "Issue that depends on another (e.g., bd-2)"],
to_id: Annotated[str, "Issue that blocks or is related to from_id (e.g., bd-1)"],
dep_type: Annotated[
DependencyType,
"Dependency type: blocks, related, parent-child, or discovered-from",
] = DEFAULT_DEPENDENCY_TYPE,
) -> str:
"""Add a dependency relationship between two issues.
Types:
- blocks: to_id must complete before from_id can start
- related: Soft connection, doesn't block progress
- parent-child: Epic/subtask hierarchical relationship
- discovered-from: Track that from_id was discovered while working on to_id
Use 'discovered-from' when you find new work during your session.
"""
client = _get_client()
params = AddDependencyParams(
from_id=from_id,
to_id=to_id,
dep_type=dep_type,
)
try:
await client.add_dependency(params)
return f"Added dependency: {from_id} depends on {to_id} ({dep_type})"
except BdError as e:
return f"Error: {str(e)}"
async def beads_quickstart() -> str:
"""Get bd quickstart guide.
Read this first to understand how to use beads (bd) commands.
"""
client = _get_client()
return await client.quickstart()
async def beads_stats() -> Stats:
"""Get statistics about issues.
Returns total issues, open, in_progress, closed, blocked, ready issues,
and average lead time in hours.
"""
client = _get_client()
return await client.stats()
async def beads_blocked() -> list[BlockedIssue]:
"""Get blocked issues.
Returns issues that have blocking dependencies, showing what blocks them.
"""
client = _get_client()
return await client.blocked()
async def beads_init(
prefix: Annotated[
str | None, "Issue prefix (e.g., 'myproject' for myproject-1, myproject-2)"
] = None,
) -> str:
"""Initialize bd in current directory.
Creates .beads/ directory and database file with optional custom prefix.
"""
client = _get_client()
params = InitParams(prefix=prefix)
return await client.init(params)