MCP server for LLM to drone communication via MAVLink
Overview
Python Model Context Protocol (MCP) server for LLM agents talking to MAVLink-enabled vehicles (typically PX4 via MAVSDK). - Python 3.10 or higher - A MAVLink endpoint (PX4 SITL is the recommended first path; do not point an untrusted agent at a live airframe without a human in the loop) 2. Install the project (this repo ships pyproject.toml and a uv.lock; there is no root requirements.txt): MAVLINK_ADDRESS decides the direction of the link: - → udpin://0.0.0.0:$MAVLINK_PORT. The server binds the port and waits. This is what PX4 SITL needs: SITL sends to 14540 and expects a listener on the other end. - → udpout://$MAVLINK_ADDRESS:$MAVLINK_PORT. The server sends to a peer already listening there, e.g. a companion link on real hardware. The MCP handshake never waits on the vehicle. The session starts immediately and the link comes up in the background, so tools report the link state instead of hanging: Once the autopilot answers, tools switch to real results.
README
MAVLink MCP Server
Python Model Context Protocol (MCP) server for LLM agents talking to MAVLink-enabled vehicles (typically PX4 via MAVSDK).
Prerequisites
- Python 3.10 or higher
- A MAVLink endpoint (PX4 SITL is the recommended first path; do not point an untrusted agent at a live airframe without a human in the loop)
Installation
- Clone the repository:
git clone https://github.com/ion-g-ion/MAVLinkMCP.git
cd MAVLinkMCP
- Install the project (this repo ships
pyproject.tomland auv.lock; there is no rootrequirements.txt):
# with pip (editable)
pip install -e .
# or with uv
uv sync
Configuration (SITL / connection)
The server reads:
| Env var | Default | Meaning |
|---|---|---|
MAVLINK_ADDRESS |
empty string | Peer host to send to. Empty means listen instead. |
MAVLINK_PORT |
14540 |
UDP port (PX4 SITL commonly uses 14540) |
MAVLINK_CONNECT_TIMEOUT |
60 |
Seconds to keep dialling the vehicle before declaring it unreachable |
MAVLINK_ADDRESS decides the direction of the link:
- Empty (default) →
udpin://0.0.0.0:$MAVLINK_PORT. The server binds the port and waits. This is what PX4 SITL needs: SITL sends to 14540 and expects a listener on the other end. - Set to a host →
udpout://$MAVLINK_ADDRESS:$MAVLINK_PORT. The server sends to a peer already listening there, e.g. a companion link on real hardware.
Example for local PX4:
export MAVLINK_PORT=14540
# MAVLINK_ADDRESS can stay empty: SITL talks to us, so we listen
Link bring-up is non-blocking
The MCP handshake never waits on the vehicle. The session starts immediately and the link comes up in the background, so tools report the link state instead of hanging:
{"status": "failed", "error": "MAVLink link is still coming up; retry shortly", "connected": false, "link_state": "connecting"}
Once the autopilot answers, tools switch to real results. If nothing answers
within MAVLINK_CONNECT_TIMEOUT, the failure becomes definitive and names the
address that was tried:
{"status": "failed", "error": "no MAVLink vehicle reachable on udpin://0.0.0.0:14540 after 60s", "connected": false, "link_state": "failed"}
Waiting for a GPS/home position estimate is deliberately not part of this gate — it is logged, but a converging position fix never blocks commands.
One vehicle link per session. On the HTTP transports each MCP session opens its own MAVSDK connection, so a second concurrent client cannot bind the same UDP port and will see the fail-closed payload above.
Usage
Installing the project puts a mavlinkmcp console script in your environment.
It serves MCP over stdio:
mavlinkmcp
# equivalently
python -m mavlinkmcp
Launching from another working directory
Point your MCP client at the console script by absolute path. Its shebang names the project interpreter, so it carries its own environment and does not care where it is launched from:
/abs/path/to/MAVLinkMCP/.venv/bin/mavlinkmcp
This is the form to put in a chat app’s stdio MCP server config.
Note that uv run resolves the project from your current directory, not from
the path you hand it — so uv run /abs/path/to/src/mavlinkmcp/server.py from
elsewhere builds an environment without this project’s dependencies and fails
on import. Name the project explicitly if you want to go through uv:
uv run --project /abs/path/to/MAVLinkMCP mavlinkmcp
uv --directory /abs/path/to/MAVLinkMCP run mavlinkmcp
Running over HTTP
The same tools can be served over a network socket instead of stdio:
# current MCP HTTP transport, on http://127.0.0.1:8000/mcp
mavlinkmcp --transport streamable-http
# pick the socket and endpoint path
mavlinkmcp --transport streamable-http --host 127.0.0.1 --port 9000 --path /drone
| Option | Applies to | Default | Meaning |
|---|---|---|---|
--transport |
all | stdio |
stdio, streamable-http, or sse |
--host |
HTTP | 127.0.0.1 |
Bind address |
--port |
HTTP | 8000 |
Bind port |
--path |
streamable-http |
/mcp |
Endpoint path |
--allowed-host |
HTTP | — | Host header to accept; repeatable, wildcards allowed |
--allow-any-host |
HTTP | off | Turn off Host/Origin checking entirely |
sse is the older transport, deprecated in the MCP spec. Prefer
streamable-http unless you have a client that needs sse. The HTTP options
are rejected under --transport stdio rather than silently ignored.
Binding beyond localhost
On a localhost bind, DNS-rebinding protection (Host/Origin validation) is on
automatically. Off localhost it cannot be inferred, so you must say what to
accept — the server refuses to start otherwise:
# declare the Host headers clients will send
mavlinkmcp --transport streamable-http --host 0.0.0.0 --port 8000 \
--allowed-host drone.lan:8000
# or turn the check off deliberately
mavlinkmcp --transport streamable-http --host 0.0.0.0 --allow-any-host
A request whose Host is not on the list is answered 421 Misdirected Request.
Before you expose it
There is no authentication on the HTTP transports. An open port here is a port that can arm, take off, and move a vehicle — a materially larger exposure than a stdio pipe that only the local client can talk to. Keep it on localhost, or put it behind a network you control plus your own auth layer.
Note also that the MAVSDK connection is established per MCP session, not per process. Over stdio that is one connection per client process. Over HTTP, every session that connects opens its own link to the same vehicle, with nothing arbitrating between them — two clients means two independent command sources to one airframe. The server binds immediately either way; it does not wait for a vehicle at startup.
Example agent usage
See examples/README.md and run:
python examples/example_agent.py
# or
uv run examples/example_agent.py
Export your LLM provider key as documented under examples/ (never commit secrets). The example uses fast-agent-mcp / FastAgent and the mavlink_mcp server entry.
Flight plans
The server can generate, store, check and fly coverage missions. The point of the split is that a route becomes reviewable before it reaches the vehicle:
get_map_view -> create_survey_plan -> render_plan_view
-> validate_plan -> preflight_check -> upload_plan
-> verify_uploaded_plan -> start_mission
Seeing the ground
get_map_view returns a georeferenced satellite view as an image together
with its exact transform. Identify a feature in the picture, report its corners
as pixels, and let the server convert them — a vision model is good at
pointing at a field and bad at inventing latitudes, so it never has to:
get_map_view() # centred on the vehicle
-> [image, {view_id, center, meters_per_pixel, drone: {pixel, heading_deg}, ...}]
create_survey_plan(name="Front Field", view_id=..., polygon_pixels=[[430,180], ...],
altitude_m=40, camera={...})
map_transform converts in either direction against a stored view. Pass
orientation="heading_up" to rotate the view so the vehicle’s forward direction
is up, which makes “the field in front of the drone” a question about the
picture rather than about compass arithmetic.
Supplying latitude_deg/longitude_deg instead looks anywhere and needs no
vehicle at all — plans can be authored and costed at a desk and flown later.
Map imagery
Fetching a basemap is the only outbound network use in this server. Tiles are fetched on demand, cached to disk, and pinned to the configured provider’s host; a tile that fails renders as flat grey and the response says so, because the georeferencing is still exact.
| Variable | Meaning |
|---|---|
MAVLINKMCP_MAP_PROVIDER |
esri (default, global aerial imagery, no key) · osm (street map, not imagery) · mapbox / maptiler (need a key) · custom · none |
MAVLINKMCP_MAP_API_KEY |
key for the providers that need one |
MAVLINKMCP_MAP_TILE_URL |
XYZ template for custom, e.g. a self-hosted tile server |
MAVLINKMCP_MAP_IMAGE_MODE |
image (default) or path, for clients that cannot display images |
none disables all network access and renders a correctly georeferenced blank
canvas — geometry and overlays still work, there is just nothing to identify
ground features from. prefetch_map_area warms the tile cache before going
somewhere without connectivity.
Attribution is not optional. Each provider’s terms require it; the notice is drawn onto every view and returned in the payload.
Views are rendered as JPEG, 768 px by default and capped at 1024. That ceiling is about tokens rather than bytes: image cost scales with pixel area, so a 768 px view is roughly 790 tokens and a 1024 px one about 1400.
Where plans live
$MAVLINKMCP_PLANS_DIR/ # default $XDG_DATA_HOME/mavlinkmcp
├── plans/north-field/
│ ├── meta.json # name, head revision, timestamps
│ ├── rev-001.json
│ └── rev-002.json
├── views/ # rendered map views + their geotransforms
└── tiles/ # tile cache
Plans are plain JSON and outlive the MCP session, so they can be read, diffed and edited by a human without this server running.
A revision’s generated content is immutable: revise_plan re-runs the
generator with new parameters and writes a new revision rather than editing
waypoints, so the stored parameters and the stored path can never disagree.
Only the lifecycle annotations (status, checks, estimate, uploaded)
change in place.
The upload gate
A plan moves draft -> validated -> uploaded, and upload_plan refuses
anything that has not passed validate_plan. Any revision starts as a draft,
so a change always invalidates the previous check.
validate_plan reports findings; only an error blocks. The most valuable one
is FAR_FROM_HOME — a polygon drawn on the wrong map produces a perfectly
well-formed plan on the other side of the world, and distance from home is what
catches it. preflight_check then adds a live go/no-go from health, GPS fix,
battery and landed state, and verify_uploaded_plan downloads the mission back
off the vehicle and diffs it against what was reviewed.
Note that the plan’s lifecycle state is reported as plan_status. The status
key is the call envelope (success / failed) that every tool in this server
returns, and it stays that.
Survey geometry
create_survey_plan generates a boustrophedon (lawnmower) sweep. Line spacing
comes from either line_spacing_m or a camera; giving both is refused rather
than silently preferring one. A camera also sets the photo trigger distance and
reports ground sample distance:
camera = {"sensor_width_mm": 13.2, "focal_length_mm": 8.8,
"image_width_px": 5472, "image_height_px": 3648,
"front_overlap": 0.75, "side_overlap": 0.65}
# at 40 m: 1.10 cm/px, 60 m footprint, 21 m line spacing, 10 m trigger distance
Omitting sweep_angle_deg sweeps along the area’s long axis, which minimises
turns — where survey time and battery actually go.
Safety note
MCP tools can arm, take off, and move a vehicle. Prefer SITL. Keep a human ready to kill switch / land. Tool failures should be treated as fail-closed by the client.
Flight plans add a review step rather than removing the need for one: validate_plan and preflight_check catch the mistakes that are mechanical (a route in the wrong place, no GPS fix, not enough battery), not the ones that are a matter of judgement. Look at render_plan_view before you fly.
Prompts
A tool description says what one call does. None of them says why the pipeline is
split, why upload_plan refuses a draft, or why a model should answer in pixels
and never in latitudes. That context is served as MCP prompts, which most
clients surface as slash commands:
| Prompt | Covers |
|---|---|
mavlink_overview |
how the server is organised, the status / plan_status envelope, the background link, the safety posture |
telemetry_guide |
which readings exist, and why a failed read is an unknown rather than a zero |
map_view_guide |
georeferenced imagery: pixels over latitudes, orientation, token cost, providers, attribution |
plan_lifecycle_guide |
plan states, immutable revisions, and what the upload gate is for |
survey_walkthrough(area?, altitude_m?) |
end-to-end, from looking at the ground to a mission running |
preflight_briefing(plan_id) |
the go/no-go sequence to run against one stored plan |
The wording lives in guides.py and interpolates the
limits from the modules that enforce them, so a number quoted in a briefing
cannot drift away from the number actually checked.
Resources
Read-only, URI-addressable descriptions of what is already on disk. A tool call is an action with a token cost; a resource is a lookup a client can do on its own. None of these fetches a tile or touches the vehicle.
| URI | Contents |
|---|---|
mavlinkmcp://map/providers |
every known tile provider and the active configuration, with attribution |
mavlinkmcp://map/limits |
the bounds a map call is checked against: size, radius, zoom, tile caps, store limits |
mavlinkmcp://map/cache |
what the tile cache and view store hold, and where they live |
mavlinkmcp://map/views |
every rendered view still on disk, newest first |
mavlinkmcp://map/views/{view_id} |
one view’s full georeference: centre, scale, rotation, bbox, ground corners |
mavlinkmcp://map/views/{view_id}/image |
the rendered JPEG, so a view can be looked at again without re-fetching tiles |
mavlinkmcp://plans/{plan_id}/geojson |
a plan’s head revision as GeoJSON — area, path and numbered waypoints |
API keys never appear in a resource. Every client on a session can read every resource, so the provider block reports whether a key is configured and never what it is, and tile templates lose their query string on the way out.
The index resources report a misconfiguration in the payload rather than raising
— “the provider needs a key it does not have” is the answer someone reading
mavlinkmcp://map/providers came for. The ones addressed by id raise instead:
there is no partial answer for a view that does not exist.
Tests
The unit tests are offline — they exercise the pure validation, geometry and
normalization helpers, never import mavsdk, never talk to a vehicle, and never
make a network request. Tile fetching is exercised by injecting a fake fetcher,
and the rendering path runs under MAVLINKMCP_MAP_PROVIDER=none. Run them from
the repository root:
python -m unittest discover
Tests import the helpers by their real package paths
(from mavlinkmcp.endpoint import ...), so the project must be installed in the
environment you run them with (uv sync or pip install -e .).
SITL integration tests
tests_sitl/ covers what the offline suite structurally cannot: the MAVSDK
handshake, the connection guard, and the mission-protocol round-trip. These need
a real autopilot, so sitl/ builds a PX4 image running the SIH dynamics model —
no Gazebo, no GPU, ~120 MB, ~7 MB of RAM:
docker build -t px4-sih:v1.14.3 sitl/
docker run -d --rm --name px4 --network host \
-e PX4_HOME_LAT=473977420 -e PX4_HOME_LON=85455940 \
px4-sih:v1.14.3
python -m unittest discover
network_mode: host is required: PX4 sends to 14540 on loopback and will not
talk off-localhost without MAV_2_BROADCAST=1, so published ports do not help.
That works on Linux and on GitHub runners, but not on Docker Desktop for macOS.
Home coordinates are scaled int32 in 1e-7 degrees, not decimal degrees —
px4-rc.simulator passes PX4_HOME_LAT straight into SIH_LOC_LAT0. Passing
47.397742 truncates to 47 and silently flies the vehicle 150 km away.
With no vehicle listening these tests skip rather than fail, after a ~1.5s
probe, so python -m unittest discover still works on a machine without Docker.
Raise MAVLINKMCP_SITL_PROBE_TIMEOUT if a slow container is being missed.
Contributing
Contributions are welcome! Please fork the repository and submit a pull request.
License
This project is licensed under the MIT License. See the LICENSE file for details.
Install
docker run -d --rm --name px4 --network host \