Skip to main content
Glama
AlanOgic

Betaflight MCP Server

by AlanOgic
README.md
# Betaflight MCP Server

A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes 26 tools to read and configure a Betaflight flight controller over USB serial using the MSP protocol.

> **MCP is not Claude-specific.** Any MCP-compatible client works: Claude Desktop, Cursor, Cline, Continue, custom LLM agents, Alexa skills, or any application using the MCP SDK.

---

## How it works

```
┌────────────────────────┐    MCP (stdio or SSE)    ┌──────────────────────────┐
│    Any MCP Client      │ ◄──────────────────────► │  betaflight-mcp server   │
│  Claude / Cursor / ... │                           │  Python · FastMCP        │
└────────────────────────┘                           └──────────┬───────────────┘
                                                                │  MSP protocol
                                                                │  pyserial · USB
                                                     ┌──────────▼───────────────┐
                                                     │   Flight Controller      │
                                                     │   Betaflight 4.x         │
                                                     └──────────────────────────┘
```

The server translates MCP tool calls into MSP (MultiWii Serial Protocol) frames, sends them to the FC over USB serial, parses the binary response and returns structured JSON.

---

## Prerequisites

| Requirement | Version | Notes |
|-------------|---------|-------|
| Python | ≥ 3.10 | 3.12+ recommended |
| Betaflight firmware | ≥ 4.0 (API ≥ 1.40) | On any F4/F7/H7 FC |
| USB cable | — | FC connected to host machine |

---

## Installation

```bash
git clone https://github.com/your-username/betaflight-mcp.git
cd betaflight-mcp

python -m venv .venv

# Linux / macOS
source .venv/bin/activate

# Windows
.venv\Scripts\activate

pip install -r requirements.txt
```

**Linux — serial port permissions** (one-time setup):
```bash
sudo usermod -aG dialout $USER
# then log out and back in
```

---

## Configuration

All settings are controlled via **environment variables** — no config file to edit.

| Variable | Default | Description |
|----------|---------|-------------|
| `BETAFLIGHT_PORT` | `/dev/ttyUSB0` (Linux) · `COM3` (Windows) | Serial port of the FC |
| `BETAFLIGHT_BAUD` | `115200` | Baud rate (must match Betaflight config) |
| `BETAFLIGHT_TIMEOUT` | `2.0` | Serial read timeout in seconds |

Set them inline or in your shell:
```bash
# inline
BETAFLIGHT_PORT=/dev/ttyACM0 python main.py

# or export
export BETAFLIGHT_PORT=/dev/ttyACM0
export BETAFLIGHT_BAUD=115200
python main.py
```

### Find your FC's serial port

```bash
# Linux
ls /dev/ttyUSB* /dev/ttyACM*

# macOS
ls /dev/tty.usbmodem* /dev/tty.usbserial*

# Windows — PowerShell
[System.IO.Ports.SerialPort]::getportnames()

# Or use the built-in tool (any platform):
python -c "
from server.tools import tool_list_serial_ports
import json; print(json.dumps(tool_list_serial_ports(), indent=2))
"
```

> **Important:** close Betaflight Configurator before starting the MCP server. Both cannot hold the serial port open at the same time.

---

## Client setup

### Claude Desktop

Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):

```json
{
  "mcpServers": {
    "betaflight": {
      "command": "/absolute/path/to/.venv/bin/python",
      "args": ["/absolute/path/to/betaflight-mcp/main.py"],
      "env": {
        "BETAFLIGHT_PORT": "/dev/ttyACM0",
        "BETAFLIGHT_BAUD": "115200"
      }
    }
  }
}
```

Restart Claude Desktop. The 26 tools appear automatically in the tool picker.

### Claude Code (CLI)

```bash
claude mcp add -s user \
  -e BETAFLIGHT_PORT=/dev/ttyACM0 \
  -e BETAFLIGHT_BAUD=115200 \
  -- betaflight \
  /absolute/path/to/.venv/bin/python \
  /absolute/path/to/betaflight-mcp/main.py
```

> **Note :** le `--` doit être placé **avant le nom** (`betaflight`), pas après. Sans lui, le parser variadique de `-e` consomme les arguments suivants.
>
> Scopes disponibles : `local` (projet courant), `project` (`.mcp.json` versionné), `user` (global, tous les projets). Vérifier avec `claude mcp list`.

### Cursor

Add to `.cursor/mcp.json` in your project (or `~/.cursor/mcp.json` globally):

```json
{
  "mcpServers": {
    "betaflight": {
      "command": "/absolute/path/to/.venv/bin/python",
      "args": ["/absolute/path/to/betaflight-mcp/main.py"],
      "env": {
        "BETAFLIGHT_PORT": "/dev/ttyACM0"
      }
    }
  }
}
```

### Cline / Continue (VS Code)

Same JSON format as above, placed in the respective extension's MCP server config.

### Custom agent (SSE / HTTP transport)

For any client that talks to an HTTP endpoint instead of launching a subprocess:

```bash
# Start server in SSE mode (default port 8000)
BETAFLIGHT_PORT=/dev/ttyACM0 python -c "
from main import app
app.run(transport='sse', host='127.0.0.1', port=8000)
"
```

Then point your client at `http://127.0.0.1:8000/sse`.

### MCP SDK (Python / TypeScript)

```python
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

server_params = StdioServerParameters(
    command="python",
    args=["/path/to/betaflight-mcp/main.py"],
    env={"BETAFLIGHT_PORT": "/dev/ttyACM0"},
)

async with stdio_client(server_params) as (read, write):
    async with ClientSession(read, write) as session:
        await session.initialize()
        result = await session.call_tool("connect", {"port": "/dev/ttyACM0"})
        result = await session.call_tool("get_fc_status", {})
```

---

## Available tools

All read operations are safe at any time. Write operations (`set_*`) require calling `save_config` afterwards to persist changes to EEPROM.

### Connection

| Tool | Parameters | Description |
|------|-----------|-------------|
| `list_serial_ports` | — | List all serial ports on the host |
| `connect` | `port`, `baudrate` | Open serial connection to the FC |
| `disconnect` | — | Close the connection |

### FC identity

| Tool | MSP | Description |
|------|-----|-------------|
| `get_board_info` | BOARD_INFO · FC_VARIANT · FC_VERSION · API_VERSION | Firmware variant (BTFL), version, target name, MCU type |

### Telemetry

| Tool | MSP | Description |
|------|-----|-------------|
| `get_fc_status` | STATUS_EX (150) | Cycle time, CPU load, arming flags, profiles |
| `get_imu_data` | RAW_IMU (102) | Accelerometer (g), gyroscope (°/s), magnetometer |
| `get_attitude` | ATTITUDE (108) | Roll / pitch / yaw in degrees |
| `get_altitude` | ALTITUDE (109) | Altitude (m) and variometer (cm/s) |
| `get_rc` | RC (105) | All RC channel values (µs) |
| `get_motors` | MOTOR (104) | Motor outputs (µs) |

### Battery

| Tool | MSP | Description |
|------|-----|-------------|
| `get_battery` | ANALOG (110) | Voltage (V), current (A), mAh drawn, RSSI |
| `get_battery_state` | BATTERY_STATE (130) | Cell count, capacity, state (OK / WARNING / CRITICAL) |
| `get_voltage_meters` | VOLTAGE_METERS (128) | All voltage meters |
| `get_current_meters` | CURRENT_METERS (129) | All current meters |

### PID tuning

| Tool | MSP | Parameters | Description |
|------|-----|-----------|-------------|
| `get_pid_values` | PID (112) | — | P/I/D per axis (roll, pitch, yaw…) |
| `set_pid_values` | SET_PID (202) | `axis`, `p`, `i`, `d` | Write P/I/D for one axis |
| `get_rates` | RC_TUNING (111) | — | All rates, expo, throttle curve |
| `set_rates` | SET_RC_TUNING (204) | any rate field | Update one or more rate fields |
| `get_pid_advanced` | PID_ADVANCED (94) | — | Feedforward, anti-gravity, TPA, D-Max, iterm relax |

### Configuration

| Tool | MSP | Description |
|------|-----|-------------|
| `get_modes` | MODE_RANGES (34) | AUX switch assignments (box_id, channel, µs range) |
| `get_feature_config` | FEATURE_CONFIG (36) | Enabled features (AIRMODE, GPS, LED_STRIP…) |
| `get_advanced_config` | ADVANCED_CONFIG (90) | ESC protocol (DSHOT), gyro/PID denominators, PWM rate |
| `get_filter_config` | FILTER_CONFIG (92) | Gyro/Dterm lowpass, notch filters, RPM filter |
| `get_sensor_config` | SENSOR_CONFIG (96) | Accelerometer, barometer, magnetometer hardware |

### System

| Tool | MSP | Description |
|------|-----|-------------|
| `save_config` | EEPROM_WRITE (250) | Persist current config to EEPROM — **always call after set_*** |
| `reboot_fc` | SET_REBOOT (68) | Reboot the flight controller |

---

## Testing

### Unit tests (no FC required)

```bash
python -m pytest tests/test_msp.py -v
# 34 tests — MSP v1/v2 framing, CRC, all response parsers
```

### Interactive inspector (no FC required)

```bash
pip install "mcp[cli]"   # one-time
mcp dev main.py
# opens http://localhost:5173 — call any tool from the browser
```

### End-to-end with a real FC

```bash
# Quick smoke test
BETAFLIGHT_PORT=/dev/ttyACM0 python3 - <<'EOF'
import json
from server.tools import tool_connect, tool_get_fc_status, tool_get_battery, tool_disconnect

print(json.dumps(tool_connect("/dev/ttyACM0"), indent=2))
print(json.dumps(tool_get_fc_status(), indent=2))
print(json.dumps(tool_get_battery(), indent=2))
tool_disconnect()
EOF
```

---

## Project structure

```
betaflight-mcp/
├── main.py                      # Entry point — FastMCP app, tool registration
├── requirements.txt
├── pyproject.toml
│
├── betaflight/
│   ├── msp_codes.py             # All MSP command codes (v1 + v2)
│   ├── msp.py                   # MSP v1 / v2 framing, CRC8/DVB-S2, serial I/O
│   ├── commands.py              # Response parsers (DataReader), all 25+ commands
│   └── serial_conn.py           # pyserial wrapper
│
├── server/
│   ├── tools.py                 # MCP tool functions + MCP_TOOLS registry
│   └── server.py                # Standalone JSON-RPC server (fallback, no SDK dep)
│
├── config/
│   └── settings.py              # Env-var based configuration
│
├── tests/
│   └── test_msp.py              # 34 unit tests (mock serial)
│
└── examples/
    └── claude_desktop_config.json
```

---

## Safety

> ⚠ **Never arm the FC via MCP with propellers attached.**

- All `set_*` operations are **not** automatically saved — always call `save_config` to persist
- `set_motor` sends raw PWM values directly to ESCs — only use with props off and FC in motor-test mode
- The server has no authentication — only expose SSE transport on trusted networks

---

## Troubleshooting

**`SerialException: [Errno 16] Device or resource busy`**
→ Close Betaflight Configurator (or any other app using the port).

**`SerialException: [Errno 2] No such file or directory`**
→ Wrong port. Run `list_serial_ports` or `ls /dev/ttyUSB* /dev/ttyACM*` to find the correct one.

**`connect` succeeds but all `get_*` return `None`**
→ Check baudrate. Betaflight default is 115200. Verify in Betaflight Configurator → Ports tab → USB VCP.

**Timeout on read (`BETAFLIGHT_TIMEOUT=2.0`)**
→ Increase timeout: `BETAFLIGHT_TIMEOUT=5.0`. Can happen on busy systems or slow FCs.

**Permission denied on Linux**
→ `sudo usermod -aG dialout $USER`, then log out and back in.

**`api_version` shows `0.0` after connect**
→ The FC didn't respond to `MSP_API_VERSION`. Check that the FC is powered (USB provides power but some FCs need a battery for full boot).

---

## License

MIT — see [LICENSE](LICENSE).