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 themeshtastic://doctorresource — 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://capabilitiesresource — one line: which capability groups are active (core,firmware,android,apple).- Prereq binaries are gated:
firmwaretools (build/flash/boards/userprefs) register only whenMESHTASTIC_FIRMWARE_ROOT+pioare 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 virtualmeshtasticdat127.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 # find ports (bash, ~0 schema tokens)
meshtastic-mcp devices --all # include non-Meshtastic serial ports
meshtastic-mcp info <port> # firmware version, region, node identity
meshtastic-mcp nodes <port> # mesh peers (long/short name, SNR, last-heard)
meshtastic-mcp boards # list all PlatformIO board envs
meshtastic-mcp boards --arch esp32s3 # filter by architecture
meshtastic-mcp boards get heltec-v3 # full metadata for one board
# All accept --json for structured output.
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
tolis free and finds a hit orders of magnitude sooner — always offer it.- Grinding needs
mvgrind(doctorprints how to build it); preview/apply do not. vanity_applyreplaces the identity, drops the old NodeNum from the node's own DB, and reboots the board. It needslora.regionset (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)
# Board lookup — use the CLI (no schema overhead):
meshtastic-mcp boards --query <slug> # find the env name
meshtastic-mcp boards get <env> # confirm arch + upload_speed
# 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:
uhubctlrequires udev rules to work without root. Runmeshtastic-mcp doctor— it will detect the permission issue and print the exactsudo curl … && sudo udevadm triggercommand 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.cpppicksBLEDfuSecure(Nordic Secure DFU, service0xFE59, control characteristic8ec90003-f315-4f60-9fb8-838830daea50) only when the board'svariant.hdefinesBLE_DFU_SECURE— today that'swio-t1000-salone. Every other nRF52 board, including RAK4631, falls through to plainBLEDfu(Adafruit's Bluefruit library): legacy service00001530-..., control characteristic00001531-.... Either way: enable notifications/indications on the control characteristic, write0x01to it, and the node disconnects and reboots into the bootloader, advertising under a new BLE name (OTAFIX boards use<BOARD>_DFU, e.g.4631_DFUfor 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 (itsDFU_SERVICE_UUIDconstant is the00001530-...one, full stop) — it has no path for aBLE_DFU_SECUREboard likewio-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.ziprelease asset (not the.uf2/.hex) — that's the format this DFU protocol expects. One call does both legs unassisted — itsjump_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>_DFUname 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 FICRDEVICEADDR(getDeviceName()infirmware/src/main.cpp) — not derived frommy_node_num/device_info's node id in any way you can compute offline. Scan (BleakScanner.discover) and match by the knownshort_nameprefix, 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_PINneeds 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 toBluetoothStatus(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_pocketis explicitly "rak4631 pin map + OLED" inplatformio.ini; don't assumehw_model: RAK4631alone 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'sLOG_INFOincluded the passkey digits inv2.7.26.54e0d8d(what this was tested against) and still does ondevelop, but a firmware security fix logged onlymatch_requestfor 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 scriptedbleaksession here, so the OS pairing prompt sat with nothing to type in and the connection was dropped. This is expectedRANDOM_PINbehavior, not a bug — switch toFIXED_PIN(a value you already know) for scripted/headless testing instead of chasing it.get_config-read the currentbluetooth.mode/fixed_pinbefore changing anything, and restore them (set_config+reboot+get_configto confirm) once testing is done — a device left inFIXED_PINcarries a known, reusable pairing credential indefinitely otherwise. Separately, firmwaredevelop(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 themeshtastic-e2eskill).
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=falsekeeps them.- Prefer the recorder windows over ad-hoc reads — they're timestamped and align with app snapshots.
- Never apply a ground key whose
verifiedis false, and never apply one without telling the operator the old node id first — the change is not reversible without the previous key.