Add birdcage-mcp FastMCP server for satellite dish control

34 tools (connection, movement, signal, system, satellite, console),
5 resources, 3 prompts. Backed by DemoDevice for offline testing.
46 tests passing against the demo backend via run_server_async.
This commit is contained in:
Ryan Malloy 2026-02-17 16:01:51 -07:00
parent 16ca4892b3
commit 8a6b99bd8c
21 changed files with 3233 additions and 0 deletions

74
mcp/tests/conftest.py Normal file
View file

@ -0,0 +1,74 @@
"""Shared fixtures for birdcage-mcp tests.
Uses FastMCP's run_server_async to spin up a real MCP server backed by
DemoDevice + DemoCraftClient. No serial hardware, no subprocesses.
"""
import json
from contextlib import asynccontextmanager
import pytest
from birdcage.demo import DemoCraftClient, DemoDevice
from fastmcp import Client, FastMCP
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.utilities.tests import run_server_async
from birdcage_mcp.state import BirdcageState
@asynccontextmanager
async def _test_lifespan(server: FastMCP):
"""Test lifespan that pre-connects a DemoDevice."""
device = DemoDevice()
device.connect()
device.initialize()
state = BirdcageState(
device=device,
craft_client=DemoCraftClient(),
demo_mode=True,
serial_port="/dev/demo",
firmware_name="g2",
connected=True,
)
yield state
def _build_server() -> FastMCP:
"""Build a FastMCP server with all tools registered."""
from birdcage_mcp import prompts, resources
from birdcage_mcp.tools import (
connection,
console,
movement,
satellite,
signal,
system,
)
mcp = FastMCP("birdcage-test", lifespan=_test_lifespan)
connection.register(mcp)
movement.register(mcp)
signal.register(mcp)
system.register(mcp)
satellite.register(mcp)
console.register(mcp)
resources.register(mcp)
prompts.register(mcp)
return mcp
@pytest.fixture
async def mcp_client():
"""Yield a connected MCP client backed by DemoDevice."""
server = _build_server()
async with (
run_server_async(server) as url,
Client(StreamableHttpTransport(url)) as client,
):
yield client
def parse_result(result) -> dict:
"""Extract the dict from a tool call result."""
return json.loads(result.content[0].text)

View file

@ -0,0 +1,29 @@
"""Tests for connection tools (connect, disconnect, status)."""
import pytest
from conftest import parse_result
from fastmcp import Client
@pytest.mark.anyio
async def test_status_shows_connected(mcp_client: Client):
result = await mcp_client.call_tool("status", {})
data = parse_result(result)
assert data["connected"] is True
assert data["demo_mode"] is True
assert data["firmware"] == "g2"
@pytest.mark.anyio
async def test_connect_demo_noop(mcp_client: Client):
result = await mcp_client.call_tool("connect", {})
data = parse_result(result)
assert data["mode"] == "demo"
assert data["status"] == "connected"
@pytest.mark.anyio
async def test_disconnect_demo_noop(mcp_client: Client):
result = await mcp_client.call_tool("disconnect", {})
data = parse_result(result)
assert data["status"] == "demo"

56
mcp/tests/test_console.py Normal file
View file

@ -0,0 +1,56 @@
"""Tests for raw console tool (safety-gated)."""
import pytest
from conftest import parse_result
from fastmcp import Client
@pytest.mark.anyio
async def test_send_raw_command(mcp_client: Client):
result = await mcp_client.call_tool(
"send_raw_command", {"command": "?"}
)
data = parse_result(result)
assert "response" in data
assert "Available commands" in data["response"]
@pytest.mark.anyio
async def test_q_command_blocked(mcp_client: Client):
result = await mcp_client.call_tool(
"send_raw_command", {"command": "q"}
)
data = parse_result(result)
assert data["blocked"] is True
assert "power cycle" in data["error"]
@pytest.mark.anyio
async def test_q_command_blocked_with_whitespace(mcp_client: Client):
result = await mcp_client.call_tool(
"send_raw_command", {"command": " Q "}
)
data = parse_result(result)
assert data["blocked"] is True
@pytest.mark.anyio
async def test_submenu_navigation(mcp_client: Client):
result = await mcp_client.call_tool(
"send_raw_command", {"command": "mot"}
)
data = parse_result(result)
assert "MOT>" in data["response"]
@pytest.mark.anyio
async def test_raw_motor_query(mcp_client: Client):
# Enter mot submenu first
await mcp_client.call_tool("send_raw_command", {"command": "mot"})
# Query position
result = await mcp_client.call_tool(
"send_raw_command", {"command": "a"}
)
data = parse_result(result)
assert "Angle[0]" in data["response"]
assert "Angle[1]" in data["response"]

100
mcp/tests/test_movement.py Normal file
View file

@ -0,0 +1,100 @@
"""Tests for movement tools."""
import pytest
from conftest import parse_result
from fastmcp import Client
from fastmcp.exceptions import ToolError
@pytest.mark.anyio
async def test_get_position(mcp_client: Client):
result = await mcp_client.call_tool("get_position", {})
data = parse_result(result)
assert "azimuth" in data
assert "elevation" in data
assert isinstance(data["azimuth"], float)
assert isinstance(data["elevation"], float)
@pytest.mark.anyio
async def test_move_to(mcp_client: Client):
result = await mcp_client.call_tool(
"move_to", {"azimuth": 200.0, "elevation": 40.0}
)
data = parse_result(result)
assert data["status"] == "moving"
assert data["target_azimuth"] == 200.0
assert data["target_elevation"] == 40.0
@pytest.mark.anyio
async def test_move_motor_az(mcp_client: Client):
result = await mcp_client.call_tool(
"move_motor", {"motor_id": 0, "degrees": 90.0}
)
data = parse_result(result)
assert data["axis"] == "azimuth"
assert data["target"] == 90.0
@pytest.mark.anyio
async def test_move_motor_el(mcp_client: Client):
result = await mcp_client.call_tool(
"move_motor", {"motor_id": 1, "degrees": 30.0}
)
data = parse_result(result)
assert data["axis"] == "elevation"
@pytest.mark.anyio
async def test_move_motor_invalid_id(mcp_client: Client):
with pytest.raises(ToolError):
await mcp_client.call_tool(
"move_motor", {"motor_id": 2, "degrees": 10.0}
)
@pytest.mark.anyio
async def test_home_motor(mcp_client: Client):
result = await mcp_client.call_tool("home_motor", {"motor_id": 1})
data = parse_result(result)
assert data["status"] == "homing"
assert data["axis"] == "elevation"
@pytest.mark.anyio
async def test_engage_release(mcp_client: Client):
result = await mcp_client.call_tool("release_motors", {})
data = parse_result(result)
assert data["status"] == "released"
result = await mcp_client.call_tool("engage_motors", {})
data = parse_result(result)
assert data["status"] == "engaged"
@pytest.mark.anyio
async def test_stow(mcp_client: Client):
result = await mcp_client.call_tool("stow", {})
data = parse_result(result)
assert data["status"] == "stowing"
assert data["target_azimuth"] == 0.0
assert data["target_elevation"] == 65.0
@pytest.mark.anyio
async def test_get_step_positions(mcp_client: Client):
result = await mcp_client.call_tool("get_step_positions", {})
data = parse_result(result)
assert "az_steps" in data
assert "el_steps" in data
assert isinstance(data["az_steps"], int)
@pytest.mark.anyio
async def test_get_el_limits(mcp_client: Client):
result = await mcp_client.call_tool("get_el_limits", {})
data = parse_result(result)
assert data["min"] == 18.0
assert data["max"] == 65.0
assert data["home"] == 65.0

View file

@ -0,0 +1,86 @@
"""Tests for satellite tracking tools."""
import pytest
from conftest import parse_result
from fastmcp import Client
@pytest.mark.anyio
async def test_search_satellites(mcp_client: Client):
result = await mcp_client.call_tool(
"search_satellites", {"query": "ISS"}
)
data = parse_result(result)
assert data["count"] >= 1
names = [r["name"] for r in data["results"]]
assert any("ISS" in n for n in names)
@pytest.mark.anyio
async def test_search_no_results(mcp_client: Client):
result = await mcp_client.call_tool(
"search_satellites", {"query": "nonexistent_xyz"}
)
data = parse_result(result)
assert data["count"] == 0
@pytest.mark.anyio
async def test_search_with_limit(mcp_client: Client):
result = await mcp_client.call_tool(
"search_satellites", {"query": "", "limit": 3}
)
data = parse_result(result)
# Empty query won't match any catalog entries
assert data["count"] >= 0
@pytest.mark.anyio
async def test_get_passes(mcp_client: Client):
result = await mcp_client.call_tool(
"get_passes", {"norad_id": 25544}
)
data = parse_result(result)
assert data["count"] > 0
p = data["passes"][0]
assert "aos_time" in p
assert "tca_time" in p
assert "los_time" in p
assert "max_elevation" in p
assert p["norad_id"] == 25544
@pytest.mark.anyio
async def test_get_next_pass(mcp_client: Client):
result = await mcp_client.call_tool(
"get_next_pass", {"norad_id": 25544}
)
data = parse_result(result)
assert data["pass"] is not None
assert data["pass"]["satellite_name"] == "ISS (ZARYA)"
@pytest.mark.anyio
async def test_get_visible_targets(mcp_client: Client):
result = await mcp_client.call_tool(
"get_visible_targets", {}
)
data = parse_result(result)
# Celestial bodies (Moon, Sun, Jupiter) are always visible
assert data["count"] >= 1
names = [t["name"] for t in data["targets"]]
# At least celestial bodies should be present
assert any(
n in names for n in ("Moon", "Sun", "Jupiter")
)
@pytest.mark.anyio
async def test_get_visible_targets_with_min_alt(mcp_client: Client):
result = await mcp_client.call_tool(
"get_visible_targets", {"min_alt": 80.0}
)
data = parse_result(result)
# Very high min_alt should filter out most targets
for t in data["targets"]:
assert t["altitude"] >= 80.0

80
mcp/tests/test_signal.py Normal file
View file

@ -0,0 +1,80 @@
"""Tests for signal measurement tools."""
import pytest
from conftest import parse_result
from fastmcp import Client
@pytest.mark.anyio
async def test_get_rssi_default(mcp_client: Client):
result = await mcp_client.call_tool("get_rssi", {})
data = parse_result(result)
assert data["reads"] == 10
assert isinstance(data["average"], int)
assert isinstance(data["current"], int)
@pytest.mark.anyio
async def test_get_rssi_custom_iterations(mcp_client: Client):
result = await mcp_client.call_tool("get_rssi", {"iterations": 5})
data = parse_result(result)
assert data["reads"] == 5
@pytest.mark.anyio
async def test_get_adc_rssi(mcp_client: Client):
result = await mcp_client.call_tool("get_adc_rssi", {})
data = parse_result(result)
assert "raw" in data
@pytest.mark.anyio
async def test_get_lock_status(mcp_client: Client):
result = await mcp_client.call_tool("get_lock_status", {})
data = parse_result(result)
assert "raw" in data
assert "Lock:" in data["raw"]
@pytest.mark.anyio
async def test_enable_lna(mcp_client: Client):
result = await mcp_client.call_tool("enable_lna", {})
data = parse_result(result)
assert data["status"] == "lna_enabled"
assert data["voltage"] == "13V"
@pytest.mark.anyio
async def test_get_dvb_config(mcp_client: Client):
result = await mcp_client.call_tool("get_dvb_config", {})
data = parse_result(result)
assert "BCM" in data["raw"]
assert "0x4515" in data["raw"]
@pytest.mark.anyio
async def test_get_channel_params(mcp_client: Client):
result = await mcp_client.call_tool("get_channel_params", {})
data = parse_result(result)
assert "Frequency" in data["raw"]
@pytest.mark.anyio
async def test_az_sweep(mcp_client: Client):
result = await mcp_client.call_tool(
"az_sweep",
{
"start_az": 195.0,
"span": 10.0,
"step_cdeg": 200,
"num_xponders": 1,
},
)
data = parse_result(result)
assert data["count"] > 0
assert len(data["points"]) == data["count"]
pt = data["points"][0]
assert "az" in pt
assert "rssi" in pt
assert "lock" in pt
assert "snr" in pt

121
mcp/tests/test_system.py Normal file
View file

@ -0,0 +1,121 @@
"""Tests for system and firmware tools."""
import pytest
from conftest import parse_result
from fastmcp import Client
from fastmcp.exceptions import ToolError
@pytest.mark.anyio
async def test_get_firmware_id(mcp_client: Client):
result = await mcp_client.call_tool("get_firmware_id", {})
data = parse_result(result)
assert "02.02.48" in data["raw"]
assert "TWELINCH" in data["raw"]
@pytest.mark.anyio
async def test_get_motor_dynamics(mcp_client: Client):
result = await mcp_client.call_tool("get_motor_dynamics", {})
data = parse_result(result)
assert data["az_max_vel"] == 65.0
assert data["el_max_vel"] == 45.0
assert data["az_accel"] == 400.0
assert data["el_accel"] == 400.0
@pytest.mark.anyio
async def test_set_max_velocity(mcp_client: Client):
result = await mcp_client.call_tool(
"set_max_velocity", {"motor_id": 0, "deg_per_sec": 30.0}
)
data = parse_result(result)
assert data["axis"] == "azimuth"
assert data["max_velocity"] == 30.0
# Verify it stuck
result = await mcp_client.call_tool("get_motor_dynamics", {})
data = parse_result(result)
assert data["az_max_vel"] == 30.0
@pytest.mark.anyio
async def test_set_max_velocity_invalid_motor(mcp_client: Client):
with pytest.raises(ToolError):
await mcp_client.call_tool(
"set_max_velocity", {"motor_id": 3, "deg_per_sec": 10.0}
)
@pytest.mark.anyio
async def test_set_max_acceleration(mcp_client: Client):
result = await mcp_client.call_tool(
"set_max_acceleration", {"motor_id": 1, "accel": 200.0}
)
data = parse_result(result)
assert data["axis"] == "elevation"
assert data["max_acceleration"] == 200.0
@pytest.mark.anyio
async def test_get_motor_life(mcp_client: Client):
result = await mcp_client.call_tool("get_motor_life", {})
data = parse_result(result)
assert "AZ total moves" in data["raw"]
@pytest.mark.anyio
async def test_get_pid_gains(mcp_client: Client):
result = await mcp_client.call_tool("get_pid_gains", {})
data = parse_result(result)
assert data["az"]["kp"] == 600.0
assert data["az"]["kv"] == 60.0
assert data["el"]["kp"] == 250.0
@pytest.mark.anyio
async def test_set_pid_gains(mcp_client: Client):
result = await mcp_client.call_tool(
"set_pid_gains",
{"motor_id": 0, "kp": 500.0, "kv": 55.0, "ki": 2.0},
)
data = parse_result(result)
assert data["status"] == "set"
assert data["kp"] == 500.0
@pytest.mark.anyio
async def test_get_a3981_diag(mcp_client: Client):
result = await mcp_client.call_tool("get_a3981_diag", {})
data = parse_result(result)
assert "OK" in data["raw"]
@pytest.mark.anyio
async def test_get_a3981_modes(mcp_client: Client):
result = await mcp_client.call_tool("get_a3981_modes", {})
data = parse_result(result)
assert "AUTO" in data["step_mode"]
@pytest.mark.anyio
async def test_nvs_dump(mcp_client: Client):
result = await mcp_client.call_tool("nvs_dump", {})
data = parse_result(result)
assert "Disable Tracker" in data["raw"]
assert "AZ Max Vel" in data["raw"]
@pytest.mark.anyio
async def test_nvs_read(mcp_client: Client):
result = await mcp_client.call_tool("nvs_read", {"index": 20})
data = parse_result(result)
assert data["index"] == 20
assert "Disable Tracker" in data["raw"]
@pytest.mark.anyio
async def test_nvs_read_missing_index(mcp_client: Client):
result = await mcp_client.call_tool("nvs_read", {"index": 999})
data = parse_result(result)
assert "not found" in data["raw"]