- Introduced a new test suite for multi-hop NextHop directed-message delivery and relay recovery in `test_nexthop_multihop_recovery.py`. This includes tests for end-to-end delivery and recovery after relay drop. - Implemented unit tests in `test_main.cpp` for NextHop routing reliability mitigations, covering: - M1: Ambiguity-aware last-byte resolution. - M2: NextHopRouter's strict-neighbor gate and hop limit checks. - M3: Route-health freshness and failure decay. - Enhanced mock classes to facilitate controlled testing of node behaviors and routing logic.
Meshtastic MCP Server
An MCP 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
meshtasticPython API: owner name, config (LocalConfig + ModuleConfig), channels, messaging, reboot/shutdown/factory-reset - Call
esptool,nrfutil,picotooldirectly 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 —
pioon$PATHor at~/.platformio/penv/bin/pio - The Meshtastic firmware repo checked out somewhere (set via
MESHTASTIC_FIRMWARE_ROOT) - Optional:
esptool,nrfutil,picotoolon$PATH(or under the firmware venv at.venv/bin/) if you want to use the direct-tool wrappers
Install
cd <firmware-repo>/mcp-server
python3 -m venv .venv
.venv/bin/pip install -e .
Verify:
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):
{
"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 (43)
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 |
USB power control (uhubctl)
| Tool | What it does |
|---|---|
uhubctl_list |
Enumerate USB hubs + attached-device VID/PID (read-only) |
uhubctl_power |
Drive a hub port on or off; off requires confirm=True |
uhubctl_cycle |
Off → wait delay_s → on; confirm=True required |
Target a port by explicit (location, port) (raw uhubctl syntax like
location="1-1.3", port=2) or by role ("nrf52", "esp32s3"). Role
lookup checks MESHTASTIC_UHUBCTL_LOCATION_<ROLE> +
MESHTASTIC_UHUBCTL_PORT_<ROLE> env vars first, then auto-detects via VID
against uhubctl's output.
Requires uhubctl on PATH:
brew install uhubctl # macOS
apt install uhubctl # Debian/Ubuntu
Modern macOS + PPPS-capable hubs generally work without root. On Linux
without udev rules, or on old macOS with driver quirks, you may need
sudo. If uhubctl returns a permission error the MCP tool raises a
clear UhubctlError pointing at the
udev-rules / sudo fallback
rather than auto-sudo'ing mid-run.
Safety
- All destructive flash/admin tools require
confirm=Trueas 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 activesession_id. Close the session first. - Flash confirmation by architecture:
erase_and_flash/update_flasherror if the env's architecture isn't ESP32 — usepio_flashfor 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) |
MESHTASTIC_MCP_TCP_HOST |
unset | host or host:port of a meshtasticd daemon to surface as a TCP device (see "TCP / native-host nodes" below) |
TCP / native-host nodes
The native-macos and native PlatformIO envs build a headless meshtasticd
binary that runs on the host (Apple Silicon / Intel macOS, or Linux Portduino).
The daemon exposes the meshtastic TCP API on port 4403 rather than a USB
serial endpoint — point the MCP server at it via MESHTASTIC_MCP_TCP_HOST:
# 1. Build + run a daemon on this host (see variants/native/portduino/platformio.ini
# for full Homebrew prereqs and CH341 LoRa-adapter setup).
pio run -e native-macos
~/.meshtasticd/meshtasticd
# 2. Point the MCP server at it.
export MESHTASTIC_MCP_TCP_HOST=localhost # or host:port, default port 4403
First-run gotcha — MAC address. meshtasticd derives its MAC from the
USB adapter's serial-number / product strings. Many cheap CH341 dongles
(MeshStick included — VID 0x1A86 / PID 0x5512) ship with iSerialNumber=0
and iProduct=0, so the daemon aborts on boot with *** Blank MAC Address not allowed!. Set the MAC explicitly in config.yaml:
# Under General:
MACAddress: 02:CA:FE:BA:BE:01
Use a locally-administered address (first byte's second-LSB set, e.g.
02:* / 06:* / 0A:* / 0E:*) to avoid colliding with a real OUI.
There is also a --hwid AA:BB:CC:DD:EE:FF CLI flag visible in
meshtasticd --help, but it is currently broken in
MAC_from_string() (src/platform/portduino/PortduinoGlue.cpp): the
function strips colons from its parameter but then reads bytes from the
global portduino_config.mac_address, so --hwid is silently overridden
when MACAddress: is also set, and crashes the daemon (uncaught
std::invalid_argument: stoi: no conversion) when it isn't. Use the YAML
form until that's fixed upstream.
list_devices will surface the daemon as tcp://localhost:4403 with
likely_meshtastic=True, so device_info, list_nodes, get_config,
set_config, set_owner, send_text, userprefs_*, and the admin RPCs
auto-select it when no port is passed. Pass port="tcp://other-host:9999"
explicitly to target a different daemon.
Tools that don't apply to a TCP/native node (no USB hardware to operate
on) raise a clear ConnectionError rather than failing mysteriously:
pio_flash, erase_and_flash, update_flash, touch_1200bps,
serial_open (use info/admin tools directly), and the vendor escape hatches
esptool_*, nrfutil_*, picotool_*. pio_flash against a native* env
similarly raises — there's no upload step; use build and run the binary
directly.
The pytest harness in tests/ still assumes USB-attached devices per role —
TCP-aware fixtures are not part of this surface yet.
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
./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-bakeoverrides.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. Includestest_peer_offline_recoverywhich uses uhubctl to power-cycle one peer mid-conversation and verifies the mesh recovers (skips without uhubctl).telemetry— periodic telemetry broadcast + on-demand request/reply (TELEMETRY_APPwithwantResponse=True).monitor— boot log has no panic markers within 60 s of reboot.recovery—uhubctlpower-cycle round-trip: verifies the hub port can be toggled off/on, the device re-enumerates with the samemy_node_num, and NVS-resident config (region, channel, modem preset) survives a hard reset. Requiresuhubctlon PATH; skips cleanly otherwise.ui— input-broker-driven screen navigation (AdminMessage.send_input_eventinjection →Screen::handleInputEvent→ frame transition). Parametrized on the screen-bearing role (heltec-v3 OLED). Captures images via USB webcam + OCRs them for HTML-report evidence. Requirespip install -e '.[ui]'andMESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=<index>; tier is auto-deselected ifcv2isn't importable.fleet— PSK-seed isolation: two labs with different seeds never overlap.admin— owner persistence across reboot, channel URL round-trip,lora.hop_limitpersistence.provisioning— region/channel baking, userPrefs survivefactory_reset(full=False).
UI tier setup
The tests/ui/ tier drives the on-device OLED via the firmware's existing
AdminMessage.send_input_event RPC (no firmware changes required) and
verifies transitions via a macro-gated log line + camera + OCR. Summary:
- Install extras:
pip install -e 'mcp-server/.[ui]'— pulls inopencv-python-headless,numpy,easyocr,Pillow. First easyocr run downloads ~100 MB of models to~/.EasyOCR/; an autouse session fixture pre-warms the reader so per-test OCR is <100 ms after that. - Point a USB webcam at the heltec-v3 OLED. Discover its index:
.venv/bin/python -c "import cv2; [print(i, cv2.VideoCapture(i).read()[0]) for i in range(5)]" - Export the per-role device env var:
export MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0 - Run:
Captures land under
./run-tests.sh tests/ui -vtests/ui_captures/<session_seed>/<test_id>/, one PNG +.ocr.txtperframe_capture()call, with a per-testtranscript.mdstepping through event → frame → OCR. The HTML report embeds the full image strip inline (pass or fail).
On macOS, cv2.VideoCapture(0) triggers the TCC Camera permission prompt
on first use. Pre-grant Terminal (or your IDE's terminal) before running.
The OpenCVBackend fails fast on 10 consecutive black frames so a silent
permission denial surfaces as a clear error, not an empty PNG strip.
No camera? Set MESHTASTIC_UI_CAMERA_BACKEND=null (or leave the device var
unset). Tests still exercise the event-injection path and log assertions;
captures just become 1×1 black PNGs.
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-reportlogevent stream; consumed by the TUI.fwlog.jsonl— firmware log mirror (meshtastic.log.linepubsub → JSONL).flash.log— tee of all pio / esptool / nrfutil / picotool subprocess output during the run (driven byMESHTASTIC_MCP_FLASH_LOG).
Live TUI
.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).
Set MESHTASTIC_UI_TUI_CAMERA=1 to mount a bottom-of-screen UI camera
panel. Left side: the latest capture PNG rendered as Unicode half-blocks
(via rich-pixels, works in any terminal — no kitty/sixel required).
Right side: live transcript tail ("step 3 — frame 4/8 name=nodelist_nodes
— OCR: Nodes 2/2") so you can see every event-injection and its result
as each UI test runs. Requires the [ui] extras for image rendering; the
transcript alone works without them.
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.pysnapshot + restoreuserPrefs.jsonc; never edituserPrefs.jsoncfrom inside a test. Use thetest_profile/no_region_profilefixtures for ephemeral overrides. SerialInterfaceholds 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_portand the three directed- send tests (test_direct_with_ack,test_traceroute,test_telemetry_request_reply) for the canonical pattern.
Layout
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 setMESHTASTIC_PIO_BIN. - "Port is held by serial session ..." — call
serial_close(session_id)orserial_listto find it. factory.binnot found after build — the env may not be ESP32; only ESP32 envs produce a.factory.bin.touch_1200bpsreportednew_port: null— the device may not have 1200bps-reset stdio, or the bootloader re-uses the same port name. Checklist_devicesmanually.