embedded-mcp
# embedded-mcp
给 AI agent 用的嵌入式开发板 MCP server:编译、烧录、读串口、擦除,带护栏。
## 为什么分层
```
protocol.py JSON-RPC / 工具分派 ← 完全不知道硬件存在
tools.py 工具实现 + 所有护栏
backends/ 工具链适配(怎么编译、怎么烧)
boards.py 板型档案(纯数据)
state.py 锁 / 预算 / 模式标记
```
**变的是工具链,不是板。** RP2040、SAMD、AVR 全都走 `arduino-cli`,加它们
一行代码都不用写,只在 `boards.py` 里加一条数据。真正需要新 Backend 的是
换了构建体系的:ESP-IDF 的 `idf.py`、PlatformIO 的 `pio`、STM32 的 `openocd`。
护栏全在工具层,Backend 只管执行——**新增工具链不需要重新实现任何一道闸**。
## 三类状态的边界不同
这是 `state.py` 存在的唯一理由:
| 状态 | 边界 | 放哪 |
| --- | --- | --- |
| 串口锁 | 物理硬件 | `~/.embedded-mcp/locks/<port>.json` |
| 下载模式标记 | 物理硬件 | `~/.embedded-mcp/mode/<port>.flash` |
| 烧录预算 | 项目 / 会话 | `~/.embedded-mcp/projects/<项目>/iteration_count` |
把它们堆在一个项目目录下看着像一类东西,插上第二块板就露馅:两个项目各锁
各的文件,等于没锁。
烧录预算**不是** flash 寿命保护(ESP32 flash 有 10 万次擦写寿命,5 次这个
量级显然不是为寿命设的),它是**熔断器**,防 agent 陷入「改一点→烧→还不对」
的失控循环。所以边界是项目,不是硬件。
## 安装
```bash
python3 -m pip install pyserial # read_serial 需要
brew install arduino-cli # 或按官方文档安装
arduino-cli core install esp32:esp32 # 按你的板子装 core
```
## 用法
```bash
python3 -m embedded_mcp doctor # 检查环境、串口、项目
python3 -m embedded_mcp boards # 列出已知板型
python3 -m embedded_mcp confirm /dev/cu.usbmodem101 # 人工确认已进下载模式
python3 -m embedded_mcp serve --project ~/code/my-sketch
```
接进 Claude Code,在项目里放 `.mcp.json`:
```json
{
"mcpServers": {
"embedded": {
"type": "stdio",
"command": "python3",
"args": ["-m", "embedded_mcp", "serve", "--project", "."],
"env": {"PYTHONPATH": "/Users/you/code/embedded-mcp"}
}
}
}
```
## 工具
| 工具 | 级别 | 护栏 |
| --- | --- | --- |
| `status` | 只读 | 无 |
| `compile` | 只读 | 无 |
| `read_serial` | 只读 | 串口互斥 |
| `flash` | 写硬件 | 预算上限 + 串口互斥 + 条件性人工确认 |
| `erase_flash` | 危险 | 强制 human-in-the-loop(MRTR 两程确认) |
`flash` 的人工确认**由板型决定**:`auto_download_mode=True` 的板(实测
ESP32-S3 的 esptool 能自己触发下载模式复位)直接烧;`False` 的板
(RP2040 要按 BOOTSEL)才要求先跑 `confirm`。
## 加一块新板
多数情况只改数据:
```python
BoardProfile(
id="my_board",
display_name="My Board",
fqbn="vendor:arch:board",
usb_vids=(0x1234,),
auto_download_mode=True,
reenumerates_after_flash=False,
crash_markers=("PANIC",),
)
```
字段大多是踩出来的:`reenumerates_after_flash` 来自「ESP32-S3 烧完从
Espressif VID 变成 Adafruit VID」;`boot_log_window_s` 来自「setup() 的输出
会在主机连上串口之前就打完」。
## 加一种工具链
继承 `Backend`,实现 `detect` / `compile` / `flash`,用 `@register` 注册。
不用碰协议层,也不用重新实现护栏。
## 已知限制
- `erase_flash` 走 MRTR(2026-07-28),**legacy 客户端不认识 `resultType:
"input_required"`**,在它们那里只能走降级路径(告知用户手工执行)。
- 本 server 只能保证经由它的调用守规矩。Host 给模型的 Bash 工具它管不了——
要拦住那条路得靠 hooks,两者互补而非替代。
## 测试
```bash
python3 tests/smoke.py # 19 项,不碰硬件
```
TDQS
Scored across 5 tools
Each tool maps to a distinct operation: compile, flash, read serial, erase flash, and status. Although flash and erase_flash both touch the flash memory, their actions and safety levels are clearly separated. No tool appears to duplicate another's purpose.
Most tools use lowercase snake_case and a verb-like pattern: erase_flash, read_serial, compile, flash. 'status' is a noun-style exception, but it is still a common and predictable command name. Minor deviation does not cause confusion.
Five tools is a tight, well-scoped set for an embedded development workflow. Each tool serves a necessary step in the compile-flash-debug cycle. No redundant or filler tools are present.
The core workflow is covered: compile, flash, read_serial, erase_flash, and status provide a complete lifecycle for flashing and debugging a board. Minor gaps remain, such as no explicit reset/reboot tool or project selection command, but agents can work around these with status and serial output. Overall the surface is well matched to its declared purpose.