From feac3f86e7f6c7c1456ffe0c29017fa51499f2b7 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 23 Oct 2025 14:10:33 -0700 Subject: [PATCH] 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 --- CHANGELOG.md | 8 ++++ integrations/beads-mcp/README.md | 2 +- integrations/beads-mcp/src/beads_mcp/tools.py | 18 ++++++- integrations/beads-mcp/tests/test_tools.py | 48 +++++++++++++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08412a8f..33d450e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **MCP Server**: Smart routing for lifecycle status changes in `update` tool (GH #123) + - `update(status="closed")` now routes to `close()` tool to respect approval workflows + - `update(status="open")` now routes to `reopen()` tool to respect approval workflows + - Prevents bypass of Claude Code approval settings for lifecycle events + - bd CLI remains unopinionated; routing happens only in MCP layer + - Users can now safely auto-approve benign updates (priority, notes) without exposing closure bypass + ## [0.14.0] - 2025-10-22 ### Added diff --git a/integrations/beads-mcp/README.md b/integrations/beads-mcp/README.md index 7792027c..33ff694d 100644 --- a/integrations/beads-mcp/README.md +++ b/integrations/beads-mcp/README.md @@ -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 diff --git a/integrations/beads-mcp/src/beads_mcp/tools.py b/integrations/beads-mcp/src/beads_mcp/tools.py index 186d7ba0..71de91de 100644 --- a/integrations/beads-mcp/src/beads_mcp/tools.py +++ b/integrations/beads-mcp/src/beads_mcp/tools.py @@ -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, diff --git a/integrations/beads-mcp/tests/test_tools.py b/integrations/beads-mcp/tests/test_tools.py index c43ad314..c08a4415 100644 --- a/integrations/beads-mcp/tests/test_tools.py +++ b/integrations/beads-mcp/tests/test_tools.py @@ -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."""