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)