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
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
|
||||
Reference in New Issue
Block a user