From d4a4beaaade36b489aab66b06a607ab526fc17b6 Mon Sep 17 00:00:00 2001 From: mohamed zwanski Date: Thu, 22 Oct 2026 21:18:43 +0100 Subject: [PATCH] Fix #72: Add feature_import_file MCP tool for importing JSON feature files Chat sessions had no way to import an existing JSON feature file, so the model invented a nonexistent CLI command (npx claude-spec feature_import). - Add feature_import_file to the feature MCP server: reads a JSON array (or {"features": [...]}) from inside the project dir and creates the features via feature_create_bulk (path containment + 5MB size limit) - Expose it to the assistant and expand-project chat sessions - Tell both prompts there is no CLI for importing features - Add test_feature_import.py Co-Authored-By: Claude Opus 5.5 --- .claude/commands/expand-project.md | 3 + CLAUDE.md | 1 + mcp_server/feature_mcp.py | 61 ++++++++++++ server/services/assistant_chat_session.py | 6 +- server/services/expand_chat_session.py | 1 + test_feature_import.py | 111 ++++++++++++++++++++++ 6 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 test_feature_import.py diff --git a/.claude/commands/expand-project.md b/.claude/commands/expand-project.md index 731505eb8..595039d2c 100644 --- a/.claude/commands/expand-project.md +++ b/.claude/commands/expand-project.md @@ -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`. --- diff --git a/CLAUDE.md b/CLAUDE.md index 6f59910be..94e489372 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/mcp_server/feature_mcp.py b/mcp_server/feature_mcp.py index 71e217d41..3408df4c1 100755 --- a/mcp_server/feature_mcp.py +++ b/mcp_server/feature_mcp.py @@ -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 @@ -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): @@ -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')")], diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index 1a16606d5..c77d0f2ff 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -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", ] @@ -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 @@ -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:** @@ -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:** diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index 66edb94e1..e4d0630c5 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -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", ] diff --git a/test_feature_import.py b/test_feature_import.py new file mode 100644 index 000000000..f731c8a7a --- /dev/null +++ b/test_feature_import.py @@ -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"]