mcp-esp32

mcp-esp32

Enables interaction with ESP32/MicroPython boards via tools for flashing firmware, running REPL code, file management, and serial capture, with support for progress notifications, cancellation, and error handling.

Category
访问服务器

README

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_firmware reports progress via MCP's notifications/progress at 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_firmware handles notifications/cancelled by 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 a kind field an agent can branch on, not a generic timeout.
  • Recovery. A dropped byte on a REPL exchange shouldn't corrupt the next one. repl_exec detects 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.Lock per port name) -- calls against different ports still run fully in parallel.

Tools

Tool Description
list_ports() Serial ports with VID/PID and a best-guess chip family, plus the simulator.
board_info(port) Chip, flash size, MAC, MicroPython version if present.
flash_firmware(port, firmware_path, erase=False, confirm_erase=False) Progress notifications every ≥5%, cancellable. erase=True requires confirm_erase=True as a separate argument.
repl_exec(port, code, timeout_s=10) Raw-REPL execution; stdout, stderr, and exception come back as separate fields.
fs_ls(port, path) Directory listing (name, size, is_dir).
fs_get(port, path, max_bytes) Read a file (base64), capped and refused up front if it exceeds max_bytes.
fs_put(port, path, content_base64, max_bytes) Write a file, chunked over several raw-REPL exchanges.
tail_serial(port, seconds) 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 in test_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 in test_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 in test_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 hardware

Running 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

pytest

37 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_put transfer content as hex-encoded lines over repeated raw-REPL exec() calls, not a dedicated binary protocol. MicroPython's raw REPL keeps globals alive across exec() calls (it's a persistent interpreter, not a fresh one per call), so fs_put opens 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_put take/return content_base64 because raw bytes aren't JSON-safe; internally fsops.py uses ubinascii.hexlify/unhexlify since 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 with SIM and 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 on kind without 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 surface tests/fixtures/fake_esptool.py can 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.

推荐服务器

Baidu Map

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选