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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ This project is currently a **Proof of Concept (PoC)** for an MCP server tailore
**Current Capabilities:**
* Supports basic ESP-IDF project build commands.
* Flash built firmware to connected ESP devices with optional port specification.
* Clean build artifacts (clean or fullclean).
* Analyze firmware size and per-component breakdown.
* Erase device flash memory.
* Monitor serial output from connected devices.
* Get project and app information.
* Includes experimental support for automatic issue fixing based on build logs.

**Vision & Future Work:**
Expand Down Expand Up @@ -77,9 +82,29 @@ Once the `esp-mcp` server is configured and running, your LLM or chatbot can int
* "Build the project located at `/path/to/my/esp-project` using the `esp-mcp`."
* "Clean the build files for the ESP32 project in the `examples/hello_world` directory."
* "Flash the firmware to my connected ESP32 device for the project in `my_app`."
* "Clean the build artifacts for the project at `/path/to/project`."
* "Show me the size breakdown of my ESP32 firmware."
* "Erase the flash on my connected ESP32."
* "Monitor the serial output from my ESP32 for 30 seconds."

The MCP server will then execute the corresponding ESP-IDF commands (like `idf.py build`, `idf.py fullclean`, `idf.py flash`) based on the tools implemented in `main.py`.

### Available Tools

| Tool | Description | ESP-IDF Command |
|------|-------------|-----------------|
| `build_esp_related_project` | Build an ESP-IDF project | `idf.py build` |
| `flash_esp_project` | Flash firmware to device | `idf.py flash` |
| `clean_esp_project` | Clean build artifacts | `idf.py clean` / `fullclean` |
| `get_esp_project_size` | Analyze firmware size | `idf.py size` |
| `get_esp_component_size` | Per-component size breakdown | `idf.py size-components` |
| `erase_esp_flash` | Erase device flash memory | `idf.py erase-flash` |
| `monitor_esp_device` | Capture serial output | `idf.py monitor` |
| `setup_project_esp_target` | Set target chip | `idf.py set-target` |
| `create_esp_project` | Create new project | `idf.py create-project` |
| `list_esp_serial_ports` | List available ports | `python -m serial.tools.list_ports` |
| `get_esp_app_info` | Get app/project info | Reads `project_description.json` |

The `result.gif` below shows an example interaction:

![Result](./result.gif)
Expand Down
155 changes: 155 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,5 +113,160 @@ async def list_esp_serial_ports() -> (str, str):

return stdout, stderr


@mcp.tool()
async def clean_esp_project(project_path: str, full_clean: bool = False) -> (str, str):
"""Clean build artifacts from an ESP-IDF project.

Args:
project_path: Path to the ESP-IDF project
full_clean: If True, performs fullclean (removes build dir entirely).
If False, performs regular clean.

Returns:
tuple: (stdout, stderr) - Clean operation logs
"""
os.chdir(project_path)
export_script = get_export_script()

clean_cmd = "fullclean" if full_clean else "clean"
returncode, stdout, stderr = await run_command_async(
f"bash -c 'source {export_script} && idf.py {clean_cmd}'"
)

open('mcp-clean.log', 'w+').write(str((stdout, stderr)))
logging.warning(f"clean result {stdout} {stderr}")
return stdout, stderr


@mcp.tool()
async def get_esp_project_size(project_path: str) -> (str, str):
"""Analyze the size of a built ESP-IDF project firmware.

Args:
project_path: Path to the ESP-IDF project (must be built first)

Returns:
tuple: (stdout, stderr) - Size analysis output showing RAM/Flash usage
"""
os.chdir(project_path)
export_script = get_export_script()

returncode, stdout, stderr = await run_command_async(
f"bash -c 'source {export_script} && idf.py size'"
)

open('mcp-size.log', 'w+').write(str((stdout, stderr)))
logging.warning(f"size result {stdout} {stderr}")
return stdout, stderr


@mcp.tool()
async def get_esp_component_size(project_path: str) -> (str, str):
"""Get detailed per-component size breakdown of an ESP-IDF project.

Args:
project_path: Path to the ESP-IDF project (must be built first)

Returns:
tuple: (stdout, stderr) - Detailed component size breakdown
"""
os.chdir(project_path)
export_script = get_export_script()

returncode, stdout, stderr = await run_command_async(
f"bash -c 'source {export_script} && idf.py size-components'"
)

open('mcp-size-components.log', 'w+').write(str((stdout, stderr)))
logging.warning(f"size-components result {stdout} {stderr}")
return stdout, stderr


@mcp.tool()
async def erase_esp_flash(project_path: str, port: str = None) -> (str, str):
"""Erase the entire flash memory of a connected ESP device.

WARNING: This will erase all data on the device including firmware,
NVS storage, and any other flash contents.

Args:
project_path: Path to any ESP-IDF project (used for idf.py context)
port: Serial port for the ESP device (optional, auto-detect if not provided)

Returns:
tuple: (stdout, stderr) - Erase operation logs
"""
os.chdir(project_path)
export_script = get_export_script()

if port:
erase_cmd = f"bash -c 'source {export_script} && idf.py -p {port} erase-flash'"
else:
erase_cmd = f"bash -c 'source {export_script} && idf.py erase-flash'"

returncode, stdout, stderr = await run_command_async(erase_cmd)

open('mcp-erase.log', 'w+').write(str((stdout, stderr)))
logging.warning(f"erase-flash result {stdout} {stderr}")
return stdout, stderr
Comment on lines +186 to +212

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Command injection risk via unsanitized port parameter.

The port parameter is directly interpolated into the shell command without validation. A malicious or malformed port value like "/dev/tty; rm -rf /" could execute arbitrary shell commands.

This issue also exists in the pre-existing flash_esp_project function (line 87), but new code should not propagate the vulnerability.

🛡️ Proposed fix: validate port parameter

Add validation at the start of the function (and consider extracting to a helper for reuse):

+import re
+
+def _validate_port(port: str | None) -> None:
+    """Validate serial port to prevent command injection."""
+    if port is not None and not re.match(r'^[/\w.-]+$', port):
+        raise ValueError(f"Invalid port format: {port}")
+

 `@mcp.tool`()
-async def erase_esp_flash(project_path: str, port: str = None) -> (str, str):
+async def erase_esp_flash(project_path: str, port: str | None = None) -> (str, str):
     """Erase the entire flash memory of a connected ESP device.
     ...
     """
+    _validate_port(port)
     os.chdir(project_path)

Additionally, static analysis correctly notes that port: str = None should be port: str | None = None per PEP 484.

🧰 Tools
🪛 Ruff (0.14.14)

[warning] 187-187: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)


[warning] 208-208: Unpacked variable returncode is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)

🤖 Prompt for AI Agents
In `@main.py` around lines 186 - 212, The erase_esp_flash function currently
interpolates the port directly into a shell string (erase_cmd) causing command
injection; update erase_esp_flash (and similarly flash_esp_project) to
validate/sanitize the port and avoid using a shell string: either build the
command as a list and call run_command_async without shell=True or safely quote
the port using shlex.quote before insertion; add a small validator helper (e.g.,
is_valid_serial_port or sanitize_port) to allow only expected
characters/patterns (e.g., /^\/dev\/\w+$/ or COM port patterns) and reject/raise
on invalid input; also change the annotation from port: str = None to port: str
| None = None (or Optional[str]) to match PEP 484.



@mcp.tool()
async def monitor_esp_device(project_path: str, port: str = None, timeout_seconds: int = 30) -> (str, str):
"""Monitor serial output from a connected ESP device.

Note: This captures output for a limited time since MCP tools can't run indefinitely.
For interactive monitoring, use idf.py monitor directly in a terminal.

Args:
project_path: Path to the ESP-IDF project
port: Serial port for the ESP device (optional, auto-detect if not provided)
timeout_seconds: How long to capture output (default: 30 seconds, max: 120)

Returns:
tuple: (stdout, stderr) - Captured serial output
"""
os.chdir(project_path)
export_script = get_export_script()

# Cap timeout to prevent runaway processes
timeout_seconds = min(timeout_seconds, 120)

if port:
monitor_cmd = f"bash -c 'source {export_script} && timeout {timeout_seconds} idf.py -p {port} monitor || true'"
else:
monitor_cmd = f"bash -c 'source {export_script} && timeout {timeout_seconds} idf.py monitor || true'"

returncode, stdout, stderr = await run_command_async(monitor_cmd)

open('mcp-monitor.log', 'w+').write(str((stdout, stderr)))
logging.warning(f"monitor result (captured {timeout_seconds}s) {stdout} {stderr}")
return stdout, stderr
Comment on lines +215 to +245

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Negative timeout_seconds values are not validated.

The code caps the maximum at 120, but negative or zero values would pass through. A negative timeout could cause timeout -5 idf.py monitor to behave unexpectedly (timeout command typically treats negative values as errors).

🛡️ Proposed fix
     # Cap timeout to prevent runaway processes
-    timeout_seconds = min(timeout_seconds, 120)
+    timeout_seconds = max(1, min(timeout_seconds, 120))

Also note: the same command injection risk via port applies here as flagged for erase_esp_flash.

🧰 Tools
🪛 Ruff (0.14.14)

[warning] 216-216: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)


[warning] 241-241: Unpacked variable returncode is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)

🤖 Prompt for AI Agents
In `@main.py` around lines 215 - 245, In monitor_esp_device, validate and
normalize timeout_seconds before building the shell command: convert to int,
ensure it's at least 1 (or set to default 30) and then cap to 120 to prevent
negative/zero values from being used in the shell timeout; update the
timeout_seconds normalization logic near where timeout_seconds is set and before
monitor_cmd is constructed (function monitor_esp_device). Also sanitize or
safely quote the port value passed into monitor_cmd (the port parameter used in
monitor_cmd) to prevent shell injection—either validate against an
allowlist/regex for expected port names or apply proper shell quoting before
interpolating into the command; keep run_command_async usage unchanged. Ensure
any log/write to mcp-monitor.log still uses the validated values.



@mcp.tool()
async def get_esp_app_info(project_path: str) -> (str, str):
"""Get information about the built ESP-IDF application.

Args:
project_path: Path to the ESP-IDF project (must be built first)

Returns:
tuple: (stdout, stderr) - App information including version, IDF version, etc.
"""
os.chdir(project_path)
export_script = get_export_script()

# Get project description from build
returncode, stdout, stderr = await run_command_async(
f"bash -c 'source {export_script} && idf.py reconfigure 2>/dev/null; cat build/project_description.json 2>/dev/null || echo \"Project not built yet\"'"
)

open('mcp-app-info.log', 'w+').write(str((stdout, stderr)))
logging.warning(f"app-info result {stdout} {stderr}")
return stdout, stderr


if __name__ == '__main__':
mcp.run(transport='stdio')