Skip to content

cmd0s/esp32-ai-loop-mcp-server

Repository files navigation

esp32-ai-loop-mcp-server

License: MIT Python 3.10+ Platforms: macOS · Windows · Linux Built with FastMCP

Give Claude Code a real ESP32 on its desk. An MCP server that closes the edit → build → flash → observe loop on real hardware. The agent compiles, flashes, and reads device output autonomously — no human-in-the-loop for every iteration.

TL;DR

Eleven MCP tools that let an AI assistant — or you — drive an ESP32 dev board over USB without ever switching to a terminal:

You: "make the heartbeat print uptime in milliseconds too"

AI: edits main.c
    → build()
    → start_monitor()
    → flash()                    (monitor steps aside, returns automatically)
    → grep_log("tick=", last_n_only=200)
    → "done — uptime_ms=15ms shows up in tick=0, ticks 1..3 firing every 5s"

The server speaks MCP over stdio, runs on macOS, Windows, and Linux, and installs in one line via uvx. Designed primarily for Claude Code but works with any MCP client (Claude Desktop, Cursor, Cline, …).

Why this exists

Firmware development without device access is guess-work. For a human, the official idf.py monitor works fine — it ties up a terminal, you watch it in real time. For an AI assistant, that's a non-starter:

  • The agent can't watch a TTY in real time; it can only run a command, wait for it to exit, and read the output.
  • An always-running idf.py monitor blocks the next idf.py flash because the port is busy.
  • Without seeing device output, every change the AI makes is speculative — it can ship code that compiles cleanly and is silently broken at runtime.

This server closes that gap. The monitor runs as a background thread inside the MCP server, writes to a log file, and automatically yields the port when a flash starts. The agent calls flash() and grep_log() as first-class MCP tools — no shell, no port juggling.

Install

Prerequisite: ESP-IDF installed and activated in the shell that launches your MCP client. On macOS/Linux: source $IDF_PATH/export.sh. On Windows (PowerShell): . $env:IDF_PATH\export.ps1. The Espressif Installation Manager (EIM) is the recommended way to install IDF on every platform.

You'll also need uv (one-time, fast, ~5 MB):

Platform Command
macOS brew install uv
Windows winget install astral-sh.uv
Linux curl -LsSf https://astral.sh/uv/install.sh | sh

Path A — claude mcp add (recommended)

From the root of your ESP-IDF project:

claude mcp add esp32-ai-loop --scope project -- uvx esp32-ai-loop-mcp-server -C .

--scope project writes a .mcp.json you can commit, so anyone who clones the repo and re-launches Claude Code gets the same tools.

Path B — .mcp.json by hand

Drop this file at your project root:

{
  "mcpServers": {
    "esp32-ai-loop": {
      "type": "stdio",
      "command": "uvx",
      "args": ["esp32-ai-loop-mcp-server", "-C", "."]
    }
  }
}

Restart Claude Code from that directory. It'll show a trust dialog the first time it sees a new .mcp.json — accept it. Tools appear as mcp__esp32-ai-loop__build, …__flash, …__start_monitor, etc.

Other MCP clients work too. Claude Desktop, Cursor, and Cline all accept the same mcpServers JSON shape — just paste it into their config file.

What you get — the 11 tools

Three groups: serial monitor, ESP-IDF actions, port discovery.

Tool What it does
start_monitor Open a port, stream every line into a log file, auto-reconnect on disconnect, pulse RTS to capture the boot banner.
close_monitor Stop the monitor and release the port.
monitor_status Is the monitor running? On which port? Where's the log? Uptime, last error.
tail_log Return the last N lines of the log — replaces shell tail.
grep_log Search the log with a regex; optionally limited to the last N lines. Saves the agent a lot of context.
build Run idf.py build.
flash Run idf.py flash with automatic monitor coordination — port is yielded for the duration, resumed afterward, even on failure.
clean Run idf.py clean.
set_target Run idf.py set-target <chip> (esp32, esp32s3, esp32c3, esp32c6, …).
reset Pulse RTS to reboot the chip without reflashing — coordinates with the monitor too.
list_ports Cross-platform USB-CDC enumeration with VID/PID and an is_known_esp flag.
Tool signatures (full parameter list)
  • start_monitor(port?, baud=115200, log_path?, truncate=False, reset_on_connect=True)
  • close_monitor()
  • monitor_status()
  • tail_log(lines=50, log_path?)
  • grep_log(pattern, lines=100, case_insensitive=False, last_n_only?, log_path?)
  • build()
  • flash(port?, target?)
  • clean()
  • set_target(target)
  • reset(port?)
  • list_ports()

All optional parameters have sensible defaults. port defaults to whatever list_ports() autodetects when exactly one ESP32-class device is plugged in.

Every connect captures a full boot

When the monitor opens (or reopens) the port, it pulses RTS to hard-reset the chip — same trick idf.py monitor uses on connect. That guarantees the next thing the AI sees in the log is the ROM banner, the bootloader header, and your app_main startup output, every single time.

For human debugging that's nice-to-have. For an AI that has to infer device state from text, a clean boot capture every iteration is the difference between "I can tell you exactly which init step failed" and "I'm guessing — please run it again and paste the output."

Disable per-call with start_monitor(reset_on_connect=False) if you need to attach to a running device without disturbing it.

How it works (architecture)

One Python process. The MCP tools and the serial monitor live as siblings inside it; they coordinate via in-process events, no signals, no PID files.

            ┌─────────────────────────────────────────┐
            │   Claude Code / Cursor / Cline / ...    │
            └─────────────────────┬───────────────────┘
                                  │ MCP (stdio JSON-RPC)
                                  ▼
   ┌─────────────────────────────────────────────────────────────┐
   │            esp32-ai-loop-mcp-server (Python)                │
   │                                                             │
   │   ┌─────────────────────┐       ┌────────────────────────┐  │
   │   │ MCP tools           │       │ monitor thread         │  │
   │   │                     │ yield │  • USB-CDC reader      │  │
   │   │ • build  • flash    │──────►│  • RTS reset on every  │  │
   │   │ • clean  • reset    │       │    connect → boot log  │  │
   │   │ • set_target        │◄──────│  • auto-reconnect      │  │
   │   │ • start/close_      │resume │  • yields on flash     │  │
   │   │   monitor           │       └────────────┬───────────┘  │
   │   │ • monitor_status    │                    │              │
   │   │ • tail_log          │                    │ append+flush │
   │   │ • grep_log          │                    ▼              │
   │   │ • list_ports        │     <project>/logs/serial.log     │
   │   └──────────┬──────────┘                                   │
   │              │ subprocess                                   │
   │              ▼                                              │
   │       idf.py build / flash                                  │
   │       (ESP-IDF env activated in the parent shell)           │
   └─────────┬────────────────────────────┬──────────────────────┘
             │ esptool (USB-CDC)          │ pyserial (USB-CDC)
             ▼                            ▼
           ┌──────────────────────────────────┐
           │           ESP32 board            │
           └──────────────────────────────────┘

The flash + monitor handoff

Three things compete for the USB-CDC port: the monitor (reading device output), esptool (when flashing), and the reset helper. Only one can hold the port at a time.

Time  │ flash() tool                    │ monitor thread                    │ Port
──────┼─────────────────────────────────┼───────────────────────────────────┼──────
 t0   │ monitor.yield_port()            │                                   │ open
      │   (sets threading.Event)        │                                   │
 t0+  │                                 │ event observed → break read loop  │
      │                                 │ → ser.close()                     │ closed
 t1   │ subprocess: idf.py flash        │                                   │
      │   esptool: open port            │                                   │ esptool
      │   ...flashing...                │   (parked, yield_event still set) │
 t2   │   esptool: hard reset, exit     │                                   │ closed
 t3   │ monitor.resume_port()           │                                   │
      │   (clears event, in finally)    │ event clear → reopen → RTS pulse  │ open
      │                                 │ → boot banner streams to log      │

Key properties:

  • Cross-platform. All coordination is in-process via threading.Event — no signals, no PID files, no named pipes. Works identically on macOS, Linux, and Windows.
  • Crash-safe. The flash tool releases the port in a finally block, so the monitor returns even if idf.py flash fails.
  • Safety-net deadline. If something kills the MCP server mid-flash, the monitor's 60-second yield ceiling kicks in and reconnects on its own.
  • Sub-200ms overhead. The monitor's read loop has a 200ms timeout, so yield + reacquire round-trip costs roughly that much vs raw esptool.

Quick start (with the bundled example)

# 1. Get the example project (or use any ESP-IDF project of yours).
git clone https://github.com/cmd0s/esp32-ai-loop-mcp-server.git
cd esp32-ai-loop-mcp-server/examples/hello-heartbeat

# 2. Activate ESP-IDF in this shell, then register the MCP server.
source $IDF_PATH/export.sh                              # macOS/Linux
# . $env:IDF_PATH\export.ps1                            # Windows PowerShell
claude mcp add esp32-ai-loop --scope project -- uvx esp32-ai-loop-mcp-server -C .

# 3. Launch Claude Code from this directory and ask:
#    "set target to esp32s3, build, flash, and watch for 30 seconds"
claude

The agent will pick set_target, build, start_monitor, flash, then grep_log("tick=") — and tell you whether the heartbeat is firing. The full session usually takes under 90 seconds end-to-end.

Comparison with adjacent tools

idf.py monitor idf.py mcp-server (built into IDF v6.0) esp32-ai-loop-mcp-server
MCP-native build / flash / set-target
Background log capture across flashes
Automatic port-yield during flash
tail_log / grep_log for AI context
reset (RTS pulse) without reflash
Cross-platform (macOS + Windows + Linux)
Persistent log file across sessions

Use idf.py mcp-server alone if you only need build/flash and you're fine watching idf.py monitor in a separate terminal. Use this server if you want the agent to drive the full loop without you copy-pasting log output back to it.

Configuration

The MCP server takes only one runtime knob — the project directory:

esp32-ai-loop-mcp-server -C /absolute/path/to/your/esp-idf/project

Everything else is read at tool-call time. The server inherits the parent shell's environment, so IDF_PATH, IDF_PYTHON_ENV_PATH, ESPPORT, etc. flow through to idf.py.

Logs go to <project>/logs/serial.log by default (override per-call with the log_path argument).

Caveats / known issues

  • ESP-IDF activation is your problem. This server shells out to idf.py and trusts that it's on PATH. If you forgot to source export.sh / export.ps1 before launching your MCP client, every build/flash tool returns a clear error pointing you at the activation step. Recommended: launch Claude Code from a shell that's already activated, or use Espressif's EIM to wrap the activation.
  • Single monitor at a time. The current design assumes one ESP32 per project. Multi-board support is on the roadmap.
  • Monitor dies with the MCP server. If you restart Claude Code, call start_monitor again. The log file persists — tail_log still works even with no monitor running.
  • Native USB-Serial/JTAG (S3/C3/C6/H2) reset quirks. RTS-driven auto-reset is universal on classic CP210x/CH9102 boards but can behave differently on chips using the SoC's built-in USB-Serial peripheral. The reset tool tries the standard pulse; if your board ignores it, use flash (which goes through esptool's full reset sequence).

About the author

Robert Mordzon

Built by Robert Mordzon — embedded + firmware + full-stack engineer and entrepreneur, with a focus on blockchain/Web3 systems. Behind the Web3 Pi project and Web3 Pi UPS hardware, and lead engineer of Pegasus Speedway — a live telemetry system for speedway (the dirt-track motorcycle sport) that locates riders to within a centimeter mid-race. Many years of broadcast-TV systems engineering before that.

This MCP server was born out of necessity: an AI assistant cannot debug a power-loss bug it never gets to observe — so I built the tool that lets it.

If this saves you a few hours of "the agent shipped broken firmware because it couldn't read the log," ⭐ the repo and let me know what you built.

License

MIT — see LICENSE.

Links

About

Autonomous compile-flash-observe loop for ESP-IDF. Serial monitor + flash wrapper + ESP-IDF MCP server for Claude Code.

Resources

License

Stars

Watchers

Forks

Contributors

Languages