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.

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.

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

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.6
File Size Uploaded
mcardupilot-2026.10.6.tar.gz 145.5 kB Details

Built distribution (wheel)

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

Total release size: 317.0 kB

Release files / mcardupilot-2026.10.6.tar.gz

Download URL mcardupilot-2026.10.6.tar.gz
Size 145.5 kB
Tags Source
SHA-256 checksum
How to use checksums
cfe4ff8c2d5d96d6344c1c261eebfc5c233167bb27dafe97c800f5cfc917c37a
BLAKE2b-256 checksum
How to use checksums
9b5dd63851c27730d97161794ee97a4c13385bd5a797eaf9d6ddcf39fedc2343
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.6-py3-none-any.whl

Download URL mcardupilot-2026.10.6-py3-none-any.whl
Size 171.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7b0c8cee5ae55114c29a07cc1d9515e64f63c149fc646b4b38b73a5f16a72b2b
BLAKE2b-256 checksum
How to use checksums
22b2498d25dc3167186e93cf8d33b5f9ad1340cee583781644b4ca7ac3beb0ec
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