init commit

This commit is contained in:
2026-04-12 07:19:48 -03:00
commit 9dbf89da02
111 changed files with 14925 additions and 0 deletions

0
mcp_servers/__init__.py Normal file
View File

View File

View File

137
mcp_servers/data/models.py Normal file
View File

@@ -0,0 +1,137 @@
"""Pydantic models for all operational data."""
from datetime import datetime
from enum import Enum
from pydantic import BaseModel
class FlightStatus(str, Enum):
ON_TIME = "ON_TIME"
DELAYED = "DELAYED"
CANCELLED = "CANCELLED"
DIVERTED = "DIVERTED"
class DelayCause(str, Enum):
WEATHER = "WEATHER"
MAINTENANCE = "MAINTENANCE"
CREW = "CREW"
ATC = "ATC"
LATE_AIRCRAFT = "LATE_AIRCRAFT"
class CrewRole(str, Enum):
CAPTAIN = "CAPTAIN"
FIRST_OFFICER = "FIRST_OFFICER"
FA = "FA"
class MPStatus(str, Enum):
GLOBAL_SERVICES = "GLOBAL_SERVICES"
K1 = "1K"
PLATINUM = "PLATINUM"
GOLD = "GOLD"
SILVER = "SILVER"
GENERAL = "GENERAL"
class FlightData(BaseModel):
flight_id: str
origin: str
destination: str
scheduled_departure: datetime
actual_departure: datetime | None = None
scheduled_arrival: datetime
actual_arrival: datetime | None = None
status: FlightStatus
delay_minutes: int = 0
delay_cause: DelayCause | None = None
aircraft_tail: str
gate: str
inbound_flight: str | None = None
crew_ids: list[str] = []
passenger_count: int = 0
class CrewMember(BaseModel):
crew_id: str
name: str
role: CrewRole
duty_hours_elapsed: float
duty_hours_limit: float
rest_hours_since_last: float
next_scheduled_flight: str | None = None
base_hub: str
class Passenger(BaseModel):
pax_id: str
name: str
mileage_plus_status: MPStatus
flight_id: str
destination: str
connection_flight: str | None = None
connection_deadline: datetime | None = None
special_needs: list[str] = []
class MELItem(BaseModel):
mel_id: str
aircraft_tail: str
system: str
description: str
restriction: str | None = None
expires: datetime
class RebookingCase(BaseModel):
pax_id: str
name: str
original_flight: str
mileage_plus_status: MPStatus
destination: str
next_available: str | None = None
urgency: str # HIGH | MEDIUM | LOW
class HubInfo(BaseModel):
code: str
name: str
city: str
timezone: str
latitude: float
longitude: float
terminals: int
gates: int
runways: int
# Hub reference data
HUBS: dict[str, HubInfo] = {
"ORD": HubInfo(
code="ORD", name="O'Hare International", city="Chicago",
timezone="America/Chicago", latitude=41.9742, longitude=-87.9073,
terminals=4, gates=191, runways=8,
),
"EWR": HubInfo(
code="EWR", name="Newark Liberty International", city="Newark",
timezone="America/New_York", latitude=40.6895, longitude=-74.1745,
terminals=3, gates=120, runways=3,
),
"IAH": HubInfo(
code="IAH", name="George Bush Intercontinental", city="Houston",
timezone="America/Chicago", latitude=29.9902, longitude=-95.3368,
terminals=5, gates=130, runways=5,
),
"SFO": HubInfo(
code="SFO", name="San Francisco International", city="San Francisco",
timezone="America/Los_Angeles", latitude=37.6213, longitude=-122.3790,
terminals=4, gates=115, runways=4,
),
"DEN": HubInfo(
code="DEN", name="Denver International", city="Denver",
timezone="America/Denver", latitude=39.8561, longitude=-104.6737,
terminals=1, gates=95, runways=6,
),
}

View File

View File

@@ -0,0 +1,124 @@
"""FAA NASSTATUS API client — live airport delay/status data, no API key required.
Endpoint: https://nasstatus.faa.gov/api/airport-status-information
Returns XML with ground stops, ground delay programs, arrival/departure delays, closures.
"""
import xml.etree.ElementTree as ET
import httpx
BASE_URL = "https://nasstatus.faa.gov/api/airport-status-information"
# Cache parsed data to avoid hitting the API for every airport query
_cache: dict | None = None
_cache_raw: str = ""
async def _fetch_all_status() -> dict[str, list[dict]]:
"""Fetch and parse all airport status information from FAA."""
global _cache, _cache_raw
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.get(BASE_URL)
resp.raise_for_status()
xml_text = resp.text
# Don't re-parse if unchanged
if xml_text == _cache_raw and _cache is not None:
return _cache
_cache_raw = xml_text
_cache = _parse_status_xml(xml_text)
return _cache
except Exception as e:
return {"_error": [{"error": str(e)}]}
def _parse_status_xml(xml_text: str) -> dict[str, list[dict]]:
"""Parse FAA airport status XML into a dict of airport → delays."""
delays_by_airport: dict[str, list[dict]] = {}
root = ET.fromstring(xml_text)
for delay_type in root.findall("Delay_type"):
name = delay_type.findtext("Name", "").strip()
# Ground Stop Programs
for program in delay_type.findall(".//Program"):
arpt = program.findtext("ARPT", "").strip()
if not arpt:
continue
delays_by_airport.setdefault(arpt, []).append({
"type": "ground_stop",
"reason": program.findtext("Reason", "unknown").strip(),
"end_time": program.findtext("End_Time", "").strip() or None,
})
# Ground Delay Programs
for gdp in delay_type.findall(".//Ground_Delay"):
arpt = gdp.findtext("ARPT", "").strip()
if not arpt:
continue
delays_by_airport.setdefault(arpt, []).append({
"type": "ground_delay_program",
"reason": gdp.findtext("Reason", "unknown").strip(),
"average_delay": gdp.findtext("Avg", "").strip() or None,
"max_delay": gdp.findtext("Max", "").strip() or None,
})
# Arrival/Departure Delays
for delay in delay_type.findall(".//Delay"):
arpt = delay.findtext("ARPT", "").strip()
if not arpt:
continue
delays_by_airport.setdefault(arpt, []).append({
"type": name.lower().replace(" ", "_") if name else "delay",
"reason": delay.findtext("Reason", "unknown").strip(),
"average_delay": delay.findtext("Avg", "").strip() or None,
"max_delay": delay.findtext("Max", "").strip() or None,
"trend": delay.findtext("Trend", "").strip() or None,
})
# Closures
for closure in delay_type.findall(".//Closure"):
arpt = closure.findtext("ARPT", "").strip()
if not arpt:
continue
delays_by_airport.setdefault(arpt, []).append({
"type": "closure",
"reason": closure.findtext("Reason", "unknown").strip(),
"begin": closure.findtext("Begin", "").strip() or None,
"end": closure.findtext("End", "").strip() or None,
})
return delays_by_airport
async def get_airport_status(airport_code: str) -> dict:
"""Fetch live FAA airport status for a single airport.
Returns ground delay programs, ground stops, closures.
Falls back to 'status_unavailable' on any error.
"""
all_status = await _fetch_all_status()
if "_error" in all_status:
return {
"airport": airport_code.upper(),
"status": "status_unavailable",
"error": all_status["_error"][0]["error"],
"source": "faa_nasstatus_live",
}
airport = airport_code.upper()
delays = all_status.get(airport, [])
return {
"airport": airport,
"has_delays": len(delays) > 0,
"status": "delays_active" if delays else "normal_operations",
"delays": delays,
"source": "faa_nasstatus_live",
}

View File

@@ -0,0 +1,197 @@
"""OpenMeteo API client — live weather data, no API key required."""
import httpx
BASE_URL = "https://api.open-meteo.com/v1/forecast"
# United hub coordinates
HUB_COORDS: dict[str, tuple[float, float]] = {
"ORD": (41.9742, -87.9073), # Chicago O'Hare
"EWR": (40.6895, -74.1745), # Newark
"IAH": (29.9902, -95.3368), # Houston Intercontinental
"SFO": (37.6213, -122.3790), # San Francisco
"DEN": (39.8561, -104.6737), # Denver
"LAX": (33.9425, -118.4081), # Los Angeles
"IAD": (38.9531, -77.4565), # Washington Dulles
}
# WMO weather interpretation codes
WMO_CODES: dict[int, str] = {
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Fog",
48: "Depositing rime fog",
51: "Light drizzle",
53: "Moderate drizzle",
55: "Dense drizzle",
61: "Slight rain",
63: "Moderate rain",
65: "Heavy rain",
71: "Slight snow",
73: "Moderate snow",
75: "Heavy snow",
77: "Snow grains",
80: "Slight rain showers",
81: "Moderate rain showers",
82: "Violent rain showers",
85: "Slight snow showers",
86: "Heavy snow showers",
95: "Thunderstorm",
96: "Thunderstorm with slight hail",
99: "Thunderstorm with heavy hail",
}
SIGNIFICANT_CODES = {45, 48, 65, 75, 82, 86, 95, 96, 99}
def _interpolate_waypoints(
origin: tuple[float, float], dest: tuple[float, float], n: int = 2
) -> list[tuple[float, float]]:
"""Generate n intermediate waypoints along a great-circle approximation."""
points = []
for i in range(1, n + 1):
frac = i / (n + 1)
lat = origin[0] + frac * (dest[0] - origin[0])
lon = origin[1] + frac * (dest[1] - origin[1])
points.append((round(lat, 4), round(lon, 4)))
return points
def _interpret_weather(code: int) -> dict:
return {
"code": code,
"condition": WMO_CODES.get(code, f"Unknown ({code})"),
"is_significant": code in SIGNIFICANT_CODES,
}
async def fetch_weather_at_point(
lat: float, lon: float, client: httpx.AsyncClient
) -> dict:
"""Fetch current weather at a single point."""
params = {
"latitude": lat,
"longitude": lon,
"current": "temperature_2m,wind_speed_10m,wind_direction_10m,weather_code,visibility,precipitation",
"timezone": "UTC",
}
resp = await client.get(BASE_URL, params=params)
resp.raise_for_status()
data = resp.json()
current = data["current"]
weather = _interpret_weather(current["weather_code"])
return {
"latitude": lat,
"longitude": lon,
"temperature_c": current["temperature_2m"],
"wind_speed_kmh": current["wind_speed_10m"],
"wind_direction_deg": current["wind_direction_10m"],
"visibility_m": current.get("visibility"),
"precipitation_mm": current.get("precipitation", 0),
"weather": weather,
}
async def get_weather_along_route(origin: str, destination: str) -> dict:
"""Fetch weather at origin, destination, and en-route waypoints."""
origin_coords = HUB_COORDS.get(origin.upper())
dest_coords = HUB_COORDS.get(destination.upper())
if not origin_coords or not dest_coords:
return {
"error": f"Unknown airport code. Known: {', '.join(HUB_COORDS.keys())}",
"origin": origin,
"destination": destination,
}
waypoints = _interpolate_waypoints(origin_coords, dest_coords)
all_points = [
("origin", origin.upper(), origin_coords),
*[(f"waypoint_{i+1}", f"WP{i+1}", wp) for i, wp in enumerate(waypoints)],
("destination", destination.upper(), dest_coords),
]
results = {}
significant_events = []
async with httpx.AsyncClient(timeout=10.0) as client:
for label, name, (lat, lon) in all_points:
try:
weather = await fetch_weather_at_point(lat, lon, client)
weather["label"] = label
weather["name"] = name
results[label] = weather
if weather["weather"]["is_significant"]:
significant_events.append(
{
"location": name,
"condition": weather["weather"]["condition"],
"code": weather["weather"]["code"],
}
)
except Exception as e:
results[label] = {"label": label, "name": name, "error": str(e)}
return {
"origin": origin.upper(),
"destination": destination.upper(),
"waypoints": results,
"significant_events": significant_events,
"source": "openmeteo_live",
}
async def get_weather_forecast_hubs() -> dict:
"""Fetch 4-hour forecast for all United hubs."""
hubs = ["ORD", "EWR", "IAH", "SFO", "DEN"]
results = {}
async with httpx.AsyncClient(timeout=10.0) as client:
for hub in hubs:
lat, lon = HUB_COORDS[hub]
params = {
"latitude": lat,
"longitude": lon,
"hourly": "temperature_2m,wind_speed_10m,weather_code,visibility,precipitation_probability",
"forecast_hours": 4,
"timezone": "UTC",
}
try:
resp = await client.get(BASE_URL, params=params)
resp.raise_for_status()
data = resp.json()
hourly = data["hourly"]
hours = []
risk_flag = False
for i in range(len(hourly["time"])):
code = hourly["weather_code"][i]
weather = _interpret_weather(code)
if weather["is_significant"]:
risk_flag = True
hours.append(
{
"time": hourly["time"][i],
"temperature_c": hourly["temperature_2m"][i],
"wind_speed_kmh": hourly["wind_speed_10m"][i],
"weather": weather,
"visibility_m": hourly.get("visibility", [None])[i]
if "visibility" in hourly
else None,
"precipitation_probability": hourly[
"precipitation_probability"
][i],
}
)
results[hub] = {
"hub": hub,
"forecast": hours,
"risk_flag": risk_flag,
}
except Exception as e:
results[hub] = {"hub": hub, "error": str(e)}
return {"hubs": results, "source": "openmeteo_live"}

View File

View File

@@ -0,0 +1,123 @@
"""Scenario: EWR Crew Duty Limit — complex Part 117 crew swap needed.
Captain hitting duty limit in 2h, backup crew needed. Tests Handover IMMEDIATE action.
"""
from datetime import datetime, timezone
from mcp_servers.data.models import (
CrewMember,
CrewRole,
DelayCause,
FlightData,
FlightStatus,
MELItem,
MPStatus,
Passenger,
RebookingCase,
)
SCENARIO_ID = "crew_swap_ewr"
SCENARIO_NAME = "EWR Crew Duty Limit"
SCENARIO_DESCRIPTION = (
"Captain on UA2180 hitting Part 117 duty limit in 1h 45min. "
"Delay cascading. 2 crew swaps needed."
)
SCENARIO_HUBS = ["EWR"]
FLIGHTS: list[FlightData] = [
FlightData(
flight_id="UA2180", origin="EWR", destination="LAX",
scheduled_departure=datetime(2026, 4, 11, 20, 0, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 23, 30, tzinfo=timezone.utc),
status=FlightStatus.DELAYED,
delay_minutes=90,
delay_cause=DelayCause.CREW,
aircraft_tail="N81201",
gate="C72",
crew_ids=["CR-3001", "CR-3002", "CR-3010", "CR-3011"],
passenger_count=210,
),
FlightData(
flight_id="UA2244", origin="EWR", destination="ORD",
scheduled_departure=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 22, 0, tzinfo=timezone.utc),
status=FlightStatus.DELAYED,
delay_minutes=45,
delay_cause=DelayCause.LATE_AIRCRAFT,
aircraft_tail="N81202",
gate="C87",
crew_ids=["CR-3003", "CR-3004", "CR-3012"],
passenger_count=178,
),
FlightData(
flight_id="UA2310", origin="EWR", destination="SFO",
scheduled_departure=datetime(2026, 4, 11, 21, 0, tzinfo=timezone.utc),
scheduled_arrival=datetime(2026, 4, 12, 0, 15, tzinfo=timezone.utc),
status=FlightStatus.ON_TIME,
aircraft_tail="N81203",
gate="C90",
crew_ids=["CR-3005", "CR-3006"],
passenger_count=195,
),
]
CREW: list[CrewMember] = [
# UA2180 — captain at limit
CrewMember(crew_id="CR-3001", name="Capt. Mitchell", role=CrewRole.CAPTAIN,
duty_hours_elapsed=12.25, duty_hours_limit=14.0,
rest_hours_since_last=10.0, next_scheduled_flight="UA2180", base_hub="EWR"),
CrewMember(crew_id="CR-3002", name="FO Vasquez", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=11.0, duty_hours_limit=14.0,
rest_hours_since_last=11.0, next_scheduled_flight="UA2180", base_hub="EWR"),
# UA2244
CrewMember(crew_id="CR-3003", name="Capt. Ali", role=CrewRole.CAPTAIN,
duty_hours_elapsed=8.0, duty_hours_limit=14.0,
rest_hours_since_last=14.0, next_scheduled_flight="UA2244", base_hub="EWR"),
CrewMember(crew_id="CR-3004", name="FO Johansson", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=8.0, duty_hours_limit=14.0,
rest_hours_since_last=13.0, next_scheduled_flight="UA2244", base_hub="EWR"),
# UA2310
CrewMember(crew_id="CR-3005", name="Capt. Reed", role=CrewRole.CAPTAIN,
duty_hours_elapsed=4.0, duty_hours_limit=14.0,
rest_hours_since_last=20.0, next_scheduled_flight="UA2310", base_hub="EWR"),
CrewMember(crew_id="CR-3006", name="FO Torres", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=4.0, duty_hours_limit=14.0,
rest_hours_since_last=18.0, next_scheduled_flight="UA2310", base_hub="EWR"),
# FAs
CrewMember(crew_id="CR-3010", name="FA Collins", role=CrewRole.FA,
duty_hours_elapsed=11.5, duty_hours_limit=14.0,
rest_hours_since_last=10.0, next_scheduled_flight="UA2180", base_hub="EWR"),
CrewMember(crew_id="CR-3011", name="FA Yamamoto", role=CrewRole.FA,
duty_hours_elapsed=11.5, duty_hours_limit=14.0,
rest_hours_since_last=10.0, next_scheduled_flight="UA2180", base_hub="EWR"),
CrewMember(crew_id="CR-3012", name="FA Petrov", role=CrewRole.FA,
duty_hours_elapsed=7.0, duty_hours_limit=14.0,
rest_hours_since_last=15.0, next_scheduled_flight="UA2244", base_hub="EWR"),
# Backup crew
CrewMember(crew_id="CR-8812", name="Capt. Foster", role=CrewRole.CAPTAIN,
duty_hours_elapsed=0.0, duty_hours_limit=14.0,
rest_hours_since_last=28.0, next_scheduled_flight=None, base_hub="EWR"),
CrewMember(crew_id="CR-8813", name="FO Chang", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=0.0, duty_hours_limit=14.0,
rest_hours_since_last=24.0, next_scheduled_flight=None, base_hub="EWR"),
]
CREW_NOTES: dict[str, list[str]] = {
"UA2180": [
"Capt. Mitchell duty limit approaching — 1h 45min remaining.",
"If departure slips past 22:15 ET, mandatory crew swap per Part 117 §117.19.",
"Backup Capt. Foster (CR-8812) on standby at crew lounge, cleared and rested.",
"FO Vasquez also approaching limit but has buffer until 23:00.",
],
"UA2244": [
"Delay is cascading from late inbound aircraft, not crew-related.",
"Gate conflict resolved — moved to C87.",
],
}
MAINTENANCE: dict[str, list[MELItem]] = {}
REBOOKINGS: list[RebookingCase] = []
PASSENGERS: list[Passenger] = []

View File

@@ -0,0 +1,117 @@
"""Scenario: SFO Maintenance Delay — MEL issue cascading to 2 flights.
Aircraft N82301 has APU MEL, delay cascading. Tests FCE maintenance-caused
delay explanation and Handover MEL flag section.
"""
from datetime import datetime, timezone
from mcp_servers.data.models import (
CrewMember,
CrewRole,
DelayCause,
FlightData,
FlightStatus,
MELItem,
MPStatus,
Passenger,
RebookingCase,
)
SCENARIO_ID = "maintenance_delay_sfo"
SCENARIO_NAME = "SFO MEL Issue"
SCENARIO_DESCRIPTION = (
"Aircraft N82301 APU inoperative (MEL). Delay cascading to 2 flights. "
"Route restriction if divert needed."
)
SCENARIO_HUBS = ["SFO"]
FLIGHTS: list[FlightData] = [
FlightData(
flight_id="UA712", origin="SFO", destination="EWR",
scheduled_departure=datetime(2026, 4, 11, 19, 0, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 12, 3, 30, tzinfo=timezone.utc),
status=FlightStatus.DELAYED,
delay_minutes=65,
delay_cause=DelayCause.MAINTENANCE,
aircraft_tail="N82301",
gate="G4",
crew_ids=["CR-4001", "CR-4002", "CR-4010"],
passenger_count=198,
),
FlightData(
flight_id="UA724", origin="SFO", destination="DEN",
scheduled_departure=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
scheduled_arrival=datetime(2026, 4, 11, 23, 45, tzinfo=timezone.utc),
status=FlightStatus.DELAYED,
delay_minutes=30,
delay_cause=DelayCause.LATE_AIRCRAFT,
aircraft_tail="N82302",
gate="G8",
crew_ids=["CR-4003", "CR-4004"],
passenger_count=165,
),
FlightData(
flight_id="UA760", origin="SFO", destination="LAX",
scheduled_departure=datetime(2026, 4, 11, 18, 30, tzinfo=timezone.utc),
scheduled_arrival=datetime(2026, 4, 11, 19, 45, tzinfo=timezone.utc),
status=FlightStatus.ON_TIME,
aircraft_tail="N82303",
gate="G12",
crew_ids=["CR-4005", "CR-4006"],
passenger_count=140,
),
]
CREW: list[CrewMember] = [
CrewMember(crew_id="CR-4001", name="Capt. Novak", role=CrewRole.CAPTAIN,
duty_hours_elapsed=6.0, duty_hours_limit=14.0,
rest_hours_since_last=16.0, next_scheduled_flight="UA712", base_hub="SFO"),
CrewMember(crew_id="CR-4002", name="FO Agrawal", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=6.0, duty_hours_limit=14.0,
rest_hours_since_last=15.0, next_scheduled_flight="UA712", base_hub="SFO"),
CrewMember(crew_id="CR-4003", name="Capt. Svensson", role=CrewRole.CAPTAIN,
duty_hours_elapsed=5.0, duty_hours_limit=14.0,
rest_hours_since_last=18.0, next_scheduled_flight="UA724", base_hub="SFO"),
CrewMember(crew_id="CR-4004", name="FO Rivera", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=5.0, duty_hours_limit=14.0,
rest_hours_since_last=17.0, next_scheduled_flight="UA724", base_hub="SFO"),
CrewMember(crew_id="CR-4005", name="Capt. Wallace", role=CrewRole.CAPTAIN,
duty_hours_elapsed=3.0, duty_hours_limit=14.0,
rest_hours_since_last=22.0, next_scheduled_flight="UA760", base_hub="SFO"),
CrewMember(crew_id="CR-4006", name="FO Zhao", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=3.0, duty_hours_limit=14.0,
rest_hours_since_last=20.0, next_scheduled_flight="UA760", base_hub="SFO"),
CrewMember(crew_id="CR-4010", name="FA Douglas", role=CrewRole.FA,
duty_hours_elapsed=5.5, duty_hours_limit=14.0,
rest_hours_since_last=16.0, next_scheduled_flight="UA712", base_hub="SFO"),
]
CREW_NOTES: dict[str, list[str]] = {
"UA712": [
"MEL item #47-3: APU inoperative on N82301.",
"Maintenance team inspecting — estimated release in 45 min.",
"APU MEL restricts routing to airports with ground power only.",
"Current SFO→EWR route unaffected, but flag if divert to BDL or HPN needed.",
],
"UA724": [
"Delay cascading from UA712 — shared gate G8 not available until UA712 pushes.",
],
}
MAINTENANCE: dict[str, list[MELItem]] = {
"N82301": [
MELItem(
mel_id="MEL-SFO-473",
aircraft_tail="N82301",
system="APU",
description="Auxiliary Power Unit inoperative. Aircraft requires external ground power for engine start and gate operations.",
restriction="Routing restricted to airports with ground power availability. Cannot divert to airports without ground power units (e.g., BDL, HPN).",
expires=datetime(2026, 4, 18, 0, 0, tzinfo=timezone.utc),
),
],
}
REBOOKINGS: list[RebookingCase] = []
PASSENGERS: list[Passenger] = []

View File

@@ -0,0 +1,85 @@
"""Scenario manager — loads and switches between operational scenarios."""
from importlib import import_module
from typing import Any
SCENARIO_MODULES = {
"normal_ops": "mcp_servers.data.scenarios.normal_ops",
"weather_disruption_ord": "mcp_servers.data.scenarios.weather_disruption_ord",
"maintenance_delay_sfo": "mcp_servers.data.scenarios.maintenance_delay_sfo",
"crew_swap_ewr": "mcp_servers.data.scenarios.crew_swap_ewr",
}
class ScenarioManager:
"""Manages the active scenario. Singleton — all MCP servers share one instance."""
def __init__(self) -> None:
self._active_id: str = "weather_disruption_ord"
self._cache: dict[str, Any] = {}
@property
def active_id(self) -> str:
return self._active_id
def set_active(self, scenario_id: str) -> dict:
if scenario_id not in SCENARIO_MODULES:
raise ValueError(
f"Unknown scenario: {scenario_id}. "
f"Available: {', '.join(SCENARIO_MODULES.keys())}"
)
self._active_id = scenario_id
return self.get_metadata()
def get_metadata(self, scenario_id: str | None = None) -> dict:
mod = self._load(scenario_id or self._active_id)
return {
"scenario_id": mod.SCENARIO_ID,
"name": mod.SCENARIO_NAME,
"description": mod.SCENARIO_DESCRIPTION,
"hubs": mod.SCENARIO_HUBS,
"flight_count": len(mod.FLIGHTS),
"disrupted_flights": sum(
1 for f in mod.FLIGHTS if f.status != "ON_TIME"
),
}
def list_scenarios(self) -> list[dict]:
return [self.get_metadata(sid) for sid in SCENARIO_MODULES]
def _load(self, scenario_id: str) -> Any:
if scenario_id not in self._cache:
self._cache[scenario_id] = import_module(SCENARIO_MODULES[scenario_id])
return self._cache[scenario_id]
@property
def _module(self) -> Any:
return self._load(self._active_id)
@property
def flights(self):
return self._module.FLIGHTS
@property
def crew(self):
return self._module.CREW
@property
def crew_notes(self) -> dict[str, list[str]]:
return self._module.CREW_NOTES
@property
def maintenance(self):
return self._module.MAINTENANCE
@property
def rebookings(self):
return self._module.REBOOKINGS
@property
def passengers(self):
return self._module.PASSENGERS
# Singleton
scenario_manager = ScenarioManager()

View File

@@ -0,0 +1,72 @@
"""Scenario: Normal Operations — all flights on time, clear weather, full crew.
Baseline scenario. Agents should produce minimal/calm output.
"""
from datetime import datetime, timezone
from mcp_servers.data.models import (
CrewMember,
CrewRole,
FlightData,
FlightStatus,
MPStatus,
Passenger,
RebookingCase,
MELItem,
)
SCENARIO_ID = "normal_ops"
SCENARIO_NAME = "Normal Operations"
SCENARIO_DESCRIPTION = "All flights on time at ORD. Clear weather. No disruptions."
SCENARIO_HUBS = ["ORD"]
FLIGHTS: list[FlightData] = [
FlightData(
flight_id="UA1440", origin="ORD", destination="SFO",
scheduled_departure=datetime(2026, 4, 11, 18, 0, tzinfo=timezone.utc),
scheduled_arrival=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
status=FlightStatus.ON_TIME, aircraft_tail="N79001", gate="H10",
crew_ids=["CR-2001", "CR-2002"], passenger_count=175,
),
FlightData(
flight_id="UA1552", origin="ORD", destination="EWR",
scheduled_departure=datetime(2026, 4, 11, 18, 30, tzinfo=timezone.utc),
scheduled_arrival=datetime(2026, 4, 11, 21, 45, tzinfo=timezone.utc),
status=FlightStatus.ON_TIME, aircraft_tail="N79002", gate="B12",
crew_ids=["CR-2003", "CR-2004"], passenger_count=190,
),
FlightData(
flight_id="UA1678", origin="ORD", destination="DEN",
scheduled_departure=datetime(2026, 4, 11, 19, 0, tzinfo=timezone.utc),
scheduled_arrival=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
status=FlightStatus.ON_TIME, aircraft_tail="N79003", gate="C8",
crew_ids=["CR-2005", "CR-2006"], passenger_count=160,
),
]
CREW: list[CrewMember] = [
CrewMember(crew_id="CR-2001", name="Capt. Hayes", role=CrewRole.CAPTAIN,
duty_hours_elapsed=4.0, duty_hours_limit=14.0,
rest_hours_since_last=18.0, next_scheduled_flight="UA1440", base_hub="ORD"),
CrewMember(crew_id="CR-2002", name="FO Park", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=4.0, duty_hours_limit=14.0,
rest_hours_since_last=16.0, next_scheduled_flight="UA1440", base_hub="ORD"),
CrewMember(crew_id="CR-2003", name="Capt. Lewis", role=CrewRole.CAPTAIN,
duty_hours_elapsed=5.0, duty_hours_limit=14.0,
rest_hours_since_last=15.0, next_scheduled_flight="UA1552", base_hub="ORD"),
CrewMember(crew_id="CR-2004", name="FO Sharma", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=5.0, duty_hours_limit=14.0,
rest_hours_since_last=14.0, next_scheduled_flight="UA1552", base_hub="ORD"),
CrewMember(crew_id="CR-2005", name="Capt. Brown", role=CrewRole.CAPTAIN,
duty_hours_elapsed=3.0, duty_hours_limit=14.0,
rest_hours_since_last=20.0, next_scheduled_flight="UA1678", base_hub="ORD"),
CrewMember(crew_id="CR-2006", name="FO Nguyen", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=3.0, duty_hours_limit=14.0,
rest_hours_since_last=19.0, next_scheduled_flight="UA1678", base_hub="ORD"),
]
CREW_NOTES: dict[str, list[str]] = {}
MAINTENANCE: dict[str, list[MELItem]] = {}
REBOOKINGS: list[RebookingCase] = []
PASSENGERS: list[Passenger] = []

View File

@@ -0,0 +1,281 @@
"""Scenario: ORD Thunderstorms — active disruption at Chicago O'Hare.
Thunderstorm line causing GDP, 4 flights delayed, 1 cancelled.
Tests FCE multi-flight notifications and Handover weather risk section.
"""
from datetime import datetime, timezone
from mcp_servers.data.models import (
CrewMember,
CrewRole,
DelayCause,
FlightData,
FlightStatus,
MELItem,
MPStatus,
Passenger,
RebookingCase,
)
SCENARIO_ID = "weather_disruption_ord"
SCENARIO_NAME = "ORD Thunderstorms"
SCENARIO_DESCRIPTION = (
"Active thunderstorm line at ORD. GDP in effect. "
"4 delays, 1 cancellation. 47 passengers need rebooking."
)
SCENARIO_HUBS = ["ORD"]
# --- Flights ---
FLIGHTS: list[FlightData] = [
# Delayed flights
FlightData(
flight_id="UA432",
origin="ORD", destination="SFO",
scheduled_departure=datetime(2026, 4, 11, 17, 45, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 20, 15, tzinfo=timezone.utc),
actual_arrival=None,
status=FlightStatus.DELAYED,
delay_minutes=55,
delay_cause=DelayCause.WEATHER,
aircraft_tail="N78501",
gate="H14",
inbound_flight="UA219",
crew_ids=["CR-1001", "CR-1002", "CR-1010", "CR-1011"],
passenger_count=186,
),
FlightData(
flight_id="UA881",
origin="ORD", destination="LAX",
scheduled_departure=datetime(2026, 4, 11, 18, 30, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 21, 0, tzinfo=timezone.utc),
actual_arrival=None,
status=FlightStatus.DELAYED,
delay_minutes=40,
delay_cause=DelayCause.WEATHER,
aircraft_tail="N78502",
gate="H22",
crew_ids=["CR-1003", "CR-1004", "CR-1012", "CR-1013"],
passenger_count=204,
),
FlightData(
flight_id="UA233",
origin="ORD", destination="DEN",
scheduled_departure=datetime(2026, 4, 11, 18, 0, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 19, 45, tzinfo=timezone.utc),
actual_arrival=None,
status=FlightStatus.DELAYED,
delay_minutes=70,
delay_cause=DelayCause.WEATHER,
aircraft_tail="N78503",
gate="C12",
crew_ids=["CR-1005", "CR-1006", "CR-1014"],
passenger_count=152,
),
FlightData(
flight_id="UA094",
origin="ORD", destination="EWR",
scheduled_departure=datetime(2026, 4, 11, 17, 15, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
actual_arrival=None,
status=FlightStatus.DELAYED,
delay_minutes=35,
delay_cause=DelayCause.WEATHER,
aircraft_tail="N78504",
gate="B8",
crew_ids=["CR-1007", "CR-1008", "CR-1015"],
passenger_count=178,
),
# Cancelled flight
FlightData(
flight_id="UA517",
origin="ORD", destination="IAH",
scheduled_departure=datetime(2026, 4, 11, 19, 0, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 21, 30, tzinfo=timezone.utc),
actual_arrival=None,
status=FlightStatus.CANCELLED,
delay_minutes=0,
delay_cause=DelayCause.WEATHER,
aircraft_tail="N78505",
gate="C18",
crew_ids=["CR-1009", "CR-1016"],
passenger_count=47,
),
# On-time flights (normal ops happening alongside disruption)
FlightData(
flight_id="UA1220",
origin="ORD", destination="IAD",
scheduled_departure=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 23, 15, tzinfo=timezone.utc),
actual_arrival=None,
status=FlightStatus.ON_TIME,
delay_minutes=0,
delay_cause=None,
aircraft_tail="N78506",
gate="B15",
crew_ids=["CR-1017", "CR-1018"],
passenger_count=145,
),
FlightData(
flight_id="UA788",
origin="ORD", destination="SFO",
scheduled_departure=datetime(2026, 4, 11, 21, 0, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 23, 30, tzinfo=timezone.utc),
actual_arrival=None,
status=FlightStatus.ON_TIME,
delay_minutes=0,
delay_cause=None,
aircraft_tail="N78507",
gate="H18",
crew_ids=["CR-1019", "CR-1020"],
passenger_count=192,
),
]
# --- Crew ---
CREW: list[CrewMember] = [
# UA432 crew
CrewMember(crew_id="CR-1001", name="Capt. Rodriguez", role=CrewRole.CAPTAIN,
duty_hours_elapsed=10.5, duty_hours_limit=14.0,
rest_hours_since_last=12.0, next_scheduled_flight="UA432", base_hub="ORD"),
CrewMember(crew_id="CR-1002", name="FO Chen", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=10.5, duty_hours_limit=14.0,
rest_hours_since_last=11.0, next_scheduled_flight="UA432", base_hub="ORD"),
# UA881 crew — captain approaching duty limit
CrewMember(crew_id="CR-1003", name="Capt. Williams", role=CrewRole.CAPTAIN,
duty_hours_elapsed=12.25, duty_hours_limit=14.0,
rest_hours_since_last=10.0, next_scheduled_flight="UA881", base_hub="ORD"),
CrewMember(crew_id="CR-1004", name="FO Patel", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=9.0, duty_hours_limit=14.0,
rest_hours_since_last=14.0, next_scheduled_flight="UA881", base_hub="ORD"),
# UA233 crew
CrewMember(crew_id="CR-1005", name="Capt. Johnson", role=CrewRole.CAPTAIN,
duty_hours_elapsed=8.0, duty_hours_limit=14.0,
rest_hours_since_last=16.0, next_scheduled_flight="UA233", base_hub="ORD"),
CrewMember(crew_id="CR-1006", name="FO Kim", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=7.5, duty_hours_limit=14.0,
rest_hours_since_last=15.0, next_scheduled_flight="UA233", base_hub="ORD"),
# UA094 crew
CrewMember(crew_id="CR-1007", name="Capt. Davis", role=CrewRole.CAPTAIN,
duty_hours_elapsed=6.0, duty_hours_limit=14.0,
rest_hours_since_last=18.0, next_scheduled_flight="UA094", base_hub="ORD"),
CrewMember(crew_id="CR-1008", name="FO Martinez", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=6.0, duty_hours_limit=14.0,
rest_hours_since_last=17.0, next_scheduled_flight="UA094", base_hub="ORD"),
# UA517 crew (cancelled flight — available for reassignment)
CrewMember(crew_id="CR-1009", name="Capt. Thompson", role=CrewRole.CAPTAIN,
duty_hours_elapsed=4.0, duty_hours_limit=14.0,
rest_hours_since_last=20.0, next_scheduled_flight=None, base_hub="ORD"),
# Flight attendants
CrewMember(crew_id="CR-1010", name="FA Brooks", role=CrewRole.FA,
duty_hours_elapsed=10.0, duty_hours_limit=14.0,
rest_hours_since_last=12.0, next_scheduled_flight="UA432", base_hub="ORD"),
CrewMember(crew_id="CR-1011", name="FA Lee", role=CrewRole.FA,
duty_hours_elapsed=10.0, duty_hours_limit=14.0,
rest_hours_since_last=13.0, next_scheduled_flight="UA432", base_hub="ORD"),
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"),
# Backup crew
CrewMember(crew_id="CR-4421", name="Capt. Okafor", role=CrewRole.CAPTAIN,
duty_hours_elapsed=0.0, duty_hours_limit=14.0,
rest_hours_since_last=24.0, next_scheduled_flight=None, base_hub="ORD"),
]
# --- Crew Notes ---
CREW_NOTES: dict[str, list[str]] = {
"UA432": [
"Ground stop at ORD — thunderstorm line moving NE across field.",
"Inbound aircraft N78501 on ground, boarding paused at gate H14.",
"Fresh crew on board, catering complete. Ready to board on release.",
"Gate hold — no gate change expected per ops.",
],
"UA881": [
"Capt. Williams approaching duty limit — monitor closely.",
"If departure slips past 22:15 CT, mandatory crew swap required.",
"Backup crew CR-4421 (Capt. Okafor) on standby, cleared and rested.",
],
"UA233": [
"Extended delay due to weather at ORD and congestion at DEN.",
"8 connecting passengers with tight connections at DEN — rebooking may be needed.",
],
"UA094": [
"Moderate delay. EWR reporting clear conditions, delay is ORD-side only.",
],
"UA517": [
"CANCELLED — thunderstorm forecast through departure window.",
"47 passengers need rebooking. 8 Global Services pax — priority handling.",
"Rebooking options loaded in system — supervisor approval pending.",
],
}
# --- Maintenance ---
MAINTENANCE: dict[str, list[MELItem]] = {
"N78501": [
MELItem(
mel_id="MEL-ORD-001",
aircraft_tail="N78501",
system="WEATHER_RADAR",
description="Weather radar intermittent — functions normally after reset cycle.",
restriction=None,
expires=datetime(2026, 4, 15, 0, 0, tzinfo=timezone.utc),
),
],
}
# --- Passengers needing rebooking (from cancelled UA517) ---
REBOOKINGS: list[RebookingCase] = [
RebookingCase(pax_id="PAX-00341", name="J. Morrison",
original_flight="UA517", mileage_plus_status=MPStatus.GLOBAL_SERVICES,
destination="IAH", next_available="UA519", urgency="HIGH"),
RebookingCase(pax_id="PAX-00342", name="S. Nakamura",
original_flight="UA517", mileage_plus_status=MPStatus.GLOBAL_SERVICES,
destination="IAH", next_available="UA519", urgency="HIGH"),
RebookingCase(pax_id="PAX-00355", name="R. Okonkwo",
original_flight="UA517", mileage_plus_status=MPStatus.K1,
destination="IAH", next_available="UA519", urgency="HIGH"),
RebookingCase(pax_id="PAX-00360", name="M. Fernandez",
original_flight="UA517", mileage_plus_status=MPStatus.PLATINUM,
destination="IAH", next_available=None, urgency="MEDIUM"),
RebookingCase(pax_id="PAX-00371", name="K. Singh",
original_flight="UA517", mileage_plus_status=MPStatus.GOLD,
destination="IAH", next_available=None, urgency="MEDIUM"),
RebookingCase(pax_id="PAX-00380", name="L. Anderson",
original_flight="UA517", mileage_plus_status=MPStatus.GENERAL,
destination="IAH", next_available=None, urgency="LOW"),
]
# --- Passengers on disrupted flights (sample) ---
PASSENGERS: list[Passenger] = [
# UA432 passengers
Passenger(pax_id="PAX-10001", name="A. Kowalski",
mileage_plus_status=MPStatus.GLOBAL_SERVICES, flight_id="UA432",
destination="SFO", connection_flight="UA1892",
connection_deadline=datetime(2026, 4, 11, 21, 30, tzinfo=timezone.utc)),
Passenger(pax_id="PAX-10002", name="B. Tanaka",
mileage_plus_status=MPStatus.K1, flight_id="UA432",
destination="SFO"),
Passenger(pax_id="PAX-10003", name="C. Dubois",
mileage_plus_status=MPStatus.GENERAL, flight_id="UA432",
destination="SFO", special_needs=["WCHR"]),
# UA517 passengers (cancelled)
Passenger(pax_id="PAX-00341", name="J. Morrison",
mileage_plus_status=MPStatus.GLOBAL_SERVICES, flight_id="UA517",
destination="IAH"),
Passenger(pax_id="PAX-00342", name="S. Nakamura",
mileage_plus_status=MPStatus.GLOBAL_SERVICES, flight_id="UA517",
destination="IAH"),
]

View File

View File

@@ -0,0 +1,5 @@
"""Entry point for running the ops MCP server as a module."""
from mcp_servers.ops.server import mcp
mcp.run()

204
mcp_servers/ops/server.py Normal file
View File

@@ -0,0 +1,204 @@
"""Ops MCP server — tools, resources, and prompts for the Handover agent only.
Covers: crew duty status, crew notes, pending rebookings, ops narrative,
crew roster, last handover brief, and handover brief prompt template.
"""
import json
from datetime import datetime, timezone
from fastmcp import FastMCP
from mcp_servers.data.scenarios.manager import scenario_manager
mcp = FastMCP(
"united-ops-internal",
instructions=(
"Internal operations tools — crew duty status, pending rebookings, "
"and ops-audience narrative generation. Restricted to ops-facing clients."
),
)
# In-memory store for the last generated handover brief
_last_handover: dict | None = None
def store_handover_brief(brief: dict) -> None:
"""Called by the handover agent after generating a brief."""
global _last_handover
_last_handover = {
**brief,
"stored_at": datetime.now(timezone.utc).isoformat(),
}
# ── Tools ──────────────────────────────────────────────────────────
@mcp.tool()
def get_crew_notes(flight_id: str) -> list[str]:
"""Free-text notes logged by crew or ops team for a flight.
Includes: maintenance write-ups, catering delays, gate conflicts,
passenger incidents, operational remarks.
"""
return scenario_manager.crew_notes.get(flight_id, [])
@mcp.tool()
def get_crew_duty_status(crew_ids: list[str]) -> list[dict]:
"""Duty hours and rest state for each crew member.
Returns per crew: crew_id, name, role, duty_hours_elapsed,
duty_hours_limit, hours_until_limit, rest_hours_since_last,
at_risk (true if within 2h of FAA Part 117 limit).
"""
results = []
crew_map = {c.crew_id: c for c in scenario_manager.crew}
for crew_id in crew_ids:
crew = crew_map.get(crew_id)
if not crew:
results.append({"crew_id": crew_id, "error": "not_found"})
continue
hours_until_limit = crew.duty_hours_limit - crew.duty_hours_elapsed
results.append({
"crew_id": crew.crew_id,
"name": crew.name,
"role": crew.role.value,
"duty_hours_elapsed": crew.duty_hours_elapsed,
"duty_hours_limit": crew.duty_hours_limit,
"hours_until_limit": round(hours_until_limit, 2),
"rest_hours_since_last": crew.rest_hours_since_last,
"at_risk": hours_until_limit <= 2.0,
"base_hub": crew.base_hub,
"next_scheduled_flight": crew.next_scheduled_flight,
})
return results
@mcp.tool()
def get_pending_rebookings(hub: str, limit: int = 20) -> list[dict]:
"""Passengers needing rebooking at a hub, sorted by urgency.
Returns: pax_id, name, mileage_plus_status, original_flight,
destination, next_available_option, urgency.
"""
# Filter rebookings by flights originating from this hub
hub_flights = {f.flight_id for f in scenario_manager.flights if f.origin == hub.upper()}
rebookings = [
r for r in scenario_manager.rebookings
if r.original_flight in hub_flights
]
urgency_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
rebookings.sort(key=lambda r: urgency_order.get(r.urgency, 3))
return [r.model_dump(mode="json") for r in rebookings[:limit]]
@mcp.tool()
def generate_narrative(context: dict) -> str:
"""Synthesizes aggregated operational context into a structured
handover brief for ops managers.
Uses Claude Sonnet via AWS Bedrock Converse API.
Output: prioritized, concise, structured by IMMEDIATE / MONITOR / FYI.
NOTE: In v1, this returns a structured template from the context data.
LLM integration will be added when Bedrock is wired up.
"""
# V1: structured template — will be replaced with Bedrock call
sections = []
immediate = context.get("immediate", [])
monitor = context.get("monitor", [])
fyi = context.get("fyi", [])
hub = context.get("hub", "ALL")
shift_time = context.get("shift_time", datetime.now(timezone.utc).strftime("%H:%M UTC"))
header = f"SHIFT HANDOVER BRIEF — {hub} / {shift_time}"
sections.append(header)
sections.append(f"Generated: {datetime.now(timezone.utc).strftime('%H:%M UTC')}")
sections.append("")
if immediate:
sections.append("━━━ IMMEDIATE ACTION ━━━")
for item in immediate:
sections.append(f"{item}")
sections.append("")
if monitor:
sections.append("━━━ MONITOR ━━━")
for item in monitor:
sections.append(f"{item}")
sections.append("")
if fyi:
sections.append("━━━ FYI ━━━")
for item in fyi:
sections.append(f" {item}")
return "\n".join(sections)
# ── Resources ──────────────────────────────────────────────────────
@mcp.resource("ops://crew/roster")
def crew_roster() -> str:
"""Full crew roster for the current scenario.
Returns per crew: crew_id, name, role, base_hub, duty status summary.
"""
roster = []
for c in scenario_manager.crew:
hours_remaining = c.duty_hours_limit - c.duty_hours_elapsed
roster.append({
"crew_id": c.crew_id,
"name": c.name,
"role": c.role.value,
"base_hub": c.base_hub,
"duty_status": "AT_RISK" if hours_remaining <= 2.0 else "OK",
"hours_until_limit": round(hours_remaining, 2),
"next_flight": c.next_scheduled_flight,
})
return json.dumps(roster)
@mcp.resource("ops://handover/latest")
def latest_handover() -> str:
"""The most recently generated shift handover brief.
Returns null if no brief has been generated yet.
"""
if _last_handover is None:
return json.dumps(None)
return json.dumps(_last_handover)
# ── Prompts ────────────────────────────────────────────────────────
@mcp.prompt()
def handover_brief(hub: str = "ALL", shift_time: str = "21:00 CT") -> str:
"""Template for generating a shift handover brief.
Defines the structured format: IMMEDIATE / MONITOR / FYI sections,
priority scoring expectations, and detail level per item.
"""
return (
f"Generate a shift handover brief for {hub} at {shift_time}. "
"Structure the output as follows:\n\n"
"HEADER: Hub, time, outgoing/incoming manager names.\n\n"
"IMMEDIATE ACTION: Items requiring action within the next 2 hours. "
"Each item: flight/issue ID, what's happening, time until critical, "
"specific action needed, and any pre-staged resources.\n\n"
"MONITOR: Items that could escalate. Each item: what to watch, "
"trigger conditions for escalation, current trajectory.\n\n"
"FYI: Resolved items or low-risk situations the incoming shift should know about.\n\n"
"Be concise. Ops managers scan, they don't read paragraphs. "
"Use the data provided — do not invent details."
)

View File

View File

@@ -0,0 +1,5 @@
"""Entry point for running the passenger MCP server as a module."""
from mcp_servers.passenger.server import mcp
mcp.run()

View File

@@ -0,0 +1,157 @@
"""Passenger MCP server — tools, resources, and prompts for the FCE agent only.
Covers: passenger notification generation, flight manifest, and
notification prompt template (multi-tone).
"""
import json
from datetime import datetime, timezone
from fastmcp import FastMCP
from mcp_servers.data.models import MPStatus
from mcp_servers.data.scenarios.manager import scenario_manager
mcp = FastMCP(
"united-ops-passenger",
instructions=(
"Passenger-facing tools — notification narrative generation "
"and flight manifest access. Restricted to customer-facing clients."
),
)
# ── Tools ──────────────────────────────────────────────────────────
@mcp.tool()
def generate_notification(context: dict) -> str:
"""Synthesizes flight disruption context into an empathetic,
actionable passenger notification.
Uses Claude Sonnet via AWS Bedrock Converse API.
Output: clear, human, no jargon, includes gate/time/status.
NOTE: In v1, this returns a structured template from the context data.
LLM integration will be added when Bedrock is wired up.
"""
# V1: structured template — will be replaced with Bedrock call
flight_id = context.get("flight_id", "")
origin = context.get("origin", "")
destination = context.get("destination", "")
status = context.get("status", "DELAYED")
delay_minutes = context.get("delay_minutes", 0)
delay_cause = context.get("delay_cause", "")
gate = context.get("gate", "")
weather_summary = context.get("weather_summary", "")
crew_notes_summary = context.get("crew_notes_summary", "")
lines = [
f"{flight_id}{origin}{destination}",
f"Status: {status}" + (f" {delay_minutes} minutes" if delay_minutes else ""),
"",
]
if weather_summary:
lines.append(f"Your flight is delayed due to {weather_summary}.")
elif delay_cause:
cause_text = {
"WEATHER": "weather conditions along your route",
"MAINTENANCE": "a routine maintenance check on your aircraft",
"CREW": "ensuring your crew is fully rested for safe operation",
"ATC": "air traffic control restrictions",
"LATE_AIRCRAFT": "the late arrival of your inbound aircraft",
}.get(delay_cause, f"{delay_cause.lower()}")
lines.append(f"Your flight is delayed due to {cause_text}.")
if crew_notes_summary:
lines.append(crew_notes_summary)
if gate:
lines.append(f"\nGate {gate} — no gate change expected.")
return "\n".join(lines)
# ── Resources ──────────────────────────────────────────────────────
@mcp.resource("ops://flights/{flight_id}/manifest")
def flight_manifest(flight_id: str) -> str:
"""Passenger manifest summary for a flight.
Returns: flight_id, passenger count, count by MP status tier,
special needs count, connection count.
Summary-level — no PII exposed through this resource.
"""
flight = None
for f in scenario_manager.flights:
if f.flight_id == flight_id:
flight = f
break
if not flight:
return json.dumps({"error": f"Flight {flight_id} not found"})
# Count passengers by status from scenario data
pax_on_flight = [
p for p in scenario_manager.passengers
if p.flight_id == flight_id
]
status_counts = {}
special_needs_count = 0
connection_count = 0
for p in pax_on_flight:
status = p.mileage_plus_status.value
status_counts[status] = status_counts.get(status, 0) + 1
if p.special_needs:
special_needs_count += 1
if p.connection_flight:
connection_count += 1
return json.dumps({
"flight_id": flight_id,
"total_passengers": flight.passenger_count,
"by_status": status_counts,
"special_needs_count": special_needs_count,
"connection_count": connection_count,
})
# ── Prompts ────────────────────────────────────────────────────────
TONE_INSTRUCTIONS = {
"empathetic": (
"Write a passenger notification about this flight disruption. "
"Be empathetic and human. Explain WHY the delay/cancellation happened "
"using the weather, maintenance, or operational data provided. "
"Tell the passenger what's happening next: new boarding time, gate, "
"rebooking options. End with reassurance. No jargon."
),
"factual": (
"Write a brief, factual notification. Include: flight number, "
"route, status, delay duration, cause (one phrase), new departure time, "
"gate. No editorial. No reassurance. Just facts."
),
"brief_sms": (
"Write an SMS-length notification (under 160 characters). "
"Format: UA{flight} {route}: {status}. {cause}. New dep: {time}. Gate {gate}."
),
}
@mcp.prompt()
def passenger_notification(tone: str = "empathetic") -> str:
"""Template for generating passenger delay/cancellation notifications.
tone: empathetic (default) | factual | brief_sms
"""
instruction = TONE_INSTRUCTIONS.get(tone, TONE_INSTRUCTIONS["empathetic"])
return (
f"{instruction}\n\n"
"Use the operational data provided below. Do not invent details. "
"If data is missing for a section, omit that section."
)

View File

View File

@@ -0,0 +1,5 @@
"""Entry point for running the shared MCP server as a module."""
from mcp_servers.shared.server import mcp
mcp.run()

View File

@@ -0,0 +1,235 @@
"""Shared MCP server — tools, resources, and prompts used by both agent clients.
Covers: flight status, weather (live OpenMeteo), airport status (live FAA),
maintenance flags, hub reference data, and delay explanation prompts.
"""
from fastmcp import FastMCP
from mcp_servers.data.models import HUBS, FlightStatus
from mcp_servers.data.real import faa, openmeteo
from mcp_servers.data.scenarios.manager import scenario_manager
mcp = FastMCP(
"united-ops-shared",
instructions=(
"Shared operational data tools for Stellar Air operations. "
"Covers: flight status, weather (live OpenMeteo), airport status "
"(live FAA), and maintenance flags. Used by all agent clients."
),
)
# ── Tools ──────────────────────────────────────────────────────────
@mcp.tool()
def get_flight_status(flight_id: str) -> dict:
"""Lightweight status check for a single flight.
Returns: flight_id, status (ON_TIME/DELAYED/CANCELLED/DIVERTED),
delay_minutes, gate, last_updated.
Use this for quick triage before pulling full operational data.
"""
for f in scenario_manager.flights:
if f.flight_id == flight_id:
return {
"flight_id": f.flight_id,
"status": f.status.value,
"delay_minutes": f.delay_minutes,
"gate": f.gate,
"origin": f.origin,
"destination": f.destination,
"source": "scenario_mock",
}
return {"error": f"Flight {flight_id} not found in active scenario"}
@mcp.tool()
def get_flight_details(flight_id: str) -> dict:
"""Full operational context for a flight.
Returns: scheduled vs actual times, delay cause code, inbound aircraft
tail number, gate assignment, crew IDs, passenger count.
"""
for f in scenario_manager.flights:
if f.flight_id == flight_id:
return f.model_dump(mode="json")
return {"error": f"Flight {flight_id} not found in active scenario"}
@mcp.tool()
def get_irregular_ops(hub: str) -> list[dict]:
"""All flights in irregular operations state at a hub.
hub: ORD | EWR | IAH | SFO | DEN
Returns list of: flight_id, irrop_type, affected_pax_count,
delay_cause, delay_minutes.
"""
results = []
for f in scenario_manager.flights:
if f.origin == hub.upper() and f.status != FlightStatus.ON_TIME:
results.append({
"flight_id": f.flight_id,
"irrop_type": f.status.value,
"affected_pax_count": f.passenger_count,
"delay_cause": f.delay_cause.value if f.delay_cause else None,
"delay_minutes": f.delay_minutes,
"origin": f.origin,
"destination": f.destination,
"gate": f.gate,
})
return results
@mcp.tool()
async def get_route_weather(origin: str, destination: str) -> dict:
"""Live weather at origin, destination, and en-route waypoints.
Source: OpenMeteo API (real-time, no API key).
Returns per waypoint: conditions, temperature, wind, visibility,
precipitation, significant events.
"""
return await openmeteo.get_weather_along_route(origin, destination)
@mcp.tool()
async def get_hub_forecasts() -> dict:
"""4-hour weather forecast for all United hubs (ORD, EWR, IAH, SFO, DEN).
Source: OpenMeteo API.
Returns per hub: hourly forecast with conditions, wind, visibility,
and a risk_flag if convective activity or low visibility expected.
"""
return await openmeteo.get_weather_forecast_hubs()
@mcp.tool()
async def get_airport_status(airport_code: str) -> dict:
"""Live FAA airport status.
Source: FAA NASSTATUS API (real-time, no API key).
Returns: delay_type, delay_reason, average_delay_minutes,
ground_delay_program active/inactive, ground_stop active/inactive.
Falls back to 'status_unavailable' if FAA API is unreachable.
"""
return await faa.get_airport_status(airport_code)
@mcp.tool()
async def get_airport_congestion(airport_code: str) -> dict:
"""Gate availability and taxi times at an airport.
Combines: FAA live status (real) + gate/taxi mock data from scenario.
Returns: faa_status, gates_available, gates_total,
avg_taxi_out_minutes, avg_taxi_in_minutes.
"""
faa_status = await faa.get_airport_status(airport_code)
hub = HUBS.get(airport_code.upper())
total_gates = hub.gates if hub else 100
# Mock congestion based on disrupted flights in scenario
disrupted = sum(
1 for f in scenario_manager.flights
if f.origin == airport_code.upper() and f.status != FlightStatus.ON_TIME
)
gates_occupied = min(total_gates, int(total_gates * 0.7) + disrupted * 3)
return {
"airport": airport_code.upper(),
"faa_status": faa_status,
"gates_available": total_gates - gates_occupied,
"gates_total": total_gates,
"avg_taxi_out_minutes": 12 + disrupted * 4,
"avg_taxi_in_minutes": 8 + disrupted * 2,
"source": "faa_live+scenario_mock",
}
@mcp.tool()
def get_maintenance_flags(aircraft_tail: str) -> list[dict]:
"""Active MEL (Minimum Equipment List) items for an aircraft.
Returns per item: mel_id, system, description, restriction
(route/ops limitations), expiry date.
"""
items = scenario_manager.maintenance.get(aircraft_tail, [])
return [item.model_dump(mode="json") for item in items]
# ── Resources ──────────────────────────────────────────────────────
@mcp.resource("ops://hubs/{hub_code}")
def hub_info(hub_code: str) -> str:
"""Static reference data for a hub.
Returns: full name, codes, coordinates, timezone, terminal/gate/runway count.
"""
hub = HUBS.get(hub_code.upper())
if not hub:
return f"Unknown hub: {hub_code}. Known: {', '.join(HUBS.keys())}"
return hub.model_dump_json()
@mcp.resource("ops://scenarios/active")
def active_scenario() -> str:
"""Current active scenario metadata."""
import json
return json.dumps(scenario_manager.get_metadata())
# ── Prompts ────────────────────────────────────────────────────────
DELAY_TEMPLATES = {
("WEATHER", "passenger"): (
"Explain this flight delay to a passenger. The cause is weather-related. "
"Be empathetic and transparent. Include what's happening with the weather, "
"what the airline is doing about it, and what the passenger should expect next. "
"Do not use aviation jargon."
),
("WEATHER", "ops_manager"): (
"Summarize this weather-caused delay for an ops manager. "
"Include: delay cause code, affected systems, GDP/ground stop status if applicable, "
"forecast window, and recommended next actions. Be concise."
),
("MAINTENANCE", "passenger"): (
"Explain this maintenance-caused delay to a passenger. "
"Be reassuring — emphasize safety. Describe what's being checked "
"without technical jargon. Give expected timeline if available."
),
("MAINTENANCE", "ops_manager"): (
"Summarize this maintenance delay for an ops manager. "
"Include: MEL item, system affected, restriction details, "
"estimated release time, and downstream impact."
),
("CREW", "passenger"): (
"Explain this crew-related delay to a passenger. "
"Frame it as 'ensuring your crew is fully rested for safe operation.' "
"Do not mention specific regulations or duty limits."
),
("CREW", "ops_manager"): (
"Summarize this crew-caused delay for an ops manager. "
"Include: Part 117 status, hours remaining, swap options, "
"backup crew availability, and timeline."
),
}
@mcp.prompt()
def delay_explainer(cause_code: str, audience: str) -> str:
"""Turns a raw delay cause code into a human-readable explanation prompt.
cause_code: WEATHER | MAINTENANCE | CREW | ATC | LATE_AIRCRAFT
audience: passenger | ops_manager
"""
key = (cause_code.upper(), audience.lower())
default_key = ("WEATHER", audience.lower())
template = DELAY_TEMPLATES.get(key, DELAY_TEMPLATES.get(default_key, ""))
return (
f"{template}\n\n"
"Use the operational data provided below. Do not invent details. "
"If data is missing for a section, omit that section."
)