diff --git a/.gitlab/ci/host-test.yml b/.gitlab/ci/host-test.yml index f2c52f4fb07..16a7b0fa11e 100644 --- a/.gitlab/ci/host-test.yml +++ b/.gitlab/ci/host-test.yml @@ -216,6 +216,7 @@ test_tools: - run_cmd pytest --noconftest test_idf_py.py --junitxml=${IDF_PATH}/XUNIT_IDF_PY.xml --ignore-result-files ${KNOWN_FAILURE_CASES_FILE_NAME} || stat=1 - run_cmd pytest --noconftest test_hints.py --junitxml=${IDF_PATH}/XUNIT_HINTS.xml --ignore-result-files ${KNOWN_FAILURE_CASES_FILE_NAME} || stat=1 - run_cmd pytest --noconftest test_idf_qemu.py --junitxml=${IDF_PATH}/XUNIT_IDF_PY_QEMU.xml --ignore-result-files ${KNOWN_FAILURE_CASES_FILE_NAME} || stat=1 + - run_cmd pytest --noconftest test_mcp_ext.py --junitxml=${IDF_PATH}/XUNIT_MCP_EXT.xml --ignore-result-files ${KNOWN_FAILURE_CASES_FILE_NAME} || stat=1 - cd ${IDF_PATH}/tools/test_bsasm - run_cmd pytest --noconftest test_bsasm.py --junitxml=${IDF_PATH}/XUNIT_BSASM.xml --ignore-result-files ${KNOWN_FAILURE_CASES_FILE_NAME} || stat=1 - cd ${IDF_PATH}/tools/test_mkdfu diff --git a/docs/en/api-guides/tools/idf-py.rst b/docs/en/api-guides/tools/idf-py.rst index 4dda25fcf33..4ea9aafd9fb 100644 --- a/docs/en/api-guides/tools/idf-py.rst +++ b/docs/en/api-guides/tools/idf-py.rst @@ -317,7 +317,7 @@ To use the MCP server with an AI assistant, configure your agent or IDE to start eim run "idf.py mcp-server" -2. Using ``idf.py`` directly: Run the MCP server with ``idf.py mcp-server`` from a shell where the ESP-IDF environment is already activated. The command must be executed from a valid ESP-IDF project directory, or use ``idf.py -C mcp-server`` to specify the project. +2. Using ``idf.py`` directly: Run the MCP server with ``idf.py mcp-server`` from a shell where the ESP-IDF environment is already activated. The server can be started from any directory. Use ``idf.py -C mcp-server`` or set the ``IDF_MCP_WORKSPACE_FOLDER`` environment variable to configure a default project. If no project is configured at startup, pass project directory explicitly in each tool call. .. code-block:: bash @@ -330,12 +330,15 @@ To use the MCP server with an AI assistant, configure your agent or IDE to start Available Tools and Resources ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -The MCP server provides the following commands you can use: +The MCP server provides the following tools: - ``set target``: Set the ESP-IDF target (esp32, esp32s3, esp32c6, etc.) - ``build project``: Build the ESP-IDF project with the current target -- ``flash project``: Flash the built project to a connected device. Specify it by port name. +- ``flash project``: Flash the built project to a connected device. Specify it by port name - ``clean project``: Clean build artifacts +- ``create project``: Create a new ESP-IDF project from the sample template. Can be used before any project exists + +All tools accept an optional ``project_dir`` argument. When omitted, the tool operates on the directory configured at startup (``-C`` flag or ``IDF_MCP_WORKSPACE_FOLDER``). You can instruct the AI model to use a specific project directory explicitly, for example when working with multiple projects or when no default project was configured at startup. The MCP server also provides these resources: diff --git a/docs/zh_CN/api-guides/tools/idf-py.rst b/docs/zh_CN/api-guides/tools/idf-py.rst index 008eeb29d6c..a1fb9b17e6b 100644 --- a/docs/zh_CN/api-guides/tools/idf-py.rst +++ b/docs/zh_CN/api-guides/tools/idf-py.rst @@ -317,7 +317,7 @@ ESP-IDF 的 MCP(Model Context Protocol,模型上下文协议)服务器可 eim run "idf.py mcp-server" -2. 直接使用 ``idf.py``:在已激活 ESP-IDF 环境的 shell 中运行 ``idf.py mcp-server`` 命令启动 MCP 服务器。必须在有效的 ESP-IDF 项目目录中执行该命令,或者使用 ``idf.py -C mcp-server`` 指定项目路径。 +2. 直接使用 ``idf.py``:在已激活 ESP-IDF 环境的 shell 中运行 ``idf.py mcp-server`` 命令启动 MCP 服务器。该服务器可以在任何目录下启动。使用 ``idf.py -C mcp-server`` 或设置 ``IDF_MCP_WORKSPACE_FOLDER`` 环境变量来配置默认项目。如果在启动时未配置任何项目,则在每次调用工具时显式传递项目目录。 .. code-block:: bash @@ -330,12 +330,15 @@ ESP-IDF 的 MCP(Model Context Protocol,模型上下文协议)服务器可 可用工具与资源 ^^^^^^^^^^^^^^ -MCP 服务器提供以下可用的命令: +MCP 服务器提供以下工具: - ``set target``:设置 ESP-IDF 的目标芯片(esp32,esp32s3,esp32c6 等) - ``build project``:使用当前目标构建 ESP-IDF 项目 -- ``flash project``:将已构建的项目烧录到已连接的设备,通过端口名称进行指定。 +- ``flash project``:将已构建的项目烧录到已连接的设备,通过端口名称进行指定 - ``clean project``:清理构建产物 +- ``create project``:基于示例模板创建新的 ESP-IDF 项目,可在尚无项目时使用 + +所有工具都接受可选的 ``project_dir`` 参数。当省略该参数时,工具将默认使用启动时配置的目录(该目录可通过 ``-C`` 参数或 ``IDF_MCP_WORKSPACE_FOLDER`` 环境变量指定)。你可以要求 AI 模型明确指定某个项目目录,例如当同时处理多个项目,或启动时未配置默认项目的情况下。 同时提供以下资源: diff --git a/tools/idf_py_actions/mcp_ext.py b/tools/idf_py_actions/mcp_ext.py index 72bd5c3c1f6..b28d82383c0 100644 --- a/tools/idf_py_actions/mcp_ext.py +++ b/tools/idf_py_actions/mcp_ext.py @@ -32,7 +32,7 @@ except ImportError: MCP_AVAILABLE = False -def is_valid_project_dir(directory: str) -> bool: +def _is_valid_project_dir(directory: str) -> bool: """ Determine if the given directory is a valid ESP-IDF project directory. - Must be a directory. @@ -47,10 +47,16 @@ def is_valid_project_dir(directory: str) -> bool: if not cmakelists_path.is_file(): return False + # Normalised patterns (whitespace removed) for whitespace-insensitive matching. + # CMake treats whitespace inside include(...) as insignificant, so + # `include( $ENV{...} )` must be accepted alongside `include($ENV{...})`. + normalised_patterns = [''.join(p.split()) for p in CMAKE_PROJECT_LINE] + try: with open(str(cmakelists_path), encoding='utf-8') as f: for line in f: - if any(proj_line in line for proj_line in CMAKE_PROJECT_LINE): + line_normalised = ''.join(line.split()) + if any(line_normalised.startswith(pattern) for pattern in normalised_patterns): return True except Exception: return False @@ -58,6 +64,45 @@ def is_valid_project_dir(directory: str) -> bool: return False +def resolve_default_project_dir(launch_dir: str) -> str | None: + """ + Returns the first valid ESP-IDF project directory from the server's launch + context, or None if none is found. + + Priority: IDF_MCP_WORKSPACE_FOLDER env var > launch_dir + + Use this from contexts without an explicit ``project_dir`` argument + """ + for candidate in [os.environ.get('IDF_MCP_WORKSPACE_FOLDER', ''), launch_dir]: + if candidate and _is_valid_project_dir(candidate): + return candidate + return None + + +def resolve_tool_project_dir(explicit_dir: str | None, launch_dir: str) -> tuple[str | None, str | None]: + """ + Resolves the effective project directory for an MCP tool call. + + Returns ``(effective_dir, None)`` on success, or ``(None, error_message)`` + on failure. When ``explicit_dir`` is provided it is validated immediately — + the fallback chain is never tried for an explicit but invalid path. + + Use this from MCP tools that accept a ``project_dir`` argument + """ + if explicit_dir is not None: + if not _is_valid_project_dir(explicit_dir): + return None, f'"{explicit_dir}" is not a valid ESP-IDF project directory.' + return explicit_dir, None + effective = resolve_default_project_dir(launch_dir) + if effective is not None: + return effective, None + return None, ( + 'No valid ESP-IDF project directory found. ' + 'Pass project_dir explicitly, set IDF_MCP_WORKSPACE_FOLDER, ' + 'or restart with: idf.py -C mcp-server' + ) + + def action_extensions(base_actions: dict, project_path: str) -> dict: """ESP-IDF MCP Server Extension""" @@ -72,50 +117,59 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: 'or use "idf.py docs" and search for EIM configuration instructions.' ) - # Verify that mcp-server was executed from a valid ESP-IDF project directory. - # This is necessary to obtain the correct context such as args, project path, etc. - if not is_valid_project_dir(project_path): - current_project = None - for candidate in [os.getcwd(), os.environ.get('IDF_MCP_WORKSPACE_FOLDER', '')]: - if is_valid_project_dir(candidate): - current_project = candidate - break - if not current_project: - raise FatalError('Open the MCP server in a valid ESP-IDF project directory.') + # Resolve the default project directory. Then derive the startup log line, and the + # bound_hint that is appended to every tool's description so the LLM driving + # the MCP client knows when (not) to pass project_dir. + startup_default_dir = resolve_default_project_dir(project_path) + if startup_default_dir is not None: + print(f'INFO: Starting ESP-IDF MCP Server. Default project: {startup_default_dir}', file=sys.stderr) + bound_hint = ( + f"This MCP server was launched with '{startup_default_dir}' as the default ESP-IDF " + 'project. Leave project_dir as None to operate on this project. Only set project_dir ' + '(absolute path to a directory containing a CMakeLists.txt with project()) when the ' + 'user explicitly asks to operate on a different ESP-IDF project.' + ) + else: + print( + 'INFO: Starting ESP-IDF MCP Server. No project directory configured at startup. ' + 'Pass project_dir in each tool call, or set IDF_MCP_WORKSPACE_FOLDER, ' + 'or restart with: idf.py -C mcp-server', + file=sys.stderr, + ) + bound_hint = ( + 'This MCP server was launched without a project context. You MUST pass project_dir ' + '(absolute path to a directory containing a CMakeLists.txt with project()) on every ' + 'call, otherwise the call will fail.' + ) + + # Initialize MCP server — project validity is checked per-tool call + mcp = FastMCP('ESP-IDF') + + # === TOOLS (Actions) === + @mcp.tool(description=f'Build the ESP-IDF project (runs `idf.py build`). {bound_hint}') + def build_project(project_dir: str | None = None) -> str: + """Build the ESP-IDF project. + + Args: + project_dir: Optional absolute path to a valid ESP-IDF project directory. + Leave as None to use the project this MCP server was launched with + (or the IDF_MCP_WORKSPACE_FOLDER environment variable). Set this + only to override that default with another project. + """ + effective_dir, error = resolve_tool_project_dir(project_dir, project_path) + if error: + return error + assert effective_dir is not None # mypy narrowing try: cmd = [ sys.executable, os.path.join(os.environ['IDF_PATH'], 'tools', 'idf.py'), '-C', - current_project, - 'mcp-server', - ] - print( - f'Starting ESP-IDF MCP Server with command: {" ".join(cmd)} in project path: {current_project}', - file=sys.stderr, - ) - subprocess.run(cmd, cwd=current_project, check=True) - return - except Exception as e: - print(f'ERROR: Failed to start ESP-IDF MCP Server: {str(e)}', file=sys.stderr) - raise FatalError(f'Failed to start ESP-IDF MCP Server: {str(e)}') from e - - # Initialize MCP server - mcp = FastMCP('ESP-IDF') - - # === TOOLS (Actions) === - @mcp.tool() - def build_project() -> str: - """Build ESP-IDF project""" - try: - cmd = [ - sys.executable, - os.path.join(os.environ['IDF_PATH'], 'tools', 'idf.py'), + effective_dir, 'build', ] - # Information logs are shown in some mcp clients using stderr - print(f'INFO: Building project with command: {" ".join(cmd)} in path: {project_path}', file=sys.stderr) - result = subprocess.run(cmd, capture_output=True, text=True, cwd=project_path) + print(f'INFO: Building project with command: {" ".join(cmd)} in path: {effective_dir}', file=sys.stderr) + result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0: print('INFO: Build successful', file=sys.stderr) return 'Successfully built project' @@ -126,18 +180,37 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: print(f'ERROR: Build failed: {str(e)}', file=sys.stderr) return f'Build failed: {str(e)}' - @mcp.tool() - def set_target(target: str) -> str: - """Set the ESP-IDF target (esp32, esp32s3, esp32c6, etc.)""" + @mcp.tool( + description=( + 'Set the ESP-IDF target chip (esp32, esp32s3, esp32c6, etc.) for the project ' + f'(runs `idf.py set-target`). {bound_hint}' + ) + ) + def set_target(target: str, project_dir: str | None = None) -> str: + """Set the ESP-IDF target for the project. + + Args: + target: Target chip identifier (e.g. esp32, esp32s3, esp32c6). + project_dir: Optional absolute path to a valid ESP-IDF project directory. + Leave as None to use the project this MCP server was launched with + (or the IDF_MCP_WORKSPACE_FOLDER environment variable). Set this + only to override that default with another project. + """ + effective_dir, error = resolve_tool_project_dir(project_dir, project_path) + if error: + return error + assert effective_dir is not None # mypy narrowing try: cmd = [ sys.executable, os.path.join(os.environ['IDF_PATH'], 'tools', 'idf.py'), + '-C', + effective_dir, 'set-target', target, ] - print(f'INFO: Setting target with command: {" ".join(cmd)} in path: {project_path}', file=sys.stderr) - result = subprocess.run(cmd, capture_output=True, text=True, cwd=project_path) + print(f'INFO: Setting target with command: {" ".join(cmd)} in path: {effective_dir}', file=sys.stderr) + result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0: print(f'INFO: Target set to: {target}', file=sys.stderr) return f'Target set to: {target}' @@ -148,9 +221,24 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: print(f'ERROR: Failed to set target: {str(e)}', file=sys.stderr) return f'Error setting target: {str(e)}' - @mcp.tool() - def flash_project(port: str | None = None) -> str: - """Flash the built project to connected device""" + @mcp.tool( + description=(f'Flash the built ESP-IDF project to a connected device (runs `idf.py flash`). {bound_hint}') + ) + def flash_project(port: str | None = None, project_dir: str | None = None) -> str: + """Flash the built ESP-IDF project to a connected device. + + Args: + port: Optional serial port to flash through (e.g. /dev/ttyUSB0, COM3). + Leave as None to let idf.py auto-detect. + project_dir: Optional absolute path to a valid ESP-IDF project directory. + Leave as None to use the project this MCP server was launched with + (or the IDF_MCP_WORKSPACE_FOLDER environment variable). Set this + only to override that default with another project. + """ + effective_dir, error = resolve_tool_project_dir(project_dir, project_path) + if error: + return error + assert effective_dir is not None # mypy narrowing try: flash_args = [] if port: @@ -160,9 +248,11 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: cmd = [ sys.executable, os.path.join(os.environ['IDF_PATH'], 'tools', 'idf.py'), + '-C', + effective_dir, ] + flash_args - print(f'INFO: Flashing project with command: {" ".join(cmd)} in path: {project_path}', file=sys.stderr) - result = subprocess.run(cmd, capture_output=True, text=True, cwd=project_path) + print(f'INFO: Flashing project with command: {" ".join(cmd)} in path: {effective_dir}', file=sys.stderr) + result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0: print('INFO: Flash successful', file=sys.stderr) @@ -174,17 +264,72 @@ 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() - def clean_project() -> str: - """Clean build artifacts""" + @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. + + Args: + project_dir: Optional absolute path to a valid ESP-IDF project directory. + Leave as None to use the project this MCP server was launched with + (or the IDF_MCP_WORKSPACE_FOLDER environment variable). Set this + only to override that default with another project. + """ + effective_dir, error = resolve_tool_project_dir(project_dir, project_path) + if error: + return error + assert effective_dir is not None # mypy narrowing + try: + cmd = [ + sys.executable, + os.path.join(os.environ['IDF_PATH'], 'tools', 'idf.py'), + '-C', + effective_dir, 'clean', ] - print(f'INFO: Cleaning project with command: {" ".join(cmd)} in path: {project_path}', file=sys.stderr) - result = subprocess.run(cmd, capture_output=True, text=True, cwd=project_path) + print(f'INFO: Cleaning project with command: {" ".join(cmd)} in path: {effective_dir}', file=sys.stderr) + result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0: print('INFO: Project cleaned successfully', file=sys.stderr) return 'Project cleaned successfully' @@ -199,15 +344,28 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: @mcp.resource('project://config') def get_project_config() -> str: """Get current project configuration""" - build_dir = args.get('build_dir', '') + effective_dir = resolve_default_project_dir(project_path) config: dict[str, Any] = {} + if effective_dir is None: + config['error'] = ( + 'No valid ESP-IDF project directory found. ' + 'Set IDF_MCP_WORKSPACE_FOLDER or restart with: idf.py -C mcp-server' + ) + return json.dumps(config, indent=2) + config['project_path'] = effective_dir + + # Use the build_dir from idf.py args when the project matches; otherwise derive it. + build_dir = ( + args.get('build_dir', '') if project_path == effective_dir else os.path.join(effective_dir, 'build') + ) + if not os.path.exists(build_dir): config['build_dir_exists'] = False return json.dumps(config, indent=2) config['build_dir'] = build_dir - proj_desc_fn = f'{build_dir}/project_description.json' + proj_desc_fn = os.path.join(build_dir, 'project_description.json') config['project_description'] = 'Project description does not exist' try: @@ -221,15 +379,26 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: @mcp.resource('project://status') def get_project_status() -> str: """Get current project build status""" + status: dict[str, Any] = {} try: - status = { - 'project_path': project_path, - 'target': get_target(project_path), - 'idf_version': idf_version(), - } + effective_dir = resolve_default_project_dir(project_path) - # Check if built - build_dir = args.build_dir + if effective_dir is None: + status['error'] = ( + 'No valid ESP-IDF project directory found. ' + 'Set IDF_MCP_WORKSPACE_FOLDER or restart with: idf.py -C mcp-server' + ) + status['idf_version'] = idf_version() + return json.dumps(status, indent=2) + + status['project_path'] = effective_dir + status['target'] = get_target(effective_dir) + status['idf_version'] = idf_version() + + # Use the build_dir from idf.py args when the project matches; otherwise derive it. + build_dir = ( + args.get('build_dir', '') if project_path == effective_dir else os.path.join(effective_dir, 'build') + ) if os.path.exists(build_dir): status['build_dir'] = build_dir artifacts = ['bootloader', 'partition_table', 'app-flash', 'flash_args'] @@ -242,7 +411,8 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: return json.dumps(status, indent=2) except Exception as e: - return f'Error getting status: {str(e)}' + status['error'] = f'Error getting status: {str(e)}' + return json.dumps(status, indent=2) @mcp.resource('project://devices') def get_connected_devices() -> str: diff --git a/tools/test_idf_py/test_mcp_ext.py b/tools/test_idf_py/test_mcp_ext.py new file mode 100644 index 00000000000..0e5e0ac6fb9 --- /dev/null +++ b/tools/test_idf_py/test_mcp_ext.py @@ -0,0 +1,591 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for idf_py_actions/mcp_ext.py. + +No full IDF environment is required — all external imports and subprocess +calls are mocked. +""" + +import importlib +import importlib.util +import json +import os +import sys +import types +from collections.abc import Callable +from pathlib import Path +from typing import Any +from unittest import mock + +import pytest + +# --------------------------------------------------------------------------- +# Helpers for creating fake project directories +# --------------------------------------------------------------------------- + +IDF_CMAKE_LINE = r'include($ENV{IDF_PATH}/tools/cmake/project.cmake)' + + +def _make_valid_project(path: Path) -> Path: + """Write a minimal valid ESP-IDF CMakeLists.txt into *path*.""" + path.mkdir(parents=True, exist_ok=True) + (path / 'CMakeLists.txt').write_text( + f'cmake_minimum_required(VERSION 3.16)\n{IDF_CMAKE_LINE}\nproject(hello_world)\n', + encoding='utf-8', + ) + return path + + +def _make_invalid_project(path: Path) -> Path: + """Write a CMakeLists.txt that does NOT include the IDF line.""" + path.mkdir(parents=True, exist_ok=True) + (path / 'CMakeLists.txt').write_text( + 'cmake_minimum_required(VERSION 3.16)\nproject(plain_cmake)\n', + encoding='utf-8', + ) + return path + + +# --------------------------------------------------------------------------- +# Fixture: stub out all non-stdlib imports so the module can be loaded +# without an IDF installation. +# --------------------------------------------------------------------------- + + +class _MockFastMCP: + """Captures tool/resource registrations so tests can invoke them.""" + + def __init__(self, name: str) -> None: + self.name = name + self.tools: dict[str, Callable[..., Any]] = {} + self.resources: dict[str, Callable[..., Any]] = {} + + def tool(self, **kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: + self.tools[fn.__name__] = fn + return fn + + return decorator + + def resource(self, uri: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: + self.resources[uri] = fn + return fn + + return decorator + + def run(self) -> None: + pass # don't block in tests + + +@pytest.fixture() +def mcp_ext(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> tuple[types.ModuleType, _MockFastMCP]: + """ + Import (or reimport) mcp_ext with all external dependencies mocked. + Returns the module object plus a _MockFastMCP instance that was used so + that tests can inspect registered tools/resources. + """ + # Build a fresh _MockFastMCP for this test + mock_mcp_instance = _MockFastMCP('ESP-IDF') + + # Stub rich_click + rich_click = types.ModuleType('rich_click') + rich_click.Context = object # type: ignore[attr-defined] + + # Stub idf_py_actions hierarchy + idf_py_actions_pkg = types.ModuleType('idf_py_actions') + errors_mod = types.ModuleType('idf_py_actions.errors') + + class FatalError(Exception): + pass + + errors_mod.FatalError = FatalError # type: ignore[attr-defined] + + tools_mod = types.ModuleType('idf_py_actions.tools') + tools_mod.PropertyDict = dict # type: ignore[attr-defined] + tools_mod.get_target = mock.Mock(return_value='esp32') # type: ignore[attr-defined] + tools_mod.idf_version = mock.Mock(return_value='5.4.0') # type: ignore[attr-defined] + + idf_py_actions_pkg.errors = errors_mod # type: ignore[attr-defined] + idf_py_actions_pkg.tools = tools_mod # type: ignore[attr-defined] + + # Stub mcp.server.fastmcp — FastMCP constructor returns our mock + mcp_pkg = types.ModuleType('mcp') + mcp_server_pkg = types.ModuleType('mcp.server') + fastmcp_mod = types.ModuleType('mcp.server.fastmcp') + fastmcp_mod.FastMCP = lambda name: mock_mcp_instance # type: ignore[attr-defined] + + stubs = { + 'rich_click': rich_click, + 'idf_py_actions': idf_py_actions_pkg, + 'idf_py_actions.errors': errors_mod, + 'idf_py_actions.tools': tools_mod, + 'mcp': mcp_pkg, + 'mcp.server': mcp_server_pkg, + 'mcp.server.fastmcp': fastmcp_mod, + } + for name, stub_mod in stubs.items(): + monkeypatch.setitem(sys.modules, name, stub_mod) + + # Load mcp_ext directly from its file so that the stub 'idf_py_actions' + # package (which is not a real package) doesn't prevent import. + mcp_ext_path = Path(__file__).parent.parent / 'idf_py_actions' / 'mcp_ext.py' + spec = importlib.util.spec_from_file_location('idf_py_actions.mcp_ext', mcp_ext_path) + mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] + # Register under both names so cross-references inside the module work + monkeypatch.setitem(sys.modules, 'idf_py_actions.mcp_ext', mod) + spec.loader.exec_module(mod) # type: ignore[union-attr] + + return mod, mock_mcp_instance + + +# --------------------------------------------------------------------------- +# Helper: call start_mcp_server and return registered tools/resources +# --------------------------------------------------------------------------- + + +def _start_server( + mcp_ext_module: tuple[types.ModuleType, _MockFastMCP], + mock_mcp_instance: _MockFastMCP, + project_path: str, +) -> tuple[dict[str, Callable[..., Any]], dict[str, Callable[..., Any]]]: + """ + Call action_extensions / start_mcp_server with *project_path* so that + tools and resources are registered on *mock_mcp_instance*. + """ + mod, _ = mcp_ext_module + ext = mod.action_extensions({}, project_path) + callback = ext['actions']['mcp-server']['callback'] + # ctx and args are only used inside resources; use simple stubs + fake_args = {'build_dir': os.path.join(project_path, 'build')} + callback('mcp-server', ctx=None, args=fake_args) + return mock_mcp_instance.tools, mock_mcp_instance.resources + + +# --------------------------------------------------------------------------- +# Tests: _is_valid_project_dir +# --------------------------------------------------------------------------- + + +class TestIsValidProjectDir: + def test_valid_project(self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP]) -> None: + mod, _ = mcp_ext + proj = _make_valid_project(tmp_path / 'my_proj') + assert mod._is_valid_project_dir(str(proj)) is True + + def test_missing_directory(self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP]) -> None: + mod, _ = mcp_ext + assert mod._is_valid_project_dir(str(tmp_path / 'nonexistent')) is False + + def test_directory_without_cmakelists(self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP]) -> None: + mod, _ = mcp_ext + d = tmp_path / 'no_cmake' + d.mkdir() + assert mod._is_valid_project_dir(str(d)) is False + + def test_cmakelists_without_idf_line(self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP]) -> None: + mod, _ = mcp_ext + proj = _make_invalid_project(tmp_path / 'plain') + assert mod._is_valid_project_dir(str(proj)) is False + + def test_cmakelists_with_spaces_in_include( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP] + ) -> None: + mod, _ = mcp_ext + path = tmp_path / 'spaced' + path.mkdir() + (path / 'CMakeLists.txt').write_text( + 'cmake_minimum_required(VERSION 3.16)\n' + 'include( $ENV{IDF_PATH}/tools/cmake/project.cmake )\n' + 'project(hello_world)\n', + encoding='utf-8', + ) + assert mod._is_valid_project_dir(str(path)) is True + + def test_commented_out_include_is_rejected( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP] + ) -> None: + mod, _ = mcp_ext + path = tmp_path / 'commented' + path.mkdir() + (path / 'CMakeLists.txt').write_text( + 'cmake_minimum_required(VERSION 3.16)\n' + '# include($ENV{IDF_PATH}/tools/cmake/project.cmake)\n' + 'project(hello_world)\n', + encoding='utf-8', + ) + assert mod._is_valid_project_dir(str(path)) is False + + def test_empty_string(self, mcp_ext: tuple[types.ModuleType, _MockFastMCP]) -> None: + mod, _ = mcp_ext + assert mod._is_valid_project_dir('') is False + + +# --------------------------------------------------------------------------- +# Tests: resolve_default_project_dir +# --------------------------------------------------------------------------- + + +class TestResolveDefaultProjectDir: + def test_env_var_takes_priority_over_default( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, _ = mcp_ext + env_proj = _make_valid_project(tmp_path / 'env_proj') + default = _make_valid_project(tmp_path / 'default') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', str(env_proj)) + result = mod.resolve_default_project_dir(str(default)) + assert result == str(env_proj) + + def test_default_used_when_env_not_set( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, _ = mcp_ext + default = _make_valid_project(tmp_path / 'default') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + result = mod.resolve_default_project_dir(str(default)) + assert result == str(default) + + def test_returns_none_when_nothing_valid( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, _ = mcp_ext + invalid = _make_invalid_project(tmp_path / 'bad') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + result = mod.resolve_default_project_dir(str(invalid)) + assert result is None + + +# --------------------------------------------------------------------------- +# Tests: tools +# --------------------------------------------------------------------------- + + +class TestBuildProject: + def test_returns_error_when_no_valid_dir( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + invalid = _make_invalid_project(tmp_path / 'bad') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + tools, _ = _start_server(mcp_ext, mock_mcp, str(invalid)) + result = tools['build_project'](project_dir=None) + assert 'No valid ESP-IDF project directory found' in result + + def test_explicit_invalid_dir_returns_error_not_fallback( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + bad = _make_invalid_project(tmp_path / 'bad') + good_env = _make_valid_project(tmp_path / 'good_env') + # Even though IDF_MCP_WORKSPACE_FOLDER is a valid project, passing an + # explicit but invalid project_dir must return an error, not silently + # fall through to the env var project. + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', str(good_env)) + tools, _ = _start_server(mcp_ext, mock_mcp, str(bad)) + result = tools['build_project'](project_dir=str(bad)) + assert str(bad) in result + assert 'not a valid' in result + + def test_explicit_dir_builds_in_correct_location( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + proj = _make_valid_project(tmp_path / 'proj') + invalid = _make_invalid_project(tmp_path / 'bad') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + monkeypatch.setenv('IDF_PATH', str(tmp_path)) + tools, _ = _start_server(mcp_ext, mock_mcp, str(invalid)) + + with mock.patch('subprocess.run') as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stderr='') + result = tools['build_project'](project_dir=str(proj)) + + assert result == 'Successfully built project' + call_args = mock_run.call_args + cmd = call_args[0][0] + assert '-C' in cmd + assert str(proj) in cmd + assert cmd[cmd.index('-C') + 1] == str(proj) + # cwd is intentionally not passed — -C is authoritative for idf.py + assert call_args[1].get('cwd') is None + + def test_build_failure_returns_error( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + proj = _make_valid_project(tmp_path / 'proj') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + monkeypatch.setenv('IDF_PATH', str(tmp_path)) + tools, _ = _start_server(mcp_ext, mock_mcp, str(proj)) + + with mock.patch('subprocess.run') as mock_run: + mock_run.return_value = mock.Mock(returncode=1, stderr='cmake error') + result = tools['build_project']() + + assert 'Build failed' in result + assert 'cmake error' in result + + +class TestSetTarget: + def test_returns_error_when_no_valid_dir( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + invalid = _make_invalid_project(tmp_path / 'bad') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + tools, _ = _start_server(mcp_ext, mock_mcp, str(invalid)) + result = tools['set_target']('esp32c6') + assert 'No valid ESP-IDF project directory found' in result + + def test_explicit_dir_sets_target( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + proj = _make_valid_project(tmp_path / 'proj') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + monkeypatch.setenv('IDF_PATH', str(tmp_path)) + tools, _ = _start_server(mcp_ext, mock_mcp, str(proj)) + + with mock.patch('subprocess.run') as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stderr='') + result = tools['set_target']('esp32s3', project_dir=str(proj)) + + assert result == 'Target set to: esp32s3' + cmd = mock_run.call_args[0][0] + assert 'set-target' in cmd + assert 'esp32s3' in cmd + assert '-C' in cmd + assert cmd[cmd.index('-C') + 1] == str(proj) + + +class TestFlashProject: + def test_returns_error_when_no_valid_dir( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + invalid = _make_invalid_project(tmp_path / 'bad') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + tools, _ = _start_server(mcp_ext, mock_mcp, str(invalid)) + result = tools['flash_project']() + assert 'No valid ESP-IDF project directory found' in result + + def test_port_and_dir_forwarded( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + proj = _make_valid_project(tmp_path / 'proj') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + monkeypatch.setenv('IDF_PATH', str(tmp_path)) + tools, _ = _start_server(mcp_ext, mock_mcp, str(proj)) + + with mock.patch('subprocess.run') as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stderr='') + result = tools['flash_project'](port='/dev/ttyUSB0', project_dir=str(proj)) + + assert 'Successfully flashed' in result + assert '/dev/ttyUSB0' in result + cmd = mock_run.call_args[0][0] + assert '-p' in cmd + assert '/dev/ttyUSB0' in cmd + assert '-C' in cmd + 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 + ) -> None: + mod, mock_mcp = mcp_ext + invalid = _make_invalid_project(tmp_path / 'bad') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + tools, _ = _start_server(mcp_ext, mock_mcp, str(invalid)) + result = tools['clean_project']() + assert 'No valid ESP-IDF project directory found' in result + + def test_explicit_dir_cleans( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + proj = _make_valid_project(tmp_path / 'proj') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + monkeypatch.setenv('IDF_PATH', str(tmp_path)) + tools, _ = _start_server(mcp_ext, mock_mcp, str(proj)) + + with mock.patch('subprocess.run') as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stderr='') + result = tools['clean_project'](project_dir=str(proj)) + + assert result == 'Project cleaned successfully' + cmd = mock_run.call_args[0][0] + assert 'clean' in cmd + assert '-C' in cmd + assert cmd[cmd.index('-C') + 1] == str(proj) + + +# --------------------------------------------------------------------------- +# Test: server starts without error when project_path is not valid +# --------------------------------------------------------------------------- + + +class TestServerStartsOutsideProject: + def test_no_fatal_error_when_project_path_invalid( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + """start_mcp_server must not raise when project_path is not a valid project.""" + mod, mock_mcp = mcp_ext + invalid = _make_invalid_project(tmp_path / 'not_a_project') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + + ext = mod.action_extensions({}, str(invalid)) + callback = ext['actions']['mcp-server']['callback'] + fake_args = {'build_dir': str(invalid / 'build')} + + # Should not raise + callback('mcp-server', ctx=None, args=fake_args) + + # FastMCP was still initialised + assert mock_mcp.name == 'ESP-IDF' + + def test_tools_registered_even_when_project_path_invalid( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + invalid = _make_invalid_project(tmp_path / 'not_a_project') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + + tools, _ = _start_server(mcp_ext, mock_mcp, str(invalid)) + assert 'build_project' in tools + assert 'set_target' in tools + assert 'flash_project' in tools + assert 'clean_project' in tools + + +# --------------------------------------------------------------------------- +# Tests: resources use resolve_default_project_dir +# --------------------------------------------------------------------------- + + +class TestGetProjectStatus: + def test_returns_error_json_when_no_valid_dir( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + invalid = _make_invalid_project(tmp_path / 'bad') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + _, resources = _start_server(mcp_ext, mock_mcp, str(invalid)) + + result = json.loads(resources['project://status']()) + assert 'error' in result + assert 'idf_version' in result + + def test_returns_status_when_valid_project( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + proj = _make_valid_project(tmp_path / 'proj') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + _, resources = _start_server(mcp_ext, mock_mcp, str(proj)) + + result = json.loads(resources['project://status']()) + assert result['project_path'] == str(proj) + assert result['target'] == 'esp32' + assert 'error' not in result + + def test_uses_env_var_when_project_path_invalid( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + invalid = _make_invalid_project(tmp_path / 'bad') + env_proj = _make_valid_project(tmp_path / 'env_proj') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', str(env_proj)) + _, resources = _start_server(mcp_ext, mock_mcp, str(invalid)) + + result = json.loads(resources['project://status']()) + assert result['project_path'] == str(env_proj) + assert 'error' not in result + + +class TestGetProjectConfig: + def test_returns_error_json_when_no_valid_dir( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + invalid = _make_invalid_project(tmp_path / 'bad') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + _, resources = _start_server(mcp_ext, mock_mcp, str(invalid)) + + result = json.loads(resources['project://config']()) + assert 'error' in result + + def test_returns_no_build_dir_when_build_missing( + self, tmp_path: Path, mcp_ext: tuple[types.ModuleType, _MockFastMCP], monkeypatch: pytest.MonkeyPatch + ) -> None: + mod, mock_mcp = mcp_ext + proj = _make_valid_project(tmp_path / 'proj') + monkeypatch.setenv('IDF_MCP_WORKSPACE_FOLDER', '') + _, resources = _start_server(mcp_ext, mock_mcp, str(proj)) + + # build/ does not exist → build_dir_exists: False + result = json.loads(resources['project://config']()) + assert result.get('build_dir_exists') is False