#!/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()