MCP: Smart routing for lifecycle status changes in update tool
- update(status="closed") now routes to close() tool - update(status="open") now routes to reopen() tool - Respects Claude Code approval workflows for lifecycle events - Prevents bypass of approval settings - bd CLI remains unopinionated; routing only in MCP layer - Users can safely auto-approve benign updates without exposing closure bypass Fixes #123 Amp-Thread-ID: https://ampcode.com/threads/T-8b85a68e-7e06-460e-9840-9c6b6a6b7e85 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -137,7 +137,7 @@ Each instance will discover and use the database in its `BEADS_WORKING_DIR` path
|
||||
- `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)
|
||||
- `update` - Update issue (status, priority, design, notes, etc). Note: `status="closed"` or `status="open"` automatically route to `close` or `reopen` tools to respect approval workflows
|
||||
- `close` - Close completed issue
|
||||
- `dep` - Add dependency (blocks, related, parent-child, discovered-from)
|
||||
- `blocked` - Get blocked issues
|
||||
|
||||
@@ -180,11 +180,27 @@ async def beads_update_issue(
|
||||
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:
|
||||
) -> Issue | list[Issue]:
|
||||
"""Update an existing issue.
|
||||
|
||||
Claim work by setting status to 'in_progress'.
|
||||
|
||||
Note: Setting status to 'closed' or 'open' will automatically route to
|
||||
beads_close_issue() or beads_reopen_issue() respectively to ensure
|
||||
proper approval workflows are followed.
|
||||
"""
|
||||
# Smart routing: intercept lifecycle status changes and route to dedicated tools
|
||||
if status == "closed":
|
||||
# Route to close tool to respect approval workflows
|
||||
reason = notes if notes else "Completed"
|
||||
return await beads_close_issue(issue_id=issue_id, reason=reason)
|
||||
|
||||
if status == "open":
|
||||
# Route to reopen tool to respect approval workflows
|
||||
reason = notes if notes else "Reopened"
|
||||
return await beads_reopen_issue(issue_ids=[issue_id], reason=reason)
|
||||
|
||||
# Normal attribute updates proceed as usual
|
||||
client = await _get_client()
|
||||
params = UpdateIssueParams(
|
||||
issue_id=issue_id,
|
||||
|
||||
@@ -360,6 +360,54 @@ async def test_update_issue_multiple_fields(sample_issue):
|
||||
mock_client.update.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_issue_routes_closed_to_close(sample_issue):
|
||||
"""Test that update with status=closed routes to close 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):
|
||||
result = await beads_update_issue(
|
||||
issue_id="bd-1",
|
||||
status="closed",
|
||||
notes="Task completed"
|
||||
)
|
||||
|
||||
# Should route to close, not update
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "closed"
|
||||
mock_client.close.assert_called_once()
|
||||
mock_client.update.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_issue_routes_open_to_reopen(sample_issue):
|
||||
"""Test that update with status=open routes to reopen tool."""
|
||||
reopened_issue = sample_issue.model_copy(
|
||||
update={"status": "open", "closed_at": None}
|
||||
)
|
||||
mock_client = AsyncMock()
|
||||
mock_client.reopen = AsyncMock(return_value=[reopened_issue])
|
||||
|
||||
with patch("beads_mcp.tools._get_client", return_value=mock_client):
|
||||
result = await beads_update_issue(
|
||||
issue_id="bd-1",
|
||||
status="open",
|
||||
notes="Needs more work"
|
||||
)
|
||||
|
||||
# Should route to reopen, not update
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "open"
|
||||
mock_client.reopen.assert_called_once()
|
||||
mock_client.update.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_beads_stats():
|
||||
"""Test beads_stats tool."""
|
||||
|
||||
Reference in New Issue
Block a user