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.
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, …).
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 monitorblocks the nextidf.py flashbecause 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.
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 |
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.
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
mcpServersJSON shape — just paste it into their config file.
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.
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.
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 │
└──────────────────────────────────┘
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
flashtool releases the port in afinallyblock, so the monitor returns even ifidf.py flashfails. - 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.
# 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"
claudeThe 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.
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.
The MCP server takes only one runtime knob — the project directory:
esp32-ai-loop-mcp-server -C /absolute/path/to/your/esp-idf/projectEverything 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).
- ESP-IDF activation is your problem. This server shells out to
idf.pyand trusts that it's onPATH. If you forgot to sourceexport.sh/export.ps1before 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_monitoragain. The log file persists —tail_logstill 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
resettool tries the standard pulse; if your board ignores it, useflash(which goes through esptool's full reset sequence).
|
|
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.
MIT — see LICENSE.
- Model Context Protocol spec — https://modelcontextprotocol.io/
- FastMCP (the framework this server is built on) — https://github.com/jlowin/fastmcp
- ESP-IDF — https://github.com/espressif/esp-idf
- ESP-IDF Installation Manager (EIM) — https://github.com/espressif/idf-im-cli
- Claude Code MCP docs — https://docs.claude.com/en/docs/claude-code/mcp