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
14
integrations/beads-mcp/.gitignore
vendored
Normal file
14
integrations/beads-mcp/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
__pycache__
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
|
/.env
|
||||||
|
/CLAUDE.md
|
||||||
|
/TODO.md
|
||||||
|
/.coverage
|
||||||
1
integrations/beads-mcp/.python-version
Normal file
1
integrations/beads-mcp/.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
91
integrations/beads-mcp/README.md
Normal file
91
integrations/beads-mcp/README.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# beads-mcp
|
||||||
|
|
||||||
|
MCP server for [beads](https://github.com/steveyegge/beads) issue tracker and agentic memory system.
|
||||||
|
Enables AI agents to manage tasks using bd CLI through Model Context Protocol.
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/steveyegge/beads
|
||||||
|
cd beads/integrations/beads-mcp
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to your Claude Desktop config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"beads": {
|
||||||
|
"command": "uv",
|
||||||
|
"args": [
|
||||||
|
"--directory",
|
||||||
|
"/path/to/beads-mcp",
|
||||||
|
"run",
|
||||||
|
"beads-mcp"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"BEADS_PATH": "/home/user/.local/bin/bd",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Environment Variables** (all optional):
|
||||||
|
- `BEADS_PATH` - Path to bd executable (default: `~/.local/bin/bd`)
|
||||||
|
- `BEADS_DB` - Path to beads database file (default: auto-discover from cwd)
|
||||||
|
- `BEADS_ACTOR` - Actor name for audit trail (default: `$USER`)
|
||||||
|
- `BEADS_NO_AUTO_FLUSH` - Disable automatic JSONL sync (default: `false`)
|
||||||
|
- `BEADS_NO_AUTO_IMPORT` - Disable automatic JSONL import (default: `false`)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
**Resource:**
|
||||||
|
- `beads://quickstart` - Quickstart guide for using beads
|
||||||
|
|
||||||
|
**Tools:**
|
||||||
|
- `init` - Initialize bd in current directory
|
||||||
|
- `create` - Create new issue (bug, feature, task, epic, chore)
|
||||||
|
- `list` - List issues with filters (status, priority, type, assignee)
|
||||||
|
- `ready` - Find tasks with no blockers ready to work on
|
||||||
|
- `show` - Show detailed issue info including dependencies
|
||||||
|
- `update` - Update issue (status, priority, design, notes, etc)
|
||||||
|
- `close` - Close completed issue
|
||||||
|
- `dep` - Add dependency (blocks, related, parent-child, discovered-from)
|
||||||
|
- `blocked` - Get blocked issues
|
||||||
|
- `stats` - Get project statistics
|
||||||
|
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Run MCP inspector:
|
||||||
|
```bash
|
||||||
|
# inside beads-mcp dir
|
||||||
|
uv run fastmcp dev src/beads_mcp/server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Type checking:
|
||||||
|
```bash
|
||||||
|
uv run mypy src/beads_mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Linting and formatting:
|
||||||
|
```bash
|
||||||
|
uv run ruff check src/beads_mcp
|
||||||
|
uv run ruff format src/beads_mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run all tests:
|
||||||
|
```bash
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
With coverage:
|
||||||
|
```bash
|
||||||
|
uv run pytest --cov=beads_mcp tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Test suite includes both mocked unit tests and integration tests with real `bd` CLI.
|
||||||
78
integrations/beads-mcp/pyproject.toml
Normal file
78
integrations/beads-mcp/pyproject.toml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
[project]
|
||||||
|
name = "beads-mcp"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "MCP server for beads issue tracker."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastmcp==2.12.4",
|
||||||
|
"pydantic==2.12.0",
|
||||||
|
"pydantic-settings==2.11.0",
|
||||||
|
]
|
||||||
|
authors = [
|
||||||
|
{name = "Beads Contributors"}
|
||||||
|
]
|
||||||
|
keywords = ["beads", "mcp", "claude", "issue-tracker", "ai-agent"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
beads-mcp = "beads_mcp.server:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
strict = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
disallow_any_generics = true
|
||||||
|
check_untyped_defs = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
warn_no_return = true
|
||||||
|
warn_unreachable = true
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py311"
|
||||||
|
line-length = 115
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E",
|
||||||
|
"W",
|
||||||
|
"F",
|
||||||
|
"I",
|
||||||
|
"UP",
|
||||||
|
"B",
|
||||||
|
"SIM",
|
||||||
|
"C4",
|
||||||
|
]
|
||||||
|
ignore = []
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"mypy>=1.18.2",
|
||||||
|
"pytest>=8.4.2",
|
||||||
|
"pytest-asyncio>=1.2.0",
|
||||||
|
"pytest-cov>=7.0.0",
|
||||||
|
"ruff>=0.14.0",
|
||||||
|
]
|
||||||
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)
|
||||||
1
integrations/beads-mcp/tests/__init__.py
Normal file
1
integrations/beads-mcp/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for beads-mcp."""
|
||||||
612
integrations/beads-mcp/tests/test_bd_client.py
Normal file
612
integrations/beads-mcp/tests/test_bd_client.py
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
"""Unit tests for BdClient."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from beads_mcp.bd_client import BdClient, BdCommandError, BdNotFoundError
|
||||||
|
from beads_mcp.models import (
|
||||||
|
AddDependencyParams,
|
||||||
|
CloseIssueParams,
|
||||||
|
CreateIssueParams,
|
||||||
|
DependencyType,
|
||||||
|
IssueStatus,
|
||||||
|
IssueType,
|
||||||
|
ListIssuesParams,
|
||||||
|
ReadyWorkParams,
|
||||||
|
ShowIssueParams,
|
||||||
|
UpdateIssueParams,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bd_client():
|
||||||
|
"""Create a BdClient instance for testing."""
|
||||||
|
return BdClient(bd_path="/usr/bin/bd", beads_db="/tmp/test.db")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_process():
|
||||||
|
"""Create a mock subprocess process."""
|
||||||
|
process = MagicMock()
|
||||||
|
process.returncode = 0
|
||||||
|
process.communicate = AsyncMock(return_value=(b"", b""))
|
||||||
|
return process
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bd_client_initialization():
|
||||||
|
"""Test BdClient initialization."""
|
||||||
|
client = BdClient(bd_path="/usr/bin/bd", beads_db="/tmp/test.db")
|
||||||
|
assert client.bd_path == "/usr/bin/bd"
|
||||||
|
assert client.beads_db == "/tmp/test.db"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bd_client_without_db():
|
||||||
|
"""Test BdClient initialization without database."""
|
||||||
|
client = BdClient(bd_path="/usr/bin/bd")
|
||||||
|
assert client.bd_path == "/usr/bin/bd"
|
||||||
|
assert client.beads_db is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_command_success(bd_client, mock_process):
|
||||||
|
"""Test successful command execution."""
|
||||||
|
result_data = {"id": "bd-1", "title": "Test issue"}
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(json.dumps(result_data).encode(), b""))
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
result = await bd_client._run_command("show", "bd-1")
|
||||||
|
|
||||||
|
assert result == result_data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_command_not_found(bd_client):
|
||||||
|
"""Test command execution when bd executable not found."""
|
||||||
|
with (
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError()),
|
||||||
|
pytest.raises(BdNotFoundError, match="bd command not found"),
|
||||||
|
):
|
||||||
|
await bd_client._run_command("show", "bd-1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_command_failure(bd_client, mock_process):
|
||||||
|
"""Test command execution failure."""
|
||||||
|
mock_process.returncode = 1
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(b"", b"Error: Issue not found"))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("asyncio.create_subprocess_exec", return_value=mock_process),
|
||||||
|
pytest.raises(BdCommandError, match="bd command failed"),
|
||||||
|
):
|
||||||
|
await bd_client._run_command("show", "bd-999")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_command_invalid_json(bd_client, mock_process):
|
||||||
|
"""Test command execution with invalid JSON output."""
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(b"invalid json", b""))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("asyncio.create_subprocess_exec", return_value=mock_process),
|
||||||
|
pytest.raises(BdCommandError, match="Failed to parse bd JSON output"),
|
||||||
|
):
|
||||||
|
await bd_client._run_command("show", "bd-1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_command_empty_output(bd_client, mock_process):
|
||||||
|
"""Test command execution with empty output."""
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(b"", b""))
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
result = await bd_client._run_command("show", "bd-1")
|
||||||
|
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ready(bd_client, mock_process):
|
||||||
|
"""Test ready method."""
|
||||||
|
issues_data = [
|
||||||
|
{
|
||||||
|
"id": "bd-1",
|
||||||
|
"title": "Issue 1",
|
||||||
|
"status": "open",
|
||||||
|
"priority": 1,
|
||||||
|
"issue_type": "bug",
|
||||||
|
"created_at": "2025-01-25T00:00:00Z",
|
||||||
|
"updated_at": "2025-01-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bd-2",
|
||||||
|
"title": "Issue 2",
|
||||||
|
"status": "open",
|
||||||
|
"priority": 2,
|
||||||
|
"issue_type": "feature",
|
||||||
|
"created_at": "2025-01-25T00:00:00Z",
|
||||||
|
"updated_at": "2025-01-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(json.dumps(issues_data).encode(), b""))
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
params = ReadyWorkParams(limit=10, priority=1)
|
||||||
|
issues = await bd_client.ready(params)
|
||||||
|
|
||||||
|
assert len(issues) == 2
|
||||||
|
assert issues[0].id == "bd-1"
|
||||||
|
assert issues[1].id == "bd-2"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ready_with_assignee(bd_client, mock_process):
|
||||||
|
"""Test ready method with assignee filter."""
|
||||||
|
issues_data = [
|
||||||
|
{
|
||||||
|
"id": "bd-1",
|
||||||
|
"title": "Issue 1",
|
||||||
|
"status": "open",
|
||||||
|
"priority": 1,
|
||||||
|
"issue_type": "bug",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(json.dumps(issues_data).encode(), b""))
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
params = ReadyWorkParams(limit=10, assignee="alice")
|
||||||
|
issues = await bd_client.ready(params)
|
||||||
|
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert issues[0].id == "bd-1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ready_invalid_response(bd_client, mock_process):
|
||||||
|
"""Test ready method with invalid response type."""
|
||||||
|
mock_process.communicate = AsyncMock(
|
||||||
|
return_value=(json.dumps({"error": "not a list"}).encode(), b"")
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
params = ReadyWorkParams(limit=10)
|
||||||
|
issues = await bd_client.ready(params)
|
||||||
|
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_issues(bd_client, mock_process):
|
||||||
|
"""Test list_issues method."""
|
||||||
|
issues_data = [
|
||||||
|
{
|
||||||
|
"id": "bd-1",
|
||||||
|
"title": "Issue 1",
|
||||||
|
"status": "open",
|
||||||
|
"priority": 1,
|
||||||
|
"issue_type": "bug",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(json.dumps(issues_data).encode(), b""))
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
params = ListIssuesParams(status="open", priority=1)
|
||||||
|
issues = await bd_client.list_issues(params)
|
||||||
|
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert issues[0].id == "bd-1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_issues_invalid_response(bd_client, mock_process):
|
||||||
|
"""Test list_issues method with invalid response type."""
|
||||||
|
mock_process.communicate = AsyncMock(
|
||||||
|
return_value=(json.dumps({"error": "not a list"}).encode(), b"")
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
params = ListIssuesParams(status="open")
|
||||||
|
issues = await bd_client.list_issues(params)
|
||||||
|
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_show(bd_client, mock_process):
|
||||||
|
"""Test show method."""
|
||||||
|
issue_data = {
|
||||||
|
"id": "bd-1",
|
||||||
|
"title": "Test issue",
|
||||||
|
"description": "Test description",
|
||||||
|
"status": "open",
|
||||||
|
"priority": 1,
|
||||||
|
"issue_type": "bug",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2024-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(json.dumps(issue_data).encode(), b""))
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
params = ShowIssueParams(issue_id="bd-1")
|
||||||
|
issue = await bd_client.show(params)
|
||||||
|
|
||||||
|
assert issue.id == "bd-1"
|
||||||
|
assert issue.title == "Test issue"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_show_invalid_response(bd_client, mock_process):
|
||||||
|
"""Test show method with invalid response type."""
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(json.dumps(["not a dict"]).encode(), b""))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("asyncio.create_subprocess_exec", return_value=mock_process),
|
||||||
|
pytest.raises(BdCommandError, match="Invalid response for show"),
|
||||||
|
):
|
||||||
|
params = ShowIssueParams(issue_id="bd-1")
|
||||||
|
await bd_client.show(params)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create(bd_client, mock_process):
|
||||||
|
"""Test create method."""
|
||||||
|
issue_data = {
|
||||||
|
"id": "bd-5",
|
||||||
|
"title": "New issue",
|
||||||
|
"description": "New description",
|
||||||
|
"status": "open",
|
||||||
|
"priority": 2,
|
||||||
|
"issue_type": "feature",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2025-01-25T00:00:00Z",
|
||||||
|
}
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(json.dumps(issue_data).encode(), b""))
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
params = CreateIssueParams(
|
||||||
|
title="New issue",
|
||||||
|
description="New description",
|
||||||
|
priority=2,
|
||||||
|
issue_type="feature",
|
||||||
|
)
|
||||||
|
issue = await bd_client.create(params)
|
||||||
|
|
||||||
|
assert issue.id == "bd-5"
|
||||||
|
assert issue.title == "New issue"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_with_optional_fields(bd_client, mock_process):
|
||||||
|
"""Test create method with all optional fields."""
|
||||||
|
issue_data = {
|
||||||
|
"id": "test-42",
|
||||||
|
"title": "New issue",
|
||||||
|
"description": "Full description",
|
||||||
|
"design": "Design notes",
|
||||||
|
"acceptance_criteria": "Acceptance criteria",
|
||||||
|
"external_ref": "gh-123",
|
||||||
|
"status": "open",
|
||||||
|
"priority": 1,
|
||||||
|
"issue_type": "feature",
|
||||||
|
"created_at": "2025-01-25T00:00:00Z",
|
||||||
|
"updated_at": "2025-01-25T00:00:00Z",
|
||||||
|
}
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(json.dumps(issue_data).encode(), b""))
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
params = CreateIssueParams(
|
||||||
|
title="New issue",
|
||||||
|
description="Full description",
|
||||||
|
design="Design notes",
|
||||||
|
acceptance="Acceptance criteria",
|
||||||
|
external_ref="gh-123",
|
||||||
|
priority=1,
|
||||||
|
issue_type="feature",
|
||||||
|
id="test-42",
|
||||||
|
deps=["bd-1", "bd-2"],
|
||||||
|
)
|
||||||
|
issue = await bd_client.create(params)
|
||||||
|
|
||||||
|
assert issue.id == "test-42"
|
||||||
|
assert issue.title == "New issue"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_invalid_response(bd_client, mock_process):
|
||||||
|
"""Test create method with invalid response type."""
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(json.dumps(["not a dict"]).encode(), b""))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("asyncio.create_subprocess_exec", return_value=mock_process),
|
||||||
|
pytest.raises(BdCommandError, match="Invalid response for create"),
|
||||||
|
):
|
||||||
|
params = CreateIssueParams(title="Test", priority=1, issue_type="task")
|
||||||
|
await bd_client.create(params)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update(bd_client, mock_process):
|
||||||
|
"""Test update method."""
|
||||||
|
issue_data = {
|
||||||
|
"id": "bd-1",
|
||||||
|
"title": "Updated title",
|
||||||
|
"status": "in_progress",
|
||||||
|
"priority": 1,
|
||||||
|
"issue_type": "bug",
|
||||||
|
"created_at": "2025-01-25T00:00:00Z",
|
||||||
|
"updated_at": "2025-01-25T00:00:00Z",
|
||||||
|
}
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(json.dumps(issue_data).encode(), b""))
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
params = UpdateIssueParams(issue_id="bd-1", status="in_progress", title="Updated title")
|
||||||
|
issue = await bd_client.update(params)
|
||||||
|
|
||||||
|
assert issue.id == "bd-1"
|
||||||
|
assert issue.status == "in_progress"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_with_optional_fields(bd_client, mock_process):
|
||||||
|
"""Test update method with all optional fields."""
|
||||||
|
issue_data = {
|
||||||
|
"id": "bd-1",
|
||||||
|
"title": "Updated title",
|
||||||
|
"design": "Design notes",
|
||||||
|
"acceptance_criteria": "Acceptance criteria",
|
||||||
|
"notes": "Additional notes",
|
||||||
|
"external_ref": "gh-456",
|
||||||
|
"status": "in_progress",
|
||||||
|
"priority": 0,
|
||||||
|
"issue_type": "bug",
|
||||||
|
"created_at": "2025-01-25T00:00:00Z",
|
||||||
|
"updated_at": "2025-01-25T00:00:00Z",
|
||||||
|
}
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(json.dumps(issue_data).encode(), b""))
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
params = UpdateIssueParams(
|
||||||
|
issue_id="bd-1",
|
||||||
|
assignee="alice",
|
||||||
|
design="Design notes",
|
||||||
|
acceptance_criteria="Acceptance criteria",
|
||||||
|
notes="Additional notes",
|
||||||
|
external_ref="gh-456",
|
||||||
|
)
|
||||||
|
issue = await bd_client.update(params)
|
||||||
|
|
||||||
|
assert issue.id == "bd-1"
|
||||||
|
assert issue.title == "Updated title"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_invalid_response(bd_client, mock_process):
|
||||||
|
"""Test update method with invalid response type."""
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(json.dumps(["not a dict"]).encode(), b""))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("asyncio.create_subprocess_exec", return_value=mock_process),
|
||||||
|
pytest.raises(BdCommandError, match="Invalid response for update"),
|
||||||
|
):
|
||||||
|
params = UpdateIssueParams(issue_id="bd-1", status="in_progress")
|
||||||
|
await bd_client.update(params)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close(bd_client, mock_process):
|
||||||
|
"""Test close method."""
|
||||||
|
issues_data = [
|
||||||
|
{
|
||||||
|
"id": "bd-1",
|
||||||
|
"title": "Closed issue",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": 1,
|
||||||
|
"issue_type": "bug",
|
||||||
|
"created_at": "2025-01-25T00:00:00Z",
|
||||||
|
"updated_at": "2025-01-25T00:00:00Z",
|
||||||
|
"closed_at": "2025-01-25T01:00:00Z",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(json.dumps(issues_data).encode(), b""))
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
params = CloseIssueParams(issue_id="bd-1", reason="Completed")
|
||||||
|
issues = await bd_client.close(params)
|
||||||
|
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert issues[0].status == "closed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_invalid_response(bd_client, mock_process):
|
||||||
|
"""Test close method with invalid response type."""
|
||||||
|
mock_process.communicate = AsyncMock(
|
||||||
|
return_value=(json.dumps({"error": "not a list"}).encode(), b"")
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("asyncio.create_subprocess_exec", return_value=mock_process),
|
||||||
|
pytest.raises(BdCommandError, match="Invalid response for close"),
|
||||||
|
):
|
||||||
|
params = CloseIssueParams(issue_id="bd-1", reason="Test")
|
||||||
|
await bd_client.close(params)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_dependency(bd_client, mock_process):
|
||||||
|
"""Test add_dependency method."""
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(b"Dependency added\n", b""))
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
params = AddDependencyParams(from_id="bd-2", to_id="bd-1", dep_type="blocks")
|
||||||
|
await bd_client.add_dependency(params)
|
||||||
|
|
||||||
|
# Should complete without raising an exception
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_dependency_failure(bd_client, mock_process):
|
||||||
|
"""Test add_dependency with failure."""
|
||||||
|
mock_process.returncode = 1
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(b"", b"Dependency already exists"))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("asyncio.create_subprocess_exec", return_value=mock_process),
|
||||||
|
pytest.raises(BdCommandError, match="bd dep add failed"),
|
||||||
|
):
|
||||||
|
params = AddDependencyParams(from_id="bd-2", to_id="bd-1", dep_type="blocks")
|
||||||
|
await bd_client.add_dependency(params)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_dependency_not_found(bd_client):
|
||||||
|
"""Test add_dependency when bd executable not found."""
|
||||||
|
with (
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError()),
|
||||||
|
pytest.raises(BdNotFoundError, match="bd command not found"),
|
||||||
|
):
|
||||||
|
params = AddDependencyParams(from_id="bd-2", to_id="bd-1", dep_type="blocks")
|
||||||
|
await bd_client.add_dependency(params)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quickstart(bd_client, mock_process):
|
||||||
|
"""Test quickstart method."""
|
||||||
|
quickstart_text = "# Beads Quickstart\n\nWelcome to beads..."
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(quickstart_text.encode(), b""))
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
result = await bd_client.quickstart()
|
||||||
|
|
||||||
|
assert result == quickstart_text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quickstart_failure(bd_client, mock_process):
|
||||||
|
"""Test quickstart with failure."""
|
||||||
|
mock_process.returncode = 1
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(b"", b"Command not found"))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("asyncio.create_subprocess_exec", return_value=mock_process),
|
||||||
|
pytest.raises(BdCommandError, match="bd quickstart failed"),
|
||||||
|
):
|
||||||
|
await bd_client.quickstart()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quickstart_not_found(bd_client):
|
||||||
|
"""Test quickstart when bd executable not found."""
|
||||||
|
with (
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError()),
|
||||||
|
pytest.raises(BdNotFoundError, match="bd command not found"),
|
||||||
|
):
|
||||||
|
await bd_client.quickstart()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stats(bd_client, mock_process):
|
||||||
|
"""Test stats method."""
|
||||||
|
stats_data = {
|
||||||
|
"total_issues": 10,
|
||||||
|
"open_issues": 5,
|
||||||
|
"in_progress_issues": 2,
|
||||||
|
"closed_issues": 3,
|
||||||
|
"blocked_issues": 1,
|
||||||
|
"ready_issues": 4,
|
||||||
|
"average_lead_time_hours": 24.5,
|
||||||
|
}
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(json.dumps(stats_data).encode(), b""))
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
result = await bd_client.stats()
|
||||||
|
|
||||||
|
assert result.total_issues == 10
|
||||||
|
assert result.open_issues == 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stats_invalid_response(bd_client, mock_process):
|
||||||
|
"""Test stats method with invalid response type."""
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(json.dumps(["not a dict"]).encode(), b""))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("asyncio.create_subprocess_exec", return_value=mock_process),
|
||||||
|
pytest.raises(BdCommandError, match="Invalid response for stats"),
|
||||||
|
):
|
||||||
|
await bd_client.stats()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_blocked(bd_client, mock_process):
|
||||||
|
"""Test blocked method."""
|
||||||
|
blocked_data = [
|
||||||
|
{
|
||||||
|
"id": "bd-1",
|
||||||
|
"title": "Blocked issue",
|
||||||
|
"status": "blocked",
|
||||||
|
"priority": 1,
|
||||||
|
"issue_type": "bug",
|
||||||
|
"created_at": "2025-01-25T00:00:00Z",
|
||||||
|
"updated_at": "2025-01-25T00:00:00Z",
|
||||||
|
"blocked_by_count": 2,
|
||||||
|
"blocked_by": ["bd-2", "bd-3"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(json.dumps(blocked_data).encode(), b""))
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
result = await bd_client.blocked()
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].id == "bd-1"
|
||||||
|
assert result[0].blocked_by_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_blocked_invalid_response(bd_client, mock_process):
|
||||||
|
"""Test blocked method with invalid response type."""
|
||||||
|
mock_process.communicate = AsyncMock(
|
||||||
|
return_value=(json.dumps({"error": "not a list"}).encode(), b"")
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
result = await bd_client.blocked()
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_init(bd_client, mock_process):
|
||||||
|
"""Test init method."""
|
||||||
|
init_output = "bd initialized successfully!"
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(init_output.encode(), b""))
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
||||||
|
from beads_mcp.models import InitParams
|
||||||
|
|
||||||
|
params = InitParams(prefix="test")
|
||||||
|
result = await bd_client.init(params)
|
||||||
|
|
||||||
|
assert "bd initialized successfully!" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_init_failure(bd_client, mock_process):
|
||||||
|
"""Test init method with command failure."""
|
||||||
|
mock_process.returncode = 1
|
||||||
|
mock_process.communicate = AsyncMock(return_value=(b"", b"Failed to initialize"))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("asyncio.create_subprocess_exec", return_value=mock_process),
|
||||||
|
pytest.raises(BdCommandError, match="bd init failed"),
|
||||||
|
):
|
||||||
|
await bd_client.init()
|
||||||
351
integrations/beads-mcp/tests/test_bd_client_integration.py
Normal file
351
integrations/beads-mcp/tests/test_bd_client_integration.py
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
"""Real integration tests for BdClient using actual bd binary."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from beads_mcp.bd_client import BdClient, BdCommandError, BdNotFoundError
|
||||||
|
from beads_mcp.models import (
|
||||||
|
AddDependencyParams,
|
||||||
|
CloseIssueParams,
|
||||||
|
CreateIssueParams,
|
||||||
|
DependencyType,
|
||||||
|
IssueStatus,
|
||||||
|
IssueType,
|
||||||
|
ListIssuesParams,
|
||||||
|
ReadyWorkParams,
|
||||||
|
ShowIssueParams,
|
||||||
|
UpdateIssueParams,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def bd_executable():
|
||||||
|
"""Verify bd is available in PATH."""
|
||||||
|
bd_path = shutil.which("bd")
|
||||||
|
if not bd_path:
|
||||||
|
pytest.fail(
|
||||||
|
"bd executable not found in PATH. "
|
||||||
|
"Please install bd or add it to your PATH before running integration tests."
|
||||||
|
)
|
||||||
|
return bd_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_db():
|
||||||
|
"""Create a temporary database file."""
|
||||||
|
fd, db_path = tempfile.mkstemp(suffix=".db", prefix="beads_test_", dir="/tmp")
|
||||||
|
os.close(fd)
|
||||||
|
# Remove the file so bd init can create it
|
||||||
|
os.unlink(db_path)
|
||||||
|
yield db_path
|
||||||
|
# Cleanup
|
||||||
|
if os.path.exists(db_path):
|
||||||
|
os.unlink(db_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def bd_client(bd_executable, temp_db):
|
||||||
|
"""Create BdClient with temporary database - fully hermetic."""
|
||||||
|
client = BdClient(bd_path=bd_executable, beads_db=temp_db)
|
||||||
|
|
||||||
|
# Initialize database with explicit BEADS_DB - no chdir needed!
|
||||||
|
env = os.environ.copy()
|
||||||
|
# Clear any existing BEADS_DB to ensure we use only temp_db
|
||||||
|
env.pop("BEADS_DB", None)
|
||||||
|
env["BEADS_DB"] = temp_db
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# Use temp dir for subprocess to run in (prevents .beads/ discovery)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="beads_test_workspace_", dir="/tmp") as temp_dir:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
bd_executable,
|
||||||
|
"init",
|
||||||
|
"--prefix",
|
||||||
|
"test",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
cwd=temp_dir, # Run in temp dir, not project dir
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
|
||||||
|
if process.returncode != 0:
|
||||||
|
pytest.fail(f"Failed to initialize test database: {stderr.decode()}")
|
||||||
|
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_and_show_issue(bd_client):
|
||||||
|
"""Test creating and showing an issue with real bd."""
|
||||||
|
# Create issue
|
||||||
|
params = CreateIssueParams(
|
||||||
|
title="Test integration issue",
|
||||||
|
description="This is a real integration test",
|
||||||
|
priority=1,
|
||||||
|
issue_type="bug",
|
||||||
|
)
|
||||||
|
created = await bd_client.create(params)
|
||||||
|
|
||||||
|
assert created.id is not None
|
||||||
|
assert created.title == "Test integration issue"
|
||||||
|
assert created.description == "This is a real integration test"
|
||||||
|
assert created.priority == 1
|
||||||
|
assert created.issue_type == "bug"
|
||||||
|
assert created.status == "open"
|
||||||
|
|
||||||
|
# Show issue
|
||||||
|
show_params = ShowIssueParams(issue_id=created.id)
|
||||||
|
shown = await bd_client.show(show_params)
|
||||||
|
|
||||||
|
assert shown.id == created.id
|
||||||
|
assert shown.title == created.title
|
||||||
|
assert shown.description == created.description
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_issues(bd_client):
|
||||||
|
"""Test listing issues with real bd."""
|
||||||
|
# Create multiple issues
|
||||||
|
for i in range(3):
|
||||||
|
params = CreateIssueParams(
|
||||||
|
title=f"Test issue {i}",
|
||||||
|
priority=i,
|
||||||
|
issue_type="task",
|
||||||
|
)
|
||||||
|
await bd_client.create(params)
|
||||||
|
|
||||||
|
# List all issues
|
||||||
|
params = ListIssuesParams()
|
||||||
|
issues = await bd_client.list_issues(params)
|
||||||
|
|
||||||
|
assert len(issues) >= 3
|
||||||
|
|
||||||
|
# List with status filter
|
||||||
|
params = ListIssuesParams(status="open")
|
||||||
|
issues = await bd_client.list_issues(params)
|
||||||
|
|
||||||
|
assert all(issue.status == "open" for issue in issues)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_issue(bd_client):
|
||||||
|
"""Test updating an issue with real bd."""
|
||||||
|
# Create issue
|
||||||
|
create_params = CreateIssueParams(
|
||||||
|
title="Issue to update",
|
||||||
|
priority=2,
|
||||||
|
issue_type="feature",
|
||||||
|
)
|
||||||
|
created = await bd_client.create(create_params)
|
||||||
|
|
||||||
|
# Update issue
|
||||||
|
update_params = UpdateIssueParams(
|
||||||
|
issue_id=created.id,
|
||||||
|
status="in_progress",
|
||||||
|
priority=0,
|
||||||
|
title="Updated title",
|
||||||
|
)
|
||||||
|
updated = await bd_client.update(update_params)
|
||||||
|
|
||||||
|
assert updated.id == created.id
|
||||||
|
assert updated.status == "in_progress"
|
||||||
|
assert updated.priority == 0
|
||||||
|
assert updated.title == "Updated title"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_issue(bd_client):
|
||||||
|
"""Test closing an issue with real bd."""
|
||||||
|
# Create issue
|
||||||
|
create_params = CreateIssueParams(
|
||||||
|
title="Issue to close",
|
||||||
|
priority=1,
|
||||||
|
issue_type="bug",
|
||||||
|
)
|
||||||
|
created = await bd_client.create(create_params)
|
||||||
|
|
||||||
|
# Close issue
|
||||||
|
close_params = CloseIssueParams(issue_id=created.id, reason="Testing complete")
|
||||||
|
closed_issues = await bd_client.close(close_params)
|
||||||
|
|
||||||
|
assert len(closed_issues) >= 1
|
||||||
|
closed = closed_issues[0]
|
||||||
|
assert closed.id == created.id
|
||||||
|
assert closed.status == "closed"
|
||||||
|
assert closed.closed_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_dependency(bd_client):
|
||||||
|
"""Test adding dependencies with real bd."""
|
||||||
|
# Create two issues
|
||||||
|
issue1 = await bd_client.create(
|
||||||
|
CreateIssueParams(title="Issue 1", priority=1, issue_type="task")
|
||||||
|
)
|
||||||
|
issue2 = await bd_client.create(
|
||||||
|
CreateIssueParams(title="Issue 2", priority=1, issue_type="task")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add dependency: issue2 blocks issue1
|
||||||
|
params = AddDependencyParams(
|
||||||
|
from_id=issue1.id, to_id=issue2.id, dep_type="blocks"
|
||||||
|
)
|
||||||
|
await bd_client.add_dependency(params)
|
||||||
|
|
||||||
|
# Verify dependency by showing issue1
|
||||||
|
show_params = ShowIssueParams(issue_id=issue1.id)
|
||||||
|
shown = await bd_client.show(show_params)
|
||||||
|
|
||||||
|
assert len(shown.dependencies) > 0
|
||||||
|
assert any(dep.id == issue2.id for dep in shown.dependencies)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ready_work(bd_client):
|
||||||
|
"""Test getting ready work with real bd."""
|
||||||
|
# Create issue with no dependencies (should be ready)
|
||||||
|
ready_issue = await bd_client.create(
|
||||||
|
CreateIssueParams(title="Ready issue", priority=1, issue_type="task")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create blocked issue
|
||||||
|
blocking_issue = await bd_client.create(
|
||||||
|
CreateIssueParams(title="Blocking issue", priority=1, issue_type="task")
|
||||||
|
)
|
||||||
|
blocked_issue = await bd_client.create(
|
||||||
|
CreateIssueParams(title="Blocked issue", priority=1, issue_type="task")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add blocking dependency
|
||||||
|
await bd_client.add_dependency(
|
||||||
|
AddDependencyParams(
|
||||||
|
from_id=blocked_issue.id,
|
||||||
|
to_id=blocking_issue.id,
|
||||||
|
dep_type="blocks",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get ready work
|
||||||
|
params = ReadyWorkParams(limit=100)
|
||||||
|
ready_issues = await bd_client.ready(params)
|
||||||
|
|
||||||
|
# ready_issue should be in ready work
|
||||||
|
ready_ids = [issue.id for issue in ready_issues]
|
||||||
|
assert ready_issue.id in ready_ids
|
||||||
|
|
||||||
|
# blocked_issue should NOT be in ready work
|
||||||
|
assert blocked_issue.id not in ready_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quickstart(bd_client):
|
||||||
|
"""Test quickstart command with real bd."""
|
||||||
|
result = await bd_client.quickstart()
|
||||||
|
|
||||||
|
assert len(result) > 0
|
||||||
|
assert "beads" in result.lower() or "bd" in result.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_with_labels(bd_client):
|
||||||
|
"""Test creating issue with labels."""
|
||||||
|
params = CreateIssueParams(
|
||||||
|
title="Issue with labels",
|
||||||
|
priority=1,
|
||||||
|
issue_type="feature",
|
||||||
|
labels=["urgent", "backend"],
|
||||||
|
)
|
||||||
|
created = await bd_client.create(params)
|
||||||
|
|
||||||
|
# Note: bd currently doesn't return labels in JSON output
|
||||||
|
# This test verifies the command succeeds with labels parameter
|
||||||
|
assert created.id is not None
|
||||||
|
assert created.title == "Issue with labels"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_with_assignee(bd_client):
|
||||||
|
"""Test creating issue with assignee."""
|
||||||
|
params = CreateIssueParams(
|
||||||
|
title="Assigned issue",
|
||||||
|
priority=1,
|
||||||
|
issue_type="task",
|
||||||
|
assignee="testuser",
|
||||||
|
)
|
||||||
|
created = await bd_client.create(params)
|
||||||
|
|
||||||
|
assert created.assignee == "testuser"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_with_filters(bd_client):
|
||||||
|
"""Test listing issues with multiple filters."""
|
||||||
|
# Create issues with different attributes
|
||||||
|
await bd_client.create(
|
||||||
|
CreateIssueParams(
|
||||||
|
title="Bug P0",
|
||||||
|
priority=0,
|
||||||
|
issue_type="bug",
|
||||||
|
assignee="alice",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await bd_client.create(
|
||||||
|
CreateIssueParams(
|
||||||
|
title="Feature P1",
|
||||||
|
priority=1,
|
||||||
|
issue_type="feature",
|
||||||
|
assignee="bob",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by priority
|
||||||
|
params = ListIssuesParams(priority=0)
|
||||||
|
issues = await bd_client.list_issues(params)
|
||||||
|
assert all(issue.priority == 0 for issue in issues)
|
||||||
|
|
||||||
|
# Filter by type
|
||||||
|
params = ListIssuesParams(issue_type="bug")
|
||||||
|
issues = await bd_client.list_issues(params)
|
||||||
|
assert all(issue.issue_type == "bug" for issue in issues)
|
||||||
|
|
||||||
|
# Filter by assignee
|
||||||
|
params = ListIssuesParams(assignee="alice")
|
||||||
|
issues = await bd_client.list_issues(params)
|
||||||
|
assert all(issue.assignee == "alice" for issue in issues)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_issue_id(bd_client):
|
||||||
|
"""Test showing non-existent issue."""
|
||||||
|
params = ShowIssueParams(issue_id="test-999")
|
||||||
|
|
||||||
|
with pytest.raises(BdCommandError, match="bd command failed"):
|
||||||
|
await bd_client.show(params)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dependency_types(bd_client):
|
||||||
|
"""Test different dependency types."""
|
||||||
|
issue1 = await bd_client.create(
|
||||||
|
CreateIssueParams(title="Issue 1", priority=1, issue_type="task")
|
||||||
|
)
|
||||||
|
issue2 = await bd_client.create(
|
||||||
|
CreateIssueParams(title="Issue 2", priority=1, issue_type="task")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test related dependency
|
||||||
|
params = AddDependencyParams(
|
||||||
|
from_id=issue1.id, to_id=issue2.id, dep_type="related"
|
||||||
|
)
|
||||||
|
await bd_client.add_dependency(params)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
show_params = ShowIssueParams(issue_id=issue1.id)
|
||||||
|
shown = await bd_client.show(show_params)
|
||||||
|
assert len(shown.dependencies) > 0
|
||||||
524
integrations/beads-mcp/tests/test_mcp_server_integration.py
Normal file
524
integrations/beads-mcp/tests/test_mcp_server_integration.py
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
"""Real integration tests for MCP server using fastmcp.Client."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastmcp.client import Client
|
||||||
|
|
||||||
|
from beads_mcp.server import mcp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def bd_executable():
|
||||||
|
"""Verify bd is available in PATH."""
|
||||||
|
bd_path = shutil.which("bd")
|
||||||
|
if not bd_path:
|
||||||
|
pytest.fail(
|
||||||
|
"bd executable not found in PATH. "
|
||||||
|
"Please install bd or add it to your PATH before running integration tests."
|
||||||
|
)
|
||||||
|
return bd_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def temp_db(bd_executable):
|
||||||
|
"""Create a temporary database file and initialize it - fully hermetic."""
|
||||||
|
# Create temp directory for database
|
||||||
|
temp_dir = tempfile.mkdtemp(prefix="beads_mcp_test_", dir="/tmp")
|
||||||
|
db_path = os.path.join(temp_dir, "test.db")
|
||||||
|
|
||||||
|
# Initialize database with explicit BEADS_DB - no chdir needed!
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
# Clear any existing BEADS_DB to ensure we use only temp db
|
||||||
|
env.pop("BEADS_DB", None)
|
||||||
|
env["BEADS_DB"] = db_path
|
||||||
|
|
||||||
|
# Use temp workspace dir for subprocess (prevents .beads/ discovery)
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix="beads_mcp_test_workspace_", dir="/tmp"
|
||||||
|
) as temp_workspace:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
bd_executable,
|
||||||
|
"init",
|
||||||
|
"--prefix",
|
||||||
|
"test",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
cwd=temp_workspace, # Run in temp workspace, not project dir
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
|
||||||
|
if process.returncode != 0:
|
||||||
|
pytest.fail(f"Failed to initialize test database: {stderr.decode()}")
|
||||||
|
|
||||||
|
yield db_path
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mcp_client(bd_executable, temp_db, monkeypatch):
|
||||||
|
"""Create MCP client with temporary database."""
|
||||||
|
from beads_mcp import tools
|
||||||
|
from beads_mcp.bd_client import BdClient
|
||||||
|
|
||||||
|
# Reset client before test
|
||||||
|
tools._client = None
|
||||||
|
|
||||||
|
# Create a pre-configured client with explicit paths (bypasses config loading)
|
||||||
|
tools._client = BdClient(bd_path=bd_executable, beads_db=temp_db)
|
||||||
|
|
||||||
|
# Create test client
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
# Reset client after test
|
||||||
|
tools._client = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quickstart_resource(mcp_client):
|
||||||
|
"""Test beads://quickstart resource."""
|
||||||
|
result = await mcp_client.read_resource("beads://quickstart")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
content = result[0].text
|
||||||
|
assert len(content) > 0
|
||||||
|
assert "beads" in content.lower() or "bd" in content.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_issue_tool(mcp_client):
|
||||||
|
"""Test create_issue tool."""
|
||||||
|
result = await mcp_client.call_tool(
|
||||||
|
"create",
|
||||||
|
{
|
||||||
|
"title": "Test MCP issue",
|
||||||
|
"description": "Created via MCP server",
|
||||||
|
"priority": 1,
|
||||||
|
"issue_type": "bug",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse the JSON response from CallToolResult
|
||||||
|
import json
|
||||||
|
|
||||||
|
issue_data = json.loads(result.content[0].text)
|
||||||
|
assert issue_data["title"] == "Test MCP issue"
|
||||||
|
assert issue_data["description"] == "Created via MCP server"
|
||||||
|
assert issue_data["priority"] == 1
|
||||||
|
assert issue_data["issue_type"] == "bug"
|
||||||
|
assert issue_data["status"] == "open"
|
||||||
|
assert "id" in issue_data
|
||||||
|
|
||||||
|
return issue_data["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_show_issue_tool(mcp_client):
|
||||||
|
"""Test show_issue tool."""
|
||||||
|
# First create an issue
|
||||||
|
create_result = await mcp_client.call_tool(
|
||||||
|
"create",
|
||||||
|
{"title": "Issue to show", "priority": 2, "issue_type": "task"},
|
||||||
|
)
|
||||||
|
import json
|
||||||
|
|
||||||
|
created = json.loads(create_result.content[0].text)
|
||||||
|
issue_id = created["id"]
|
||||||
|
|
||||||
|
# Show the issue
|
||||||
|
show_result = await mcp_client.call_tool("show", {"issue_id": issue_id})
|
||||||
|
|
||||||
|
issue = json.loads(show_result.content[0].text)
|
||||||
|
assert issue["id"] == issue_id
|
||||||
|
assert issue["title"] == "Issue to show"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_issues_tool(mcp_client):
|
||||||
|
"""Test list_issues tool."""
|
||||||
|
# Create some issues first
|
||||||
|
await mcp_client.call_tool(
|
||||||
|
"create", {"title": "Issue 1", "priority": 0, "issue_type": "bug"}
|
||||||
|
)
|
||||||
|
await mcp_client.call_tool(
|
||||||
|
"create", {"title": "Issue 2", "priority": 1, "issue_type": "feature"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# List all issues
|
||||||
|
result = await mcp_client.call_tool("list", {})
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
issues = json.loads(result.content[0].text)
|
||||||
|
assert len(issues) >= 2
|
||||||
|
|
||||||
|
# List with status filter
|
||||||
|
result = await mcp_client.call_tool("list", {"status": "open"})
|
||||||
|
issues = json.loads(result.content[0].text)
|
||||||
|
assert all(issue["status"] == "open" for issue in issues)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_issue_tool(mcp_client):
|
||||||
|
"""Test update_issue tool."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Create issue
|
||||||
|
create_result = await mcp_client.call_tool(
|
||||||
|
"create", {"title": "Issue to update", "priority": 2, "issue_type": "task"}
|
||||||
|
)
|
||||||
|
created = json.loads(create_result.content[0].text)
|
||||||
|
issue_id = created["id"]
|
||||||
|
|
||||||
|
# Update issue
|
||||||
|
update_result = await mcp_client.call_tool(
|
||||||
|
"update",
|
||||||
|
{
|
||||||
|
"issue_id": issue_id,
|
||||||
|
"status": "in_progress",
|
||||||
|
"priority": 0,
|
||||||
|
"title": "Updated title",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = json.loads(update_result.content[0].text)
|
||||||
|
assert updated["id"] == issue_id
|
||||||
|
assert updated["status"] == "in_progress"
|
||||||
|
assert updated["priority"] == 0
|
||||||
|
assert updated["title"] == "Updated title"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_issue_tool(mcp_client):
|
||||||
|
"""Test close_issue tool."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Create issue
|
||||||
|
create_result = await mcp_client.call_tool(
|
||||||
|
"create", {"title": "Issue to close", "priority": 1, "issue_type": "bug"}
|
||||||
|
)
|
||||||
|
created = json.loads(create_result.content[0].text)
|
||||||
|
issue_id = created["id"]
|
||||||
|
|
||||||
|
# Close issue
|
||||||
|
close_result = await mcp_client.call_tool(
|
||||||
|
"close", {"issue_id": issue_id, "reason": "Test complete"}
|
||||||
|
)
|
||||||
|
|
||||||
|
closed_issues = json.loads(close_result.content[0].text)
|
||||||
|
assert len(closed_issues) >= 1
|
||||||
|
closed = closed_issues[0]
|
||||||
|
assert closed["id"] == issue_id
|
||||||
|
assert closed["status"] == "closed"
|
||||||
|
assert closed["closed_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ready_work_tool(mcp_client):
|
||||||
|
"""Test ready_work tool."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Create a ready issue (no dependencies)
|
||||||
|
ready_result = await mcp_client.call_tool(
|
||||||
|
"create", {"title": "Ready work", "priority": 1, "issue_type": "task"}
|
||||||
|
)
|
||||||
|
ready_issue = json.loads(ready_result.content[0].text)
|
||||||
|
|
||||||
|
# Create blocked issue
|
||||||
|
blocking_result = await mcp_client.call_tool(
|
||||||
|
"create", {"title": "Blocking issue", "priority": 1, "issue_type": "task"}
|
||||||
|
)
|
||||||
|
blocking_issue = json.loads(blocking_result.content[0].text)
|
||||||
|
|
||||||
|
blocked_result = await mcp_client.call_tool(
|
||||||
|
"create", {"title": "Blocked issue", "priority": 1, "issue_type": "task"}
|
||||||
|
)
|
||||||
|
blocked_issue = json.loads(blocked_result.content[0].text)
|
||||||
|
|
||||||
|
# Add blocking dependency
|
||||||
|
await mcp_client.call_tool(
|
||||||
|
"dep",
|
||||||
|
{
|
||||||
|
"from_id": blocked_issue["id"],
|
||||||
|
"to_id": blocking_issue["id"],
|
||||||
|
"dep_type": "blocks",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get ready work
|
||||||
|
result = await mcp_client.call_tool("ready", {"limit": 100})
|
||||||
|
ready_issues = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
ready_ids = [issue["id"] for issue in ready_issues]
|
||||||
|
assert ready_issue["id"] in ready_ids
|
||||||
|
assert blocked_issue["id"] not in ready_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_dependency_tool(mcp_client):
|
||||||
|
"""Test add_dependency tool."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Create two issues
|
||||||
|
issue1_result = await mcp_client.call_tool(
|
||||||
|
"create", {"title": "Issue 1", "priority": 1, "issue_type": "task"}
|
||||||
|
)
|
||||||
|
issue1 = json.loads(issue1_result.content[0].text)
|
||||||
|
|
||||||
|
issue2_result = await mcp_client.call_tool(
|
||||||
|
"create", {"title": "Issue 2", "priority": 1, "issue_type": "task"}
|
||||||
|
)
|
||||||
|
issue2 = json.loads(issue2_result.content[0].text)
|
||||||
|
|
||||||
|
# Add dependency
|
||||||
|
result = await mcp_client.call_tool(
|
||||||
|
"dep",
|
||||||
|
{"from_id": issue1["id"], "to_id": issue2["id"], "dep_type": "blocks"},
|
||||||
|
)
|
||||||
|
|
||||||
|
message = result.content[0].text
|
||||||
|
assert "Added dependency" in message
|
||||||
|
assert issue1["id"] in message
|
||||||
|
assert issue2["id"] in message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_with_all_fields(mcp_client):
|
||||||
|
"""Test create_issue with all optional fields."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
result = await mcp_client.call_tool(
|
||||||
|
"create",
|
||||||
|
{
|
||||||
|
"title": "Full issue",
|
||||||
|
"description": "Complete description",
|
||||||
|
"priority": 0,
|
||||||
|
"issue_type": "feature",
|
||||||
|
"assignee": "testuser",
|
||||||
|
"labels": ["urgent", "backend"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
issue = json.loads(result.content[0].text)
|
||||||
|
assert issue["title"] == "Full issue"
|
||||||
|
assert issue["description"] == "Complete description"
|
||||||
|
assert issue["priority"] == 0
|
||||||
|
assert issue["issue_type"] == "feature"
|
||||||
|
assert issue["assignee"] == "testuser"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_with_filters(mcp_client):
|
||||||
|
"""Test list_issues with various filters."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Create issues with different attributes
|
||||||
|
await mcp_client.call_tool(
|
||||||
|
"create",
|
||||||
|
{
|
||||||
|
"title": "Bug P0",
|
||||||
|
"priority": 0,
|
||||||
|
"issue_type": "bug",
|
||||||
|
"assignee": "alice",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await mcp_client.call_tool(
|
||||||
|
"create",
|
||||||
|
{
|
||||||
|
"title": "Feature P1",
|
||||||
|
"priority": 1,
|
||||||
|
"issue_type": "feature",
|
||||||
|
"assignee": "bob",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by priority
|
||||||
|
result = await mcp_client.call_tool("list", {"priority": 0})
|
||||||
|
issues = json.loads(result.content[0].text)
|
||||||
|
assert all(issue["priority"] == 0 for issue in issues)
|
||||||
|
|
||||||
|
# Filter by type
|
||||||
|
result = await mcp_client.call_tool("list", {"issue_type": "bug"})
|
||||||
|
issues = json.loads(result.content[0].text)
|
||||||
|
assert all(issue["issue_type"] == "bug" for issue in issues)
|
||||||
|
|
||||||
|
# Filter by assignee
|
||||||
|
result = await mcp_client.call_tool("list", {"assignee": "alice"})
|
||||||
|
issues = json.loads(result.content[0].text)
|
||||||
|
assert all(issue["assignee"] == "alice" for issue in issues)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ready_work_with_priority_filter(mcp_client):
|
||||||
|
"""Test ready_work with priority filter."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Create issues with different priorities
|
||||||
|
await mcp_client.call_tool(
|
||||||
|
"create", {"title": "P0 issue", "priority": 0, "issue_type": "bug"}
|
||||||
|
)
|
||||||
|
await mcp_client.call_tool(
|
||||||
|
"create", {"title": "P1 issue", "priority": 1, "issue_type": "task"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get ready work with priority filter
|
||||||
|
result = await mcp_client.call_tool("ready", {"priority": 0, "limit": 100})
|
||||||
|
issues = json.loads(result.content[0].text)
|
||||||
|
assert all(issue["priority"] == 0 for issue in issues)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_partial_fields(mcp_client):
|
||||||
|
"""Test update_issue with partial field updates."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Create issue
|
||||||
|
create_result = await mcp_client.call_tool(
|
||||||
|
"create",
|
||||||
|
{
|
||||||
|
"title": "Original title",
|
||||||
|
"description": "Original description",
|
||||||
|
"priority": 2,
|
||||||
|
"issue_type": "task",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
created = json.loads(create_result.content[0].text)
|
||||||
|
issue_id = created["id"]
|
||||||
|
|
||||||
|
# Update only status
|
||||||
|
update_result = await mcp_client.call_tool(
|
||||||
|
"update", {"issue_id": issue_id, "status": "in_progress"}
|
||||||
|
)
|
||||||
|
updated = json.loads(update_result.content[0].text)
|
||||||
|
assert updated["status"] == "in_progress"
|
||||||
|
assert updated["title"] == "Original title" # Unchanged
|
||||||
|
assert updated["priority"] == 2 # Unchanged
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dependency_types(mcp_client):
|
||||||
|
"""Test different dependency types."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Create issues
|
||||||
|
issue1_result = await mcp_client.call_tool(
|
||||||
|
"create", {"title": "Issue 1", "priority": 1, "issue_type": "task"}
|
||||||
|
)
|
||||||
|
issue1 = json.loads(issue1_result.content[0].text)
|
||||||
|
|
||||||
|
issue2_result = await mcp_client.call_tool(
|
||||||
|
"create", {"title": "Issue 2", "priority": 1, "issue_type": "task"}
|
||||||
|
)
|
||||||
|
issue2 = json.loads(issue2_result.content[0].text)
|
||||||
|
|
||||||
|
# Test related dependency
|
||||||
|
result = await mcp_client.call_tool(
|
||||||
|
"dep",
|
||||||
|
{"from_id": issue1["id"], "to_id": issue2["id"], "dep_type": "related"},
|
||||||
|
)
|
||||||
|
|
||||||
|
message = result.content[0].text
|
||||||
|
assert "Added dependency" in message
|
||||||
|
assert "related" in message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stats_tool(mcp_client):
|
||||||
|
"""Test stats tool."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Create some issues to get stats
|
||||||
|
await mcp_client.call_tool(
|
||||||
|
"create", {"title": "Stats test 1", "priority": 1, "issue_type": "bug"}
|
||||||
|
)
|
||||||
|
await mcp_client.call_tool(
|
||||||
|
"create", {"title": "Stats test 2", "priority": 2, "issue_type": "task"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get stats
|
||||||
|
result = await mcp_client.call_tool("stats", {})
|
||||||
|
stats = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
assert "total_issues" in stats
|
||||||
|
assert "open_issues" in stats
|
||||||
|
assert stats["total_issues"] >= 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_blocked_tool(mcp_client):
|
||||||
|
"""Test blocked tool."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Create two issues
|
||||||
|
blocking_result = await mcp_client.call_tool(
|
||||||
|
"create", {"title": "Blocking issue", "priority": 1, "issue_type": "task"}
|
||||||
|
)
|
||||||
|
blocking_issue = json.loads(blocking_result.content[0].text)
|
||||||
|
|
||||||
|
blocked_result = await mcp_client.call_tool(
|
||||||
|
"create", {"title": "Blocked issue", "priority": 1, "issue_type": "task"}
|
||||||
|
)
|
||||||
|
blocked_issue = json.loads(blocked_result.content[0].text)
|
||||||
|
|
||||||
|
# Add blocking dependency
|
||||||
|
await mcp_client.call_tool(
|
||||||
|
"dep",
|
||||||
|
{
|
||||||
|
"from_id": blocked_issue["id"],
|
||||||
|
"to_id": blocking_issue["id"],
|
||||||
|
"dep_type": "blocks",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get blocked issues
|
||||||
|
result = await mcp_client.call_tool("blocked", {})
|
||||||
|
blocked_issues = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
# Should have at least the one we created
|
||||||
|
blocked_ids = [issue["id"] for issue in blocked_issues]
|
||||||
|
assert blocked_issue["id"] in blocked_ids
|
||||||
|
|
||||||
|
# Find our blocked issue and verify it has blocking info
|
||||||
|
our_blocked = next(issue for issue in blocked_issues if issue["id"] == blocked_issue["id"])
|
||||||
|
assert our_blocked["blocked_by_count"] >= 1
|
||||||
|
assert blocking_issue["id"] in our_blocked["blocked_by"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_init_tool(mcp_client, bd_executable):
|
||||||
|
"""Test init tool."""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Create a completely separate temp directory and database
|
||||||
|
with tempfile.TemporaryDirectory(prefix="beads_init_test_", dir="/tmp") as temp_dir:
|
||||||
|
new_db_path = os.path.join(temp_dir, "new_test.db")
|
||||||
|
|
||||||
|
# Temporarily override the client's BEADS_DB for this test
|
||||||
|
from beads_mcp import tools
|
||||||
|
|
||||||
|
# Save original client
|
||||||
|
original_client = tools._client
|
||||||
|
|
||||||
|
# Create a new client pointing to the new database path
|
||||||
|
from beads_mcp.bd_client import BdClient
|
||||||
|
tools._client = BdClient(bd_path=bd_executable, beads_db=new_db_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Call init tool
|
||||||
|
result = await mcp_client.call_tool("init", {"prefix": "test-init"})
|
||||||
|
output = result.content[0].text
|
||||||
|
|
||||||
|
# Verify output contains success message
|
||||||
|
assert "bd initialized successfully!" in output
|
||||||
|
finally:
|
||||||
|
# Restore original client
|
||||||
|
tools._client = original_client
|
||||||
364
integrations/beads-mcp/tests/test_tools.py
Normal file
364
integrations/beads-mcp/tests/test_tools.py
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
"""Integration tests for MCP tools."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from beads_mcp.bd_client import BdClient
|
||||||
|
from beads_mcp.models import BlockedIssue, 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_client():
|
||||||
|
"""Mock the BdClient for all tests."""
|
||||||
|
from beads_mcp import tools
|
||||||
|
|
||||||
|
# Reset client before each test
|
||||||
|
tools._client = None
|
||||||
|
yield
|
||||||
|
# Reset client after each test
|
||||||
|
tools._client = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_issue():
|
||||||
|
"""Create a sample issue for testing."""
|
||||||
|
return Issue(
|
||||||
|
id="bd-1",
|
||||||
|
title="Test issue",
|
||||||
|
description="Test description",
|
||||||
|
status="open",
|
||||||
|
priority=1,
|
||||||
|
issue_type="bug",
|
||||||
|
created_at="2024-01-01T00:00:00Z",
|
||||||
|
updated_at="2024-01-01T00:00:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beads_ready_work(sample_issue):
|
||||||
|
"""Test beads_ready_work tool."""
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.ready = AsyncMock(return_value=[sample_issue])
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||||
|
issues = await beads_ready_work(limit=10, priority=1)
|
||||||
|
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert issues[0].id == "bd-1"
|
||||||
|
mock_client.ready.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beads_ready_work_no_params():
|
||||||
|
"""Test beads_ready_work with default parameters."""
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.ready = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||||
|
issues = await beads_ready_work()
|
||||||
|
|
||||||
|
assert len(issues) == 0
|
||||||
|
mock_client.ready.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beads_list_issues(sample_issue):
|
||||||
|
"""Test beads_list_issues tool."""
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.list_issues = AsyncMock(return_value=[sample_issue])
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||||
|
issues = await beads_list_issues(status="open", priority=1)
|
||||||
|
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert issues[0].id == "bd-1"
|
||||||
|
mock_client.list_issues.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beads_show_issue(sample_issue):
|
||||||
|
"""Test beads_show_issue tool."""
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.show = AsyncMock(return_value=sample_issue)
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||||
|
issue = await beads_show_issue(issue_id="bd-1")
|
||||||
|
|
||||||
|
assert issue.id == "bd-1"
|
||||||
|
assert issue.title == "Test issue"
|
||||||
|
mock_client.show.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beads_create_issue(sample_issue):
|
||||||
|
"""Test beads_create_issue tool."""
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.create = AsyncMock(return_value=sample_issue)
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||||
|
issue = await beads_create_issue(
|
||||||
|
title="New issue",
|
||||||
|
description="New description",
|
||||||
|
priority=2,
|
||||||
|
issue_type="feature",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert issue.id == "bd-1"
|
||||||
|
mock_client.create.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beads_create_issue_with_labels(sample_issue):
|
||||||
|
"""Test beads_create_issue with labels."""
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.create = AsyncMock(return_value=sample_issue)
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||||
|
issue = await beads_create_issue(
|
||||||
|
title="New issue", labels=["bug", "urgent"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert issue.id == "bd-1"
|
||||||
|
mock_client.create.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beads_update_issue(sample_issue):
|
||||||
|
"""Test beads_update_issue tool."""
|
||||||
|
updated_issue = sample_issue.model_copy(
|
||||||
|
update={"status": "in_progress"}
|
||||||
|
)
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.update = AsyncMock(return_value=updated_issue)
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||||
|
issue = await beads_update_issue(issue_id="bd-1", status="in_progress")
|
||||||
|
|
||||||
|
assert issue.status == "in_progress"
|
||||||
|
mock_client.update.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beads_close_issue(sample_issue):
|
||||||
|
"""Test beads_close_issue tool."""
|
||||||
|
closed_issue = sample_issue.model_copy(
|
||||||
|
update={"status": "closed", "closed_at": "2024-01-02T00:00:00Z"}
|
||||||
|
)
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.close = AsyncMock(return_value=[closed_issue])
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||||
|
issues = await beads_close_issue(issue_id="bd-1", reason="Completed")
|
||||||
|
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert issues[0].status == "closed"
|
||||||
|
mock_client.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beads_add_dependency_success():
|
||||||
|
"""Test beads_add_dependency tool success."""
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.add_dependency = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||||
|
result = await beads_add_dependency(
|
||||||
|
from_id="bd-2", to_id="bd-1", dep_type="blocks"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Added dependency" in result
|
||||||
|
assert "bd-2" in result
|
||||||
|
assert "bd-1" in result
|
||||||
|
mock_client.add_dependency.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beads_add_dependency_error():
|
||||||
|
"""Test beads_add_dependency tool error handling."""
|
||||||
|
from beads_mcp.bd_client import BdError
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.add_dependency = AsyncMock(
|
||||||
|
side_effect=BdError("Dependency already exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||||
|
result = await beads_add_dependency(
|
||||||
|
from_id="bd-2", to_id="bd-1", dep_type="blocks"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Error" in result
|
||||||
|
mock_client.add_dependency.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beads_quickstart():
|
||||||
|
"""Test beads_quickstart tool."""
|
||||||
|
quickstart_text = "# Beads Quickstart\n\nWelcome to beads..."
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.quickstart = AsyncMock(return_value=quickstart_text)
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||||
|
result = await beads_quickstart()
|
||||||
|
|
||||||
|
assert "Beads Quickstart" in result
|
||||||
|
mock_client.quickstart.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_client_lazy_initialization():
|
||||||
|
"""Test that client is lazily initialized on first use."""
|
||||||
|
from beads_mcp import tools
|
||||||
|
|
||||||
|
# Clear client
|
||||||
|
tools._client = None
|
||||||
|
|
||||||
|
# Verify client is None before first use
|
||||||
|
assert tools._client is None
|
||||||
|
|
||||||
|
# Mock BdClient to avoid actual bd calls
|
||||||
|
mock_client_instance = AsyncMock()
|
||||||
|
mock_client_instance.ready = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools.BdClient") as MockBdClient:
|
||||||
|
MockBdClient.return_value = mock_client_instance
|
||||||
|
|
||||||
|
# First call should initialize client
|
||||||
|
await beads_ready_work()
|
||||||
|
|
||||||
|
# Verify BdClient was instantiated
|
||||||
|
MockBdClient.assert_called_once()
|
||||||
|
|
||||||
|
# Verify client is now set
|
||||||
|
assert tools._client is not None
|
||||||
|
|
||||||
|
# Second call should reuse client
|
||||||
|
MockBdClient.reset_mock()
|
||||||
|
await beads_ready_work()
|
||||||
|
|
||||||
|
# Verify BdClient was NOT called again
|
||||||
|
MockBdClient.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_issues_with_all_filters(sample_issue):
|
||||||
|
"""Test beads_list_issues with all filter parameters."""
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.list_issues = AsyncMock(return_value=[sample_issue])
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||||
|
issues = await beads_list_issues(
|
||||||
|
status="open",
|
||||||
|
priority=1,
|
||||||
|
issue_type="bug",
|
||||||
|
assignee="user1",
|
||||||
|
limit=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(issues) == 1
|
||||||
|
mock_client.list_issues.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_issue_multiple_fields(sample_issue):
|
||||||
|
"""Test beads_update_issue with multiple fields."""
|
||||||
|
updated_issue = sample_issue.model_copy(
|
||||||
|
update={
|
||||||
|
"status": "in_progress",
|
||||||
|
"priority": 0,
|
||||||
|
"title": "Updated title",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.update = AsyncMock(return_value=updated_issue)
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||||
|
issue = await beads_update_issue(
|
||||||
|
issue_id="bd-1",
|
||||||
|
status="in_progress",
|
||||||
|
priority=0,
|
||||||
|
title="Updated title",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert issue.status == "in_progress"
|
||||||
|
assert issue.priority == 0
|
||||||
|
assert issue.title == "Updated title"
|
||||||
|
mock_client.update.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beads_stats():
|
||||||
|
"""Test beads_stats tool."""
|
||||||
|
stats_data = Stats(
|
||||||
|
total_issues=10,
|
||||||
|
open_issues=5,
|
||||||
|
in_progress_issues=2,
|
||||||
|
closed_issues=3,
|
||||||
|
blocked_issues=1,
|
||||||
|
ready_issues=4,
|
||||||
|
average_lead_time_hours=24.5,
|
||||||
|
)
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.stats = AsyncMock(return_value=stats_data)
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||||
|
result = await beads_stats()
|
||||||
|
|
||||||
|
assert result.total_issues == 10
|
||||||
|
assert result.open_issues == 5
|
||||||
|
mock_client.stats.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beads_blocked():
|
||||||
|
"""Test beads_blocked tool."""
|
||||||
|
blocked_issue = BlockedIssue(
|
||||||
|
id="bd-1",
|
||||||
|
title="Blocked issue",
|
||||||
|
description="",
|
||||||
|
status="blocked",
|
||||||
|
priority=1,
|
||||||
|
issue_type="bug",
|
||||||
|
created_at="2024-01-01T00:00:00Z",
|
||||||
|
updated_at="2024-01-01T00:00:00Z",
|
||||||
|
blocked_by_count=2,
|
||||||
|
blocked_by=["bd-2", "bd-3"],
|
||||||
|
)
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.blocked = AsyncMock(return_value=[blocked_issue])
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||||
|
result = await beads_blocked()
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].id == "bd-1"
|
||||||
|
assert result[0].blocked_by_count == 2
|
||||||
|
mock_client.blocked.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beads_init():
|
||||||
|
"""Test beads_init tool."""
|
||||||
|
init_output = "bd initialized successfully!"
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.init = AsyncMock(return_value=init_output)
|
||||||
|
|
||||||
|
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||||
|
result = await beads_init(prefix="test")
|
||||||
|
|
||||||
|
assert "bd initialized successfully!" in result
|
||||||
|
mock_client.init.assert_called_once()
|
||||||
1544
integrations/beads-mcp/uv.lock
generated
Normal file
1544
integrations/beads-mcp/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user