Skip to main content

mcardupilot

ArduPilot SITL instances, flights and dataflash logs for LLM agents, as a Python library with an MCP server on top of it.

Several sessions on one workstation fly SITL at the same time. Each SITL instance number picks its ports (MAVLink TCP 5760 + 10n, the JSON backend 9002 + 10n), so mcardupilot hands out instances from a shared pool through flock leases. A lease names its holder, and the kernel drops it when the holder exits, so a crashed run never strands an instance.

Status: phases 0 to 4 of docs/PLAN.md: settings, leases, the Sitl library class, missions, log housekeeping, the MCP flight tools, and log reading.

Documentation

The documentation site lives in docs-site/ (Astro and Starlight), to be published at https://mcardupilot.warehack.ing: a tutorial, how-to guides, a tool and settings reference generated from the code (make -C docs-site data), and explanations of the design.

Flying through the tools

open_sitl starts a SITL on a leased instance and returns a session id. A thread per session owns the MAVLink link and keeps it alive between calls (GCS heartbeat and RC overrides, paced on sim time), so the failsafes stay quiet while the agent thinks. A hover hop:

open_sitl(model="quadplane")                       # frame defaults from the tree's vehicleinfo.json
arm(session_id, mode="QLOITER")
rc_override(session_id, channels={3: 2000})        # climb
wait_until(session_id, "altitude", 5, then_rc={3: 1500})   # hold at 5 m, applied in the same tick
set_mode(session_id, "QLAND")
wait_until(session_id, "disarmed", timeout_s=120)
close_sitl(session_id)                             # SITL killed, instance freed, BIN kept

wait_until returns within about 85 s with a reason (met, timeout, died, cap) and a snapshot whose seq goes back in as since_seq; long flights are loops of bounded waits.

Agent time is sim time: at speedup N every second spent between calls is N sim seconds with the last RC still held. So react inside the wait (then_rc, then_mode, then_speedup, applied in the tick the condition is met), schedule against the autopilot clock (sim_time until t = snapshot.t_sim_s + 15, not time, which counts from the call), and slow down to think (set_speedup(0.05): SITL cannot pause, but applies a fractional SIM_SPEEDUP live). A wait that settles in the server returns the snapshot taken in that tick, a measurement at that moment.

A manoeuvre of several steps still costs one round trip per step that way, so fly_steps runs the whole sequence inside the server. Each step applies its actions (rc, mode, speedup, params) in the tick it starts and then waits on one wait_until condition; the next step starts in the tick the previous one settled. The hop above, after arm:

fly_steps(session_id, steps=[
    {"rc": {3: 2000}, "until": "altitude", "value": 5},
    {"rc": {3: 1500}, "until": "sim_time", "value": 15, "relative": True},   # hold 15 s
    {"mode": "QLAND", "until": "disarmed", "timeout_s": 120},
])

Every step is validated before any runs. The call returns within about 85 s with each settled step's reason, start and end sim time and settle-tick snapshot, or with status running, which steps_status follows; abort_steps stops it with RC left as last set. A program cannot arm or upload a mission, since those need round trips with retries. On an idle-paced session a running program keeps the sim at full speed, whether or not a call is waiting on it, and idle applies once it ends.

Session tools: open_sitl, snapshot, get_param, set_param, set_mode, arm, disarm, set_speedup, rc_override, upload_mission, wait_until, close_sitl, list_sessions. Step tools: fly_steps, steps_status, abort_steps. Wind tools: set_wind, wind_history. Build tools: build_sitl, build_status, cancel_build, list_builds. Log tools: list_logs, log_summary, log_fields, log_messages, log_compare, pin_log. Lease tools: lease_instance, release_instance, kill_instance (strays only, never another holder's), list_instances.

Wind and gusts

open_sitl(wind=...), set_wind and a step's wind action set a mean wind (speed, the direction it blows from, an optional updraft) and its gusts. gusts: "dryden" runs the MIL-F-8785C model in mcardupilot.turbulence (exact update) every 0.05 sim s from the current height and airspeed and sends SITL the sum as SIM_WIND_SPD, SIM_WIND_DIR and SIM_WIND_DIR_Z, so gusts evolve through every wait; "sitl" hands turbulence to SITL's own SIM_WIND_TURB; "off" is steady. scale strengthens or weakens the gusts, and a seed repeats them exactly in another flight, since real SITL ticks on an exact 20 Hz sim clock. Snapshots show the mean wind and the gust now; wind_history returns up to two minutes of it.

Building SITL

build_sitl runs ./waf configure --board sitl (only when needed) and ./waf plane (or another vehicle) as a background job; build_status follows it with bounded waits and waf's [n/N] progress, cancel_build stops it, and a finished build reports the binary and its provenance. Only trees named in MCARDUPILOT_BUILD_TREES are built, plus the default tree while its git checkout is clean, so a patched tree other runs fly is never rebuilt by accident. waf runs under MCARDUPILOT_BUILD_PYTHON, else ~/venv-ardupilot, else python3 outside this server's venv, and a tree copied from another keeps a waf lock naming the original, which the build detects and rebinds instead of building into it.

Reading logs

log_summary splits a flight into phases at every flight-mode change, arm and disarm, and (in AUTO) mission item, with each phase's duration and averages: airspeed, groundspeed, throttle, lift motors (found from the log's own SERVOn_FUNCTION), pitch, peak roll, angle of attack, current, voltage, peak height. It also returns the firmware, every autopilot text with its time, and message counts. log_fields returns a time series of any message's columns with their units, thinned to fit; log_messages returns whole messages; list_logs lists the kept logs. A log is named by a session id (its live log), a kept log's name, or a path. Reads go through pymavlink's per-type index, so a 45 MB log summarizes in about a third of a second.

Kept logs are held to log_budget_gb: after every close that keeps a log, and once at server start, the oldest kept logs past the budget are deleted (the close's pruned lists them). Only the kept log directory is pruned. A log that is pinned (pin_log), written in the last ten minutes, or under a running SITL is never taken. server_info shows logs_used_gb beside the budget, and list_logs which logs are pinned.

Each phase also carries energy_wh, BAT volts times amps integrated by the trapezoid rule with the samples interpolated to the phase bounds, so the phases add up to the summary's own energy_wh.

log_compare(a, b) is for A/B tests (mcardupilot.logcompare.compare in the library). It keys each flight's phases by mode, armed state and mission item and pairs them by longest common subsequence, so an extra phase in one flight is listed on its own instead of shifting every later pair. Each pair, and the totals (duration, energy, peak height, peak current), give every number as {a, b, delta, pct}. It also diffs the parameters (last PARM value per name, with ArduPilot's own STAT_* and ground-pressure values set apart), the autopilot texts (those that differ only in their numbers grouped by a template), and the firmware banners.

A MODE record stamped at a log's first timestamp is the mode when the log file opened, not a switch at that moment: ArduPilot writes its startup records then.

Every Claude session runs its own server on the shared data directory. At start a server kills SITLs that a dead server's sessions left behind, judged by the lease: an instance whose lock it can take has no live owner.

Run it

uv run mcardupilot                                   # stdio MCP server
claude mcp add mcardupilot-dev -- uv run --directory ~/claude/mcardupilot mcardupilot   # the working tree
claude mcp add mcardupilot -- uvx --from ~/claude/mcardupilot mcardupilot                # a built copy
uv run python -m mcardupilot.leases status           # who holds which instance
uv run python -m mcardupilot.logs prune --root DIR   # dry run: oldest logs past the budget

uv run --directory runs the editable install, so a restarted server has every edit. uvx --from installs a copy, rebuilt whenever pyproject.toml or anything under src/ changes (cache-keys). From PyPI it is just claude mcp add mcardupilot -- uvx mcardupilot.

Settings

Environment variables, or a .env in the working directory.

Variable Default Meaning
MCARDUPILOT_ARDUPILOT ~/ardupilot ArduPilot tree (read and run only)
MCARDUPILOT_BINARY <ardupilot>/build/sitl/bin/arduplane SITL binary
MCARDUPILOT_DATA_DIR ~/.local/share/mcardupilot leases/, sessions/, jobs/, kept logs
MCARDUPILOT_INSTANCE_POOL 40-59 "40-59" or "40,41,45-47"; 0, 14 and 15 are refused
MCARDUPILOT_WAIT_CAP_S 85 longest any waiting tool blocks
MCARDUPILOT_SESSION_IDLE_S 1800 idle sessions close after this
MCARDUPILOT_STALL_S 30 a session's SITL whose clock stops this long is dead
MCARDUPILOT_BUILD_TREES empty trees build_sitl may build, separated by : or ,
MCARDUPILOT_BUILD_DEFAULT_TREE 1 also build the default tree while its checkout is clean
MCARDUPILOT_BUILD_PYTHON auto the interpreter waf runs under
MCARDUPILOT_LOG_BUDGET_GB 20 kept logs are pruned past this
MCARDUPILOT_TRANSPORT stdio stdio or http
MCARDUPILOT_HTTP_HOST / _PORT 127.0.0.1 / 8374 for http

Instance 0 belongs to autotest.py, and 14 and 15 land on the desktop containers' VNC ports, so the pool validator refuses them.

Library

from mcardupilot.sitl import Sitl

with Sitl(frame="quadplane", defaults=[".../default_params/quadplane.parm"], speedup=10) as s:
    s.arm_on_pad("QLOITER")     # leased a free pool instance, launched, waited for home
    s.rc[2] = 1800
    s.wait(6)                   # sim seconds, on the autopilot's clock
    print(s.height(), s.provenance["ardupilot_sha"])
# SITL's process group is killed, its ports are free, the lease is released

instance=None (the default) takes the first pool instance whose lock is free and whose ports (MAVLink TCP, the JSON backend, and a bridge's own) nothing else holds; instance=42 asks for one and raises InstanceBusy naming the holder. A SITL that exits or PANICs raises SitlDied after being killed. A script that dies without close() still kills its SITL at exit; one that is SIGKILLed leaves <run_root>/<n>/sitl.pid, which mcardupilot.sitl.kill_instance reads.

mcardupilot.mission uploads items given in metres from home (nav, do, box_mission), retrying while ArduPilot ignores mission traffic after boot, and reads a mission back. keep_logs="last" with log_dest= moves a flight's BIN out of the instance directory at close(); mcardupilot.logs.prune deletes the oldest logs past log_budget_gb, never one that is pinned (logs.pin), written in the last ten minutes, or under a SITL that is still running.

A subclass sets its own state, then calls start(), and overrides tick(kind, msg) to drive time-varying inputs from every pumped message. A Bridge (see sitl.py) plugs in an external physics process on SITL's JSON backend.

Development

uv run pytest -q                 # about 20 s on 8 workers, against tests/fake_autopilot.py
uv run pytest -m sitl            # real hops on a stock arduplane (MCARDUPILOT_STOCK_ARDUPILOT)
uvx ruff check . && uvx ruff format --check .

Metadata

Release files for mcardupilot 2026.10.3.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mcardupilot 2026.10.3.1
File Size Uploaded
mcardupilot-2026.10.3.1.tar.gz 77.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mcardupilot 2026.10.3.1
File Interpreter ABI Platform
mcardupilot-2026.10.3.1-py3-none-any.whl Python 3 none any Details

Total release size: 170.9 kB

Release files / mcardupilot-2026.10.3.1.tar.gz

Download URL mcardupilot-2026.10.3.1.tar.gz
Size 77.6 kB
Tags Source
SHA-256 checksum
How to use checksums
e2c96243b33e566e0445365bbc9b535697391394324bcd04c65ad7cb13bad83b
BLAKE2b-256 checksum
How to use checksums
67d1839038101f963da836f11378ed898434925ccf7a9b172d7e58ea2b4fe3da
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"EndeavourOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / mcardupilot-2026.10.3.1-py3-none-any.whl

Download URL mcardupilot-2026.10.3.1-py3-none-any.whl
Size 93.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
68e203d974ef0fdf2cb45c089d2b0ca8a9efa17569e4d4046e396d87e05cd1b2
BLAKE2b-256 checksum
How to use checksums
9c8b57a18506118aae6b1064efbe64b12491e26f9198659f7239ffb9111425ac
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"EndeavourOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page