6b15571e14
* Start of MCP server and test suite * Add MCP server for interacting with meshtastic devices and testing framework / TUI * Update mcp-server/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix mcp-server review feedback from thread Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/91dc128a-ed50-4d07-8bb2-3dc6623a05f7 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Enhance StreamAPI and PhoneAPI for improved log record handling and concurrency control * Semgrep fixes * Trunk and semgrep fixes * optimize pio streaming tee file writes Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * chore: remove redundant log handle assignment Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Consolidate type imports and remove placeholder test files * Add tests for config persistence and more exchange messages * Refactor position test to validate on-demand request/reply behavior * Remove position request/reply test and update README for telemetry behavior * Fix transmit history file to get removed on factory reset --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
271 lines
15 KiB
Markdown
271 lines
15 KiB
Markdown
# Meshtastic MCP Server
|
|
|
|
An [MCP](https://modelcontextprotocol.io) server for working with the Meshtastic firmware repo and connected devices. Lets Claude Code / Claude Desktop:
|
|
|
|
- Discover USB-connected Meshtastic devices
|
|
- Enumerate PlatformIO board variants (166+) with Meshtastic metadata
|
|
- Build, clean, flash, erase-and-flash (factory), and OTA-update firmware
|
|
- Read serial logs via `pio device monitor` (with board-specific exception decoders)
|
|
- Trigger 1200bps touch-reset for bootloader entry (nRF52, ESP32-S3, RP2040)
|
|
- Query and administer a running node via the [`meshtastic` Python API](https://github.com/meshtastic/python): owner name, config (LocalConfig + ModuleConfig), channels, messaging, reboot/shutdown/factory-reset
|
|
- Call `esptool`, `nrfutil`, `picotool` directly when PlatformIO doesn't cover the operation
|
|
|
|
## Design principle
|
|
|
|
**PlatformIO first.** Its `pio run -t upload` knows the correct protocol, offsets, and post-build chain for every variant in `variants/`. Direct vendor-tool wrappers (`esptool_*`, `nrfutil_*`, `picotool_*`) exist as escape hatches for operations pio doesn't cover (blank-chip erase, DFU `.zip` packages, BOOTSEL-mode inspection).
|
|
|
|
## Prerequisites
|
|
|
|
- Python ≥ 3.11
|
|
- [PlatformIO Core](https://platformio.org/install/cli) — `pio` on `$PATH` or at `~/.platformio/penv/bin/pio`
|
|
- The Meshtastic firmware repo checked out somewhere (set via `MESHTASTIC_FIRMWARE_ROOT`)
|
|
- Optional: `esptool`, `nrfutil`, `picotool` on `$PATH` (or under the firmware venv at `.venv/bin/`) if you want to use the direct-tool wrappers
|
|
|
|
## Install
|
|
|
|
```bash
|
|
cd <firmware-repo>/mcp-server
|
|
python3 -m venv .venv
|
|
.venv/bin/pip install -e .
|
|
```
|
|
|
|
Verify:
|
|
|
|
```bash
|
|
MESHTASTIC_FIRMWARE_ROOT=<firmware-repo> .venv/bin/python -m meshtastic_mcp
|
|
```
|
|
|
|
The server blocks on stdin (that's correct — it speaks MCP over stdio). Ctrl-C to exit.
|
|
|
|
## Register with Claude Code
|
|
|
|
Edit `~/.claude/settings.json` (global) or `<firmware-repo>/.claude/settings.local.json` (project-only):
|
|
|
|
```json
|
|
{
|
|
"mcpServers": {
|
|
"meshtastic": {
|
|
"command": "<firmware-repo>/mcp-server/.venv/bin/python",
|
|
"args": ["-m", "meshtastic_mcp"],
|
|
"env": {
|
|
"MESHTASTIC_FIRMWARE_ROOT": "<firmware-repo>"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Replace `<firmware-repo>` with the absolute path, e.g. `/Users/you/GitHub/firmware`. Restart Claude Code after editing.
|
|
|
|
## Register with Claude Desktop
|
|
|
|
Same `mcpServers` block, but in `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows).
|
|
|
|
## Tools (38)
|
|
|
|
### Discovery & metadata
|
|
|
|
| Tool | What it does |
|
|
| -------------- | ------------------------------------------------------------------------------------------ |
|
|
| `list_devices` | USB/serial port listing, flags likely-Meshtastic candidates |
|
|
| `list_boards` | PlatformIO envs with `custom_meshtastic_*` metadata; filters by arch/supported/query/level |
|
|
| `get_board` | Full env dict incl. raw pio config |
|
|
|
|
### Build & flash
|
|
|
|
| Tool | What it does |
|
|
| ----------------- | -------------------------------------------------------------------- |
|
|
| `build` | `pio run -e <env>` (+ mtjson target) |
|
|
| `clean` | `pio run -e <env> -t clean` |
|
|
| `pio_flash` | `pio run -e <env> -t upload --upload-port <port>` — any architecture |
|
|
| `erase_and_flash` | ESP32 full factory flash via `bin/device-install.sh` |
|
|
| `update_flash` | ESP32 OTA app-partition update via `bin/device-update.sh` |
|
|
| `touch_1200bps` | 1200-baud open/close to trigger USB CDC bootloader entry |
|
|
|
|
### Serial log sessions
|
|
|
|
Backed by long-running `pio device monitor` subprocesses with a 10k-line ring buffer per session and board-specific filters (`esp32_exception_decoder` auto-selected when you pass `env=`).
|
|
|
|
| Tool | What it does |
|
|
| -------------- | ------------------------------------------------------------------ |
|
|
| `serial_open` | Start a monitor session; returns `session_id` |
|
|
| `serial_read` | Cursor-based pull; reports `dropped` if lines aged out of the ring |
|
|
| `serial_list` | All active sessions |
|
|
| `serial_close` | Terminate a session |
|
|
|
|
### Device reads
|
|
|
|
| Tool | What it does |
|
|
| ------------- | --------------------------------------------------------------------------- |
|
|
| `device_info` | my_node_num, long/short name, firmware version, region, channel, node count |
|
|
| `list_nodes` | Full node database with position, SNR, RSSI, last_heard, battery |
|
|
|
|
_The tool tables below document 38 currently registered MCP server tools._
|
|
|
|
### Device writes
|
|
|
|
| Tool | What it does |
|
|
| ------------------- | -------------------------------------------------------------------------- |
|
|
| `set_owner` | Long name + optional short name (≤4 chars) |
|
|
| `get_config` | One section or all (LocalConfig + ModuleConfig) |
|
|
| `set_config` | Dot-path field write: `lora.region`=`"US"`, `device.role`=`"ROUTER"`, etc. |
|
|
| `get_channel_url` | Primary-only or include_all=admin URL |
|
|
| `set_channel_url` | Import channels from a Meshtastic URL |
|
|
| `set_debug_log_api` | Enable or disable debug logging for the Meshtastic Python API client |
|
|
| `send_text` | Broadcast or direct text message |
|
|
| `reboot` | `localNode.reboot(secs)` — requires `confirm=True` |
|
|
| `shutdown` | `localNode.shutdown(secs)` — requires `confirm=True` |
|
|
| `factory_reset` | `localNode.factoryReset(full?)` — requires `confirm=True` |
|
|
|
|
### Direct hardware tools (escape hatches)
|
|
|
|
| Tool | What it does |
|
|
| --------------------- | --------------------------------------------------------- |
|
|
| `esptool_chip_info` | Read chip, MAC, crystal, flash size |
|
|
| `esptool_erase_flash` | Full-chip erase (destructive) |
|
|
| `esptool_raw` | Pass-through; confirm=True required for write/erase/merge |
|
|
| `nrfutil_dfu` | DFU-flash a `.zip` package |
|
|
| `nrfutil_raw` | Pass-through |
|
|
| `picotool_info` | Read Pico BOOTSEL-mode info |
|
|
| `picotool_load` | Load a UF2 |
|
|
| `picotool_raw` | Pass-through |
|
|
|
|
## Safety
|
|
|
|
- **All destructive flash/admin tools require `confirm=True`** as a tool-level gate, on top of any permission prompt from Claude.
|
|
- **Serial port is exclusive.** If a `serial_*` session is active on a port, `device_info`/admin tools on the same port will fail fast with a pointer at the active `session_id`. Close the session first.
|
|
- **Flash confirmation by architecture**: `erase_and_flash` / `update_flash` error if the env's architecture isn't ESP32 — use `pio_flash` for nRF52/RP2040/STM32.
|
|
|
|
## Environment variables
|
|
|
|
| Var | Default | Purpose |
|
|
| -------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- |
|
|
| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo |
|
|
| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location |
|
|
| `MESHTASTIC_ESPTOOL_BIN` | `<firmware>/.venv/bin/esptool` → `$PATH` | Override esptool |
|
|
| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil |
|
|
| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool |
|
|
| `MESHTASTIC_MCP_SEED` | `mcp-<user>-<host>` | PSK seed for test-harness session (CI override) |
|
|
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) |
|
|
|
|
## Hardware Test Suite
|
|
|
|
`mcp-server/tests/` holds a pytest-based integration suite that exercises
|
|
real USB-connected Meshtastic devices against the MCP server surface. Separate
|
|
from the native C++ unit tests in the firmware repo's top-level `test/`
|
|
directory — this one validates the device-facing behavior end-to-end.
|
|
|
|
### Invocation
|
|
|
|
```bash
|
|
./mcp-server/run-tests.sh # full suite (auto-detect + auto-bake-if-needed)
|
|
./mcp-server/run-tests.sh --force-bake # reflash devices before testing
|
|
./mcp-server/run-tests.sh --assume-baked # skip the bake step (caller vouches for state)
|
|
./mcp-server/run-tests.sh tests/mesh # one tier
|
|
./mcp-server/run-tests.sh tests/mesh/test_traceroute.py # one file
|
|
./mcp-server/run-tests.sh -k telemetry # pytest name filter
|
|
```
|
|
|
|
The wrapper auto-detects connected devices (VID `0x239A` → `nrf52` → env
|
|
`rak4631`; `0x303A` or `0x10C4` → `esp32s3` → env `heltec-v3`), exports
|
|
`MESHTASTIC_MCP_ENV_<ROLE>` env vars, and invokes pytest. Overrides via
|
|
per-role env vars: `MESHTASTIC_MCP_ENV_NRF52=heltec-mesh-node-t114 ./run-tests.sh`.
|
|
|
|
No hardware connected? The wrapper narrows to `tests/unit/` only and says so
|
|
in the pre-flight header.
|
|
|
|
### Tiers (run in this order)
|
|
|
|
- **`bake`** (`tests/test_00_bake.py`) — flashes both hub roles with the
|
|
session's test profile. Has a skip-if-already-baked check (region + channel
|
|
match); `--force-bake` overrides.
|
|
- **`unit`** — pure Python, no hardware. boards / PIO wrapper /
|
|
userPrefs-parse / testing-profile fixtures.
|
|
- **`mesh`** — 2-device mesh: formation, broadcast delivery, direct+ACK,
|
|
traceroute, bidirectional. Parametrized over both directions.
|
|
- **`telemetry`** — periodic telemetry broadcast + on-demand request/reply
|
|
(`TELEMETRY_APP` with `wantResponse=True`).
|
|
- **`monitor`** — boot log has no panic markers within 60 s of reboot.
|
|
- **`fleet`** — PSK-seed isolation: two labs with different seeds never
|
|
overlap.
|
|
- **`admin`** — owner persistence across reboot, channel URL round-trip,
|
|
`lora.hop_limit` persistence.
|
|
- **`provisioning`** — region/channel baking, userPrefs survive
|
|
`factory_reset(full=False)`.
|
|
|
|
### Artifacts (regenerated every run, under `tests/`)
|
|
|
|
- `report.html` — self-contained pytest-html report. Each test gets a
|
|
**Meshtastic debug** section attached on failure with a 200-line firmware
|
|
log tail + device-state dump. Open this first on failures.
|
|
- `junit.xml` — CI-parseable.
|
|
- `reportlog.jsonl` — `pytest-reportlog` event stream; consumed by the TUI.
|
|
- `fwlog.jsonl` — firmware log mirror (`meshtastic.log.line` pubsub → JSONL).
|
|
- `flash.log` — tee of all pio / esptool / nrfutil / picotool subprocess
|
|
output during the run (driven by `MESHTASTIC_MCP_FLASH_LOG`).
|
|
|
|
### Live TUI
|
|
|
|
```bash
|
|
.venv/bin/meshtastic-mcp-test-tui
|
|
.venv/bin/meshtastic-mcp-test-tui tests/mesh # pytest args pass through
|
|
```
|
|
|
|
Textual-based wrapper over `run-tests.sh` with a live test tree, tier
|
|
counters, pytest output pane, firmware-log pane, and a device-status strip.
|
|
Key bindings: `r` re-run focused, `f` filter, `d` failure detail, `g` open
|
|
`report.html`, `x` export reproducer bundle, `l` cycle fw-log filter, `q`
|
|
quit (SIGINT → SIGTERM → SIGKILL escalation).
|
|
|
|
### Slash commands
|
|
|
|
Three AI-assisted workflows are wired up for Claude Code operators
|
|
(`.claude/commands/`) and Copilot operators (`.github/prompts/`):
|
|
`/test` (run + interpret), `/diagnose` (read-only health report), `/repro`
|
|
(flake triage, N-times re-run with log diff).
|
|
|
|
### House rules (for human + agent contributors)
|
|
|
|
- Session-scoped fixtures in `tests/conftest.py` snapshot + restore
|
|
`userPrefs.jsonc`; **never edit `userPrefs.jsonc` from inside a test**.
|
|
Use the `test_profile` / `no_region_profile` fixtures for ephemeral
|
|
overrides.
|
|
- `SerialInterface` holds an **exclusive port lock**; sequence calls
|
|
open → mutate → close, then next device. No parallel calls to the
|
|
same port.
|
|
- Directed PKI-encrypted sends need **bilateral NodeInfo warmup** —
|
|
both sides must hold the other's current pubkey. See
|
|
`tests/mesh/_receive.py::nudge_nodeinfo_port` and the three directed-
|
|
send tests (`test_direct_with_ack`, `test_traceroute`,
|
|
`test_telemetry_request_reply`) for the canonical pattern.
|
|
|
|
## Layout
|
|
|
|
```text
|
|
mcp-server/
|
|
├── pyproject.toml
|
|
├── README.md
|
|
└── src/meshtastic_mcp/
|
|
├── __main__.py # entry: python -m meshtastic_mcp
|
|
├── server.py # FastMCP app + @app.tool() registrations (thin)
|
|
├── config.py # firmware_root, pio_bin, esptool_bin, etc.
|
|
├── pio.py # subprocess wrapper (timeouts, JSON, tail_lines)
|
|
├── devices.py # list_devices (findPorts + comports)
|
|
├── boards.py # list_boards / get_board (pio project config parse + cache)
|
|
├── flash.py # build, clean, flash, erase_and_flash, update_flash, touch_1200bps
|
|
├── serial_session.py # SerialSession + reader thread + ring buffer
|
|
├── registry.py # session registry + per-port locks
|
|
├── connection.py # connect(port) ctx mgr — SerialInterface + port lock
|
|
├── info.py # device_info, list_nodes
|
|
├── admin.py # set_owner, get/set_config, channels, send_text, reboot/shutdown/factory_reset
|
|
└── hw_tools.py # esptool / nrfutil / picotool wrappers
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
- **"Could not locate Meshtastic firmware root"** — set `MESHTASTIC_FIRMWARE_ROOT`.
|
|
- **"Could not find `pio`"** — install PlatformIO or set `MESHTASTIC_PIO_BIN`.
|
|
- **"Port is held by serial session ..."** — call `serial_close(session_id)` or `serial_list` to find it.
|
|
- **`factory.bin` not found after build** — the env may not be ESP32; only ESP32 envs produce a `.factory.bin`.
|
|
- **`touch_1200bps` reported `new_port: null`** — the device may not have 1200bps-reset stdio, or the bootloader re-uses the same port name. Check `list_devices` manually.
|