diff --git a/agents/fce.py b/agents/fce.py
index 8eb4227..24a1ea8 100644
--- a/agents/fce.py
+++ b/agents/fce.py
@@ -53,7 +53,9 @@ async def run_fce(
status = flight_status.get("status", "")
delay_minutes = flight_status.get("delay_minutes", 0)
- should_notify = status in ("DELAYED", "CANCELLED", "DIVERTED") and delay_minutes >= 10
+ should_notify = status in ("CANCELLED", "DIVERTED") or (
+ status == "DELAYED" and delay_minutes >= 10
+ )
await emit("node_exit", node="triage", result={"should_notify": should_notify})
diff --git a/mcp_servers/data/scenarios/weather_disruption_ord.py b/mcp_servers/data/scenarios/weather_disruption_ord.py
index f7e5bf5..1e003ba 100644
--- a/mcp_servers/data/scenarios/weather_disruption_ord.py
+++ b/mcp_servers/data/scenarios/weather_disruption_ord.py
@@ -185,6 +185,31 @@ CREW: list[CrewMember] = [
CrewMember(crew_id="CR-1012", name="FA Garcia", role=CrewRole.FA,
duty_hours_elapsed=11.5, duty_hours_limit=14.0,
rest_hours_since_last=10.0, next_scheduled_flight="UA881", base_hub="ORD"),
+ CrewMember(crew_id="CR-1013", name="FA Herrera", role=CrewRole.FA,
+ duty_hours_elapsed=9.0, duty_hours_limit=14.0,
+ rest_hours_since_last=11.0, next_scheduled_flight="UA881", base_hub="ORD"),
+ CrewMember(crew_id="CR-1014", name="FA Novak", role=CrewRole.FA,
+ duty_hours_elapsed=7.5, duty_hours_limit=14.0,
+ rest_hours_since_last=15.0, next_scheduled_flight="UA233", base_hub="ORD"),
+ CrewMember(crew_id="CR-1015", name="FA Park", role=CrewRole.FA,
+ duty_hours_elapsed=6.0, duty_hours_limit=14.0,
+ rest_hours_since_last=17.0, next_scheduled_flight="UA094", base_hub="ORD"),
+ CrewMember(crew_id="CR-1016", name="FO Santos", role=CrewRole.FIRST_OFFICER,
+ duty_hours_elapsed=4.0, duty_hours_limit=14.0,
+ rest_hours_since_last=20.0, next_scheduled_flight=None, base_hub="ORD"),
+ # On-time flight crew
+ CrewMember(crew_id="CR-1017", name="Capt. Walsh", role=CrewRole.CAPTAIN,
+ duty_hours_elapsed=3.0, duty_hours_limit=14.0,
+ rest_hours_since_last=22.0, next_scheduled_flight="UA1220", base_hub="ORD"),
+ CrewMember(crew_id="CR-1018", name="FO Lindgren", role=CrewRole.FIRST_OFFICER,
+ duty_hours_elapsed=3.0, duty_hours_limit=14.0,
+ rest_hours_since_last=21.0, next_scheduled_flight="UA1220", base_hub="ORD"),
+ CrewMember(crew_id="CR-1019", name="Capt. Rivera", role=CrewRole.CAPTAIN,
+ duty_hours_elapsed=2.0, duty_hours_limit=14.0,
+ rest_hours_since_last=24.0, next_scheduled_flight="UA788", base_hub="ORD"),
+ CrewMember(crew_id="CR-1020", name="FO Cheng", role=CrewRole.FIRST_OFFICER,
+ duty_hours_elapsed=2.0, duty_hours_limit=14.0,
+ rest_hours_since_last=22.0, next_scheduled_flight="UA788", base_hub="ORD"),
# Backup crew
CrewMember(crew_id="CR-4421", name="Capt. Okafor", role=CrewRole.CAPTAIN,
duty_hours_elapsed=0.0, duty_hours_limit=14.0,
diff --git a/pyproject.toml b/pyproject.toml
index 592d66c..d9f365d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,6 +22,7 @@ dependencies = [
dev = [
"pytest",
"pytest-asyncio",
+ "httpx",
"ruff",
]
@@ -34,3 +35,6 @@ select = ["E", "F", "I"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
+markers = [
+ "live: tests that hit external APIs (OpenMeteo, FAA)",
+]
diff --git a/tests/base.py b/tests/base.py
new file mode 100644
index 0000000..bd50625
--- /dev/null
+++ b/tests/base.py
@@ -0,0 +1,82 @@
+"""Test base — two execution modes from one test suite.
+
+Modes (set via CONTRACT_TEST_MODE env var):
+
+ inprocess (default) — httpx.AsyncClient with ASGI transport, full FastAPI
+ stack in-process. Fast, no server needed.
+
+ live — httpx.AsyncClient against CONTRACT_TEST_URL.
+ Tests against a running deployment (Kind, EC2, etc).
+
+Usage:
+ # In-process (default)
+ pytest tests/
+
+ # Against live server
+ CONTRACT_TEST_MODE=live CONTRACT_TEST_URL=http://unt.local.ar pytest tests/
+"""
+
+import asyncio
+import os
+
+import httpx
+import pytest_asyncio
+
+
+def get_mode() -> str:
+ return os.getenv("CONTRACT_TEST_MODE", "inprocess")
+
+
+def get_base_url() -> str:
+ return os.getenv("CONTRACT_TEST_URL", "http://localhost:8040")
+
+
+@pytest_asyncio.fixture
+async def client() -> httpx.AsyncClient:
+ """Async HTTP client for API tests."""
+ if get_mode() == "live":
+ async with httpx.AsyncClient(base_url=get_base_url(), timeout=30.0) as c:
+ yield c
+ else:
+ from httpx import ASGITransport
+ from api.main import app
+
+ transport = ASGITransport(app=app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
+ yield c
+
+
+# Alias for agent tests
+agent_client = client
+
+
+class ContractHelpers:
+ """Reusable assertion and utility methods."""
+
+ @staticmethod
+ def assert_status(response: httpx.Response, expected: int):
+ assert response.status_code == expected, (
+ f"Expected {expected}, got {response.status_code}: {response.text[:200]}"
+ )
+
+ @staticmethod
+ def assert_has_fields(data: dict, *fields: str):
+ for f in fields:
+ assert f in data, f"Missing field: {f}. Keys: {list(data.keys())}"
+
+ @staticmethod
+ def assert_is_list(data, min_length: int = 0):
+ assert isinstance(data, list), f"Expected list, got {type(data)}"
+ assert len(data) >= min_length, f"Expected >= {min_length} items, got {len(data)}"
+
+ @staticmethod
+ async def poll_run(client: httpx.AsyncClient, run_id: str, timeout: int = 60) -> dict:
+ """Poll an agent run until completion."""
+ deadline = asyncio.get_event_loop().time() + timeout
+ while asyncio.get_event_loop().time() < deadline:
+ res = await client.get(f"/agents/runs/{run_id}")
+ data = res.json()
+ if data.get("status") in ("completed", "error"):
+ return data
+ await asyncio.sleep(1)
+ raise TimeoutError(f"Run {run_id} did not complete in {timeout}s")
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..aec452b
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,3 @@
+"""Root conftest — exports async client fixtures."""
+
+from tests.base import client, agent_client # noqa: F401
diff --git a/tests/endpoints.py b/tests/endpoints.py
new file mode 100644
index 0000000..4353926
--- /dev/null
+++ b/tests/endpoints.py
@@ -0,0 +1,35 @@
+"""Centralized API endpoint paths — single source of truth."""
+
+
+class Endpoints:
+ # Scenarios
+ SCENARIOS = "/scenarios"
+ SCENARIO_ACTIVE = "/scenarios/active"
+
+ # Scenario data
+ DATA_FLIGHTS = "/scenarios/data/flights"
+ DATA_CREW = "/scenarios/data/crew"
+ DATA_CREW_NOTES = "/scenarios/data/crew-notes"
+ DATA_MAINTENANCE = "/scenarios/data/maintenance"
+ DATA_REBOOKINGS = "/scenarios/data/rebookings"
+
+ @staticmethod
+ def flight(flight_id: str) -> str:
+ return f"/scenarios/data/flights/{flight_id}"
+
+ @staticmethod
+ def crew(crew_id: str) -> str:
+ return f"/scenarios/data/crew/{crew_id}"
+
+ @staticmethod
+ def crew_notes(flight_id: str) -> str:
+ return f"/scenarios/data/crew-notes/{flight_id}"
+
+ # Agents
+ AGENT_FCE = "/agents/fce"
+ AGENT_HANDOVER = "/agents/handover"
+ AGENT_RUNS = "/agents/runs"
+
+ @staticmethod
+ def run(run_id: str) -> str:
+ return f"/agents/runs/{run_id}"
diff --git a/tests/helpers.py b/tests/helpers.py
new file mode 100644
index 0000000..bc003b8
--- /dev/null
+++ b/tests/helpers.py
@@ -0,0 +1,39 @@
+"""Reusable test data and setup helpers."""
+
+import httpx
+
+from tests.endpoints import Endpoints as E
+
+
+async def set_scenario(client: httpx.AsyncClient, scenario_id: str) -> dict:
+ res = await client.put(E.SCENARIO_ACTIVE, json={"scenario_id": scenario_id})
+ return res.json()
+
+
+async def get_flights(client: httpx.AsyncClient) -> list[dict]:
+ return (await client.get(E.DATA_FLIGHTS)).json()
+
+
+async def get_disrupted_flights(client: httpx.AsyncClient) -> list[dict]:
+ return [f for f in await get_flights(client) if f["status"] != "ON_TIME"]
+
+
+async def get_first_disrupted_flight_id(client: httpx.AsyncClient) -> str:
+ flights = await get_disrupted_flights(client)
+ assert len(flights) > 0, "No disrupted flights in current scenario"
+ return flights[0]["flight_id"]
+
+
+async def trigger_fce(client: httpx.AsyncClient, flight_id: str) -> str:
+ res = await client.post(E.AGENT_FCE, json={"flight_id": flight_id})
+ data = res.json()
+ assert "run_id" in data, f"FCE trigger failed: {data}"
+ return data["run_id"]
+
+
+async def trigger_handover(client: httpx.AsyncClient, hubs: list[str] | None = None) -> str:
+ body = {"hubs": hubs} if hubs else {}
+ res = await client.post(E.AGENT_HANDOVER, json=body)
+ data = res.json()
+ assert "run_id" in data, f"Handover trigger failed: {data}"
+ return data["run_id"]
diff --git a/tests/test_agents.py b/tests/test_agents.py
new file mode 100644
index 0000000..bf0143b
--- /dev/null
+++ b/tests/test_agents.py
@@ -0,0 +1,101 @@
+"""Contract tests — agent execution via API."""
+
+import pytest
+
+from tests.base import ContractHelpers as H
+from tests.endpoints import Endpoints as E
+from tests.helpers import set_scenario, trigger_fce, trigger_handover
+
+
+class TestFCEAgent:
+ @pytest.mark.asyncio
+ async def test_trigger_returns_run_id(self, agent_client):
+ await set_scenario(agent_client, "weather_disruption_ord")
+ res = await agent_client.post(E.AGENT_FCE, json={"flight_id": "UA432"})
+ H.assert_status(res, 200)
+ H.assert_has_fields(res.json(), "run_id", "status")
+ assert res.json()["status"] == "running"
+
+ @pytest.mark.asyncio
+ async def test_completes_with_notification(self, agent_client):
+ await set_scenario(agent_client, "weather_disruption_ord")
+ run_id = await trigger_fce(agent_client, "UA432")
+ result = await H.poll_run(agent_client, run_id)
+
+ assert result["status"] == "completed"
+ r = result["result"]
+ H.assert_has_fields(r, "type", "status", "notification_text", "data_sources", "duration_ms")
+ assert r["type"].endswith("_NOTIFICATION")
+ assert len(r["notification_text"]) > 0
+ assert r["duration_ms"] > 0
+
+ @pytest.mark.asyncio
+ async def test_includes_live_data(self, agent_client):
+ await set_scenario(agent_client, "weather_disruption_ord")
+ run_id = await trigger_fce(agent_client, "UA432")
+ result = await H.poll_run(agent_client, run_id)
+
+ sources = result["result"]["data_sources"]
+ assert "weather_live" in sources
+ assert "faa_status_live" in sources
+
+ @pytest.mark.asyncio
+ async def test_notification_mentions_flight(self, agent_client):
+ await set_scenario(agent_client, "weather_disruption_ord")
+ run_id = await trigger_fce(agent_client, "UA432")
+ result = await H.poll_run(agent_client, run_id)
+
+ text = result["result"]["notification_text"]
+ assert "UA432" in text
+ assert "ORD" in text or "SFO" in text
+
+
+class TestHandoverAgent:
+ @pytest.mark.asyncio
+ async def test_trigger_returns_run_id(self, agent_client):
+ res = await agent_client.post(E.AGENT_HANDOVER, json={})
+ H.assert_status(res, 200)
+ H.assert_has_fields(res.json(), "run_id", "status")
+
+ @pytest.mark.asyncio
+ async def test_completes_with_brief(self, agent_client):
+ await set_scenario(agent_client, "weather_disruption_ord")
+ run_id = await trigger_handover(agent_client, hubs=["ORD"])
+ result = await H.poll_run(agent_client, run_id)
+
+ assert result["status"] == "completed"
+ r = result["result"]
+ H.assert_has_fields(r, "type", "brief_text", "summary", "items", "duration_ms")
+ assert r["type"] == "HANDOVER_BRIEF"
+ assert len(r["brief_text"]) > 0
+
+ @pytest.mark.asyncio
+ async def test_identifies_disruptions(self, agent_client):
+ await set_scenario(agent_client, "weather_disruption_ord")
+ run_id = await trigger_handover(agent_client, hubs=["ORD"])
+ result = await H.poll_run(agent_client, run_id)
+
+ items = result["result"]["items"]
+ assert len(items["immediate"]) > 0, "Should find immediate items in disruption scenario"
+
+ @pytest.mark.asyncio
+ async def test_hub_filtering(self, agent_client):
+ await set_scenario(agent_client, "weather_disruption_ord")
+ run_id = await trigger_handover(agent_client, hubs=["ORD"])
+ result = await H.poll_run(agent_client, run_id)
+
+ assert result["result"]["hubs"] == ["ORD"]
+
+
+class TestAgentRuns:
+ @pytest.mark.asyncio
+ async def test_list_runs(self, agent_client):
+ res = await agent_client.get(E.AGENT_RUNS)
+ H.assert_status(res, 200)
+ H.assert_is_list(res.json())
+
+ @pytest.mark.asyncio
+ async def test_unknown_run(self, agent_client):
+ res = await agent_client.get(E.run("nonexistent"))
+ H.assert_status(res, 200)
+ assert "error" in res.json()
diff --git a/tests/test_data_clients.py b/tests/test_data_clients.py
new file mode 100644
index 0000000..32d9102
--- /dev/null
+++ b/tests/test_data_clients.py
@@ -0,0 +1,172 @@
+"""Tests for real data clients (OpenMeteo, FAA).
+
+These hit live APIs — marked with pytest.mark for selective running.
+"""
+
+import pytest
+
+from mcp_servers.data.real.openmeteo import (
+ HUB_COORDS,
+ WMO_CODES,
+ _interpolate_waypoints,
+ _interpret_weather,
+ get_weather_along_route,
+ get_weather_forecast_hubs,
+)
+from mcp_servers.data.real.faa import get_airport_status, _parse_status_xml
+
+
+class TestOpenMeteoHelpers:
+ def test_interpolate_waypoints_count(self):
+ origin = (41.97, -87.91)
+ dest = (37.62, -122.38)
+ points = _interpolate_waypoints(origin, dest, n=2)
+ assert len(points) == 2
+
+ def test_interpolate_waypoints_between(self):
+ origin = (0.0, 0.0)
+ dest = (30.0, 60.0)
+ points = _interpolate_waypoints(origin, dest, n=2)
+ for lat, lon in points:
+ assert 0 < lat < 30
+ assert 0 < lon < 60
+
+ def test_interpret_weather_clear(self):
+ result = _interpret_weather(0)
+ assert result["condition"] == "Clear sky"
+ assert result["is_significant"] is False
+
+ def test_interpret_weather_thunderstorm(self):
+ result = _interpret_weather(95)
+ assert result["condition"] == "Thunderstorm"
+ assert result["is_significant"] is True
+
+ def test_interpret_weather_unknown(self):
+ result = _interpret_weather(999)
+ assert "Unknown" in result["condition"]
+
+ def test_hub_coords_complete(self):
+ for hub in ["ORD", "EWR", "IAH", "SFO", "DEN"]:
+ assert hub in HUB_COORDS
+ lat, lon = HUB_COORDS[hub]
+ assert -90 <= lat <= 90
+ assert -180 <= lon <= 180
+
+
+class TestFAAHelpers:
+ def test_parse_status_xml_ground_stop(self):
+ xml = """
+
+
+ Ground Stop Programs
+
+
+ SFO
+ thunderstorms
+ 6:45 pm PDT
+
+
+
+
+ """
+ result = _parse_status_xml(xml)
+ assert "SFO" in result
+ assert len(result["SFO"]) == 1
+ assert result["SFO"][0]["type"] == "ground_stop"
+ assert result["SFO"][0]["reason"] == "thunderstorms"
+
+ def test_parse_status_xml_gdp(self):
+ xml = """
+
+
+ Ground Delay Programs
+
+
+ EWR
+ wind
+ 45 minutes
+ 1 hour
+
+
+
+
+ """
+ result = _parse_status_xml(xml)
+ assert "EWR" in result
+ assert result["EWR"][0]["type"] == "ground_delay_program"
+ assert result["EWR"][0]["average_delay"] == "45 minutes"
+
+ def test_parse_status_xml_empty(self):
+ xml = ""
+ result = _parse_status_xml(xml)
+ assert result == {}
+
+ def test_parse_status_xml_multiple_airports(self):
+ xml = """
+
+
+ Ground Stop Programs
+
+ SFOweather
+ ORDvolume
+
+
+
+ """
+ result = _parse_status_xml(xml)
+ assert "SFO" in result
+ assert "ORD" in result
+
+
+@pytest.mark.live
+class TestOpenMeteoLive:
+ """Tests that hit the live OpenMeteo API."""
+
+ @pytest.mark.asyncio
+ async def test_weather_along_route(self):
+ result = await get_weather_along_route("ORD", "SFO")
+ assert result["origin"] == "ORD"
+ assert result["destination"] == "SFO"
+ assert "waypoints" in result
+ assert "origin" in result["waypoints"]
+ assert "destination" in result["waypoints"]
+ # Check a waypoint has weather data
+ wp = result["waypoints"]["origin"]
+ assert "temperature_c" in wp
+ assert "weather" in wp
+
+ @pytest.mark.asyncio
+ async def test_weather_unknown_airport(self):
+ result = await get_weather_along_route("ORD", "FAKE")
+ assert "error" in result
+
+ @pytest.mark.asyncio
+ async def test_hub_forecasts(self):
+ result = await get_weather_forecast_hubs()
+ assert "hubs" in result
+ for hub in ["ORD", "EWR", "IAH", "SFO", "DEN"]:
+ assert hub in result["hubs"]
+ hub_data = result["hubs"][hub]
+ if "error" not in hub_data:
+ assert "forecast" in hub_data
+ assert len(hub_data["forecast"]) > 0
+
+
+@pytest.mark.live
+class TestFAALive:
+ """Tests that hit the live FAA API."""
+
+ @pytest.mark.asyncio
+ async def test_airport_status_known(self):
+ result = await get_airport_status("ORD")
+ assert result["airport"] == "ORD"
+ assert result["source"] == "faa_nasstatus_live"
+ assert result["status"] in ("normal_operations", "delays_active", "status_unavailable")
+
+ @pytest.mark.asyncio
+ async def test_airport_status_no_delays(self):
+ """Small airport unlikely to have delays."""
+ result = await get_airport_status("BTV")
+ assert result["airport"] == "BTV"
+ # Either normal or unavailable, but should not crash
+ assert "status" in result
diff --git a/tests/test_mcp_servers.py b/tests/test_mcp_servers.py
new file mode 100644
index 0000000..2ba80bf
--- /dev/null
+++ b/tests/test_mcp_servers.py
@@ -0,0 +1,206 @@
+"""Tests for MCP servers — tools, resources, prompts via the MCP protocol."""
+
+import json
+
+import pytest
+
+from fastmcp import Client
+
+
+def _client(module: str) -> Client:
+ return Client({"mcpServers": {"default": {
+ "command": "uv",
+ "args": ["run", "python", "-m", module],
+ }}})
+
+
+def _parse_result(result) -> any:
+ """Parse a fastmcp CallToolResult into Python data."""
+ if hasattr(result, "content"):
+ texts = [c.text for c in result.content if hasattr(c, "text")]
+ elif isinstance(result, list):
+ texts = [c.text for c in result if hasattr(c, "text")]
+ else:
+ return result
+
+ if not texts:
+ return None
+ combined = texts[0] if len(texts) == 1 else "[" + ",".join(texts) + "]"
+ try:
+ return json.loads(combined)
+ except (json.JSONDecodeError, TypeError):
+ return combined
+
+
+class TestSharedServer:
+ @pytest.mark.asyncio
+ async def test_list_tools(self):
+ async with _client("mcp_servers.shared") as c:
+ tools = await c.list_tools()
+ names = [t.name for t in tools]
+ assert "get_flight_status" in names
+ assert "get_flight_details" in names
+ assert "get_irregular_ops" in names
+ assert "get_route_weather" in names
+ assert "get_hub_forecasts" in names
+ assert "get_airport_status" in names
+ assert "get_airport_congestion" in names
+ assert "get_maintenance_flags" in names
+
+ @pytest.mark.asyncio
+ async def test_flight_status_found(self):
+ async with _client("mcp_servers.shared") as c:
+ result = await c.call_tool("get_flight_status", {"flight_id": "UA432"})
+ data = _parse_result(result)
+ assert data["flight_id"] == "UA432"
+ assert data["status"] == "DELAYED"
+ assert data["delay_minutes"] == 55
+
+ @pytest.mark.asyncio
+ async def test_flight_status_not_found(self):
+ async with _client("mcp_servers.shared") as c:
+ result = await c.call_tool("get_flight_status", {"flight_id": "FAKE999"})
+ data = _parse_result(result)
+ assert "error" in data
+
+ @pytest.mark.asyncio
+ async def test_irregular_ops(self):
+ async with _client("mcp_servers.shared") as c:
+ result = await c.call_tool("get_irregular_ops", {"hub": "ORD"})
+ data = _parse_result(result)
+ assert isinstance(data, list)
+ assert len(data) >= 4
+ for f in data:
+ assert f["irrop_type"] in ("DELAYED", "CANCELLED", "DIVERTED")
+
+ @pytest.mark.asyncio
+ async def test_maintenance_flags(self):
+ async with _client("mcp_servers.shared") as c:
+ result = await c.call_tool("get_maintenance_flags", {"aircraft_tail": "N78501"})
+ data = _parse_result(result)
+ assert isinstance(data, list)
+ assert len(data) >= 1
+ assert "system" in data[0]
+
+ @pytest.mark.asyncio
+ async def test_maintenance_flags_empty(self):
+ async with _client("mcp_servers.shared") as c:
+ result = await c.call_tool("get_maintenance_flags", {"aircraft_tail": "FAKE"})
+ data = _parse_result(result)
+ # Empty list may come back as [] or as "[]" string
+ if isinstance(data, list):
+ assert len(data) == 0
+ else:
+ assert data == "[]" or data is None
+
+ @pytest.mark.asyncio
+ async def test_list_prompts(self):
+ async with _client("mcp_servers.shared") as c:
+ prompts = await c.list_prompts()
+ names = [p.name for p in prompts]
+ assert "delay_explainer" in names
+
+
+class TestOpsServer:
+ @pytest.mark.asyncio
+ async def test_list_tools(self):
+ async with _client("mcp_servers.ops") as c:
+ tools = await c.list_tools()
+ names = [t.name for t in tools]
+ assert "get_crew_notes" in names
+ assert "get_crew_duty_status" in names
+ assert "get_pending_rebookings" in names
+ assert "generate_narrative" in names
+
+ @pytest.mark.asyncio
+ async def test_crew_notes(self):
+ async with _client("mcp_servers.ops") as c:
+ result = await c.call_tool("get_crew_notes", {"flight_id": "UA432"})
+ data = _parse_result(result)
+ assert isinstance(data, list)
+ assert len(data) > 0
+
+ @pytest.mark.asyncio
+ async def test_crew_duty_status(self):
+ async with _client("mcp_servers.ops") as c:
+ result = await c.call_tool(
+ "get_crew_duty_status",
+ {"crew_ids": ["CR-1001", "CR-1003"]}
+ )
+ data = _parse_result(result)
+ assert isinstance(data, list)
+ assert len(data) == 2
+ for c_data in data:
+ assert "hours_until_limit" in c_data
+ assert "at_risk" in c_data
+
+ @pytest.mark.asyncio
+ async def test_crew_at_risk_detection(self):
+ async with _client("mcp_servers.ops") as c:
+ result = await c.call_tool(
+ "get_crew_duty_status",
+ {"crew_ids": ["CR-1003"]}
+ )
+ data = _parse_result(result)
+ crew = data[0] if isinstance(data, list) else data
+ assert crew["at_risk"] is True
+ assert crew["hours_until_limit"] <= 2.0
+
+ @pytest.mark.asyncio
+ async def test_pending_rebookings(self):
+ async with _client("mcp_servers.ops") as c:
+ result = await c.call_tool("get_pending_rebookings", {"hub": "ORD"})
+ data = _parse_result(result)
+ assert isinstance(data, list)
+ assert len(data) > 0
+ urgencies = [r["urgency"] for r in data]
+ assert urgencies[0] == "HIGH"
+
+ @pytest.mark.asyncio
+ async def test_list_resources(self):
+ async with _client("mcp_servers.ops") as c:
+ resources = await c.list_resources()
+ uris = [str(r.uri) for r in resources]
+ assert "ops://crew/roster" in uris
+ assert "ops://handover/latest" in uris
+
+ @pytest.mark.asyncio
+ async def test_list_prompts(self):
+ async with _client("mcp_servers.ops") as c:
+ prompts = await c.list_prompts()
+ names = [p.name for p in prompts]
+ assert "handover_brief" in names
+
+
+class TestPassengerServer:
+ @pytest.mark.asyncio
+ async def test_list_tools(self):
+ async with _client("mcp_servers.passenger") as c:
+ tools = await c.list_tools()
+ names = [t.name for t in tools]
+ assert "generate_notification" in names
+
+ @pytest.mark.asyncio
+ async def test_generate_notification(self):
+ async with _client("mcp_servers.passenger") as c:
+ result = await c.call_tool("generate_notification", {
+ "context": {
+ "flight_id": "UA432",
+ "origin": "ORD",
+ "destination": "SFO",
+ "status": "DELAYED",
+ "delay_minutes": 55,
+ "delay_cause": "WEATHER",
+ "gate": "H14",
+ }
+ })
+ text = _parse_result(result)
+ assert "UA432" in text
+ assert "DELAYED" in text or "delayed" in text
+
+ @pytest.mark.asyncio
+ async def test_list_prompts(self):
+ async with _client("mcp_servers.passenger") as c:
+ prompts = await c.list_prompts()
+ names = [p.name for p in prompts]
+ assert "passenger_notification" in names
diff --git a/tests/test_models.py b/tests/test_models.py
new file mode 100644
index 0000000..c714ce9
--- /dev/null
+++ b/tests/test_models.py
@@ -0,0 +1,150 @@
+"""Tests for data models and scenario manager."""
+
+import pytest
+
+from mcp_servers.data.models import (
+ HUBS,
+ CrewMember,
+ CrewRole,
+ DelayCause,
+ FlightData,
+ FlightStatus,
+ HubInfo,
+ MELItem,
+ MPStatus,
+ Passenger,
+ RebookingCase,
+)
+from mcp_servers.data.scenarios.manager import ScenarioManager, SCENARIO_MODULES
+
+
+class TestModels:
+ def test_flight_data_serializes(self):
+ from datetime import datetime, timezone
+
+ f = FlightData(
+ flight_id="UA100",
+ origin="ORD",
+ destination="SFO",
+ scheduled_departure=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc),
+ scheduled_arrival=datetime(2026, 1, 1, 15, 0, tzinfo=timezone.utc),
+ status=FlightStatus.DELAYED,
+ delay_minutes=30,
+ delay_cause=DelayCause.WEATHER,
+ aircraft_tail="N12345",
+ gate="A1",
+ )
+ d = f.model_dump(mode="json")
+ assert d["flight_id"] == "UA100"
+ assert d["status"] == "DELAYED"
+ assert d["delay_cause"] == "WEATHER"
+
+ def test_crew_member_serializes(self):
+ c = CrewMember(
+ crew_id="CR-1",
+ name="Test Pilot",
+ role=CrewRole.CAPTAIN,
+ duty_hours_elapsed=10.0,
+ duty_hours_limit=14.0,
+ rest_hours_since_last=12.0,
+ base_hub="ORD",
+ )
+ d = c.model_dump(mode="json")
+ assert d["role"] == "CAPTAIN"
+ assert d["duty_hours_elapsed"] == 10.0
+
+ def test_mp_status_values(self):
+ assert MPStatus.GLOBAL_SERVICES.value == "GLOBAL_SERVICES"
+ assert MPStatus.K1.value == "1K"
+
+ def test_hubs_reference_data(self):
+ assert "ORD" in HUBS
+ assert "EWR" in HUBS
+ assert "IAH" in HUBS
+ assert "SFO" in HUBS
+ assert "DEN" in HUBS
+ assert len(HUBS) == 5
+
+ ord = HUBS["ORD"]
+ assert ord.code == "ORD"
+ assert ord.city == "Chicago"
+ assert ord.latitude == pytest.approx(41.97, abs=0.01)
+ assert ord.gates > 0
+ assert ord.runways > 0
+
+
+class TestScenarioManager:
+ def test_list_scenarios(self):
+ mgr = ScenarioManager()
+ scenarios = mgr.list_scenarios()
+ assert len(scenarios) == len(SCENARIO_MODULES)
+ ids = [s["scenario_id"] for s in scenarios]
+ assert "normal_ops" in ids
+ assert "weather_disruption_ord" in ids
+ assert "maintenance_delay_sfo" in ids
+ assert "crew_swap_ewr" in ids
+
+ def test_default_active_scenario(self):
+ mgr = ScenarioManager()
+ assert mgr.active_id == "weather_disruption_ord"
+
+ def test_switch_scenario(self):
+ mgr = ScenarioManager()
+ result = mgr.set_active("normal_ops")
+ assert mgr.active_id == "normal_ops"
+ assert result["scenario_id"] == "normal_ops"
+
+ def test_switch_invalid_scenario(self):
+ mgr = ScenarioManager()
+ with pytest.raises(ValueError, match="Unknown scenario"):
+ mgr.set_active("nonexistent")
+
+ def test_flights_per_scenario(self):
+ mgr = ScenarioManager()
+ for sid in SCENARIO_MODULES:
+ mgr.set_active(sid)
+ assert len(mgr.flights) > 0, f"No flights in {sid}"
+ for f in mgr.flights:
+ assert f.flight_id, f"Empty flight_id in {sid}"
+ assert f.origin, f"Empty origin in {sid}"
+
+ def test_crew_per_scenario(self):
+ mgr = ScenarioManager()
+ for sid in SCENARIO_MODULES:
+ mgr.set_active(sid)
+ assert len(mgr.crew) > 0, f"No crew in {sid}"
+ for c in mgr.crew:
+ assert c.crew_id
+ assert c.duty_hours_limit > 0
+
+ def test_scenario_internal_consistency(self):
+ """Crew IDs on flights should exist in the crew roster."""
+ mgr = ScenarioManager()
+ for sid in SCENARIO_MODULES:
+ mgr.set_active(sid)
+ crew_ids = {c.crew_id for c in mgr.crew}
+ for f in mgr.flights:
+ for cid in f.crew_ids:
+ assert cid in crew_ids, (
+ f"Flight {f.flight_id} in {sid} references crew {cid} "
+ f"not in roster"
+ )
+
+ def test_disrupted_flights_have_cause(self):
+ mgr = ScenarioManager()
+ for sid in SCENARIO_MODULES:
+ mgr.set_active(sid)
+ for f in mgr.flights:
+ if f.status != FlightStatus.ON_TIME:
+ assert f.delay_cause is not None, (
+ f"Flight {f.flight_id} in {sid} is {f.status} "
+ f"but has no delay_cause"
+ )
+
+ def test_metadata_counts(self):
+ mgr = ScenarioManager()
+ mgr.set_active("weather_disruption_ord")
+ meta = mgr.get_metadata()
+ assert meta["flight_count"] == len(mgr.flights)
+ disrupted = sum(1 for f in mgr.flights if f.status != FlightStatus.ON_TIME)
+ assert meta["disrupted_flights"] == disrupted
diff --git a/tests/test_scenario_data.py b/tests/test_scenario_data.py
new file mode 100644
index 0000000..de4db39
--- /dev/null
+++ b/tests/test_scenario_data.py
@@ -0,0 +1,133 @@
+"""Contract tests — scenario data CRUD."""
+
+import pytest
+
+from tests.base import ContractHelpers as H
+from tests.endpoints import Endpoints as E
+from tests.helpers import set_scenario
+
+
+class TestFlights:
+ @pytest.mark.asyncio
+ async def test_list_flights(self, client):
+ await set_scenario(client, "weather_disruption_ord")
+ res = await client.get(E.DATA_FLIGHTS)
+ H.assert_status(res, 200)
+ data = res.json()
+ H.assert_is_list(data, min_length=1)
+ H.assert_has_fields(data[0], "flight_id", "origin", "destination", "status")
+
+ @pytest.mark.asyncio
+ async def test_flights_change_with_scenario(self, client):
+ await set_scenario(client, "weather_disruption_ord")
+ ord_ids = {f["flight_id"] for f in (await client.get(E.DATA_FLIGHTS)).json()}
+
+ await set_scenario(client, "crew_swap_ewr")
+ ewr_ids = {f["flight_id"] for f in (await client.get(E.DATA_FLIGHTS)).json()}
+
+ assert ord_ids != ewr_ids, "Different scenarios should have different flights"
+ await set_scenario(client, "weather_disruption_ord")
+
+ @pytest.mark.asyncio
+ async def test_patch_flight_delay(self, client):
+ await set_scenario(client, "weather_disruption_ord")
+ res = await client.patch(E.flight("UA432"), json={"delay_minutes": 120})
+ H.assert_status(res, 200)
+ assert res.json()["delay_minutes"] == 120
+
+ flights = (await client.get(E.DATA_FLIGHTS)).json()
+ ua432 = next(f for f in flights if f["flight_id"] == "UA432")
+ assert ua432["delay_minutes"] == 120
+
+ @pytest.mark.asyncio
+ async def test_patch_flight_status(self, client):
+ await set_scenario(client, "weather_disruption_ord")
+ res = await client.patch(E.flight("UA432"), json={"status": "CANCELLED"})
+ H.assert_status(res, 200)
+ assert res.json()["status"] == "CANCELLED"
+
+ @pytest.mark.asyncio
+ async def test_patch_unknown_flight(self, client):
+ res = await client.patch(E.flight("FAKE"), json={"delay_minutes": 10})
+ H.assert_status(res, 200)
+ assert "error" in res.json()
+
+
+class TestCrew:
+ @pytest.mark.asyncio
+ async def test_list_crew(self, client):
+ await set_scenario(client, "weather_disruption_ord")
+ res = await client.get(E.DATA_CREW)
+ H.assert_status(res, 200)
+ data = res.json()
+ H.assert_is_list(data, min_length=1)
+ H.assert_has_fields(data[0], "crew_id", "name", "role", "hours_until_limit", "at_risk")
+
+ @pytest.mark.asyncio
+ async def test_patch_crew_duty_hours(self, client):
+ await set_scenario(client, "weather_disruption_ord")
+ res = await client.patch(E.crew("CR-1001"), json={"duty_hours_elapsed": 13.5})
+ H.assert_status(res, 200)
+ data = res.json()
+ assert data["duty_hours_elapsed"] == 13.5
+ assert data["at_risk"] is True
+ assert data["hours_until_limit"] == 0.5
+
+
+class TestCrewNotes:
+ @pytest.mark.asyncio
+ async def test_get_notes(self, client):
+ await set_scenario(client, "weather_disruption_ord")
+ res = await client.get(E.DATA_CREW_NOTES)
+ H.assert_status(res, 200)
+ data = res.json()
+ assert isinstance(data, dict)
+ assert "UA432" in data
+ assert len(data["UA432"]) > 0
+
+ @pytest.mark.asyncio
+ async def test_update_notes(self, client):
+ await set_scenario(client, "weather_disruption_ord")
+ new_notes = ["Test note alpha", "Test note beta"]
+ res = await client.put(E.crew_notes("UA432"), json={"notes": new_notes})
+ H.assert_status(res, 200)
+ assert res.json()["notes"] == new_notes
+
+ all_notes = (await client.get(E.DATA_CREW_NOTES)).json()
+ assert all_notes["UA432"] == new_notes
+
+
+class TestMaintenance:
+ @pytest.mark.asyncio
+ async def test_get_maintenance(self, client):
+ await set_scenario(client, "weather_disruption_ord")
+ res = await client.get(E.DATA_MAINTENANCE)
+ H.assert_status(res, 200)
+ assert isinstance(res.json(), dict)
+
+ @pytest.mark.asyncio
+ async def test_maintenance_has_mel_items(self, client):
+ await set_scenario(client, "maintenance_delay_sfo")
+ data = (await client.get(E.DATA_MAINTENANCE)).json()
+ assert len(data) > 0
+ for tail, items in data.items():
+ for item in items:
+ H.assert_has_fields(item, "mel_id", "system", "description")
+
+
+class TestRebookings:
+ @pytest.mark.asyncio
+ async def test_get_rebookings(self, client):
+ await set_scenario(client, "weather_disruption_ord")
+ res = await client.get(E.DATA_REBOOKINGS)
+ H.assert_status(res, 200)
+ data = res.json()
+ H.assert_is_list(data, min_length=1)
+ H.assert_has_fields(data[0], "pax_id", "name", "urgency", "original_flight")
+
+ @pytest.mark.asyncio
+ async def test_empty_in_normal_ops(self, client):
+ await set_scenario(client, "normal_ops")
+ data = (await client.get(E.DATA_REBOOKINGS)).json()
+ assert len(data) == 0
+ await set_scenario(client, "weather_disruption_ord")
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
new file mode 100644
index 0000000..bec9cd0
--- /dev/null
+++ b/tests/test_scenarios.py
@@ -0,0 +1,53 @@
+"""Contract tests — scenario management."""
+
+import pytest
+
+from tests.base import ContractHelpers as H
+from tests.endpoints import Endpoints as E
+
+
+class TestListScenarios:
+ @pytest.mark.asyncio
+ async def test_returns_list(self, client):
+ res = await client.get(E.SCENARIOS)
+ H.assert_status(res, 200)
+ H.assert_is_list(res.json(), min_length=4)
+
+ @pytest.mark.asyncio
+ async def test_each_has_metadata(self, client):
+ data = (await client.get(E.SCENARIOS)).json()
+ for s in data:
+ H.assert_has_fields(s, "scenario_id", "name", "description", "hubs", "flight_count")
+
+ @pytest.mark.asyncio
+ async def test_known_scenarios_present(self, client):
+ ids = [s["scenario_id"] for s in (await client.get(E.SCENARIOS)).json()]
+ assert "normal_ops" in ids
+ assert "weather_disruption_ord" in ids
+ assert "maintenance_delay_sfo" in ids
+ assert "crew_swap_ewr" in ids
+
+
+class TestActiveScenario:
+ @pytest.mark.asyncio
+ async def test_get_active(self, client):
+ res = await client.get(E.SCENARIO_ACTIVE)
+ H.assert_status(res, 200)
+ H.assert_has_fields(res.json(), "scenario_id", "name")
+
+ @pytest.mark.asyncio
+ async def test_switch_scenario(self, client):
+ res = await client.put(E.SCENARIO_ACTIVE, json={"scenario_id": "normal_ops"})
+ H.assert_status(res, 200)
+ assert res.json()["scenario_id"] == "normal_ops"
+
+ res = await client.get(E.SCENARIO_ACTIVE)
+ assert res.json()["scenario_id"] == "normal_ops"
+
+ await client.put(E.SCENARIO_ACTIVE, json={"scenario_id": "weather_disruption_ord"})
+
+ @pytest.mark.asyncio
+ async def test_switch_invalid(self, client):
+ res = await client.put(E.SCENARIO_ACTIVE, json={"scenario_id": "nonexistent"})
+ H.assert_status(res, 200)
+ assert "error" in res.json()
diff --git a/uv.lock b/uv.lock
index 8041776..2f590f7 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1880,6 +1880,56 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
]
+[[package]]
+name = "stellar-ops"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "anthropic" },
+ { name = "boto3" },
+ { name = "fastapi" },
+ { name = "fastmcp" },
+ { name = "httpx" },
+ { name = "langchain-anthropic" },
+ { name = "langchain-aws" },
+ { name = "langfuse" },
+ { name = "langgraph" },
+ { name = "mcp", extra = ["cli"] },
+ { name = "pydantic" },
+ { name = "uvicorn", extra = ["standard"] },
+ { name = "websockets" },
+]
+
+[package.optional-dependencies]
+dev = [
+ { name = "httpx" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "anthropic" },
+ { name = "boto3" },
+ { name = "fastapi" },
+ { name = "fastmcp", specifier = ">=3.0" },
+ { name = "httpx" },
+ { name = "httpx", marker = "extra == 'dev'" },
+ { name = "langchain-anthropic" },
+ { name = "langchain-aws" },
+ { name = "langfuse" },
+ { name = "langgraph" },
+ { name = "mcp", extras = ["cli"] },
+ { name = "pydantic", specifier = ">=2.0" },
+ { name = "pytest", marker = "extra == 'dev'" },
+ { name = "pytest-asyncio", marker = "extra == 'dev'" },
+ { name = "ruff", marker = "extra == 'dev'" },
+ { name = "uvicorn", extras = ["standard"] },
+ { name = "websockets" },
+]
+provides-extras = ["dev"]
+
[[package]]
name = "tenacity"
version = "9.1.4"
@@ -1934,54 +1984,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/11/e1/7ec67882ad8fc9f86384bef6421fa252c9cbe5744f8df6ce77afc9eca1f5/uncalled_for-0.3.1-py3-none-any.whl", hash = "sha256:074cdc92da8356278f93d0ded6f2a66dd883dbecaf9bc89437646ee2289cc200", size = 11361, upload-time = "2026-04-07T13:05:05.341Z" },
]
-[[package]]
-name = "united-ops"
-version = "0.1.0"
-source = { virtual = "." }
-dependencies = [
- { name = "anthropic" },
- { name = "boto3" },
- { name = "fastapi" },
- { name = "fastmcp" },
- { name = "httpx" },
- { name = "langchain-anthropic" },
- { name = "langchain-aws" },
- { name = "langfuse" },
- { name = "langgraph" },
- { name = "mcp", extra = ["cli"] },
- { name = "pydantic" },
- { name = "uvicorn", extra = ["standard"] },
- { name = "websockets" },
-]
-
-[package.optional-dependencies]
-dev = [
- { name = "pytest" },
- { name = "pytest-asyncio" },
- { name = "ruff" },
-]
-
-[package.metadata]
-requires-dist = [
- { name = "anthropic" },
- { name = "boto3" },
- { name = "fastapi" },
- { name = "fastmcp", specifier = ">=3.0" },
- { name = "httpx" },
- { name = "langchain-anthropic" },
- { name = "langchain-aws" },
- { name = "langfuse" },
- { name = "langgraph" },
- { name = "mcp", extras = ["cli"] },
- { name = "pydantic", specifier = ">=2.0" },
- { name = "pytest", marker = "extra == 'dev'" },
- { name = "pytest-asyncio", marker = "extra == 'dev'" },
- { name = "ruff", marker = "extra == 'dev'" },
- { name = "uvicorn", extras = ["standard"] },
- { name = "websockets" },
-]
-provides-extras = ["dev"]
-
[[package]]
name = "urllib3"
version = "2.6.3"