Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ MCP tools available to the agent:
- `feature_clear_in_progress` - Clear in-progress status
- `feature_create_bulk` - Initialize all features (used by initializer)
- `feature_create` - Create a single feature
- `feature_report_bug` - Report a bug on an existing feature and move it back to pending (used by the assistant)
- `feature_add_dependency` - Add dependency between features (with cycle detection)
- `feature_remove_dependency` - Remove a dependency
- `feature_set_dependencies` - Set all dependencies for a feature at once
Expand Down
68 changes: 68 additions & 0 deletions mcp_server/feature_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- feature_clear_in_progress: Clear in-progress status
- feature_create_bulk: Create multiple features at once
- feature_create: Create a single feature
- feature_report_bug: Report a bug on an existing feature (reopens it as pending)
- feature_add_dependency: Add a dependency between features
- feature_remove_dependency: Remove a dependency
- feature_get_ready: Get features ready to implement
Expand Down Expand Up @@ -689,6 +690,73 @@ def feature_create(
return json.dumps({"error": str(e)})


@mcp.tool()
def feature_report_bug(
feature_id: Annotated[int, Field(description="The ID of the existing feature the bug belongs to", ge=1)],
bug_description: Annotated[str, Field(min_length=1, description="What is broken: observed vs. expected behavior")],
steps: Annotated[list[str] | None, Field(
default=None,
description="Optional steps to reproduce the bug / verify the fix; appended to the feature's steps",
)] = None,
) -> str:
"""Report a bug against an existing feature and send it back to pending.

Use this instead of creating a new feature when the user reports that
something which was already implemented does not work correctly. The bug
report is appended to the feature's description (and any extra steps to
its steps), and the feature is marked as not passing so a coding agent
picks it up again. This keeps the backlog small and tied to the original
feature rather than accumulating "fix X" features.

Args:
feature_id: The ID of the feature that has the bug
bug_description: Clear description of the bug (observed vs. expected)
steps: Optional reproduction/verification steps to add to the feature

Returns:
JSON with the updated feature details, or error if not found.
"""
bug_description = bug_description.strip()
if not bug_description:
return json.dumps({"error": "bug_description must not be empty"})
extra_steps = [s.strip() for s in (steps or []) if s and s.strip()]

session = get_session()
try:
feature = session.query(Feature).filter(Feature.id == feature_id).first()
if feature is None:
return json.dumps({"error": f"Feature with ID {feature_id} not found"})

if feature.in_progress:
return json.dumps({
"error": f"Feature #{feature_id} is currently in progress; "
"wait for the agent to finish before reporting a bug against it"
})

was_passing = bool(feature.passes)
feature.description = f"{feature.description}\n\n**Bug report:** {bug_description}"
if extra_steps:
# Reassign (not mutate) so SQLAlchemy detects the JSON change
feature.steps = [*(feature.steps or []), *extra_steps]
feature.passes = False
feature.in_progress = False
session.commit()
session.refresh(feature)

status = "reopened and moved back to pending" if was_passing else "updated (still pending)"
return json.dumps({
"success": True,
"message": f"Bug reported on feature #{feature_id} '{feature.name}': {status}",
"was_passing": was_passing,
"feature": feature.to_dict(),
})
except Exception as e:
session.rollback()
return json.dumps({"error": f"Failed to report bug: {str(e)}"})
finally:
session.close()


@mcp.tool()
def feature_add_dependency(
feature_id: Annotated[int, Field(ge=1, description="Feature to add dependency to")],
Expand Down
36 changes: 31 additions & 5 deletions server/services/assistant_chat_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@
"mcp__features__feature_get_by_id",
"mcp__features__feature_get_ready",
"mcp__features__feature_get_blocked",
"mcp__features__feature_get_graph",
]

# Feature management tools (create/skip but not mark_passing)
# Feature management tools (create/skip/report bugs but not mark_passing)
FEATURE_MANAGEMENT_TOOLS = [
"mcp__features__feature_create",
"mcp__features__feature_create_bulk",
"mcp__features__feature_skip",
"mcp__features__feature_report_bug",
]

# Interactive tools
Expand Down Expand Up @@ -107,7 +109,12 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str:

Your role is to help users understand the codebase, answer questions about features, and manage the project backlog. You can READ files and CREATE/MANAGE features, but you cannot modify source code.

You have MCP tools available for feature management. Use them directly by calling the tool -- do not suggest CLI commands, bash commands, or curl commands to the user. You can create features yourself using the feature_create and feature_create_bulk tools.
You have MCP tools available for feature management. Use them directly by calling the tool -- do not suggest CLI commands, bash commands, or curl commands to the user. You can create features yourself using the feature_create and feature_create_bulk tools, and report bugs against existing features with feature_report_bug.

You typically help with one of three kinds of requests:
1. **Help** -- questions about the codebase, features, or project progress
2. **New feature** -- something the project does not do yet (use feature_create)
3. **Bug report** -- something that was already implemented but does not work correctly (use feature_report_bug on the existing feature instead of creating a new one)

## What You CAN Do

Expand All @@ -119,16 +126,17 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str:

**Feature Management:**
- Create new features/test cases in the backlog
- Report bugs on existing features (appends the bug to the feature and moves it back to pending)
- Skip features to deprioritize them (move to end of queue)
- View feature statistics and progress

## What You CANNOT Do

- Modify, create, or delete source code files -- you have NO Write/Edit/Bash tools
- Mark features as passing or failing (that is the coding agent's job after real implementation)
- Mark features as passing (that is the coding agent's job after real implementation)
- Run bash commands or execute code

**You must NEVER implement code yourself.** Even if the user pastes code, asks for a "quick fix", or insists -- do not attempt to write or edit files. Implementation is done exclusively by the coding agents. When the user wants code changed or a bug fixed, create a feature for it with `feature_create` (or `feature_create_bulk`) describing the change, then tell the user to start the coding agent to implement it.
**You must NEVER implement code yourself.** Even if the user pastes code, asks for a "quick fix", or insists -- do not attempt to write or edit files. Implementation is done exclusively by the coding agents. When the user wants code changed, create a feature for it with `feature_create` (or `feature_create_bulk`) describing the change. When the user reports a bug in something that already exists, use `feature_report_bug` on the affected feature. Then tell the user to start the coding agent to implement it.

## Project Specification

Expand All @@ -147,8 +155,10 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str:
- **feature_get_by_id**: Get details for a specific feature
- **feature_get_ready**: See features ready for implementation
- **feature_get_blocked**: See features blocked by dependencies
- **feature_get_graph**: List all features (id, name, category, status) -- use this to find the feature a bug belongs to
- **feature_create**: Create a single feature in the backlog
- **feature_create_bulk**: Create multiple features at once
- **feature_report_bug**: Report a bug on an existing feature; it is reopened as pending so a coding agent fixes it
- **feature_skip**: Move a feature to the end of the queue

**Interactive:**
Expand All @@ -174,6 +184,22 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str:
[calls feature_create with appropriate parameters]
You: Done! I've added "S3 Sync Integration" to your backlog. It's now visible on the kanban board.

## Reporting Bugs

When a user reports that something is broken, prefer fixing the existing feature over adding a new one -- this keeps the backlog small and clear:

1. Find the feature the bug belongs to: call `feature_get_graph` to list features, then `feature_get_by_id` to confirm. Read the relevant code if it helps pinpoint the problem.
2. If it is ambiguous which feature is affected, use `ask_user` to let the user pick from the likely candidates.
3. Call `feature_report_bug` with the feature_id, a clear bug_description (observed vs. expected behavior), and optionally steps to reproduce/verify the fix.
4. Only if the bug does not belong to any existing feature, create a new feature with `feature_create` instead.
5. Tell the user which feature was reopened and that the coding agent will pick it up.

**Example interaction:**
User: "The login form accepts an empty password"
You: [calls feature_get_graph, finds feature #12 "User login"]
[calls feature_report_bug with feature_id=12, bug_description="Login form submits with an empty password; it should show a validation error", steps=["Submit the login form with an empty password", "Verify a validation error is shown and no request is sent"]]
You: I've reported this bug on feature #12 "User login" and moved it back to pending. Start the coding agent to fix it.

## Guidelines

1. Be concise and helpful
Expand Down Expand Up @@ -332,7 +358,7 @@ async def start(self) -> AsyncGenerator[dict, None]:
# New conversations don't need history loading
self._history_loaded = True
try:
greeting = f"Hello! I'm your project assistant for **{self.project_name}**. I can help you understand the codebase, explain features, and answer questions about the project. What would you like to know?"
greeting = f"Hello! I'm your project assistant for **{self.project_name}**. I can answer questions about the codebase, add new features to the backlog, or take a bug report on an existing feature. What would you like to do?"

# Store the greeting in the database
# conversation_id is guaranteed non-None here (set on line 206 above)
Expand Down
125 changes: 125 additions & 0 deletions test_feature_report_bug.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""
Tests for the feature_report_bug MCP tool and its exposure to the assistant.

Run with: python -m pytest test_feature_report_bug.py
"""

import json

import pytest

import mcp_server.feature_mcp as feature_mcp
from api.database import Feature, create_database
from server.services.assistant_chat_session import (
ASSISTANT_FEATURE_TOOLS,
DISALLOWED_ASSISTANT_TOOLS,
)


@pytest.fixture
def session_maker(tmp_path, monkeypatch):
_engine, maker = create_database(tmp_path)
monkeypatch.setattr(feature_mcp, "_session_maker", maker)
yield maker
_engine.dispose()


def _add_feature(maker, **overrides) -> int:
session = maker()
try:
feature = Feature(
priority=1,
category="Auth",
name="User login",
description="Users can log in.",
steps=["Open login page", "Submit valid credentials"],
passes=True,
in_progress=False,
)
for key, value in overrides.items():
setattr(feature, key, value)
session.add(feature)
session.commit()
return int(feature.id)
finally:
session.close()


def _get(maker, feature_id: int) -> Feature:
session = maker()
try:
feature: Feature = session.query(Feature).filter(Feature.id == feature_id).one()
session.expunge(feature)
return feature
finally:
session.close()


def test_reopens_passing_feature_with_bug_details(session_maker):
fid = _add_feature(session_maker)

result = json.loads(feature_mcp.feature_report_bug(
feature_id=fid,
bug_description="Empty password is accepted",
steps=["Submit with an empty password", " ", "Verify a validation error is shown"],
))

assert result["success"] is True
assert result["was_passing"] is True
feature = _get(session_maker, fid)
assert feature.passes is False
assert feature.in_progress is False
assert feature.description.startswith("Users can log in.")
assert "**Bug report:** Empty password is accepted" in feature.description
assert feature.steps == [
"Open login page",
"Submit valid credentials",
"Submit with an empty password",
"Verify a validation error is shown",
]
# Priority is unchanged; no new feature is created
assert feature.priority == 1
session = session_maker()
try:
assert session.query(Feature).count() == 1
finally:
session.close()


def test_pending_feature_without_steps(session_maker):
fid = _add_feature(session_maker, passes=False)

result = json.loads(feature_mcp.feature_report_bug(feature_id=fid, bug_description="Typo in label"))

assert result["success"] is True
assert result["was_passing"] is False
feature = _get(session_maker, fid)
assert feature.steps == ["Open login page", "Submit valid credentials"]
assert "Typo in label" in feature.description


def test_rejects_in_progress_feature(session_maker):
fid = _add_feature(session_maker, passes=False, in_progress=True)

result = json.loads(feature_mcp.feature_report_bug(feature_id=fid, bug_description="Broken"))

assert "error" in result
feature = _get(session_maker, fid)
assert feature.in_progress is True
assert feature.description == "Users can log in."


def test_missing_feature_and_blank_description(session_maker):
assert "error" in json.loads(feature_mcp.feature_report_bug(feature_id=999, bug_description="x"))

fid = _add_feature(session_maker)
assert "error" in json.loads(feature_mcp.feature_report_bug(feature_id=fid, bug_description=" "))
assert _get(session_maker, fid).passes is True


def test_assistant_can_report_bugs_but_not_mark_status():
assert "mcp__features__feature_report_bug" in ASSISTANT_FEATURE_TOOLS
assert "mcp__features__feature_get_graph" in ASSISTANT_FEATURE_TOOLS
assert "mcp__features__feature_report_bug" not in DISALLOWED_ASSISTANT_TOOLS
assert "mcp__features__feature_mark_passing" in DISALLOWED_ASSISTANT_TOOLS
assert "mcp__features__feature_mark_failing" in DISALLOWED_ASSISTANT_TOOLS
Loading