Files
Skald-Circle/scripts/mcp/serpapi_flights/server.py
T
2026-07-10 15:02:09 +01:00

472 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""SerpAPI Google Flights MCP server (JSON-RPC 2.0 over stdio).
Capabilities:
serpapi_search_flights — search one-way or round-trip flights via Google Flights
through SerpAPI, returning prices, airlines, durations,
layovers, and CO2 emissions.
Auth:
API key is read from env var SERPAPI_API_KEY, or from the file at
SERPAPI_API_KEY_FILE (default: ./secrets/serpapi_api_key.txt).
Run with:
python3 scripts/mcp/serpapi_flights/server.py
"""
from __future__ import annotations
import json
import os
import re
import sys
from typing import Any
import httpx
# Log to stderr so stdout stays clean for JSON-RPC.
def log(msg: str) -> None:
print(f"[serpapi_flights_mcp] {msg}", file=sys.stderr, flush=True)
# ── API key / client init ──────────────────────────────────────────────────────
SERPAPI_BASE_URL = "https://serpapi.com"
_DEFAULT_KEY_FILE = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"secrets",
"serpapi_api_key.txt",
)
_init_error: str | None = None
def _get_api_key() -> str | None:
# 1. Environment variable
key = os.environ.get("SERPAPI_API_KEY", "").strip()
if key:
return key
# 2. File
key_file = os.environ.get("SERPAPI_API_KEY_FILE", _DEFAULT_KEY_FILE)
if os.path.exists(key_file):
try:
with open(key_file) as f:
key = f.read().strip()
if key:
return key
except OSError as e:
global _init_error
_init_error = f"Failed to read API key file {key_file}: {e}"
log(_init_error)
return None
_init_error = (
"SerpAPI API key not found. "
"Set SERPAPI_API_KEY env var or create secrets/serpapi_api_key.txt "
"with just the key on the first line."
)
log(_init_error)
return None
def _serpapi_request(params: dict) -> dict:
"""Make a synchronous GET to SerpAPI /search. Raises on HTTP errors."""
api_key = _get_api_key()
if not api_key:
raise _InitError(_init_error or "SerpAPI API key not configured.")
full_params = {"api_key": api_key, **params}
with httpx.Client(timeout=30.0, headers={"User-Agent": "skald-serpapi-mcp/2.0"}) as client:
response = client.get(f"{SERPAPI_BASE_URL}/search", params=full_params)
response.raise_for_status()
return response.json()
class _InitError(Exception):
"""Raised when the API key is missing or unreadable."""
# ── Error mapping ──────────────────────────────────────────────────────────────
def _format_api_error(e: Exception) -> str:
if isinstance(e, _InitError):
return f"Error: {e}"
if isinstance(e, httpx.HTTPStatusError):
status = e.response.status_code
if status == 401:
return "Error: Invalid SerpAPI API key. Check secrets/serpapi_api_key.txt or the SERPAPI_API_KEY env var."
if status == 429:
return "Error: SerpAPI rate limit exceeded. Wait a moment and retry."
if status == 400:
return f"Error: Bad request — {e.response.text[:200]}. Verify airport codes (3-letter IATA) and dates."
return f"Error: SerpAPI request failed (HTTP {status})."
if isinstance(e, httpx.TimeoutException):
return "Error: Request to SerpAPI timed out (30s). The service may be slow or unreachable; retry."
if isinstance(e, httpx.RequestError):
return f"Error: Network error contacting SerpAPI: {e}"
return f"Error: Unexpected error: {type(e).__name__}: {e}"
# ── Output formatting ──────────────────────────────────────────────────────────
def _format_flight_results(data: dict, max_results: int) -> str:
"""Render SerpAPI Google Flights results as plain text for the LLM."""
best_flights = data.get("best_flights", []) or []
other_flights = data.get("other_flights", []) or []
price_insights = data.get("price_insights") or {}
if not best_flights and not other_flights:
return "No flights found for the given route and dates."
lines: list[str] = []
if price_insights:
pi = price_insights
if pi.get("lowest_price"):
lines.append(f"Lowest price: {pi['lowest_price']}")
if pi.get("typical_price_range"):
lo, hi = pi["typical_price_range"][0], pi["typical_price_range"][1]
lines.append(f"Typical range: {lo} {hi}")
if lines:
lines.append("")
lines.append("Flights:")
lines.append("")
all_flights = (best_flights + other_flights)[:max_results]
for i, flight in enumerate(all_flights, 1):
segments = flight.get("flights", []) or []
total_duration = flight.get("total_duration", 0) # minutes
price = flight.get("price", 0)
layovers = flight.get("layovers", []) or []
hours, minutes = divmod(total_duration, 60)
duration_str = f"{hours}h {minutes}m" if hours > 0 else f"{minutes}m"
cheapest_tag = " (CHEAPEST)" if i == 1 and best_flights else ""
lines.append(f"#{i}: {price}{cheapest_tag}")
lines.append("")
for seg in segments:
dep = seg.get("departure_airport", {}) or {}
arr = seg.get("arrival_airport", {}) or {}
airline = seg.get("airline", "?")
flight_num = seg.get("flight_number", "?")
seg_dur = seg.get("duration", 0)
seg_h, seg_m = divmod(seg_dur, 60)
seg_dur_str = f"{seg_h}h {seg_m}m" if seg_h > 0 else f"{seg_m}m"
lines.append(f" {airline} {flight_num}")
lines.append(f" {dep.get('id', '?')} {dep.get('time', '?')} -> {arr.get('id', '?')} {arr.get('time', '?')}")
lines.append(f" Duration: {seg_dur_str}")
lines.append("")
if layovers:
parts = []
for lo in layovers:
lo_dur = lo.get("duration", 0)
lo_h, lo_m = divmod(lo_dur, 60)
parts.append(f"{lo.get('id', '?')} ({lo_h}h {lo_m}m)")
lines.append(f" Layovers: {' -> '.join(parts)}")
lines.append("")
lines.append(f" Total duration: {duration_str}")
emissions = flight.get("carbon_emissions") or {}
if emissions.get("this_flight") is not None:
lines.append(f" CO2: {emissions['this_flight']}g")
lines.append("")
return "\n".join(lines).rstrip()
# ── Tool implementation ────────────────────────────────────────────────────────
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_IATA_RE = re.compile(r"^[A-Za-z]{3}$")
_VALID_CABINS = {"economy", "premium_economy", "business", "first"}
def _validate_int(value: Any, name: str, lo: int, hi: int, default: int) -> int:
if value is None:
return default
try:
v = int(value)
except (TypeError, ValueError):
raise _ValidationError(f"'{name}' must be an integer between {lo} and {hi}.")
if v < lo or v > hi:
raise _ValidationError(f"'{name}' must be between {lo} and {hi} (got {v}).")
return v
class _ValidationError(Exception):
"""Raised for invalid tool arguments; message is returned to the LLM."""
def _serpapi_search_flights(args: dict) -> str:
# ── Required params ────────────────────────────────────────────────────────
departure_id = (args.get("departure_id") or "").strip().upper()
arrival_id = (args.get("arrival_id") or "").strip().upper()
outbound_date = (args.get("outbound_date") or "").strip()
if not departure_id:
raise _ValidationError("Missing required parameter 'departure_id' (3-letter IATA airport or city code, e.g. 'JFK', 'MIL').")
if not _IATA_RE.match(departure_id):
raise _ValidationError(f"'departure_id' must be exactly 3 ASCII letters (got '{departure_id}'). Use an airport code (e.g. 'JFK') or a city code (e.g. 'NYC', 'MIL', 'LON').")
if not arrival_id:
raise _ValidationError("Missing required parameter 'arrival_id' (3-letter IATA airport or city code).")
if not _IATA_RE.match(arrival_id):
raise _ValidationError(f"'arrival_id' must be exactly 3 ASCII letters (got '{arrival_id}'). Use an airport code (e.g. 'FCO') or a city code (e.g. 'ROM').")
if not outbound_date:
raise _ValidationError("Missing required parameter 'outbound_date' (YYYY-MM-DD).")
if not _DATE_RE.match(outbound_date):
raise _ValidationError(f"'outbound_date' must be in YYYY-MM-DD format (got '{outbound_date}').")
# ── Optional params ────────────────────────────────────────────────────────
return_date = (args.get("return_date") or "").strip() or None
if return_date and not _DATE_RE.match(return_date):
raise _ValidationError(f"'return_date' must be in YYYY-MM-DD format (got '{return_date}').")
adults = _validate_int(args.get("adults"), "adults", 1, 10, 1)
children = _validate_int(args.get("children"), "children", 0, 8, 0)
infants_in_seat = _validate_int(args.get("infants_in_seat"), "infants_in_seat", 0, 4, 0)
infants_on_lap = _validate_int(args.get("infants_on_lap"), "infants_on_lap", 0, 4, 0)
stops_raw = args.get("stops")
stops: int | None = None
if stops_raw is not None:
try:
stops = int(stops_raw)
except (TypeError, ValueError):
raise _ValidationError("'stops' must be 0 (non-stop only), 1 (max 1 stop), or 2 (max 2 stops).")
if stops not in (0, 1, 2):
raise _ValidationError(f"'stops' must be 0, 1, or 2 (got {stops}).")
currency = (args.get("currency") or "EUR").strip().upper()
if not re.match(r"^[A-Z]{3}$", currency):
raise _ValidationError(f"'currency' must be a 3-letter ISO code (got '{currency}').")
preferred_cabins = args.get("preferred_cabins")
if preferred_cabins is not None:
preferred_cabins = str(preferred_cabins).strip().lower()
if preferred_cabins not in _VALID_CABINS:
raise _ValidationError(f"'preferred_cabins' must be one of: {', '.join(sorted(_VALID_CABINS))}.")
hl = args.get("hl") or "en"
max_results = _validate_int(args.get("max_results"), "max_results", 1, 50, 10)
# ── Build SerpAPI params ───────────────────────────────────────────────────
api_params: dict[str, Any] = {
"engine": "google_flights",
"departure_id": departure_id,
"arrival_id": arrival_id,
"outbound_date": outbound_date,
"adults": adults,
"children": children,
"infants_in_seat": infants_in_seat,
"infants_on_lap": infants_on_lap,
"currency": currency,
"hl": hl,
}
if return_date:
api_params["return_date"] = return_date
api_params["type"] = "1" # round-trip
else:
api_params["type"] = "2" # one-way
if stops is not None:
api_params["stops"] = stops
if preferred_cabins:
api_params["preferred_cabins"] = preferred_cabins
# ── Call ───────────────────────────────────────────────────────────────────
try:
data = _serpapi_request(api_params)
except Exception as e:
return _format_api_error(e)
if data.get("error"):
return f"Error: SerpAPI returned an error: {data['error']}"
return _format_flight_results(data, max_results)
# ── Tool manifest ──────────────────────────────────────────────────────────────
TOOLS = [
{
"name": "serpapi_search_flights",
"description": (
"Search one-way or round-trip flights on Google Flights via SerpAPI. "
"Returns a plain-text list of routes with price, airline + flight number, "
"departure/arrival times, segment durations, total duration, layovers, "
"and CO2 emissions. The first result is typically the cheapest.\n"
"Both airport codes (3 letters, e.g. 'JFK', 'FCO') and city codes "
"(3 letters covering all airports of a city, e.g. 'NYC', 'ROM', 'MIL', "
"'LON') are accepted for departure_id and arrival_id — prefer city codes "
"when the user does not name a specific airport."
),
"inputSchema": {
"type": "object",
"properties": {
"departure_id": {
"type": "string",
"description": "Departure airport or city code — exactly 3 ASCII letters. Examples: 'JFK' (New York JFK), 'MIL' (any Milan airport), 'LON' (any London airport), 'ROM' (any Rome airport).",
},
"arrival_id": {
"type": "string",
"description": "Arrival airport or city code — exactly 3 ASCII letters. See departure_id for examples.",
},
"outbound_date": {
"type": "string",
"description": "Outbound date in YYYY-MM-DD format (e.g. '2026-08-01').",
},
"return_date": {
"type": "string",
"description": "Return date in YYYY-MM-DD for round-trip searches. Omit for one-way.",
},
"adults": {
"type": "integer",
"description": "Number of adult passengers (12+). Default 1, max 10.",
},
"children": {
"type": "integer",
"description": "Number of children (2-11). Default 0, max 8.",
},
"infants_in_seat": {
"type": "integer",
"description": "Number of infants occupying a seat. Default 0, max 4.",
},
"infants_on_lap": {
"type": "integer",
"description": "Number of infants on an adult's lap (under 2). Default 0, max 4.",
},
"stops": {
"type": "integer",
"enum": [0, 1, 2],
"description": "Maximum number of stops: 0 = non-stop only, 1 = max 1 stop, 2 = max 2 stops. Omit to allow any.",
},
"currency": {
"type": "string",
"description": "ISO 4217 currency code for prices (e.g. 'EUR', 'USD', 'GBP'). Default 'EUR'.",
},
"preferred_cabins": {
"type": "string",
"enum": ["economy", "premium_economy", "business", "first"],
"description": "Cabin class filter. Omit to search all cabins.",
},
"hl": {
"type": "string",
"description": "Language code for results (e.g. 'en', 'it', 'fr'). Default 'en'.",
},
"max_results": {
"type": "integer",
"description": "Maximum number of flight options to return. Default 10, max 50.",
},
},
"required": ["departure_id", "arrival_id", "outbound_date"],
},
},
]
# ── JSON-RPC dispatch ──────────────────────────────────────────────────────────
TOOL_DISPATCH = {
"serpapi_search_flights": _serpapi_search_flights,
}
def _ok(req_id: Any, result: Any) -> str:
return json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result})
def _text_result(req_id: Any, text: str, is_error: bool = False) -> str:
payload: dict = {
"jsonrpc": "2.0",
"id": req_id,
"result": {"content": [{"type": "text", "text": text}]},
}
if is_error:
payload["result"]["isError"] = True
return json.dumps(payload)
def handle_request(msg: dict) -> str | None:
method = msg.get("method", "")
req_id = msg.get("id")
if method == "initialize":
return _ok(req_id, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "serpapi_flights",
"version": "2.0.0",
},
})
if method == "notifications/initialized":
return None
if method == "tools/list":
return _ok(req_id, {"tools": TOOLS})
if method == "tools/call":
params = msg.get("params", {})
tool_name = params.get("name", "")
tool_args = params.get("arguments", {}) or {}
handler = TOOL_DISPATCH.get(tool_name)
if handler is None:
return _text_result(req_id, f"Error: Unknown tool: {tool_name}", is_error=True)
try:
text = handler(tool_args)
except _ValidationError as e:
return _text_result(req_id, f"Error: {e}", is_error=True)
except Exception as e:
log(f"Unhandled exception in tool '{tool_name}': {e}")
return _text_result(req_id, f"Error: Internal error in tool '{tool_name}': {e}", is_error=True)
is_err = text.startswith("Error:")
return _text_result(req_id, text, is_error=is_err)
return json.dumps({
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
})
# ── Main loop ──────────────────────────────────────────────────────────────────
def main() -> None:
log("Starting SerpAPI Google Flights MCP server")
# Validate API key eagerly so configuration errors surface at startup.
_get_api_key()
try:
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError as e:
log(f"Invalid JSON input: {e}")
continue
resp = handle_request(msg)
if resp is not None:
sys.stdout.write(resp + "\n")
sys.stdout.flush()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()