First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+471
View File
@@ -0,0 +1,471 @@
#!/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()