Scaffold: package, settings, errors, server factory, shared contracts

protocol/types.py and arsdk/types.py are the interfaces the parallel streams
build against: command/arg/enum/expectation specs and the safety tiers on one
side, frame encoding and the buffer conventions on the other.

Logging goes to stderr throughout, since stdout carries JSON-RPC. That is also
why this package will speak ARSDK itself rather than through pyparrot, which
prints from its receive thread.
This commit is contained in:
2026-10-01 23:53:15 -06:00
parent 40814040a2
commit 5732befe82
16 changed files with 2191 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.venv/
__pycache__/
.ruff_cache/
.pytest_cache/
dist/
captures/
*.egg-info/
+43
View File
@@ -0,0 +1,43 @@
# mcbebop
An MCP server for the Parrot Bebop 2, so an agent can talk to the drone without
anyone writing code for each question.
Parrot abandoned the Bebop line, but the aircraft is still a capable, cheap
platform and its protocol is fully described by Parrot's own XML, which this
package vendors. Every one of the **264 commands** is reachable; nothing is
hand-wrapped.
> **Status: in development.** Not flight-tested. See the safety section.
## What it talks to
| Interface | Notes |
|---|---|
| ARSDK3 over UDP | commands and telemetry; this package implements the protocol directly |
| ARStream2 | live H.264 video. The Bebop 2 serves no RTSP, despite what older libraries assume |
| FTP | media, flight plans, blackbox logs |
| Telnet | read-only shell, only after the drone's debug mode is enabled |
## Safety
Commands are classified by consequence. Observing and configuring are open;
anything that can spin a motor or change the flight envelope refuses until
`arm()` is called with a reason, and re-locks on a timer and on disconnect.
`Landing` and `Emergency` are deliberately never locked, because refusing to
land an airborne aircraft is the more dangerous answer.
`connect(target="sim")` runs everything against a protocol-accurate simulator,
which is where anything involving motion should be rehearsed.
## Install
```bash
uvx mcbebop
claude mcp add mcbebop -- uvx mcbebop
```
## Credits
`arsdk-xml/` is Parrot SA's own protocol definition, BSD-3-Clause. See
`arsdk-xml/PROVENANCE.md`.
+53
View File
@@ -0,0 +1,53 @@
[project]
name = "mcbebop"
version = "2026.10.02"
description = "MCP server for the Parrot Bebop 2 drone: telemetry, camera, files, and every ARSDK command"
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
keywords = ["mcp", "drone", "parrot", "bebop", "arsdk", "fastmcp"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering",
]
dependencies = [
# 4.x is the current family; <5 because FastMCP has moved import paths
# across majors before (mcvnc was broken by the 3 -> 4 move).
"fastmcp>=4.0.10,<5",
"pydantic-settings>=2.7",
"zeroconf>=0.147", # mDNS discovery of _arsdk-090c._udp
"pillow>=11.0", # downscale camera frames before they reach the model
]
[project.urls]
Homepage = "https://warehack.ing"
Repository = "https://git.supported.systems/rsp2k/mcbebop"
[project.scripts]
mcbebop = "mcbebop.server:main"
[build-system]
requires = ["uv_build>=0.11.3,<0.12.0"]
build-backend = "uv_build"
[tool.uv.build-backend]
source-exclude = ["CLAUDE.md", ".env", ".env.*", ".mcp.json", "tests", "captures"]
[dependency-groups]
dev = ["ruff>=0.16", "pytest>=8.0", "pytest-asyncio>=0.25"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
markers = ["drone: needs a real Bebop 2 on the network (run with '-m drone')"]
addopts = "-m 'not drone'"
[tool.ruff]
line-length = 110
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "W", "UP", "B", "SIM", "RUF"]
+8
View File
@@ -0,0 +1,8 @@
"""MCP server for the Parrot Bebop 2."""
from importlib.metadata import PackageNotFoundError, version
try:
__version__ = version("mcbebop")
except PackageNotFoundError: # running from a source tree without an install
__version__ = "0.0.0"
View File
+113
View File
@@ -0,0 +1,113 @@
"""Wire-level vocabulary for the ARSDK3 client.
Stage-0 contract. The constants are what the live Bebop 2 actually uses,
recorded in bebop-2's `docs/notes/protocol.md` and confirmed against firmware
4.7.1 on 2026-10-01.
"""
from __future__ import annotations
import enum
import struct
from dataclasses import dataclass
from typing import Any, Protocol
DEFAULT_IP = "192.168.42.1"
DISCOVERY_PORT = 44444 # TCP, the JSON handshake
MDNS_SERVICE = "_arsdk-090c._udp.local."
# Ports we tell the drone to send to, and that we therefore bind.
D2C_PORT = 43210 # telemetry
VIDEO_STREAM_PORT = 55004 # ARStream2 RTP (H.264)
VIDEO_CONTROL_PORT = 55005 # ARStream2 RTCP
# The header on every frame: data type, buffer id, sequence, total size
# INCLUDING these 7 bytes.
FRAME_HEADER = struct.Struct("<BBBI")
# Then the command: project (u8), class (u8), command (u16 -- not u8).
COMMAND_HEADER = struct.Struct("<BBH")
class DataType(enum.IntEnum):
ACK = 1
DATA = 2 # no acknowledgement expected
LOW_LATENCY = 3
DATA_WITH_ACK = 4
class BufferId(enum.IntEnum):
"""Buffer ids are a fixed convention, not negotiated."""
PING = 0 # drone -> us
PONG = 1 # our reply
C2D_NON_ACK = 10 # piloting and camera: fire and forget
C2D_ACK = 11 # everything else
C2D_HIGH_PRIO = 12 # emergency
C2D_VIDEO_ACK = 13
D2C_VIDEO = 125
D2C_NON_ACK = 126
D2C_ACK = 127 # we must acknowledge these
@staticmethod
def ack_for(buffer_id: int) -> int:
"""The buffer an acknowledgement travels on."""
return (buffer_id + 128) % 256
@dataclass(frozen=True)
class Frame:
data_type: DataType
buffer_id: int
seq: int
payload: bytes
def encode(self) -> bytes:
size = FRAME_HEADER.size + len(self.payload)
return FRAME_HEADER.pack(int(self.data_type), self.buffer_id, self.seq, size) + self.payload
@staticmethod
def decode_all(data: bytes) -> list[Frame]:
"""One datagram can carry several frames back to back.
A frame claiming a size below the header length would never advance the
cursor, so it ends the parse rather than looping forever.
"""
out: list[Frame] = []
while len(data) >= FRAME_HEADER.size:
dt, buf, seq, size = FRAME_HEADER.unpack_from(data)
if size < FRAME_HEADER.size or size > len(data):
break
out.append(Frame(DataType(dt), buf, seq, data[FRAME_HEADER.size : size]))
data = data[size:]
return out
@dataclass(frozen=True)
class Event:
"""A decoded drone-to-controller message."""
ids: tuple[int, int, int]
name: str # the command name, e.g. "BatteryStateChanged"
values: dict[str, Any] # keyed `<Command>_<arg>`, as our captures are
at: float # time.monotonic()
class HandshakeError(RuntimeError):
pass
class NotConnected(RuntimeError):
pass
class Session(Protocol):
"""What `arsdk/session.py` exposes to the tools layer."""
@property
def connected(self) -> bool: ...
async def send(self, spec: Any, args: dict[str, Any], *, confirm: bool = True) -> dict[str, Any]: ...
def state(self, keys: list[str] | None = None) -> dict[str, Any]: ...
def subscribe(self, fn: Any) -> Any: ...
+25
View File
@@ -0,0 +1,25 @@
"""Settings, from the environment with an `MCBEBOP_` prefix."""
from __future__ import annotations
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="MCBEBOP_", env_file=".env", extra="ignore")
drone_ip: str = Field(default="192.168.42.1", description="Address of the drone's own access point.")
connect_timeout: float = Field(default=5.0, description="Seconds to wait for the TCP handshake.")
arm_minutes: int = Field(default=5, description="How long arm() keeps motion commands unlocked.")
snapshot_max_width: int = Field(
default=640, description="Camera frames are downscaled to this width before being returned."
)
capture_dir: Path = Field(
default=Path("captures"), description="Where recordings and snapshots are written."
)
transport: str = Field(default="stdio", description="stdio or http.")
host: str = Field(default="127.0.0.1", description="Bind address when transport is http.")
port: int = Field(default=8440, description="Port when transport is http.")
+59
View File
@@ -0,0 +1,59 @@
"""ToolError factories.
Each message is written for the agent that will read it, and says what to do
next rather than only what went wrong.
"""
from __future__ import annotations
from fastmcp.exceptions import ToolError
def not_connected() -> ToolError:
return ToolError(
"Not connected to a drone. Call connect() first. If the aircraft is powered on but "
"unreachable, check this machine has joined its access point (an SSID like 'Bebop2-XXXXXX')."
)
def already_connected(target: str) -> ToolError:
return ToolError(f"Already connected to {target}. Call disconnect() before connecting elsewhere.")
def handshake_refused(status: int) -> ToolError:
return ToolError(
f"The drone refused the connection (status {status}). It serves one controller at a time, "
"so close FreeFlight on any phone or tablet that is holding the link."
)
def unknown_command(name: str, suggestions: list[str]) -> ToolError:
hint = f" Did you mean: {', '.join(suggestions)}?" if suggestions else ""
return ToolError(f"No command named '{name}'. Use list_commands() to search the 264 defined.{hint}")
def not_sendable(name: str) -> ToolError:
return ToolError(
f"'{name}' is an event the drone emits, not a command you can send. "
"Read it with get_state() or watch_state() instead."
)
def unsupported(name: str, support: str | None) -> ToolError:
return ToolError(
f"'{name}' does not declare support for the Bebop 2 (product 090c); its support field is "
f"{support!r}. Sending it is likely to be ignored. Use command_info() to check."
)
def needs_arming(name: str, tier: str) -> ToolError:
return ToolError(
f"'{name}' is a {tier} command: it can move the aircraft or change its flight envelope, so it "
"is locked. If that is genuinely what you want, call arm() with a reason first, and make sure "
"the propellers are off or the aircraft is somewhere safe to move. To rehearse without risk, "
"connect(target='sim') and send it to the simulator."
)
def bad_argument(command: str, detail: str) -> ToolError:
return ToolError(f"Cannot encode arguments for '{command}': {detail}. Call command_info('{command}').")
View File
View File
View File
+137
View File
@@ -0,0 +1,137 @@
"""Shared vocabulary for the protocol layer.
Stage-0 contract: `protocol/` implements these, `arsdk/` and `tools/` consume
them. Changing anything here affects every stream, so changes are announced.
Everything is derived from Parrot's own XML in `arsdk-xml/`, so the encoder
never carries a hand-written table of commands.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from typing import Protocol
BEBOP2_PRODUCT_ID = "090c"
class Direction(enum.StrEnum):
"""Which way a command travels.
The XML has no direction attribute. `<comment result=...>` means "this is
what happens when you send it" and `triggered=` means "this is when the
drone emits it"; no command carries both. Where neither is present, fall
back to the class-name suffix (`...State`, `...Event` are events). Note
`common.Controller` is a mixed class, so the suffix rule alone is wrong.
"""
TO_DRONE = "to_drone"
FROM_DRONE = "from_drone"
class Buffer(enum.StrEnum):
"""Which ARNetwork buffer carries the command, from the `buffer` attribute.
Absent is the common case and means the acknowledged buffer. The values
decide both the frame's data type and its buffer id, so this is not
cosmetic.
"""
ACK = "ack" # attribute absent: DATA_WITH_ACK, buffer 11
NON_ACK = "non_ack" # buffer="NON_ACK": DATA_NO_ACK, buffer 10
HIGH_PRIO = "high_prio" # buffer="HIGH_PRIO": buffer 12. Emergency only.
class Tier(enum.StrEnum):
"""What a command can do, which decides whether it needs an unlock.
Ordered by consequence. `safety.py` assigns these; `tools/command.py`
enforces them.
"""
OBSERVE = "observe" # ask the drone to report; changes nothing
CONFIG = "config" # camera, media, clock: no effect on flight
ENVELOPE = "envelope" # limits, geofence, home point, radio, reboot
MOTION = "motion" # can spin motors or move the aircraft
@dataclass(frozen=True)
class EnumSpec:
"""One member of an `<arg type="enum">`.
`value` is the member's 0-based position among its siblings, which is what
goes on the wire. Names are kept verbatim because some are not valid
identifiers (`2_4ghz`, `30_FPS`).
"""
name: str
value: int
doc: str = ""
@dataclass(frozen=True)
class ArgSpec:
name: str
type: str # u8 i8 u16 i16 u32 i32 u64 float double string enum bitfield:<w>:<e>
doc: str = ""
members: tuple[EnumSpec, ...] = ()
@property
def is_enum(self) -> bool:
return self.type == "enum"
@property
def is_bitfield(self) -> bool:
return self.type.startswith("bitfield:")
@dataclass(frozen=True)
class Expectation:
"""A confirming event the drone should emit, parsed from `<expectations>`.
`fields` maps an event argument to either a literal (an enum member name)
or `this.<argname>`, meaning "echoes the value you sent". 82 of the 101
sendable commands carry one, so most sends can be confirmed by the drone's
own report rather than only by the ack.
"""
ids: tuple[int, int, int]
fields: dict[str, str] = field(default_factory=dict)
alternatives: tuple[Expectation, ...] = ()
delayed: bool = False
@dataclass(frozen=True)
class CommandSpec:
project: str
klass: str
name: str
ids: tuple[int, int, int] # project, class, command
args: tuple[ArgSpec, ...] = ()
buffer: Buffer = Buffer.ACK
direction: Direction = Direction.TO_DRONE
tier: Tier = Tier.CONFIG
support: str | None = None
deprecated: bool = False
title: str = ""
doc: str = ""
expectations: tuple[Expectation, ...] = ()
@property
def full_name(self) -> str:
return f"{self.project}.{self.klass}.{self.name}"
@property
def event_key_prefix(self) -> str:
"""Telemetry keys are `<Command>_<arg>`, matching our existing captures."""
return self.name
class ProtocolIndex(Protocol):
"""What `xml_index.py` exposes."""
def get(self, full_name: str) -> CommandSpec | None: ...
def by_ids(self, ids: tuple[int, int, int]) -> CommandSpec | None: ...
def search(self, text: str) -> list[CommandSpec]: ...
def all(self) -> list[CommandSpec]: ...
+65
View File
@@ -0,0 +1,65 @@
"""Server factory and entry point.
stdout carries the JSON-RPC transport, so every diagnostic goes to stderr.
That constraint is also why this package speaks ARSDK itself rather than
through pyparrot, which prints from its receive thread.
"""
import logging
import os
import sys
from fastmcp import FastMCP
from mcbebop import __version__
from mcbebop.config import Settings
INSTRUCTIONS = """
Tools for a Parrot Bebop 2 drone (firmware 4.7.1, ARSDK3).
Start with connect(). Nothing else works until a session is open. Use
connect(target="sim") to talk to a built-in simulator instead of the aircraft,
which is the right place to rehearse anything that would move a real drone.
Every one of Parrot's 264 commands is reachable through send_command(); browse
them with list_commands() and read one with command_info(). Commands are
classified by consequence: observe and config are open, while anything that
moves the aircraft or changes its flight envelope refuses until arm() is called.
preflight_check() is the quickest way to learn whether the aircraft is healthy.
""".strip()
def build_server(settings: Settings | None = None) -> FastMCP:
"""Build the server. Tests call this directly so they can inject settings."""
settings = settings or Settings()
mcp = FastMCP("mcbebop", instructions=INSTRUCTIONS, version=__version__)
mcp.settings_obj = settings # type: ignore[attr-defined]
from mcbebop.tools import register_all
register_all(mcp, settings)
return mcp
def main() -> None:
logging.basicConfig(
stream=sys.stderr,
level=os.environ.get("MCBEBOP_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
settings = Settings()
print(f"mcbebop v{__version__} transport={settings.transport} drone={settings.drone_ip}", file=sys.stderr)
server = build_server(settings)
if settings.transport == "http":
server.run(
transport="http",
host=settings.host,
port=settings.port,
json_response=True,
stateless_http=True,
show_banner=False,
)
else:
server.run(show_banner=False)
+17
View File
@@ -0,0 +1,17 @@
"""Tool registration.
Each module exposes `register(mcp, settings)`; this is the only place that
knows the full set, so tool naming stays consistent.
"""
from fastmcp import FastMCP
from mcbebop.config import Settings
def register_all(mcp: FastMCP, settings: Settings) -> None:
# Modules are added here as each stream lands. Stage 0 registers nothing,
# so the server starts and answers list_tools with an empty set.
modules: list = []
for module in modules:
module.register(mcp, settings)
+40
View File
@@ -0,0 +1,40 @@
"""Guards that the package stays coherent as the streams land."""
import tomllib
from pathlib import Path
import mcbebop
from mcbebop.server import build_server
def _pyproject() -> dict:
return tomllib.loads((Path(__file__).parent.parent / "pyproject.toml").read_text())
def test_version_matches_pyproject():
# importlib normalises 2026.10.02 to 2026.10.2; compare as release tuples.
declared = _pyproject()["project"]["version"]
assert tuple(int(p) for p in declared.split(".")) == tuple(int(p) for p in mcbebop.__version__.split("."))
def test_entry_point_is_importable():
module, _, attr = _pyproject()["project"]["scripts"]["mcbebop"].partition(":")
mod = __import__(module, fromlist=[attr])
assert callable(getattr(mod, attr))
def test_server_builds():
assert build_server() is not None
def test_vendored_xml_present_and_parses():
import xml.etree.ElementTree as ET
root = Path(__file__).parent.parent / "arsdk-xml"
total = 0
for name in ("common.xml", "ardrone3.xml"):
proj = ET.parse(root / name).getroot()
# Parrot's element is <class>; pyparrot renamed it and we reverted that.
assert proj.find("class") is not None, f"{name} has no <class> elements"
total += sum(len(c.findall("cmd")) for c in proj.iter("class"))
assert total == 264, f"expected 264 commands, found {total}"
Generated
+1624
View File
File diff suppressed because it is too large Load Diff