fix: Remove unsupported engines field and add runtime version checking

Fixes Claude Code marketplace plugin installation failure (bd-183).

Problem: The plugin.json manifest included an engines field (borrowed from npm)
to specify minimum bd CLI version requirements. However, Claude Code's plugin
manifest schema doesn't recognize this field, causing validation errors when
installing via /plugin marketplace add.

Solution:
1. Remove the engines field from plugin.json
2. Add runtime version checking in the MCP server startup
3. Update documentation to reflect automatic version checking

Changes:
- .claude-plugin/plugin.json: Remove unsupported engines field
- integrations/beads-mcp/src/beads_mcp/bd_client.py:
  - Add BdVersionError exception class
  - Add _check_version() method to validate bd CLI >= 0.9.0
  - Use bd version command (not bd --version)
- integrations/beads-mcp/src/beads_mcp/tools.py:
  - Make _get_client() async to support version checking
  - Update all tool functions to await _get_client()
  - Add version check on first MCP server use
- .claude-plugin/commands/bd-version.md: Update to mention automatic checking
- PLUGIN.md: Document automatic version validation at startup

Benefits:
- Plugin installs successfully via Claude Code marketplace
- Clear error messages if bd CLI version is too old
- Version check happens once per MCP server lifetime (not per command)
- Users get actionable update instructions in error messages

Closes bd-183
This commit is contained in:
Steve Yegge
2025-10-14 15:54:50 -07:00
parent 29938f67c2
commit a4d816d1f4
6 changed files with 289 additions and 98 deletions

View File

@@ -2,6 +2,7 @@
import asyncio
import json
import re
from .config import load_config
from .models import (
@@ -43,6 +44,12 @@ class BdCommandError(BdError):
self.returncode = returncode
class BdVersionError(BdError):
"""Raised when bd version is incompatible with MCP server."""
pass
class BdClient:
"""Client for calling bd CLI commands and parsing JSON output."""
@@ -144,6 +151,57 @@ class BdClient:
stderr=stdout_str,
) from e
async def _check_version(self) -> None:
"""Check that bd CLI version meets minimum requirements.
Raises:
BdVersionError: If bd version is incompatible
BdNotFoundError: If bd command not found
"""
# Minimum required version
min_version = (0, 9, 0)
try:
process = await asyncio.create_subprocess_exec(
self.bd_path,
"version",
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}'. "
f"Install bd from: https://github.com/steveyegge/beads"
) from e
if process.returncode != 0:
raise BdCommandError(
f"bd version failed: {stderr.decode()}",
stderr=stderr.decode(),
returncode=process.returncode or 1,
)
# Parse version from output like "bd version 0.9.2"
version_output = stdout.decode().strip()
match = re.search(r"(\d+)\.(\d+)\.(\d+)", version_output)
if not match:
raise BdVersionError(
f"Could not parse bd version from: {version_output}"
)
version = tuple(int(x) for x in match.groups())
if version < min_version:
min_ver_str = ".".join(str(x) for x in min_version)
cur_ver_str = ".".join(str(x) for x in version)
install_cmd = "curl -fsSL https://raw.githubusercontent.com/steveyegge/beads/main/install.sh | bash"
raise BdVersionError(
f"bd version {cur_ver_str} is too old. "
f"This MCP server requires bd >= {min_ver_str}. "
f"Update with: {install_cmd}"
)
async def ready(self, params: ReadyWorkParams | None = None) -> list[Issue]:
"""Get ready work (issues with no blocking dependencies).

View File

@@ -22,21 +22,33 @@ from .models import (
# Global client instance - initialized on first use
_client: BdClient | None = None
_version_checked: bool = False
# Default constants
DEFAULT_ISSUE_TYPE: IssueType = "task"
DEFAULT_DEPENDENCY_TYPE: DependencyType = "blocks"
def _get_client() -> BdClient:
async def _get_client() -> BdClient:
"""Get a BdClient instance, creating it on first use.
Performs version check on first initialization.
Returns:
Configured BdClient instance (config loaded automatically)
Raises:
BdError: If bd is not installed or version is incompatible
"""
global _client
global _client, _version_checked
if _client is None:
_client = BdClient()
# Check version once per server lifetime
if not _version_checked:
await _client._check_version()
_version_checked = True
return _client
@@ -50,7 +62,7 @@ async def beads_ready_work(
Ready work = status is 'open' AND no blocking dependencies.
Perfect for agents to claim next work!
"""
client = _get_client()
client = await _get_client()
params = ReadyWorkParams(limit=limit, priority=priority, assignee=assignee)
return await client.ready(params)
@@ -67,7 +79,7 @@ async def beads_list_issues(
limit: Annotated[int, "Maximum number of issues to return (1-1000)"] = 50,
) -> list[Issue]:
"""List all issues with optional filters."""
client = _get_client()
client = await _get_client()
params = ListIssuesParams(
status=status,
@@ -86,7 +98,7 @@ async def beads_show_issue(
Includes full description, dependencies, and dependents.
"""
client = _get_client()
client = await _get_client()
params = ShowIssueParams(issue_id=issue_id)
return await client.show(params)
@@ -111,7 +123,7 @@ async def beads_create_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()
client = await _get_client()
params = CreateIssueParams(
title=title,
description=description,
@@ -143,7 +155,7 @@ async def beads_update_issue(
Claim work by setting status to 'in_progress'.
"""
client = _get_client()
client = await _get_client()
params = UpdateIssueParams(
issue_id=issue_id,
status=status,
@@ -166,7 +178,7 @@ async def beads_close_issue(
Mark work as done when you've finished implementing/fixing it.
"""
client = _get_client()
client = await _get_client()
params = CloseIssueParams(issue_id=issue_id, reason=reason)
return await client.close(params)
@@ -189,7 +201,7 @@ async def beads_add_dependency(
Use 'discovered-from' when you find new work during your session.
"""
client = _get_client()
client = await _get_client()
params = AddDependencyParams(
from_id=from_id,
to_id=to_id,
@@ -207,7 +219,7 @@ async def beads_quickstart() -> str:
Read this first to understand how to use beads (bd) commands.
"""
client = _get_client()
client = await _get_client()
return await client.quickstart()
@@ -217,7 +229,7 @@ async def beads_stats() -> Stats:
Returns total issues, open, in_progress, closed, blocked, ready issues,
and average lead time in hours.
"""
client = _get_client()
client = await _get_client()
return await client.stats()
@@ -226,7 +238,7 @@ async def beads_blocked() -> list[BlockedIssue]:
Returns issues that have blocking dependencies, showing what blocks them.
"""
client = _get_client()
client = await _get_client()
return await client.blocked()
@@ -239,6 +251,6 @@ async def beads_init(
Creates .beads/ directory and database file with optional custom prefix.
"""
client = _get_client()
client = await _get_client()
params = InitParams(prefix=prefix)
return await client.init(params)