Fix bd-143: Prevent daemon auto-sync from wiping out issues.jsonl with empty database

- Added safety check to exportToJSONLWithStore (daemon path)
- Refuses to export 0 issues over non-empty JSONL file
- Added --force flag to override safety check when intentional
- Added test coverage for empty database export protection
- Prevents data loss when daemon has wrong/empty database

Amp-Thread-ID: https://ampcode.com/threads/T-de18e0ad-bd17-46ec-994b-0581e257dcde
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-10-25 16:36:18 -07:00
parent 3241b7fbfc
commit de03466da9
10 changed files with 858 additions and 41 deletions

View File

@@ -26,6 +26,7 @@ from beads_mcp.tools import (
beads_show_issue,
beads_stats,
beads_update_issue,
current_workspace, # ContextVar for per-request workspace routing
)
# Setup logging for lifecycle events
@@ -96,9 +97,43 @@ signal.signal(signal.SIGINT, signal_handler)
logger.info("beads-mcp server initialized with lifecycle management")
def with_workspace(func: Callable[..., T]) -> Callable[..., T]:
"""Decorator to set workspace context for the duration of a tool call.
Extracts workspace_root parameter from tool call kwargs, resolves it,
and sets current_workspace ContextVar for the request duration.
Falls back to BEADS_WORKING_DIR if workspace_root not provided.
This enables per-request workspace routing for multi-project support.
"""
@wraps(func)
async def wrapper(*args, **kwargs):
# Extract workspace_root parameter (if provided)
workspace_root = kwargs.get('workspace_root')
# Determine workspace: parameter > env > None
workspace = workspace_root or os.environ.get("BEADS_WORKING_DIR")
# Set ContextVar for this request
token = current_workspace.set(workspace)
try:
# Execute tool with workspace context set
return await func(*args, **kwargs)
finally:
# Always reset ContextVar after tool completes
current_workspace.reset(token)
return wrapper
def require_context(func: Callable[..., T]) -> Callable[..., T]:
"""Decorator to enforce context has been set before write operations.
Passes if either:
- workspace_root was provided on tool call (via ContextVar), OR
- BEADS_WORKING_DIR is set (from set_context)
Only enforces if BEADS_REQUIRE_CONTEXT=1 is set in environment.
This allows backward compatibility while adding safety for multi-repo setups.
"""
@@ -106,9 +141,11 @@ def require_context(func: Callable[..., T]) -> Callable[..., T]:
async def wrapper(*args, **kwargs):
# Only enforce if explicitly enabled
if os.environ.get("BEADS_REQUIRE_CONTEXT") == "1":
if not os.environ.get("BEADS_CONTEXT_SET"):
# Check ContextVar or environment
workspace = current_workspace.get() or os.environ.get("BEADS_WORKING_DIR")
if not workspace:
raise ValueError(
"Context not set. Call set_context with your workspace root before performing write operations."
"Context not set. Either provide workspace_root parameter or call set_context() first."
)
return await func(*args, **kwargs)
return wrapper
@@ -243,6 +280,7 @@ async def where_am_i(workspace_root: str | None = None) -> str:
# Register all tools
@mcp.tool(name="ready", description="Find tasks that have no blockers and are ready to be worked on.")
@with_workspace
async def ready_work(
limit: int = 10,
priority: int | None = None,
@@ -257,6 +295,7 @@ async def ready_work(
name="list",
description="List all issues with optional filters (status, priority, type, assignee).",
)
@with_workspace
async def list_issues(
status: IssueStatus | None = None,
priority: int | None = None,
@@ -279,6 +318,7 @@ async def list_issues(
name="show",
description="Show detailed information about a specific issue including dependencies and dependents.",
)
@with_workspace
async def show_issue(issue_id: str, workspace_root: str | None = None) -> Issue:
"""Show detailed information about a specific issue."""
return await beads_show_issue(issue_id=issue_id)
@@ -289,6 +329,7 @@ async def show_issue(issue_id: str, workspace_root: str | None = None) -> Issue:
description="""Create a new issue (bug, feature, task, epic, or chore) with optional design,
acceptance criteria, and dependencies.""",
)
@with_workspace
@require_context
async def create_issue(
title: str,
@@ -325,6 +366,7 @@ async def create_issue(
description="""Update an existing issue's status, priority, assignee, description, design notes,
or acceptance criteria. Use this to claim work (set status=in_progress).""",
)
@with_workspace
@require_context
async def update_issue(
issue_id: str,
@@ -363,6 +405,7 @@ async def update_issue(
name="close",
description="Close (complete) an issue. Mark work as done when you've finished implementing/fixing it.",
)
@with_workspace
@require_context
async def close_issue(issue_id: str, reason: str = "Completed", workspace_root: str | None = None) -> list[Issue]:
"""Close (complete) an issue."""
@@ -373,6 +416,7 @@ async def close_issue(issue_id: str, reason: str = "Completed", workspace_root:
name="reopen",
description="Reopen one or more closed issues. Sets status to 'open' and clears closed_at timestamp.",
)
@with_workspace
@require_context
async def reopen_issue(issue_ids: list[str], reason: str | None = None, workspace_root: str | None = None) -> list[Issue]:
"""Reopen one or more closed issues."""
@@ -384,6 +428,7 @@ async def reopen_issue(issue_ids: list[str], reason: str | None = None, workspac
description="""Add a dependency between issues. Types: blocks (hard blocker),
related (soft link), parent-child (epic/subtask), discovered-from (found during work).""",
)
@with_workspace
@require_context
async def add_dependency(
issue_id: str,
@@ -403,6 +448,7 @@ async def add_dependency(
name="stats",
description="Get statistics: total issues, open, in_progress, closed, blocked, ready, and average lead time.",
)
@with_workspace
async def stats(workspace_root: str | None = None) -> Stats:
"""Get statistics about tasks."""
return await beads_stats()
@@ -412,6 +458,7 @@ async def stats(workspace_root: str | None = None) -> Stats:
name="blocked",
description="Get blocked issues showing what dependencies are blocking them from being worked on.",
)
@with_workspace
async def blocked(workspace_root: str | None = None) -> list[BlockedIssue]:
"""Get blocked issues."""
return await beads_blocked()
@@ -422,6 +469,7 @@ async def blocked(workspace_root: str | None = None) -> list[BlockedIssue]:
description="""Initialize bd in current directory. Creates .beads/ directory and
database with optional custom prefix for issue IDs.""",
)
@with_workspace
@require_context
async def init(prefix: str | None = None, workspace_root: str | None = None) -> str:
"""Initialize bd in current directory."""
@@ -432,6 +480,7 @@ async def init(prefix: str | None = None, workspace_root: str | None = None) ->
name="debug_env",
description="Debug tool: Show environment and working directory information",
)
@with_workspace
async def debug_env(workspace_root: str | None = None) -> str:
"""Debug tool to check working directory and environment variables."""
info = []

View File

@@ -1,6 +1,10 @@
"""MCP tools for beads issue tracker."""
import asyncio
import os
import subprocess
from contextvars import ContextVar
from functools import lru_cache
from typing import Annotated, TYPE_CHECKING
from .bd_client import create_bd_client, BdClientBase, BdError
@@ -25,10 +29,15 @@ from .models import (
UpdateIssueParams,
)
# Global client instance - initialized on first use
_client: BdClientBase | None = None
_version_checked: bool = False
_client_registered: bool = False
# ContextVar for request-scoped workspace routing
current_workspace: ContextVar[str | None] = ContextVar('workspace', default=None)
# Connection pool for per-project daemon sockets
_connection_pool: dict[str, BdClientBase] = {}
_pool_lock = asyncio.Lock()
# Version checking state (per-pool client)
_version_checked: set[str] = set()
# Default constants
DEFAULT_ISSUE_TYPE: IssueType = "task"
@@ -50,41 +59,108 @@ def _register_client_for_cleanup(client: BdClientBase) -> None:
pass
async def _get_client() -> BdClientBase:
"""Get a BdClient instance, creating it on first use.
def _resolve_workspace_root(path: str) -> str:
"""Resolve workspace root to git repo root if inside a git repo.
Args:
path: Directory path to resolve
Returns:
Git repo root if inside git repo, otherwise the original path
"""
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=path,
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
return result.stdout.strip()
except Exception:
pass
return os.path.abspath(path)
Performs version check on first initialization.
@lru_cache(maxsize=128)
def _canonicalize_path(path: str) -> str:
"""Canonicalize workspace path to handle symlinks and git repos.
This ensures that different paths pointing to the same project
(e.g., via symlinks) use the same daemon connection.
Args:
path: Workspace directory path
Returns:
Canonical path (handles symlinks and submodules correctly)
"""
# 1. Resolve symlinks
real = os.path.realpath(path)
# 2. Check for local .beads directory (submodule edge case)
# Submodules should use their own .beads, not the parent repo's
if os.path.exists(os.path.join(real, ".beads")):
return real
# 3. Try to find git toplevel
# This ensures we connect to the right daemon for the git repo
return _resolve_workspace_root(real)
async def _get_client() -> BdClientBase:
"""Get a BdClient instance for the current workspace.
Uses connection pool to manage per-project daemon sockets.
Workspace is determined by current_workspace ContextVar or BEADS_WORKING_DIR env.
Performs version check on first connection to each workspace.
Uses daemon client if available, falls back to CLI client.
Returns:
Configured BdClientBase instance (config loaded automatically)
Configured BdClientBase instance for the current workspace
Raises:
BdError: If bd is not installed or version is incompatible
BdError: If no workspace is set, or bd is not installed, or version is incompatible
"""
global _client, _version_checked, _client_registered
if _client is None:
# Check if daemon should be used (default: yes)
use_daemon = os.environ.get("BEADS_USE_DAEMON", "1") == "1"
workspace_root = os.environ.get("BEADS_WORKING_DIR")
_client = create_bd_client(
prefer_daemon=use_daemon,
working_dir=workspace_root
# Determine workspace from ContextVar or environment
workspace = current_workspace.get() or os.environ.get("BEADS_WORKING_DIR")
if not workspace:
raise BdError(
"No workspace set. Either provide workspace_root parameter or call set_context() first."
)
# Register for cleanup on first creation
if not _client_registered:
_register_client_for_cleanup(_client)
_client_registered = True
# Canonicalize path to handle symlinks and deduplicate connections
canonical = _canonicalize_path(workspace)
# Thread-safe connection pool access
async with _pool_lock:
if canonical not in _connection_pool:
# Create new client for this workspace
use_daemon = os.environ.get("BEADS_USE_DAEMON", "1") == "1"
client = create_bd_client(
prefer_daemon=use_daemon,
working_dir=canonical
)
# Register for cleanup
_register_client_for_cleanup(client)
# Add to pool
_connection_pool[canonical] = client
client = _connection_pool[canonical]
# Check version once per workspace (only for CLI client)
if canonical not in _version_checked:
if hasattr(client, '_check_version'):
await client._check_version()
_version_checked.add(canonical)
# Check version once per server lifetime (only for CLI client)
if not _version_checked:
if hasattr(_client, '_check_version'):
await _client._check_version()
_version_checked = True
return _client
return client
async def beads_ready_work(