| name | meshtastic-device-ops |
| license | GPL-3.0-only |
| description | Discover, connect to, drive, configure, observe, and flash Meshtastic devices through the Meshtastic MCP server. Use when an agent needs to bring up a radio (serial or TCP), read or change device/channel config, send messages, inspect the node DB, watch live packets/telemetry/logs via the recorder, recover a bricked device, or build/flash firmware. Covers the non-e2e MCP tool surface; for cross-plane device↔app testing use the `meshtastic-e2e` skill. |
Meshtastic device operations (via the MCP server)
The Meshtastic MCP server exposes a large tool surface, plus resources and prompts. This
skill maps common operator intents to the right tools and the safe order to use them.
First: know your environment
doctor (tool) or the meshtastic://doctor resource — what's installed, what's
missing, and the exact command to acquire it. Call this first if any tool fails with a
missing-prerequisite error, or before an e2e/flash run.
meshtastic://capabilities resource — one line: which capability groups are active
(core, firmware, android, apple).
- Prereq binaries are gated:
firmware tools (build/flash/boards/userprefs) register only
when MESHTASTIC_FIRMWARE_ROOT + pio are present. Core (admin/recorder/transport) is always on.
Connect
Two transports, same admin surface:
- Serial: a USB port from
list_devices (e.g. /dev/cu.usbmodem101).
- TCP:
tcp://host:port (a networked node, or a virtual meshtasticd at 127.0.0.1:4403).
Prefer the CLI subcommands for read-only discovery — they cost no MCP schema tokens
and work without the server running:
meshtastic-mcp devices
meshtastic-mcp devices --all
meshtastic-mcp info <port>
meshtastic-mcp nodes <port>
meshtastic-mcp boards
meshtastic-mcp boards --arch esp32s3
meshtastic-mcp boards get heltec-v3
Fall back to MCP tools when already in an MCP session or when you need board metadata
for a follow-on build_start:
list_devices # find candidates (include_unknown=true to see every port)
device_info <port> # firmware version, node identity, channel summary
list_nodes <port> # the node DB (long/short name, SNR, last-heard, position)
One MCP call per serial port at a time — the port lock is exclusive and non-blocking:
contention fails fast with "busy … Retry shortly" (it does not queue). Open → act → close.
Configure (mutating — confirm-gated)
get_config <port> <section> # lora | device | position | power | network | display | ...
set_config <port> <section> <field>=<value> ...
get_channel_url <port> # the shareable channel URL (keys)
set_channel_url <port> <url>
set_owner <port> --long ... --short ...
After a write, reboot then re-read to prove it persisted to NVS, not just RAM
(reboot <port> → get_config). Region (lora.region) and network.enabled_protocols
are the two that bite — see meshtastic-e2e topology.md.
Vanity identity (chosen node id / app colour)
A node's number is crc32(x25519_public_key) and every app paints it with the low 24 bits
read as RGB, so both are chosen by grinding keys, not by setting a field.
vanity_grind_start color=crimson tol=6 # or pattern=dc80, or both; returns job_id
vanity_grind_poll <job_id> # hits[]; NEVER use one with verified=false
vanity_preview <private_key> # what any key gives you — no device, no GPU
vanity_apply <private_key> <port> confirm=true
tol is free and finds a hit orders of magnitude sooner — always offer it.
- Grinding needs
mvgrind (doctor prints how to build it); preview/apply do not.
vanity_apply replaces the identity, drops the old NodeNum from the node's own DB, and
reboots the board. It needs lora.region set (keygen is skipped while UNSET) and refuses an
unclamped key. Keep the previous key if a way back matters.
- Hits are private keys. Don't echo them further than needed; see
docs/vanity.md.
Message + observe
send_text <port> <text> [--dest <nodeId>] # broadcast (^all) or directed
The recorder is always capturing to JSONL; query windows instead of tailing:
packets_window # recent RX/TX packets (portnum, from/to, payload) — wire truth
telemetry_timeline # device/environment metrics over time
logs_window # firmware log lines
events_window # recorder-marked events
mark_event # drop a labeled marker to anchor a later query
recorder_status / recorder_pause / recorder_resume / recorder_export
For app-visible delivery vs wire truth (broadcast shows an error icon in a flat mesh even when
delivered) see meshtastic-e2e references/loop-outbound.md.
Recover + flash (firmware capability)
meshtastic-mcp boards --query <slug>
meshtastic-mcp boards get <env>
# Build + flash via MCP (async to avoid 60 s client timeout):
build_start <env> # returns build_id immediately
build_poll <build_id> # poll until status=done
pio_flash <env> <port> / erase_and_flash <env> <port> / update_flash <env> <port>
touch_1200bps <port> # bounce into the bootloader (nRF/RP2040)
Chip-specific escape hatches when pio can't help: esptool_*, nrfutil_*, picotool_*
(raw passthroughs; destructive ones are confirm-gated). For a wedged USB device, power-cycle
the hub port with uhubctl_list / uhubctl_power / uhubctl_cycle.
Linux: uhubctl requires udev rules to work without root. Run
meshtastic-mcp doctor — it will detect the permission issue and print
the exact sudo curl … && sudo udevadm trigger command to fix it.
BLE OTA DFU (manual — no MCP tool covers this yet)
Every flash/pio_flash/nrfutil_dfu path above is USB (serial or UF2). There is
no MCP tool for the wireless leg — the Nordic legacy BLE DFU that the Meshtastic
Android app uses for its in-app bootloader/firmware upgrade, and that an
Adafruit/OTAFIX-family nRF52 bootloader (e.g. meshtastic/Adafruit_nRF52_Bootloader_OTAFIX)
speaks natively. To validate that path from a dev machine instead of a phone:
- Buttonless jump from app mode is one of two GATT services, chosen at compile
time — check which before assuming a UUID.
NRF52Bluetooth.cpp picks
BLEDfuSecure (Nordic Secure DFU, service 0xFE59, control characteristic
8ec90003-f315-4f60-9fb8-838830daea50) only when the board's variant.h
defines BLE_DFU_SECURE — today that's wio-t1000-s alone. Every other
nRF52 board, including RAK4631, falls through to plain BLEDfu
(Adafruit's Bluefruit library): legacy service 00001530-..., control
characteristic 00001531-.... Either way: enable notifications/indications
on the control characteristic, write 0x01 to it, and the node disconnects
and reboots into the bootloader, advertising under a new BLE name (OTAFIX
boards use <BOARD>_DFU, e.g. 4631_DFU for RAK4631 — see that repo's
README "BLE advertising names" table).
- Legacy DFU transfer in bootloader mode. The bootloader's own GATT
service is always the older Nordic Legacy DFU (
00001530-..., control
point ...1531, data ...1532) regardless of which service the app used
to jump there. recrof/nrf_dfu_py
(pure Python + bleak) speaks only this legacy service (its
DFU_SERVICE_UUID constant is the 00001530-... one, full stop) — it has
no path for a BLE_DFU_SECURE board like wio-t1000-s, so it's a match
for RAK4631 and most other nRF52 targets, not a universal tool. Clone it,
pip install bleak, then from that checkout:
python3 dfu_cli.py --scan <firmware-or-bootloader.zip> <device-name-or-addr>.
Use the *-ota.zip release asset (not the .uf2/.hex) — that's the format
this DFU protocol expects. One call does both legs unassisted — its
jump_to_bootloader() sends the exact same 2-byte legacy opcode write
described in step 1, then it rescans and transfers — so giving it the
app-mode name/address up front is usually enough; you don't need a
separate manual jump. Do the jump as its own step only when you need to
debug the jump in isolation (its post-jump bootloader rescan matches by
substring against a literal "DFU"/MAC-increment heuristic, not the
board's exact advertised name, so once already in bootloader mode,
re-running against the bootloader's own <BOARD>_DFU name is the more
reliable retry).
- Finding the device's BLE name is a scan-and-match, and a name prefix can be
ambiguous — resolve it to exactly one device before acting. The app's
advertised name is
<short_name>_<hex><hex> where the hex suffix is the
last two bytes of the nRF52's FICR DEVICEADDR (getDeviceName() in
firmware/src/main.cpp) — not derived from my_node_num/device_info's
node id in any way you can compute offline. Scan (BleakScanner.discover)
and match by the known short_name prefix, but confirm the scan turned up
exactly one match before connecting: nrf_dfu_py (and most such tools)
connects to the first match among the names/addresses you give it, so an
ambiguous prefix on a mesh with more than one device sharing it can jump or
flash the wrong node.
bluetooth.mode = RANDOM_PIN needs a human (or the app) watching for the
passkey — an unattended agent session can't complete pairing, screen or no
screen. The firmware sends the 6-digit passkey to BluetoothStatus (the
app's pairing UI reads it from there) and additionally shows it on-screen
#if HAS_SCREEN — some RAK4631 builds do have one (e.g. rak_wismesh_pocket
is explicitly "rak4631 pin map + OLED" in platformio.ini; don't assume
hw_model: RAK4631 alone tells you whether the running build has a
display). capture_screen (see Hardware UI checks above) can read an
OLED when one's present — worth reaching for on a future attempt, since
neither it nor a live debug log was actually checked in real time here;
the passkey/display behavior below is reasoned from firmware source
after the fact, not confirmed against what this device actually showed.
Whether the passkey is visible in a live debug log (set_debug_log_api)
depends on the exact firmware build — onPairingPasskey's LOG_INFO
included the passkey digits in
v2.7.26.54e0d8d (what this was tested against) and still does on
develop, but a firmware security fix logged only match_request for a
stretch of the 2.7.x line in between (redacting pairing secrets from
logs) — don't assume the log line carries it on an arbitrary build; the
app's pairing UI is the one path guaranteed to receive it regardless.
Neither was being watched in a scripted bleak session here, so the OS
pairing prompt sat with nothing to type in and the connection was
dropped. This is
expected RANDOM_PIN behavior, not a bug — switch to FIXED_PIN (a value
you already know) for scripted/headless testing instead of chasing it.
get_config-read the current bluetooth.mode/fixed_pin before changing
anything, and restore them (set_config + reboot + get_config to
confirm) once testing is done — a device left in FIXED_PIN carries a
known, reusable pairing credential indefinitely otherwise.
Separately, firmware develop (2.8) does carry two real nRF52 BLE-pairing
fixes not yet in 2.7.x that are worth knowing about if pairing looks
flaky on a 2.7.x build: a passkey callback that wasn't restored after a
BT disable/re-enable cycle without a reboot (#11027), and a BLE-task
stack overflow that could crash the device mid-pairing on nrf52840
targets (#11190).
macOS-specific friction, all one-time per machine/device pair, not per session:
- Bluetooth must be explicitly on (Control Center) —
bleak/CoreBluetooth error
clearly (BleakBluetoothNotAvailableError: POWERED_OFF) when it isn't, so this
fails fast rather than silently.
- The terminal app driving the script needs Bluetooth permission granted
(System Settings → Privacy & Security → Bluetooth) — without it, scanning
either errors or (confusingly) just finds nothing.
- A newly-enumerated USB device can trigger a silent macOS "accessory" permission
popup that hides the port from
list_devices/ls /dev/cu.* until approved —
if a device that was just flashed or reset seems to vanish from USB entirely,
check for that popup before assuming a bad flash.
Hardware UI (OLED) checks
send_input_event drives the device's buttons; capture_screen grabs the OLED (camera/OCR
optional — see doctor for the [ui] extra). This is device-only; for app UI use meshtastic-e2e.
Grounded answers
android_docs_search / android_docs_fetch — Android/Compose/API questions answered from
the official Knowledge Base (no guessing) when working with the Android app.
Prompts (slash workflows)
bringup_device — connect + verify a healthy baseline (firmware, region, node count).
inbound_loop / triage_e2e_failure — e2e workflows (see the meshtastic-e2e skill).
Hard rules
- One call per serial port at a time (exclusive non-blocking lock).
- Mutations are confirm-gated and reversible-by-reboot only for RAM writes — re-read after reboot.
factory_reset(full=true) wipes BLE bonds + the identity key; full=false keeps them.
- Prefer the recorder windows over ad-hoc reads — they're timestamped and align with app snapshots.
- Never apply a ground key whose
verified is false, and never apply one without telling the
operator the old node id first — the change is not reversible without the previous key.