mcp-esp32
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-esp32 flash firmware to /dev/ttyUSB0 using firmware.bin"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mcp-esp32
An MCP server that puts an ESP32 / MicroPython board behind tools an agent can call: enumerate ports, flash firmware, run code over the raw REPL, move files, capture serial output.
The problem this repository is actually about: flashing a board takes tens of seconds, boards reset unexpectedly, the serial link drops bytes, and an agent needs to be able to cancel a flash it started. None of that fits the request/response shape most MCP servers use. This repo is a demonstration of the parts of MCP built for exactly that -- progress notifications, cancellation, and structured (not thrown-and-forgotten) error handling -- applied to a device that is genuinely slow, stateful, and failure-prone.
Result: eight tools, a from-scratch raw-REPL client, a flashing orchestrator that reports progress at least every 5% and can be cancelled mid-write with no orphaned process, and a from-scratch fault-injecting simulator so all of that is verifiable without a board attached. 37 tests, all green, no hardware required. See Verification status below for exactly what that does and does not prove.
Why a long-running hardware operation is a harder MCP shape than a REST wrapper
A REST wrapper around esptool is POST /flash and a 200 once it's done (or
a client-side timeout if it isn't). That throws away everything that
actually matters when the thing on the other end of a serial cable takes 30
seconds to flash and can silently vanish partway through:
Progress. A flash with no feedback for 30 seconds is indistinguishable from a hang, to both a human and an agent deciding whether to keep waiting.
flash_firmwarereports progress via MCP'snotifications/progressat least every 5%, not just at the end.Cancellation. An agent that started a flash against the wrong port needs to be able to stop it -- not just stop waiting for it, but actually stop the write and not leave a subprocess or a half-open serial port behind.
flash_firmwarehandlesnotifications/cancelledby signalling the in-flight write to stop, waiting (inside a shielded scope, so the cleanup itself can't be cancelled) for it to actually stop, and only then letting the cancellation propagate.Timeouts vs. failure. A REST call that times out tells you nothing about whether the device is busy, dead, or reset. Every failure mode here is a distinct, structured exception (
DeviceResetError,FlashTimeoutError,ReplDesyncError, ...) with akindfield an agent can branch on, not a generic timeout.Recovery. A dropped byte on a REPL exchange shouldn't corrupt the next one.
repl_execdetects a desynchronised raw REPL and resynchronises before the next call, instead of returning garbage or hanging.Statefulness under concurrency. A board has one UART. Two tool calls racing against the same port would interleave bytes and corrupt both, so every tool call is serialised per-port (an
asyncio.Lockper port name) -- calls against different ports still run fully in parallel.
Related MCP server: esp-mcp
Tools
Tool | Description |
| Serial ports with VID/PID and a best-guess chip family, plus the simulator. |
| Chip, flash size, MAC, MicroPython version if present. |
| Progress notifications every ≥5%, cancellable. |
| Raw-REPL execution; stdout, stderr, and exception come back as separate fields. |
| Directory listing (name, size, is_dir). |
| Read a file (base64), capped and refused up front if it exceeds |
| Write a file, chunked over several raw-REPL exchanges. |
| Bounded (≤30s) raw serial capture, no REPL protocol involved. |
Architecture
server.py MCP tool registration; adapts Context.report_progress and
cancellation for flash_firmware. Everything else is a
direct pass-through to toolkit.py.
toolkit.py Backend-agnostic async tool implementations. No mcp.Context
here -- every reliability property is tested by calling
these functions directly.
backends/base.py Resolves a port string to a BoardHandle: transport
factory, flash-session factory, identify(). Ports named
"SIM*" route to the simulator; anything else routes to
the serial backend.
raw_repl.py MicroPython raw-REPL client (transport-agnostic).
fsops.py ls/get/put built on raw_repl.exec(), chunked, size-capped.
flasher.py Flash orchestration: progress throttling, cancellation,
SimulatorFlashSession (talks to the simulator) and
SubprocessFlashSession (drives the real esptool CLI).
sim_flash_protocol.py
Wire format for the simulator's flash handshake.
backends/simulator.py
A pty-backed fake device: real raw-REPL protocol, real
code execution against an in-memory filesystem, and a
configurable fault injector.
backends/serial_backend.py
Real hardware via pyserial + the esptool CLI. Not
exercised by anything in this repository -- see below.The simulator, and exactly what it reproduces
The simulator (backends/simulator.py) is not a mock of the raw-REPL
client -- it's a second, independent implementation of the device side of
that protocol, running in a background thread on the other end of a real
pty. Code sent to it is genuinely executed (via a sandboxed exec, with
fake os/machine/sys/ubinascii modules backing an in-memory
filesystem), so raw_repl.py cannot tell it apart from a real board at the
protocol level.
Its flash handshake is a separate, deliberately simple framed protocol
(sim_flash_protocol.py) -- sync, begin, N acknowledged data blocks, end --
not a reimplementation of esptool's real SLIP/ROM-bootloader wire format.
Reproducing that byte-for-byte (chip-specific stub loaders, ROM quirks, SLIP
escaping) is a separate project from what this repository demonstrates. What
matters for exercising the MCP long-running-operation surface is the shape
of a flash: a handshake, many acknowledged writes, an end -- with the same
opportunities for a reset, a wedged link, or a cancellation. See
Design notes for why this was the simpler option.
A FaultConfig on the simulated board injects, on demand:
FlashFault.RESET_MID_FLASH-- the device sends an explicit reset marker after N data blocks and then goes silent, standing in for a brown-out or watchdog reset partway through a write. Tested intest_flasher_simulator.py::test_device_reset_mid_flash_is_a_structured_error_not_a_hang.FlashFault.SILENT_TIMEOUT-- the device stops responding with no reset marker at all, standing in for a wedged link. Tested intest_silent_timeout_surfaces_as_flash_timeout_error.ReplFault.GARBAGE-- one raw-REPL response is replaced with bytes that don't match any expected marker, standing in for a dropped/corrupted byte. Tested intest_raw_repl.py::test_garbage_on_the_line_is_detected_and_resynced_transparently.block_delay_s-- adds latency per flash block, used to make cancellation-mid-flash deterministically testable without a real multi- second flash.
Cancellation itself doesn't need fault injection -- it's tested by cancelling
a real (simulated) flash in progress and asserting partial progress was
reported and, for the subprocess path, that the child process was actually
reaped (test_flasher_subprocess.py::test_cancellation_leaves_no_orphan_process).
Per-port locking is tested by timing two concurrent calls against the same
simulated port (they serialise) against two calls on different ports (they
don't) -- test_toolkit.py::test_concurrent_calls_on_same_port_are_serialised.
Verification status
Everything in this repository was built and tested against the simulator
described above. No physical ESP32 or other hardware was available or used
at any point. backends/serial_backend.py (real pyserial + the esptool
CLI) is written to the same BoardHandle contract the simulator satisfies
and its subprocess-lifecycle logic (progress parsing, cancel-and-reap,
reset-string detection) is tested against a stand-in script
(tests/fixtures/fake_esptool.py) that mimics esptool's stdout shape --
but the module itself has never been run against a real board or even a
real copy of esptool. Treat it as "should work", not "verified".
Installation
python -m venv .venv
.venv/bin/pip install -e ".[dev]" # simulator only
.venv/bin/pip install -e ".[serial]" # adds pyserial + esptool for real hardwareRunning it
mcp-esp32 # starts the MCP server on stdio
mcp-esp32 --demo # narrated walkthrough of every tool against the simulator, no MCP client needed--demo calls toolkit.py directly (the same functions the tests call) and
prints each result, including a deliberate reset-mid-flash fault, so the
whole tool surface is visible without wiring up an MCP client.
Testing
pytest37 tests, no hardware, no network access, no secrets. CI
(.github/workflows/ci.yml) runs the suite plus --demo on Python 3.11 and
3.12.
Design notes
Decisions made without a way to check them against real hardware; simpler option chosen in each case.
Simulated flash protocol instead of real SLIP/ROM-bootloader bytes. Reimplementing esptool's actual wire protocol (chip-specific stub loaders, ROM quirks) would be a second project and wouldn't change what's being demonstrated -- the MCP-level orchestration around a slow, faulty write. The simulator's protocol has the same shape (sync/begin/data×N/end, acknowledged blocks, an explicit reset signal) and is documented as such in
sim_flash_protocol.py.fs_get/fs_puttransfer content as hex-encoded lines over repeated raw-REPLexec()calls, not a dedicated binary protocol. MicroPython's raw REPL keeps globals alive acrossexec()calls (it's a persistent interpreter, not a fresh one per call), sofs_putopens a file handle in one call and writes to it in several more -- this works identically against real hardware and the simulator, and needed no new wire protocol.Base64 in the MCP tool signatures, hex on the wire.
fs_get/fs_puttake/returncontent_base64because raw bytes aren't JSON-safe; internallyfsops.pyusesubinascii.hexlify/unhexlifysince that's what MicroPython actually has available on-device. MAX_TRANSFER_BYTES (512 KiB) is checked before a transfer starts, not discovered partway through.Ports named
SIM*always route to the simulator (backends/base.py), auto-created on first use. No environment variable or config flag needed to use the simulator -- call any tool with a port starting withSIMand it exists.Errors are structured return values, not raised exceptions, for every expected failure mode (reset, timeout, desync, confirmation-required). A tool call that hits one of these returns
{"error": {"kind": ..., ...}}rather than an MCP tool error, so an agent can branch onkindwithout parsing a message string. Actual bugs still raise and surface as normal tool errors.Cancellation cleanup runs inside an
anyio.CancelScope(shield=True). Without shielding, the first checkpoint after catching the cancellation would immediately re-raise (cancel scopes are level-triggered), which would skip waiting for the flash thread to actually stop before the request is torn down.esptool is shelled out to as a CLI subprocess, not used as a library. Its Python API is not considered stable across versions; the CLI's stdout format (
(NN %)progress lines, specific fatal-error strings) is what every existing esptool-wrapping tool already depends on, and it's the surfacetests/fixtures/fake_esptool.pycan stand in for without needing esptool internals to match.
Requirements
Python 3.11+. mcp for the server itself; pyserial and esptool are
optional (only needed for the serial backend against real hardware) and
their absence never breaks import -- see tests/test_optional_deps.py.
Available Tools
8 toolsboard_infoB
Chip family, flash size, MAC address, and MicroPython version (if any) for port.
Inputs: port (required).
flash_firmwareA
Write firmware_path to port.
Reports progress at least every 5% via MCP progress notifications and
can be cancelled mid-flash. erase=True wipes the whole flash first and
requires confirm_erase=True as a separate explicit argument.
Inputs: port (required), firmware_path (required), erase, confirm_erase.
fs_getA
Read a file from the board. Content comes back base64-encoded; refuses files over max_bytes.
Inputs: path (required), port (required), max_bytes.
fs_lsB
List a directory on the board's filesystem.
Inputs: port (required), path.
fs_putA
Write a base64-encoded payload to a file on the board, chunked over several raw-REPL exchanges.
Inputs: path (required), port (required), content_base64 (required), max_bytes.
list_portsA
List serial ports, including the simulator, with a best-guess chip identification.
Inputs: no named parameters. Returns: result
repl_execA
Run code on port over MicroPython's raw REPL; stdout/stderr/exception come back separately.
Inputs: code (required), port (required), timeout_s.
tail_serialA
Capture raw serial output for up to 30 seconds without going through the REPL protocol.
Inputs: port (required), seconds.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 8 tools
Each tool has a clear, distinct purpose: port discovery, board info, flashing, REPL execution, filesystem operations, and serial monitoring. There is no overlap between them, and even related tools like fs_ls/fs_get/fs_put are differentiated by operation type.
Naming patterns are mixed: some are verb_noun (list_ports, flash_firmware, tail_serial), some are prefix-based (fs_ls, fs_get, fs_put), and repl_exec inverts the usual order. While readable, the lack of a single consistent convention makes the API slightly less predictable.
With exactly 8 tools, the server is well-scoped for its purpose of managing ESP32 boards. Each tool covers a distinct aspect of the workflow (discovery, flashing, code execution, filesystem, serial) without bloat or trivial tools.
The toolset covers the core lifecycle: identify port, get board info, flash firmware, execute code, and manage files. The only notable gap is lack of a delete/rename filesystem operation, but this is a minor omission that doesn't compromise the primary workflows.
Maintenance
Related MCP Connectors
Manage files and folders directly from your workspace. Read and write files, list directories, cre…
Control Unreal Engine to browse assets, import content, and manage levels and sequences. Automate…
A comprehensive Model Context Protocol (MCP) server that enables AI assistants to interact with yo…
Lean 4 MCP server: compile, prove theorems, and formalize math with Mathlib.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceEnables ESP-IDF firmware development through MCP by providing tools to manage ESP32 targets, serial ports, and project compilation/building operations.-
- FlicenseNot gradedqualityDmaintenanceAn MCP server for managing ESP-IDF workflows, enabling LLMs to build, flash, and test firmware for ESP32 and related microcontrollers. It provides tools for project creation, target configuration, and serial port management to simplify embedded development.158-
- FlicenseBqualityDmaintenanceMCP server for controlling MicroPython devices (ESP32, RP2040, etc.) via USB Serial or WebREPL, enabling code execution, file operations, and device management from MCP clients.299-
- AlicenseNot gradedqualityBmaintenanceEnables AI agents to build, flash, and monitor ESP-IDF projects on real hardware, including serial sessions and pytest-embedded hardware tests.1MIT