init commit
This commit is contained in:
0
mcp_servers/__init__.py
Normal file
0
mcp_servers/__init__.py
Normal file
0
mcp_servers/data/__init__.py
Normal file
0
mcp_servers/data/__init__.py
Normal file
0
mcp_servers/data/mock/__init__.py
Normal file
0
mcp_servers/data/mock/__init__.py
Normal file
137
mcp_servers/data/models.py
Normal file
137
mcp_servers/data/models.py
Normal 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,
|
||||
),
|
||||
}
|
||||
0
mcp_servers/data/real/__init__.py
Normal file
0
mcp_servers/data/real/__init__.py
Normal file
124
mcp_servers/data/real/faa.py
Normal file
124
mcp_servers/data/real/faa.py
Normal 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",
|
||||
}
|
||||
197
mcp_servers/data/real/openmeteo.py
Normal file
197
mcp_servers/data/real/openmeteo.py
Normal 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"}
|
||||
0
mcp_servers/data/scenarios/__init__.py
Normal file
0
mcp_servers/data/scenarios/__init__.py
Normal file
123
mcp_servers/data/scenarios/crew_swap_ewr.py
Normal file
123
mcp_servers/data/scenarios/crew_swap_ewr.py
Normal 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] = []
|
||||
117
mcp_servers/data/scenarios/maintenance_delay_sfo.py
Normal file
117
mcp_servers/data/scenarios/maintenance_delay_sfo.py
Normal 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] = []
|
||||
85
mcp_servers/data/scenarios/manager.py
Normal file
85
mcp_servers/data/scenarios/manager.py
Normal 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()
|
||||
72
mcp_servers/data/scenarios/normal_ops.py
Normal file
72
mcp_servers/data/scenarios/normal_ops.py
Normal 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] = []
|
||||
281
mcp_servers/data/scenarios/weather_disruption_ord.py
Normal file
281
mcp_servers/data/scenarios/weather_disruption_ord.py
Normal 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"),
|
||||
]
|
||||
0
mcp_servers/ops/__init__.py
Normal file
0
mcp_servers/ops/__init__.py
Normal file
5
mcp_servers/ops/__main__.py
Normal file
5
mcp_servers/ops/__main__.py
Normal 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
204
mcp_servers/ops/server.py
Normal 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."
|
||||
)
|
||||
0
mcp_servers/passenger/__init__.py
Normal file
0
mcp_servers/passenger/__init__.py
Normal file
5
mcp_servers/passenger/__main__.py
Normal file
5
mcp_servers/passenger/__main__.py
Normal 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()
|
||||
157
mcp_servers/passenger/server.py
Normal file
157
mcp_servers/passenger/server.py
Normal 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."
|
||||
)
|
||||
0
mcp_servers/shared/__init__.py
Normal file
0
mcp_servers/shared/__init__.py
Normal file
5
mcp_servers/shared/__main__.py
Normal file
5
mcp_servers/shared/__main__.py
Normal 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()
|
||||
235
mcp_servers/shared/server.py
Normal file
235
mcp_servers/shared/server.py
Normal 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."
|
||||
)
|
||||
Reference in New Issue
Block a user