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
3 changes: 3 additions & 0 deletions .claude/commands/expand-project.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ feature_create_bulk(features=[
- Include ALL features you promised to create
- Each feature needs: category, name, description, steps (array of strings)
- The tool will return the count of created features - verify it matches your expected count
- Never tell the user to run a CLI, npx, or curl command to create or import features - there is none. Always call the MCP tools yourself.

**Importing from a JSON file:** If the user already has features saved in a JSON file inside the project (for example `prompts/new_features.json`), call `feature_import_file(file_path="prompts/new_features.json")` instead of retyping them. The file must contain a JSON array of feature objects (or an object with a `"features"` array) in the same format as `feature_create_bulk`.

---

Expand Down
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_import_file` - Import features from a JSON file inside the project (used by assistant/expand chats)
- `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
61 changes: 61 additions & 0 deletions mcp_server/feature_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- feature_claim_and_get: Atomically claim and get feature details
- feature_clear_in_progress: Clear in-progress status
- feature_create_bulk: Create multiple features at once
- feature_import_file: Import features from a JSON file in the project
- feature_create: Create a single feature
- feature_add_dependency: Add a dependency between features
- feature_remove_dependency: Remove a dependency
Expand Down Expand Up @@ -52,6 +53,9 @@
# Configuration from environment
PROJECT_DIR = Path(os.environ.get("PROJECT_DIR", ".")).resolve()

# Upper bound for feature_import_file to avoid loading huge files into memory
MAX_IMPORT_FILE_BYTES = 5 * 1024 * 1024


# Pydantic models for input validation
class MarkPassingInput(BaseModel):
Expand Down Expand Up @@ -635,6 +639,63 @@ def feature_create_bulk(
return json.dumps({"error": str(e)})


@mcp.tool()
def feature_import_file(
file_path: Annotated[str, Field(min_length=1, description="Path to a JSON feature file, relative to the project directory (absolute paths must be inside the project)")]
) -> str:
"""Import features from a JSON file inside the project directory.

Use this when the user already has a feature list saved as a JSON file
(e.g. prompts/new_features.json) and wants it added to the backlog.
There is no CLI or npx command for importing features -- this tool is
the way to do it.

The file must contain either a JSON array of features, or an object with
a "features" array. Each feature uses the same format as
feature_create_bulk: category, name, description, steps, and optionally
depends_on_indices.

Args:
file_path: Path to the JSON file, relative to the project directory

Returns:
JSON with: created (int), with_dependencies (int), file (str) - or error
"""
try:
candidate = Path(file_path)
if not candidate.is_absolute():
candidate = PROJECT_DIR / candidate
resolved = candidate.resolve()

if not resolved.is_relative_to(PROJECT_DIR):
return json.dumps({"error": f"File must be inside the project directory: {file_path}"})
if not resolved.is_file():
return json.dumps({"error": f"File not found: {file_path}"})
if resolved.stat().st_size > MAX_IMPORT_FILE_BYTES:
return json.dumps({"error": f"File is too large to import (max {MAX_IMPORT_FILE_BYTES} bytes)"})

try:
data = json.loads(resolved.read_text(encoding="utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as e:
return json.dumps({"error": f"File is not valid JSON: {e}"})

features = data.get("features") if isinstance(data, dict) else data
if not isinstance(features, list) or not features:
return json.dumps({
"error": "Expected a non-empty JSON array of features, or an object with a 'features' array"
})
for i, item in enumerate(features):
if not isinstance(item, dict):
return json.dumps({"error": f"Feature at index {i} is not a JSON object"})

result = json.loads(feature_create_bulk(features))
if "error" not in result:
result["file"] = resolved.relative_to(PROJECT_DIR).as_posix()
return json.dumps(result)
except Exception as e:
return json.dumps({"error": str(e)})


@mcp.tool()
def feature_create(
category: Annotated[str, Field(min_length=1, max_length=100, description="Feature category (e.g., 'Authentication', 'API', 'UI')")],
Expand Down
6 changes: 5 additions & 1 deletion server/services/assistant_chat_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
FEATURE_MANAGEMENT_TOOLS = [
"mcp__features__feature_create",
"mcp__features__feature_create_bulk",
"mcp__features__feature_import_file",
"mcp__features__feature_skip",
]

Expand Down Expand Up @@ -107,7 +108,7 @@ 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. There is no CLI, npx, or REST command for importing features -- if the user has a JSON feature file in the project, import it with the feature_import_file tool.

## What You CAN Do

Expand Down Expand Up @@ -149,6 +150,7 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str:
- **feature_get_blocked**: See features blocked by dependencies
- **feature_create**: Create a single feature in the backlog
- **feature_create_bulk**: Create multiple features at once
- **feature_import_file**: Import features from a JSON file in the project (e.g. `prompts/new_features.json`)
- **feature_skip**: Move a feature to the end of the queue

**Interactive:**
Expand All @@ -166,6 +168,8 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str:

For **multiple features**, call `feature_create_bulk` with an array of feature objects.

If the user already has the features in a **JSON file** inside the project, call `feature_import_file` with the file's path relative to the project root. The file must hold a JSON array of feature objects (or an object with a `"features"` array) in the same format as `feature_create_bulk`. Never tell the user to run a command to import features.

You can ask clarifying questions if the user's request is vague, or make reasonable assumptions for simple requests.

**Example interaction:**
Expand Down
1 change: 1 addition & 0 deletions server/services/expand_chat_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
EXPAND_FEATURE_TOOLS = [
"mcp__features__feature_create",
"mcp__features__feature_create_bulk",
"mcp__features__feature_import_file",
"mcp__features__feature_get_stats",
]

Expand Down
111 changes: 111 additions & 0 deletions test_feature_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Feature Import Tests
====================

Tests for the feature_import_file MCP tool (importing features from a JSON
file inside the project directory).
Run with: python -m pytest test_feature_import.py
"""

import json

import pytest

import mcp_server.feature_mcp as feature_mcp
from api.database import Feature, create_database

SAMPLE_FEATURES = [
{
"category": "style",
"name": "Dark theme",
"description": "App supports a dark theme",
"steps": ["Open settings", "Toggle dark theme", "Verify colors change"],
},
{
"category": "style",
"name": "Theme persistence",
"description": "Selected theme survives a reload",
"steps": ["Select dark theme", "Reload page", "Verify dark theme is active"],
"depends_on_indices": [0],
},
]


@pytest.fixture
def project(tmp_path, monkeypatch):
"""Point the MCP server at a fresh project with an initialized database."""
project_dir = tmp_path.resolve()
engine, session_maker = create_database(project_dir)
monkeypatch.setattr(feature_mcp, "PROJECT_DIR", project_dir)
monkeypatch.setattr(feature_mcp, "_session_maker", session_maker)
yield project_dir, session_maker
engine.dispose()


def _features(session_maker):
session = session_maker()
try:
return session.query(Feature).order_by(Feature.priority).all()
finally:
session.close()


def _import(path):
return json.loads(feature_mcp.feature_import_file(path))


def test_imports_array_with_dependencies(project):
project_dir, session_maker = project
(project_dir / "prompts").mkdir()
(project_dir / "prompts" / "theme_features.json").write_text(json.dumps(SAMPLE_FEATURES), encoding="utf-8")

result = _import("prompts/theme_features.json")

assert result == {"created": 2, "with_dependencies": 1, "file": "prompts/theme_features.json"}
features = _features(session_maker)
assert [f.name for f in features] == ["Dark theme", "Theme persistence"]
assert features[1].dependencies == [features[0].id]
assert not any(f.passes for f in features)


def test_imports_object_with_features_key(project):
project_dir, session_maker = project
(project_dir / "features.json").write_text(json.dumps({"features": SAMPLE_FEATURES[:1]}), encoding="utf-8")

result = _import(str(project_dir / "features.json"))

assert result["created"] == 1
assert len(_features(session_maker)) == 1


def test_rejects_path_outside_project(project, tmp_path_factory):
_, session_maker = project
outside = tmp_path_factory.mktemp("outside") / "features.json"
outside.write_text(json.dumps(SAMPLE_FEATURES), encoding="utf-8")

assert "inside the project" in _import(str(outside))["error"]
assert "inside the project" in _import("../" + outside.parent.name + "/features.json")["error"]
assert _features(session_maker) == []


@pytest.mark.parametrize(
"content, message",
[
("{not json", "not valid JSON"),
("[]", "non-empty JSON array"),
('{"other": []}', "non-empty JSON array"),
('["just a string"]', "not a JSON object"),
('[{"name": "missing fields"}]', "missing required fields"),
],
)
def test_rejects_invalid_content(project, content, message):
project_dir, session_maker = project
(project_dir / "bad.json").write_text(content, encoding="utf-8")

assert message in _import("bad.json")["error"]
assert _features(session_maker) == []


def test_missing_file(project):
assert "File not found" in _import("nope.json")["error"]