diff --git a/tools/idf_py_actions/mcp_ext.py b/tools/idf_py_actions/mcp_ext.py index 08550402ac5..5a7432bcc4d 100644 --- a/tools/idf_py_actions/mcp_ext.py +++ b/tools/idf_py_actions/mcp_ext.py @@ -258,6 +258,48 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: print(f'ERROR: Flash failed: {str(e)}', file=sys.stderr) return f'Error flashing: {str(e)}' + @mcp.tool( + description=( + 'Create a new ESP-IDF project from the sample template (runs `idf.py create-project`). ' + 'A directory named is created inside . Use this only when the user asks ' + 'to bootstrap a new project; do not use it on an existing project.' + ) + ) + def create_project(name: str, path: str | None = None) -> str: + """Create a new ESP-IDF project from the sample template. + + Args: + name: Name of the new project; also becomes the subdirectory name. + path: Optional absolute path to the parent directory in which the + / subdirectory will be created. Leave as None to create + it in the directory the MCP server was launched from. + """ + parent_dir = path or project_path or os.getcwd() + if not os.path.isdir(parent_dir): + return f'Parent directory does not exist: {parent_dir}' + try: + cmd = [ + sys.executable, + os.path.join(os.environ['IDF_PATH'], 'tools', 'idf.py'), + '-C', + parent_dir, + 'create-project', + name, + ] + print(f'INFO: Creating project "{name}" in {parent_dir}', file=sys.stderr) + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode == 0: + project_path_new = os.path.join(parent_dir, name) + print(f'INFO: Project "{name}" created at {project_path_new}', file=sys.stderr) + return f'Project "{name}" created at {project_path_new}' + else: + output = result.stderr or result.stdout + print(f'ERROR: Failed to create project: {output}', file=sys.stderr) + return f'Failed to create project "{name}": {output}' + except Exception as e: + print(f'ERROR: Failed to create project: {str(e)}', file=sys.stderr) + return f'Failed to create project "{name}": {str(e)}' + @mcp.tool(description=f'Remove build artifacts from the ESP-IDF project (runs `idf.py clean`). {bound_hint}') def clean_project(project_dir: str | None = None) -> str: """Remove build artifacts from the ESP-IDF project. diff --git a/tools/test_idf_py/test_mcp_ext.py b/tools/test_idf_py/test_mcp_ext.py index d891d9ebaac..d5dea6c5d56 100644 --- a/tools/test_idf_py/test_mcp_ext.py +++ b/tools/test_idf_py/test_mcp_ext.py @@ -365,6 +365,65 @@ class TestFlashProject: assert cmd[cmd.index('-C') + 1] == str(proj) +class TestCreateProject: + def test_creates_project_with_explicit_path( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + monkeypatch.setenv('IDF_PATH', str(tmp_path)) + # Start from a non-project directory — that is the whole point of this tool + tools, _ = _start_server(mcp_ext, mock_mcp, str(tmp_path)) + + with mock.patch('subprocess.run') as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout='', stderr='') + result = tools['create_project']('my_app', path=str(tmp_path)) + + assert 'my_app' in result + assert str(tmp_path) in result + cmd = mock_run.call_args[0][0] + assert 'create-project' in cmd + assert 'my_app' in cmd + assert '-C' in cmd + assert cmd[cmd.index('-C') + 1] == str(tmp_path) + + def test_uses_project_path_when_no_path_given( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + monkeypatch.setenv('IDF_PATH', str(tmp_path)) + tools, _ = _start_server(mcp_ext, mock_mcp, str(tmp_path)) + + with mock.patch('subprocess.run') as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout='', stderr='') + tools['create_project']('my_app') + + cmd = mock_run.call_args[0][0] + assert cmd[cmd.index('-C') + 1] == str(tmp_path) + + def test_returns_error_when_parent_dir_missing( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + tools, _ = _start_server(mcp_ext, mock_mcp, str(tmp_path)) + + result = tools['create_project']('my_app', path=str(tmp_path / 'nonexistent')) + assert 'does not exist' in result + + def test_returns_error_on_idf_failure( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + monkeypatch.setenv('IDF_PATH', str(tmp_path)) + tools, _ = _start_server(mcp_ext, mock_mcp, str(tmp_path)) + + with mock.patch('subprocess.run') as mock_run: + mock_run.return_value = mock.Mock(returncode=3, stdout='', stderr='directory not empty') + result = tools['create_project']('my_app', path=str(tmp_path)) + + assert 'Failed to create project' in result + assert 'directory not empty' in result + + class TestCleanProject: def test_returns_error_when_no_valid_dir( self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch