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:
committed by
GitHub
parent
69cff96d9d
commit
1b1380e6c3
7
integrations/beads-mcp/src/beads_mcp/__init__.py
Normal file
7
integrations/beads-mcp/src/beads_mcp/__init__.py
Normal 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"
|
||||
6
integrations/beads-mcp/src/beads_mcp/__main__.py
Normal file
6
integrations/beads-mcp/src/beads_mcp/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Entry point for running beads_mcp as a module."""
|
||||
|
||||
from beads_mcp.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
428
integrations/beads-mcp/src/beads_mcp/bd_client.py
Normal file
428
integrations/beads-mcp/src/beads_mcp/bd_client.py
Normal 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()
|
||||
111
integrations/beads-mcp/src/beads_mcp/config.py
Normal file
111
integrations/beads-mcp/src/beads_mcp/config.py
Normal 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)
|
||||
151
integrations/beads-mcp/src/beads_mcp/models.py
Normal file
151
integrations/beads-mcp/src/beads_mcp/models.py
Normal 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
|
||||
206
integrations/beads-mcp/src/beads_mcp/server.py
Normal file
206
integrations/beads-mcp/src/beads_mcp/server.py
Normal 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()
|
||||
244
integrations/beads-mcp/src/beads_mcp/tools.py
Normal file
244
integrations/beads-mcp/src/beads_mcp/tools.py
Normal 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)
|
||||
Reference in New Issue
Block a user