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.

Prompts

Tools say what each call does; the server's MCP prompts say how to do a whole job well, with the lessons from flying SITL through these tools written in: keep idle pacing on (the default), react inside a wait with then_rc, time holds from a settle, end a QLOITER climb a metre early, read the log before quoting a number, close the session. Every flight prompt ends by asking for a plain answer, the evidence from the log, and a recipe that flies the same flight again.

  • hop flies a hover hop to a height, holds it and lands, in optional seeded wind.
  • ab_test flies one manoeuvre with two parameter sets, or two builds via binary_a and binary_b, in the same seeded air, and judges the difference against the spread.
  • postmortem arrives with a log's phases and autopilot texts already summarized (repeats folded), then sends the agent to log_fields for the moments that matter.
  • preflight arrives with the workstation's live state: binary and staleness, leases and strays, kept logs against the budget, build Python. It changes nothing.

open_sitl takes binary (a SITL executable) or tree (an ArduPilot tree, flying its build/sitl/bin/arduplane), which is what lets ab_test compare builds. Over HTTP only binaries inside the configured trees run.

Presets

A project that flies its own airframe repeats the same long open_sitl call. A preset is that call's arguments in a JSON file shipped beside the project's sim files, with paths relative to the preset itself:

{
  "description": "Hover hop on the keel model with the harness parameters",
  "model": "quadplane-elevon:../models/keel.json",
  "params": ["../params/common.parm", "../params/airframe.parm"],
  "set": {"SERVO_AUTO_TRIM": 0},
  "rc": {"5": 1425}
}

Put the directory in MCARDUPILOT_PRESET_DIRS, and open_sitl(preset="hover") boots with it. list_presets shows what is there. Arguments given alongside override the preset's, and set, rc and wind merge key by key. Over HTTP presets open by name only.

Recipes and repro

Every session opened with open_sitl writes a recipe beside its kept log when it closes: <stem>.recipe.json, holding the open arguments as they took effect (a preset merged in, the parameter files SITL actually loaded, the whole overlay, the binary that flew) and every call that acted on the aircraft, in order, with its outcome. list_logs marks the logs that have one, and the log budget prunes it with its log.

repro(log) flies a recipe again through the same tools: it reopens the session, replays the calls (each wait and step program runs to its end, so a slower machine replays the flight, not the clock), closes, and returns log_compare of the two flights. Pass binary or tree to fly a new build against the old flight. It is the same recipe, not the same bytes, since commands land in whatever sim tick they reach: a gusty hover flown twice on real ArduPilot agreed within 0.4% on every flying phase and 0.3% on energy.

Exact flights

SITL computes the same flight every time: two idle boots at speedups 10 and 4, side by side, agree on every IMU sample. Flights differ because an input sent over MAVLink lands on whichever main-loop tick follows its arrival, a wall-clock matter (and a Lua script does no better, since SITL runs scripting on a thread that sleeps on the wall clock). So open_sitl(exact=true) pins SITL's clock, logs from boot and sends no RC overrides, taking its parameters and wind at boot; fly_exact(session_id, items) then uploads the mission behind a NAV_DELAY that waits, disarmed, for a whole sim second, and a DO_AUX_FUNCTION that arms from inside the mission. Nothing steers it after that, and the steering tools say so. A repro of the kept log is a copy: its identical checks the two logs record by record from the anchor (truth, sensors, estimator, outputs, battery) and names the first difference. On stock ArduPilot, in SITL's own turbulence, a takeoff, loiter and landing replayed identical across more than 60,000 IMU samples.

Sets of flights

One replay cannot tell a change from noise: SITL flights are not bit-for-bit repeatable, and a flight that crosses a hard threshold can come out either way. batch_compare(a=[...], b=[...]) compares two sets of any dataflash logs (a harness's included), phase by phase and in total: both distributions per metric, the gap and its size in pooled standard deviations, Welch's p, and q, that p adjusted for the dozens of tests one comparison runs (Benjamini-Hochberg). shown lists what clears q < 0.05. Measures add numbers of your own (a log field reduced over a window, a phase metric, an autopilot text), and with an op and value they become yes/no outcomes, compared by rate with Fisher's exact test.

batch_fly(log, n) flies a kept log's recipe n times on side A and n on side B, interleaved, and compares them. With no B given it flies the same binary on both sides: a null control. On real ArduPilot, three gusty hovers a side on the identical binary showed nothing across 87 tests, with hover current agreeing within 0.34%; the control is what any real gap has to beat. repro flags a flying phase that differs far more than the others, the mark of a flight near a threshold, and points to batch_fly.

Video

A session can be watched live, and any flight can be rendered to an MP4 afterwards. Both draw the same picture: an FPV nose camera with a HUD (height, airspeed, the true wind and its gust, mode, throttle, lift, current, autopilot messages), a chase camera from behind, or the FPV view with the chase view inset.

video_feed(session_id) starts a feed and returns its links: vlc http://127.0.0.1:<8400 + instance>/video.ts for VLC or mpv, and a page with an MJPEG stream for a browser. The pose is SITL's own simulated state, from the FlightGear packets it sends with --enable-fgview (which every session now launches with; it costs SITL about 4% more CPU). While anyone watches, the session runs no faster than real time, so the flight looks the way it would fly; idle pacing still holds it between the agent's calls, which the HUD shows as HOLDING.

render_video(log) renders a dataflash log, from SITL's own records of where the aircraft was (SIM, SIM2) and the wind it flew (its logged SIM_WIND_* changes), so the video plays at the speed the aircraft flew whatever speedup the session ran at. A kept log's video lands beside it as <stem>.<layout>.mp4, and the log budget counts and prunes it with the log; any other log renders into <data_dir>/videos. open_sitl(record_video="pip") does this at close. video_frame returns one frame as an image, live or from a log, so the agent can look too. Encoding uses NVENC when the GPU has it, else libx264; ffmpeg must be on PATH.

render_film(log, camera="chase"|"ground") renders the same log in Blender as a lit 3D scene (sky, sun, shadow, a runway along the home heading) to <stem>.film-<camera>.mp4. The server computes every pose and camera position; Blender only builds the scene and renders frames, each of which goes to ffmpeg and is deleted as soon as it is complete. An airframe description (JSON: STL parts with colours, and rotors that each spin with their own logged output) draws your own aircraft; a preset names it with "airframe", the recipe records it, and every film of that preset's flights wears it. MCARDUPILOT_FILM_AIRFRAME sets a default, and without any the film draws a simple quadplane with spinning props. MCARDUPILOT_FILM_BLENDER is blender on PATH, a path, or docker:<image>.

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.

Container mode builds explicit inputs instead of a working tree: give image (by digest, name@sha256:...; a bare tag is refused) and commit, optionally patches in order, and tree names the source repository, which is only read. A fresh local clone is checked out at the commit, its submodules cloned from the source's own (so nothing needs the network), the patches applied after git apply --check, and the build runs in the image at one fixed path with no network, SOURCE_DATE_EPOCH from the commit and ccache off. The output lands in <data_dir>/builds/<build_id>/ in tree layout with a manifest.json (inputs, toolchain versions, binary sha256 and size, build time); build_id is a hash of the inputs, so asking again answers from the finished build. reproduce=true builds a second time from a fresh checkout and records whether the binary came out byte for byte the same, and if not, which ELF sections differ. open_sitl(tree=<build dir>) flies it, and its provenance then carries the build id, image, commit and patch hashes from the manifest.

Reading logs

log_summary splits a flight into phases at every flight-mode change, arm and disarm, takeoff and touchdown (ArduPlane's STAT.isFlying, so a hover's averages leave out ground idle), 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 /path/to/mcardupilot mcardupilot   # the working tree
claude mcp add mcardupilot -- uvx --from /path/to/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_PRESET_DIRS empty directories of open_sitl presets, separated by : or ,; <data_dir>/presets is searched last
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_VIDEO_HOST 127.0.0.1 where live feeds listen; 0.0.0.0 to watch from the LAN
MCARDUPILOT_VIDEO_PORT_BASE 8400 a session's live feed is on this plus its instance
MCARDUPILOT_VIDEO_SIZE / _FPS 960x540 / 30 for live feeds and renders
MCARDUPILOT_VIDEO_ENCODER auto h264_nvenc, libx264, or the first that works
MCARDUPILOT_VIDEO_FGVIEW 1 launch SITL with --enable-fgview for live pose
MCARDUPILOT_FILM_BLENDER blender Blender 4.2+ for render_film: a command, a path, or docker:<image>
MCARDUPILOT_FILM_SIZE / _ENGINE 1280x720 / eevee a film's size, and eevee or cycles
MCARDUPILOT_FILM_AIRFRAME unset an airframe description for films (parts, colours, rotors); a log's preset can name its own
MCARDUPILOT_FILM_MESH unset one STL in one colour, when no description is given
MCARDUPILOT_FILM_AXES / _SCALE / _CG / _FACES x,y,z / 1 / centre / 60000 how the mesh sits: forward, left, up; units to metres; CG in mesh units; decimation
MCARDUPILOT_CALLS_DB unset record tool calls to this SQLite file (needs the instrumentation extra); unset records nothing
MCARDUPILOT_CALLS_MODE meta meta stores timing, outcome and sizes only; full adds redacted arguments and results
MCARDUPILOT_CALLS_RETENTION_DAYS 30 older call records are deleted
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.

Call records

With uvx 'mcardupilot[instrumentation]' and MCARDUPILOT_CALLS_DB=~/.local/share/mcardupilot/calls.db, every tool call becomes a row in that local SQLite file through fastmcp-feedback: the tool, its timing, its outcome (ok, error, or a soft error such as a build that failed or a wait whose SITL died), redacted error text and payload sizes. No arguments or results are stored in the default meta mode, and nothing leaves the machine. Builds, renders and sessions also record job.started and job.finished events, keyed by a uuid that the starting call's row carries in extra.job, so a start call that returned ok for work that failed later is one join away. Without the variable nothing is recorded and no file is created. Several server processes can share one database.

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.7

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.7
File Size Uploaded
mcardupilot-2026.10.7.tar.gz 179.0 kB Details

Built distribution (wheel)

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

Total release size: 387.5 kB

Release files / mcardupilot-2026.10.7.tar.gz

Download URL mcardupilot-2026.10.7.tar.gz
Size 179.0 kB
Tags Source
SHA-256 checksum
How to use checksums
252c8fc359125a473f70ea2749481c1a7e95613e8472ff51db87a484edb05d68
BLAKE2b-256 checksum
How to use checksums
d6d155e6e75e4de1660982499c3470739179382b898c5b2a9246bc833e19088c
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.7-py3-none-any.whl

Download URL mcardupilot-2026.10.7-py3-none-any.whl
Size 208.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8ef1568ba060f254dfdaabbf34c0e6c953a7bcf45474257687eb829133757cf9
BLAKE2b-256 checksum
How to use checksums
e82206ed8533450b67c08e1e8fc30736290a60789f45503bcba9fa7afe0595de
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