From be74313d0b96efda6a4454d92c21805b22ac8f8d Mon Sep 17 00:00:00 2001 From: Marek Fiala Date: Wed, 19 Aug 2026 08:56:03 +0200 Subject: [PATCH 1/2] fix(tools): detach MCP tool subprocess stdin from JSON-RPC pipe idf.py mcp-server tool handlers were spawning idf.py without redirecting stdin, so the child inherited the long-lived MCP JSON-RPC transport and could hang indefinitely on tools/call. Pass stdin=subprocess.DEVNULL on every spawn of idf.py. Closes https://github.com/espressif/esp-idf/issues/18961 --- tools/idf_py_actions/mcp_ext.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tools/idf_py_actions/mcp_ext.py b/tools/idf_py_actions/mcp_ext.py index 02df06b4c1e..51e9e307943 100644 --- a/tools/idf_py_actions/mcp_ext.py +++ b/tools/idf_py_actions/mcp_ext.py @@ -153,6 +153,9 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: mcp = _MCPServer('ESP-IDF') # === TOOLS (Actions) === + # Tool handlers spawn idf.py with stdin=subprocess.DEVNULL. The MCP server's + # stdin is the long-lived JSON-RPC transport pipe; a child that inherits it + # can hang instead of exiting. @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. @@ -176,7 +179,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: 'build', ] 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) + result = subprocess.run(cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL) if result.returncode == 0: print('INFO: Build successful', file=sys.stderr) return 'Successfully built project' @@ -217,7 +220,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: target, ] 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) + result = subprocess.run(cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL) if result.returncode == 0: print(f'INFO: Target set to: {target}', file=sys.stderr) return f'Target set to: {target}' @@ -259,7 +262,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: effective_dir, ] + flash_args 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) + result = subprocess.run(cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL) if result.returncode == 0: print('INFO: Flash successful', file=sys.stderr) @@ -300,7 +303,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: name, ] print(f'INFO: Creating project "{name}" in {parent_dir}', file=sys.stderr) - result = subprocess.run(cmd, capture_output=True, text=True) + result = subprocess.run(cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL) 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) @@ -336,7 +339,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: 'clean', ] 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) + result = subprocess.run(cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL) if result.returncode == 0: print('INFO: Project cleaned successfully', file=sys.stderr) return 'Project cleaned successfully' From 7613daadc65c7f64ba6d70a4e333c5d98c0253b4 Mon Sep 17 00:00:00 2001 From: Marek Fiala Date: Wed, 19 Aug 2026 08:56:11 +0200 Subject: [PATCH 2/2] fix(tools): send mcp-server startup diagnostics to stderr idf.py mcp-server printed startup, shutdown, and error messages on stdout. On the stdio MCP transport, stdout is the JSON-RPC channel, so those non-JSON lines can confuse or break strict clients. Route them to stderr like the other diagnostics in mcp_ext.py. Co-authored-by: Cursor --- tools/idf_py_actions/mcp_ext.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tools/idf_py_actions/mcp_ext.py b/tools/idf_py_actions/mcp_ext.py index 51e9e307943..16588f2e16f 100644 --- a/tools/idf_py_actions/mcp_ext.py +++ b/tools/idf_py_actions/mcp_ext.py @@ -437,15 +437,16 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: except Exception as e: return f'Error getting devices: {str(e)}' - # Start the MCP server - print('MCP Server running on stdio...') + # Start the MCP server. Diagnostics should go to stderr — stdout is reserved + # for the JSON-RPC transport and any non-JSON bytes can confuse MCP clients. + print('MCP Server running on stdio...', file=sys.stderr) try: mcp.run() except KeyboardInterrupt: - print('\nMCP Server stopped.') + print('\nMCP Server stopped.', file=sys.stderr) except Exception as e: - print(f'MCP Server error: {e}') + print(f'MCP Server error: {e}', file=sys.stderr) # Return the action extension return {