
Flightclaw
- 1k installs
- 65 repo stars
- Updated June 6, 2026
- jackculpan/flightclaw
flightclaw is a Python agent skill and local MCP server that searches Google Flights, finds cheapest travel dates, and tracks route prices for developers who need programmatic fare research and drop alerts.
About
flightclaw is a Python agent skill and local MCP server that queries Google Flights through the flights pip library to search routes, surface cheapest dates across ranges, and monitor fares over time. The project exposes 6 MCP tools—search_flights, search_dates, track_flight, check_prices, list_tracked, and remove_tracked—and ships 4 CLI scripts under scripts/ for terminal workflows. Search filters cover passengers, airlines, USD price caps, duration, departure and arrival times, layover limits, multi-airport codes, date ranges, and 6 sort modes from BEST to DURATION. Price history persists in data/tracked.json with optional target-price alerts, and returned fares use the user's auto-detected local currency. Install with npx skills add jackculpan/flightclaw or pip install flights mcp[cli] on Python 3.10+, then wire server.py into Claude Code or any MCP client. Reach for flightclaw when planning conference travel, comparing relocation routes, or estimating location-based product costs inside an agent session.
- Search Google Flights data for routes, dates, airlines, cabin classes, stops and price filters
- Track price changes for specific routes over time with historical monitoring
- Set up price-drop alerts for chosen routes and travel parameters
- Runs as both a CLI tool and an MCP server
- Supports multi-airport searches and flexible date ranges in one command
Flightclaw by the numbers
- 1,030 all-time installs (skills.sh)
- +6 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #466 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jackculpan/flightclaw --skill flightclawAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1k |
|---|---|
| repo stars | ★ 65 |
| Security audit | 1 / 3 scanners passed |
| Last updated | June 6, 2026 |
| Repository | jackculpan/flightclaw ↗ |
How do you track Google Flights prices with MCP?
Quickly research real flight prices, cheapest dates, and route trends when planning travel, events, or location-based product ideas.
Who is it for?
Developers wiring Claude Code or MCP clients who need programmatic Google Flights search, cheapest-date discovery, and ongoing route price monitoring.
Skip if: Teams needing airline booking APIs, corporate travel management, or production fare-commerce integrations instead of personal research tooling.
When should I use this skill?
A developer asks to search flights, find cheapest travel dates, add a route to tracking, or check tracked fares for price drops.
What you get
Route search results, cheapest-date calendars, data/tracked.json price history, and target-price drop alerts
- data/tracked.json price history
- flight search results
- price-drop alerts
By the numbers
- Exposes 6 MCP tools for search, date lookup, tracking, and price alerts
- Ships 4 CLI scripts in scripts/ for search-flights, track-flight, check-prices, and list-tracked
- Requires Python 3.10+ and 2 pip packages: flights and mcp[cli]
Files
flightclaw
FlightClaw is a personal travel-booking agent. It remembers who you are, who you travel with, the loyalty programs and cards you hold, and how you like to fly — then recommends, books, pays for, and learns from each trip.
Personalization data (travelers, preferences, cards/points, companion groups, trip history) is stored server-side in the private flightclaw-api Worker (D1), reached via FLIGHTCLAW_API_URL + FLIGHTCLAW_API_KEY. Payment for bookings routes through Link virtual cards (duffel_book_with_link).
The flow
1. Onboarding (one time)
Set this up once, then reuse forever.
1. Who you are — save_traveler for yourself (full passport-accurate name, DOB, contact, loyalty programmes), then set_me to mark that profile as you and record home airports. 2. Companions — save_traveler for each person you travel with (relationship: spouse/partner/child/parent/friend/colleague). Group them with save_group (e.g. family = jack,jane). 3. Preferences — set_preferences: cabin by haul (e.g. short-haul ECONOMY, long-haul BUSINESS), preferred/avoided airlines, alliance, seat, departure window, max stops, red-eye tolerance, baggage, meal, and budget sensitivity (cheapest / balanced / comfort). 4. Cards & points — save_card for each card; set_points_balance for each loyalty/transfer program. Then enrich with the Card Links MCP (find_transfer_programs_for_airline, list_transfer_partners) so you know which airlines each card's points can reach.
2. Planning a trip
1. Ask where they want to go and who's coming — reuse a group with get_group (it returns the exact passengers string for booking) or make a new one with save_group. 2. Recommend — recommend_flights(origin, destination, date, ...). It loads the saved preferences, picks the cabin by haul, drops avoided airlines, and ranks options on price/duration/stops/preferred-airline/departure-window/ red-eye, returning the top 3 with a "why this fits you" for each. 3. Awards / points option — if they want to spend points, call the Award Travel Finder MCP (search_availability, search_all_airlines, get_pricing) using their stored loyalty programs and points balances, and present award options alongside the cash fares ("best overall / cheapest / best points value").
3. Booking & paying
1. Get a bookable, payable offer with duffel_search_flights (real fares/ conditions). duffel_get_offer / duffel_get_seat_map for extras. 2. Confirm the choice with the user, then `duffel_book_with_link` with the group's passengers string. This creates a Link spend request (the user approves the charge, ≤ $500), returns a virtual card + Duffel checkout URL, and you complete payment via Chrome automation. For higher amounts use duffel_book_flight (Duffel balance) or duffel_create_checkout. 3. `log_trip` right after booking (route, dates, travelers, cabin, price, order_id) so it enters history and the follow-up queue.
4. Post-trip follow-up & learning (the real magic)
1. trips_pending_followup surfaces trips that have completed/returned. 2. Ask how each went, then record_trip_feedback(id, feedback, learnings=...). Durable lessons (e.g. "prefers window on long-haul", "dislikes early departures") are appended to the user's preferences, so the next recommend_flights is sharper. Over time FlightClaw learns the traveler.
Tools
Personalization (backend-backed)
- Travelers:
save_traveler,list_travelers,get_traveler,delete_traveler,
set_me, get_me, import_local_passengers (one-time migration of any old local data/passengers.json).
- Preferences:
set_preferences,get_preferences,update_preferences. - Cards/points:
save_card,list_cards,delete_card,set_points_balance,
list_points.
- Groups:
save_group,list_groups,get_group,delete_group. - Trips:
log_trip,list_trips,get_trip,trips_pending_followup,
record_trip_feedback.
- Recommendation:
recommend_flights.
Search & tracking — search_flights, search_dates, track_flight, check_prices, list_tracked, remove_tracked.
Booking (Duffel) — duffel_search_flights, duffel_search_multi_city, duffel_get_offer, duffel_get_seat_map, duffel_book_flight, duffel_book_with_link, duffel_create_checkout, duffel_list_orders, duffel_get_order, duffel_request_change, duffel_confirm_change, duffel_cancel_order, duffel_confirm_cancel, duffel_check_alerts, link_list_payment_methods.
External MCP integration
FlightClaw stores the user's cards/points; the agent enriches and acts on them using two other MCP servers when present:
- Card Links — transfer partners and card comparisons for the user's stored
cards.
- Award Travel Finder — award availability and points pricing across
airlines/programs.
When surfacing card recommendations from Card Links, always include its disclaimers: not financial advice; affiliate links may earn commission; card terms change — verify current offers with the issuer.
Setup
pip install flights "mcp[cli]"
export FLIGHTCLAW_API_URL="https://flightclaw-api.<your>.workers.dev"
export FLIGHTCLAW_API_KEY="<your API key>"
claude mcp add flightclaw -- python3 /path/to/flightclaw/server.pyThe Worker (flightclaw-api) holds the Duffel token and D1 profile store; apply schema.sql once with wrangler d1 execute flightclaw-db --remote --file schema.sql.
Data
Personalization data is server-side (D1). Price-tracking history (data/tracked.json) and a local Duffel order cache (data/duffel_orders.json) remain local and are gitignored.
data/
__pycache__/
*.pyc
.flightclaw_api_key
.dev.vars
"""Credit card & points tools for FlightClaw — backend (D1) backed.
Records which cards and loyalty/points balances the user holds, for award and
transfer-partner awareness. NOTE: actual payment still routes through Link
virtual cards (duffel_book_with_link) — these records are for optimization, not
charging. Pair with the Card Links MCP (transfer partners) and the Award Travel
Finder MCP (award availability) at the agent layer.
"""
import profile_api
def register_cards_tools(mcp):
"""Register card + points tools on the MCP server."""
@mcp.tool()
def save_card(
id: str,
issuer: str,
product: str,
network: str | None = None,
region: str | None = None,
notes: str | None = None,
) -> str:
"""Save or update a credit card the user holds (for points/transfer awareness).
Args:
id: Short slug, e.g. 'amex-plat'
issuer: Card issuer, e.g. 'American Express'
product: Product name, e.g. 'Platinum'
network: amex | visa | mastercard (optional)
region: Card region, e.g. US, UK, AU (optional)
notes: Free-form notes, e.g. transfer partners or perks (optional)
"""
try:
result = profile_api.upsert_card({
"id": id, "issuer": issuer, "product": product,
"network": network, "region": region, "notes": notes,
})
except profile_api.ProfileError as e:
return f"Error: {e}"
if "error" in result:
return f"Error: {result['error']}"
return f"Saved card '{id.lower().strip()}'. {len(result.get('cards', []))} card(s) on file."
@mcp.tool()
def list_cards() -> str:
"""List the user's saved cards and points balances."""
try:
data = profile_api.get_cards()
except profile_api.ProfileError as e:
return f"Error: {e}"
cards = data.get("cards", [])
points = data.get("points", [])
if not cards and not points:
return "No cards or points saved. Use save_card / set_points_balance."
lines = []
if cards:
lines.append("Cards:")
for c in cards:
line = f" {c['id']}: {c['issuer']} {c['product']}"
if c.get("network"):
line += f" ({c['network']})"
if c.get("region"):
line += f" [{c['region']}]"
if c.get("notes"):
line += f" — {c['notes']}"
lines.append(line)
if points:
lines.append("Points balances:")
for p in points:
lines.append(f" {p['program']}: {p['balance']:,}")
return "\n".join(lines)
@mcp.tool()
def delete_card(id: str) -> str:
"""Delete a saved card.
Args:
id: Card slug to delete (e.g. 'amex-plat')
"""
try:
result = profile_api.delete_card(id)
except profile_api.ProfileError as e:
return f"Error: {e}"
if not result.get("ok"):
return f"No card '{id}' found."
return f"Deleted card '{id}'."
@mcp.tool()
def set_points_balance(program: str, balance: int) -> str:
"""Set or update a loyalty/points balance.
Args:
program: Program name (e.g. 'Amex Membership Rewards', 'Avios', 'United MileagePlus')
balance: Current points/miles balance
"""
try:
profile_api.set_points(program, balance)
except profile_api.ProfileError as e:
return f"Error: {e}"
return f"Set {program} balance to {balance:,}."
@mcp.tool()
def list_points() -> str:
"""List the user's loyalty/points balances."""
try:
data = profile_api.get_cards()
except profile_api.ProfileError as e:
return f"Error: {e}"
points = data.get("points", [])
if not points:
return "No points balances saved. Use set_points_balance."
lines = [f" {p['program']}: {p['balance']:,}" for p in points]
return "Points balances:\n" + "\n".join(lines)
"""Thin HTTP client for the flightclaw-api wrapper (private Duffel proxy)."""
import json
import os
import urllib.error
import urllib.parse
import urllib.request
def _get_config():
base_url = os.environ.get("FLIGHTCLAW_API_URL", "").rstrip("/")
api_key = os.environ.get("FLIGHTCLAW_API_KEY", "")
if not base_url or not api_key:
raise RuntimeError(
"FLIGHTCLAW_API_URL and FLIGHTCLAW_API_KEY must be set. "
"These point to your private flightclaw-api Worker."
)
return base_url, api_key
def _request(method, path, body=None, params=None):
"""Make an authenticated request to flightclaw-api."""
base_url, api_key = _get_config()
url = f"{base_url}{path}"
if params:
qs = "&".join(f"{k}={urllib.parse.quote(str(v))}" for k, v in params.items())
url = f"{url}?{qs}"
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(
url,
data=data,
method=method,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"User-Agent": "flightclaw/1.0",
},
)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
error_body = e.read().decode()
try:
err = json.loads(error_body)
msg = err.get("error", error_body)
except json.JSONDecodeError:
msg = error_body
raise RuntimeError(f"API error ({e.code}): {msg}")
def is_configured():
"""Check if the API wrapper is configured."""
return bool(
os.environ.get("FLIGHTCLAW_API_URL")
and os.environ.get("FLIGHTCLAW_API_KEY")
)
def search(origin, destination, date, return_date=None, cabin="ECONOMY",
adults=1, children=0, infants=0, max_connections=1):
body = {
"origin": origin, "destination": destination, "date": date,
"cabin": cabin, "adults": adults, "children": children,
"infants": infants, "max_connections": max_connections,
}
if return_date:
body["return_date"] = return_date
return _request("POST", "/search", body)
def search_multi(slices, cabin="ECONOMY", adults=1, children=0, infants=0, max_connections=1):
"""Multi-city search. slices is a list of {origin, destination, date} dicts."""
body = {
"slices": slices, "cabin": cabin, "adults": adults,
"children": children, "infants": infants, "max_connections": max_connections,
}
return _request("POST", "/search/multi", body)
def get_offer(offer_id):
return _request("GET", "/offer", params={"offer_id": offer_id})
def get_seat_map(offer_id):
return _request("GET", "/seat-map", params={"offer_id": offer_id})
def book(offer_id, passengers, payment_type="balance", services=None):
body = {
"offer_id": offer_id,
"passengers": passengers,
"payment_type": payment_type,
}
if services:
body["services"] = services
return _request("POST", "/book", body)
def hold(offer_id, passengers, services=None):
body = {
"offer_id": offer_id,
"passengers": passengers,
}
if services:
body["services"] = services
return _request("POST", "/hold", body)
def pay(order_id, amount, currency, payment_type="balance"):
return _request("POST", "/pay", {
"order_id": order_id,
"amount": amount,
"currency": currency,
"payment_type": payment_type,
})
def get_order(order_id):
return _request("GET", "/order", params={"order_id": order_id})
def request_change(order_id, slices_to_remove, slices_to_add):
return _request("POST", "/change/request", {
"order_id": order_id,
"slices_to_remove": slices_to_remove,
"slices_to_add": slices_to_add,
})
def get_change_request(change_request_id):
return _request("GET", "/change/request",
params={"change_request_id": change_request_id})
def create_change(change_offer_id):
return _request("POST", "/change/create",
{"change_offer_id": change_offer_id})
def confirm_change(change_id, amount, currency, payment_type="balance"):
return _request("POST", "/change/confirm", {
"change_id": change_id,
"amount": amount,
"currency": currency,
"payment_type": payment_type,
})
def cancel(order_id):
return _request("POST", "/cancel", {"order_id": order_id})
def confirm_cancel(cancellation_id):
return _request("POST", "/cancel/confirm",
{"cancellation_id": cancellation_id})
def create_checkout(offer_id, passengers, amount, currency,
flight_summary="", services=None):
body = {
"offer_id": offer_id,
"passengers": passengers,
"amount": amount,
"currency": currency,
"flight_summary": flight_summary,
}
if services:
body["services"] = services
return _request("POST", "/checkout/create", body)
def get_webhook_alerts(order_id=None):
params = {}
if order_id:
params["order_id"] = order_id
return _request("GET", "/webhooks/alerts", params=params)
"""Duffel response formatters, order persistence, and helpers for FlightClaw."""
import json
import os
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
ORDERS_FILE = os.path.join(DATA_DIR, "duffel_orders.json")
def load_orders():
if os.path.exists(ORDERS_FILE):
with open(ORDERS_FILE, "r") as f:
return json.load(f)
return []
def save_orders(orders):
os.makedirs(DATA_DIR, exist_ok=True)
with open(ORDERS_FILE, "w") as f:
json.dump(orders, f, indent=2)
def upsert_order(order_data):
orders = load_orders()
for i, o in enumerate(orders):
if o["id"] == order_data["id"]:
orders[i] = order_data
save_orders(orders)
return
orders.append(order_data)
save_orders(orders)
def fmt_segment(seg):
carrier = seg.get("marketing_carrier", {}).get("iata_code", "?")
fnum = seg.get("marketing_carrier_flight_number", "")
orig = seg.get("origin", {}).get("iata_code", "?")
dest = seg.get("destination", {}).get("iata_code", "?")
dep = seg.get("departing_at", "")[:16].replace("T", " ")
arr = seg.get("arriving_at", "")[:16].replace("T", " ")
return f" {carrier}{fnum}: {orig} {dep} -> {dest} {arr}"
def fmt_offer(offer, index=None):
prefix = f"Option {index}: " if index else ""
owner = offer.get("owner", {}).get("name", "?")
lines = [f"{prefix}{offer.get('total_currency')} {offer.get('total_amount')} | {owner}"]
for s in offer.get("slices", []):
orig = s.get("origin", {}).get("iata_code", "?")
dest = s.get("destination", {}).get("iata_code", "?")
dur = s.get("duration", "?")
lines.append(f" {orig} -> {dest} ({dur})")
for seg in s.get("segments", []):
lines.append(fmt_segment(seg))
cond = offer.get("conditions", {})
chg = cond.get("change_before_departure")
if chg:
if chg.get("allowed"):
pen = f" (penalty: {chg['penalty_currency']} {chg['penalty_amount']})" if chg.get("penalty_amount") else ""
lines.append(f" Changeable: Yes{pen}")
else:
lines.append(" Changeable: No")
ref = cond.get("refund_before_departure")
if ref:
if ref.get("allowed"):
pen = f" (penalty: {ref['penalty_currency']} {ref['penalty_amount']})" if ref.get("penalty_amount") else ""
lines.append(f" Refundable: Yes{pen}")
else:
lines.append(" Refundable: No")
lines.append(f" Offer ID: {offer.get('id')}")
expires = offer.get("expires_at", "")[:16].replace("T", " ")
lines.append(f" Expires: {expires}")
return "\n".join(lines)
def fmt_order(order):
lines = [
f"Order: {order.get('id')}",
f"Booking ref: {order.get('booking_reference')}",
f"Airline: {order.get('owner', {}).get('name', '?')}",
f"Total: {order.get('total_currency')} {order.get('total_amount')}",
]
pax = ", ".join(
f"{p.get('given_name')} {p.get('family_name')}"
for p in order.get("passengers", [])
)
lines.append(f"Passengers: {pax}")
for s in order.get("slices", []):
orig = s.get("origin", {}).get("iata_code", "?")
dest = s.get("destination", {}).get("iata_code", "?")
changeable = "changeable" if s.get("changeable") else "not changeable"
lines.append(f" {orig} -> {dest} ({changeable})")
for seg in s.get("segments", []):
lines.append(fmt_segment(seg))
if order.get("cancelled_at"):
lines.append(f" CANCELLED at {order['cancelled_at']}")
return "\n".join(lines)
def fmt_change_offer(co, index=None):
prefix = f"Option {index}: " if index else ""
cost = float(co.get("change_total_amount", "0"))
currency = co.get("change_total_currency") or co.get("new_total_currency", "?")
penalty = ""
if co.get("penalty_amount"):
penalty = f" (penalty: {co['penalty_currency']} {co['penalty_amount']})"
if cost > 0:
cost_str = f"Additional cost: {currency} {co['change_total_amount']}{penalty}"
elif cost < 0:
cost_str = f"Refund: {currency} {abs(cost):.2f}{penalty}"
else:
cost_str = f"No additional cost{penalty}"
lines = [f"{prefix}{cost_str}"]
lines.append(f" New total: {co.get('new_total_currency')} {co.get('new_total_amount')}")
for s in co.get("slices", {}).get("add", []):
orig = s.get("origin", {}).get("iata_code", "?")
dest = s.get("destination", {}).get("iata_code", "?")
dur = s.get("duration", "?")
lines.append(f" NEW: {orig} -> {dest} ({dur})")
for seg in s.get("segments", []):
lines.append(fmt_segment(seg))
lines.append(f" Change offer ID: {co.get('id')}")
expires = co.get("expires_at", "")[:16].replace("T", " ")
lines.append(f" Expires: {expires}")
return "\n".join(lines)
def _load_traveler_map():
"""Build {name: profile} from the backend, falling back to local JSON."""
try:
import profile_api
if profile_api.is_configured():
return {t["name"]: t for t in profile_api.list_travelers()}
except Exception:
pass
from passenger_profiles import load_passengers
return {p["name"]: p for p in load_passengers()}
def resolve_passengers(passengers_str):
"""Resolve passenger string to list of dicts.
Accepts JSON array or comma-separated traveler profile names.
Names resolve from the backend traveler store (falls back to local JSON).
Returns (list, None) on success or (None, error_str) on failure.
"""
try:
return json.loads(passengers_str), None
except (json.JSONDecodeError, ValueError):
pass
profile_map = _load_traveler_map()
names = [n.strip().lower() for n in passengers_str.split(",")]
pax = []
for name in names:
if name not in profile_map:
return None, f"Unknown traveler '{name}'. Use list_travelers."
p = profile_map[name]
pax.append({
"given_name": p["given_name"],
"family_name": p["family_name"],
"born_on": p.get("born_on"),
"gender": p.get("gender"),
"title": p.get("title"),
"email": p.get("email"),
"phone_number": p.get("phone_number"),
})
return pax, None
"""Duffel + Link agent-billing tools — pay for Duffel bookings via a Link virtual card."""
import json
import os
import duffel_api
import link_payment
from duffel_fmt import fmt_offer, resolve_passengers
def _amount_to_cents(amount_str, currency):
"""Convert a Duffel amount string ('234.50') + currency to integer minor units."""
zero_decimal = {"JPY", "KRW", "VND", "CLP", "ISK", "UGX", "RWF", "PYG"}
amount = float(amount_str)
if currency.upper() in zero_decimal:
return int(round(amount))
return int(round(amount * 100))
def _build_context(offer, passengers):
"""Produce the >=100-char context string the user sees in the approval dialog."""
pax_count = len(passengers) if passengers else 1
legs = []
for s in offer.get("slices", []):
orig = s.get("origin", {}).get("iata_code", "?")
dest = s.get("destination", {}).get("iata_code", "?")
segs = s.get("segments", [])
dep = segs[0].get("departing_at", "")[:10] if segs else ""
legs.append(f"{orig}->{dest} {dep}".strip())
route = "; ".join(legs) or "flight booking"
owner = offer.get("owner", {}).get("name", "airline")
total = f"{offer.get('total_currency')} {offer.get('total_amount')}"
ctx = (
f"Flight booking via Duffel on {owner}. "
f"Route: {route}. Passengers: {pax_count}. Total: {total}. "
f"Payment will be charged to the airline/OTA at checkout."
)
while len(ctx) < 100:
ctx += " Booked through FlightClaw."
return ctx
def _build_line_items(offer):
items = []
base = offer.get("base_amount")
tax = offer.get("tax_amount")
currency = offer.get("total_currency", "")
if base:
items.append(f"description:Base fare,amount:{_amount_to_cents(base, currency)}")
if tax:
items.append(f"description:Taxes & fees,amount:{_amount_to_cents(tax, currency)}")
return items
def register_duffel_link_tools(mcp):
"""Register the Duffel + Link agent-billing tools on the MCP server."""
@mcp.tool()
def link_list_payment_methods() -> str:
"""List Link payment methods on the user's account. Use to pick a payment_method_id."""
try:
methods = link_payment.list_payment_methods()
except link_payment.LinkError as e:
return f"Link error: {e}"
if not methods:
return "No payment methods. Run `link-cli payment-methods add` to add one."
lines = []
for m in methods:
brand = m.get("brand") or m.get("card_brand") or ""
last4 = m.get("last4") or m.get("card_last4") or ""
label = f"{brand} ****{last4}".strip()
lines.append(f" {m.get('id', '?')} {label}")
return "\n".join(lines)
@mcp.tool()
def duffel_book_with_link(
offer_id: str,
passengers: str,
payment_method_id: str | None = None,
services: str | None = None,
test: bool = False,
) -> str:
"""Pay for a Duffel offer with a Link virtual card. Returns card details + Duffel checkout URL.
Flow:
1. Fetches the offer to get amount/currency/summary.
2. Creates a Duffel hosted checkout page (server-side).
3. Creates a Link spend request for the offer amount and waits for user approval.
4. On approval, returns the virtual card credential and the checkout URL.
5. The agent then uses Chrome browser automation to enter the card on the checkout page.
Args:
offer_id: Duffel offer ID (from duffel_search_flights).
passengers: Comma-separated profile names ("jack,jane") or JSON passenger array.
payment_method_id: Link payment method ID. Omit to use the first one on the account.
services: Optional JSON array of extras, e.g. [{"id":"ase_xxx","quantity":1}].
test: If True, creates a Link testmode credential.
"""
if not link_payment.is_authenticated():
return "Not signed in to link-cli. Run `link-cli auth login` first."
pax, err = resolve_passengers(passengers)
if err:
return err
try:
offer = duffel_api.get_offer(offer_id)
except Exception as e:
return f"Could not fetch offer: {e}"
amount_str = offer.get("total_amount")
currency = offer.get("total_currency")
if not amount_str or not currency:
return "Offer is missing total_amount/total_currency."
if pax and not pax[0].get("id"):
offer_pax = offer.get("passengers", [])
for i, p in enumerate(pax):
if i < len(offer_pax):
p["id"] = offer_pax[i]["id"]
svc_list = None
if services:
try:
svc_list = json.loads(services)
except json.JSONDecodeError as e:
return f"Invalid services JSON: {e}"
summary = fmt_offer(offer)
try:
checkout = duffel_api.create_checkout(
offer_id, pax, amount_str, currency, summary, svc_list,
)
except Exception as e:
return f"Checkout creation failed: {e}"
base = os.environ.get("FLIGHTCLAW_API_URL", "").rstrip("/")
checkout_url = f"{base}{checkout.get('checkout_url', '')}"
fee = checkout.get("fee", 0)
amount_cents = _amount_to_cents(amount_str, currency)
if amount_cents > 50000:
return (
f"Offer total {currency} {amount_str} ({amount_cents}c) exceeds Link's "
f"$500 spend-request limit. Use duffel_book_flight (Duffel balance) instead."
)
try:
pm_id = payment_method_id or link_payment.first_payment_method_id()
except link_payment.LinkError as e:
return f"Link error: {e}"
context = _build_context(offer, pax)
line_items = _build_line_items(offer)
total = [f"description:Total,amount:{amount_cents}"]
try:
spend_request = link_payment.create_card_spend_request(
payment_method_id=pm_id,
amount_cents=amount_cents,
currency=currency,
merchant_name=offer.get("owner", {}).get("name") or "Duffel",
merchant_url=checkout_url,
context=context,
line_items=line_items,
total=total,
test=test,
)
except link_payment.LinkError as e:
return f"Spend request failed: {e}"
sr = spend_request[0] if isinstance(spend_request, list) else spend_request
status = (sr or {}).get("status", "unknown")
sr_id = (sr or {}).get("id", "")
if status != "approved":
return f"Spend request {sr_id} ended with status: {status}."
try:
full = link_payment.retrieve_with_card(sr_id)
except link_payment.LinkError as e:
return f"Could not retrieve card: {e}"
full = full[0] if isinstance(full, list) else full
card = link_payment.extract_card(full or {})
if not card or not card.get("number"):
return f"Spend request {sr_id} approved but no card credential was returned."
return (
f"Link payment approved (spend_request={sr_id}).\n\n"
f"Duffel checkout URL: {checkout_url}\n"
f"Service fee included: {currency} {fee:.2f}\n\n"
f"Virtual card to enter on the checkout page:\n"
f" Number: {card['number']}\n"
f" Expiry: {card['exp_month']:02d}/{card['exp_year']}\n"
f" CVC: {card['cvc']}\n"
f" Holder: {card.get('holder_name') or '(use any name)'}\n\n"
f"{summary}\n\n"
f"Next step: use Chrome automation to navigate to the checkout URL and "
f"submit these card details. Confirm with the user before submitting."
)
"""Duffel MCP tools for FlightClaw - search, booking, and order management."""
import json
import duffel_api
from duffel_fmt import (
fmt_change_offer,
fmt_offer,
fmt_order,
load_orders,
resolve_passengers,
upsert_order,
)
def register_duffel_tools(mcp):
"""Register all Duffel tools on the MCP server."""
@mcp.tool(annotations={"readOnlyHint": True, "idempotentHint": True})
def duffel_search_flights(
origin: str,
destination: str,
date: str,
return_date: str | None = None,
cabin: str = "ECONOMY",
adults: int = 1,
children: int = 0,
infants: int = 0,
results: int = 5,
max_connections: int = 1,
) -> str:
"""Search bookable flights via Duffel with real-time fares and conditions.
Args:
origin: Origin IATA code (e.g. LHR)
destination: Destination IATA code (e.g. JFK)
date: Departure date (YYYY-MM-DD)
return_date: Return date for round trips (YYYY-MM-DD)
cabin: ECONOMY, PREMIUM_ECONOMY, BUSINESS, or FIRST
adults: Adults (default 1)
children: Children (default 0)
infants: Infants (default 0)
results: Max results (default 5)
max_connections: Max connections per slice (default 1)
"""
try:
data = duffel_api.search(
origin.strip().upper(), destination.strip().upper(),
date, return_date, cabin, adults, children, infants,
max_connections,
)
except Exception as e:
return f"Search error: {e}"
offers = data.get("offers", [])
if not offers:
return f"No flights found for {origin} -> {destination} on {date}"
offers.sort(key=lambda o: float(o.get("total_amount", "999999")))
show = offers[:results]
route = f"{origin} -> {destination}"
if return_date:
route += f" (return {return_date})"
output = [f"Duffel search: {route} on {date} ({cabin})", ""]
for i, offer in enumerate(show, 1):
output.append(fmt_offer(offer, index=i))
output.append("")
output.append(f"{len(offers)} offer(s) found. Showing top {len(show)}.")
output.append("Use duffel_book_flight with an offer ID to book.")
return "\n".join(output)
@mcp.tool(annotations={"readOnlyHint": True, "idempotentHint": True})
def duffel_search_multi_city(
slices: str,
cabin: str = "ECONOMY",
adults: int = 1,
children: int = 0,
infants: int = 0,
results: int = 5,
max_connections: int = 1,
) -> str:
"""Search multi-city flights via Duffel.
Args:
slices: JSON array, e.g. [{"origin":"LHR","destination":"JFK","date":"2026-07-01"},
{"origin":"JFK","destination":"LAX","date":"2026-07-05"}]
cabin: ECONOMY, PREMIUM_ECONOMY, BUSINESS, or FIRST
adults: Adults (default 1)
children: Children (default 0)
infants: Infants (default 0)
results: Max results (default 5)
max_connections: Max connections per slice (default 1)
"""
try:
slice_list = json.loads(slices)
except json.JSONDecodeError as e:
return f"Invalid slices JSON: {e}"
if not isinstance(slice_list, list) or len(slice_list) < 2:
return "Slices must be a JSON array with at least 2 segments."
for s in slice_list:
if not all(k in s for k in ("origin", "destination", "date")):
return "Each slice must have origin, destination, and date."
try:
data = duffel_api.search_multi(
slice_list, cabin, adults, children, infants, max_connections,
)
except Exception as e:
return f"Search error: {e}"
offers = data.get("offers", [])
if not offers:
route = " -> ".join(
f"{s['origin']}-{s['destination']}" for s in slice_list
)
return f"No multi-city flights found for {route}"
offers.sort(key=lambda o: float(o.get("total_amount", "999999")))
show = offers[:results]
route = " -> ".join(
f"{s['origin']}-{s['destination']}" for s in slice_list
)
output = [f"Multi-city search: {route} ({cabin})", ""]
for i, offer in enumerate(show, 1):
output.append(fmt_offer(offer, index=i))
output.append("")
output.append(f"{len(offers)} offer(s) found. Showing top {len(show)}.")
output.append("Use duffel_book_flight with an offer ID to book.")
return "\n".join(output)
@mcp.tool(annotations={"readOnlyHint": True, "idempotentHint": True})
def duffel_get_offer(offer_id: str) -> str:
"""Get offer details including conditions and available extras.
Args:
offer_id: Duffel offer ID from search results
"""
try:
offer = duffel_api.get_offer(offer_id)
except Exception as e:
return f"Error: {e}"
lines = [fmt_offer(offer)]
services = offer.get("available_services") or []
if services:
lines.append("\nAvailable extras:")
for svc in services:
stype = svc.get("type", "?")
price = f"{offer.get('total_currency')} {svc.get('total_amount')}"
meta = svc.get("metadata", {})
desc = meta.get("type", stype)
if meta.get("maximum_weight_kg"):
desc += f" ({meta['maximum_weight_kg']}kg)"
lines.append(f" {desc}: {price} (max qty: {svc.get('maximum_quantity', 1)}) | ID: {svc['id']}")
lines.append("\nPass service IDs to duffel_book_flight via services parameter.")
return "\n".join(lines)
@mcp.tool(annotations={"readOnlyHint": True, "idempotentHint": True})
def duffel_get_seat_map(offer_id: str) -> str:
"""Get seat map with available seats and prices.
Args:
offer_id: Duffel offer ID from search results
"""
try:
seat_maps = duffel_api.get_seat_map(offer_id)
except Exception as e:
return f"Error: {e}"
if not seat_maps:
return "No seat map available for this offer."
output = []
for sm in seat_maps:
segment = sm.get("segment_id", "?")
output.append(f"Segment: {segment}")
cabins = sm.get("cabins", [])
for cabin_info in cabins:
cabin_class = cabin_info.get("cabin_class", "?")
output.append(f" Cabin: {cabin_class}")
rows = cabin_info.get("rows", [])
for row in rows:
sections = row.get("sections", [])
for section in sections:
seats = section.get("elements", [])
for seat in seats:
if seat.get("type") != "seat":
continue
designator = seat.get("designator", "?")
available = seat.get("available_services", [])
if not available:
continue
svc = available[0]
price = f"{svc.get('total_currency', '?')} {svc.get('total_amount', '?')}"
disclosures = ", ".join(seat.get("disclosures", []))
extra = f" ({disclosures})" if disclosures else ""
output.append(f" {designator}: {price}{extra} | ID: {svc.get('id', '?')}")
output.append("")
output.append("Pass seat service IDs to duffel_book_flight via services parameter.")
return "\n".join(output)
@mcp.tool(annotations={"idempotentHint": False})
def duffel_book_flight(
offer_id: str,
passengers: str,
payment_type: str = "balance",
services: str | None = None,
) -> str:
"""Book a flight. Accepts profile names or JSON passenger array.
Args:
offer_id: Offer ID from search results
passengers: Comma-separated profile names (e.g. "jack,jane") or
JSON array with given_name, family_name, born_on, gender, title, email, phone_number
payment_type: "balance" or "arc_bsp_cash"
services: Optional JSON array e.g. [{"id":"ase_xxx","quantity":1}]
"""
pax, err = resolve_passengers(passengers)
if err:
return err
# Auto-assign offer passenger IDs if missing
if pax and not pax[0].get("id"):
try:
offer = duffel_api.get_offer(offer_id)
offer_pax = offer.get("passengers", [])
for i, p in enumerate(pax):
if i < len(offer_pax):
p["id"] = offer_pax[i]["id"]
except Exception:
pass # Let Duffel return the error if IDs are wrong
svc_list = None
if services:
try:
svc_list = json.loads(services)
except json.JSONDecodeError as e:
return f"Invalid services JSON: {e}"
try:
order = duffel_api.book(offer_id, pax, payment_type, svc_list)
except Exception as e:
return f"Booking failed: {e}"
upsert_order(order)
return "Flight booked!\n\n" + fmt_order(order)
@mcp.tool(annotations={"readOnlyHint": True})
def duffel_list_orders() -> str:
"""List all Duffel orders stored locally."""
orders = load_orders()
if not orders:
return "No Duffel orders. Use duffel_book_flight to create one."
output = [fmt_order(o) for o in orders]
output.append(f"\n{len(orders)} order(s).")
return "\n\n".join(output)
@mcp.tool(annotations={"idempotentHint": True}) # persists via upsert_order; not read-only
def duffel_get_order(order_id: str) -> str:
"""Get live order status from Duffel.
Args:
order_id: Duffel order ID (e.g. ord_xxx)"""
try:
order = duffel_api.get_order(order_id)
except Exception as e:
return f"Error: {e}"
upsert_order(order)
return fmt_order(order)
@mcp.tool(annotations={"idempotentHint": False})
def duffel_request_change(
order_id: str,
new_date: str,
slice_index: int = 0,
new_origin: str | None = None,
new_destination: str | None = None,
cabin: str | None = None,
) -> str:
"""Request a flight change. Returns options with fees.
Args:
order_id: Duffel order ID
new_date: New departure date (YYYY-MM-DD)
slice_index: 0=outbound, 1=return
new_origin: New origin IATA (optional)
new_destination: New destination IATA (optional)
cabin: ECONOMY, PREMIUM_ECONOMY, BUSINESS, or FIRST (optional)
"""
try:
order = duffel_api.get_order(order_id)
except Exception as e:
return f"Error: {e}"
slices = order.get("slices", [])
if slice_index >= len(slices):
return f"Invalid slice_index {slice_index}. Order has {len(slices)} slice(s)."
target = slices[slice_index]
if not target.get("changeable"):
orig = target.get("origin", {}).get("iata_code", "?")
dest = target.get("destination", {}).get("iata_code", "?")
return f"Slice {slice_index} ({orig} -> {dest}) is not changeable."
origin = new_origin or target["origin"]["iata_code"]
destination = new_destination or target["destination"]["iata_code"]
cabin_map = {"ECONOMY": "economy", "PREMIUM_ECONOMY": "premium_economy",
"BUSINESS": "business", "FIRST": "first"}
if cabin and cabin not in cabin_map:
return f"Unknown cabin '{cabin}'. Use ECONOMY, PREMIUM_ECONOMY, BUSINESS, or FIRST."
cabin_class = cabin_map.get(cabin) if cabin else None
if not cabin_class:
segs = target.get("segments", [])
if segs and segs[0].get("passengers"):
cabin_class = segs[0]["passengers"][0].get("cabin_class", "economy")
else:
cabin_class = "economy"
try:
result = duffel_api.request_change(
order_id,
[{"slice_id": target["id"]}],
[{"origin": origin.upper(), "destination": destination.upper(),
"departure_date": new_date, "cabin_class": cabin_class}],
)
except Exception as e:
return f"Change request failed: {e}"
offers = result.get("order_change_offers", [])
if not offers:
return f"No change options for {origin} -> {destination} on {new_date}."
output = [f"Change options for order {order_id}:", ""]
for i, co in enumerate(offers, 1):
output.append(fmt_change_offer(co, index=i))
output.append("")
output.append(f"{len(offers)} option(s). Use duffel_confirm_change with a change_offer_id.")
return "\n".join(output)
@mcp.tool(annotations={"idempotentHint": False})
def duffel_confirm_change(
change_offer_id: str,
payment_type: str = "balance",
) -> str:
"""Confirm a flight change from duffel_request_change.
Args:
change_offer_id: Change offer ID
payment_type: "balance" or "arc_bsp_cash" """
try:
change = duffel_api.create_change(change_offer_id)
except Exception as e:
return f"Change failed: {e}"
amount = float(change.get("change_total_amount", "0"))
currency = change.get("change_total_currency", "GBP")
try:
duffel_api.confirm_change(
change["id"], change.get("change_total_amount", "0"), currency, payment_type,
)
except Exception as e:
return f"Confirm failed: {e}"
lines = ["Flight changed!", f"Order {change.get('order_id')} updated."]
if amount > 0:
lines.append(f"Charged: {currency} {amount:.2f}")
elif amount < 0:
lines.append(f"Refund: {currency} {abs(amount):.2f}")
lines.append("\nUse duffel_get_order to see updated details.")
return "\n".join(lines)
@mcp.tool(annotations={"destructiveHint": True})
def duffel_cancel_order(order_id: str) -> str:
"""Request cancellation quote. Shows refund before confirming.
Args:
order_id: Duffel order ID"""
try:
cancellation = duffel_api.cancel(order_id)
except Exception as e:
return f"Cancel failed: {e}"
lines = [
f"Cancel quote for {order_id}:",
f" Refund: {cancellation.get('refund_currency')} {cancellation.get('refund_amount')}",
f" Refund to: {cancellation.get('refund_to')}",
"",
f"Confirm with duffel_confirm_cancel(cancellation_id=\"{cancellation.get('id')}\")",
]
return "\n".join(lines)
@mcp.tool(annotations={"destructiveHint": True, "idempotentHint": False})
def duffel_confirm_cancel(cancellation_id: str) -> str:
"""Confirm cancellation. Irreversible.
Args:
cancellation_id: From duffel_cancel_order"""
try:
result = duffel_api.confirm_cancel(cancellation_id)
except Exception as e:
return f"Confirm failed: {e}"
return (
f"Order {result.get('order_id')} cancelled. "
f"Refund: {result.get('refund_currency')} {result.get('refund_amount')}"
)
@mcp.tool(annotations={"idempotentHint": False})
def duffel_create_checkout(
offer_id: str,
passengers: str,
services: str | None = None,
) -> str:
"""Create a checkout page for card payment. Returns URL for user to pay.
Args:
offer_id: Offer ID from search results
passengers: Profile names (e.g. "jack,jane") or JSON passenger array
services: Optional JSON array e.g. [{"id":"ase_xxx","quantity":1}]
"""
pax, err = resolve_passengers(passengers)
if err:
return err
svc_list = None
if services:
try:
svc_list = json.loads(services)
except json.JSONDecodeError as e:
return f"Invalid services JSON: {e}"
# Get offer to know amount/currency and assign passenger IDs
try:
offer = duffel_api.get_offer(offer_id)
except Exception as e:
return f"Error: {e}"
if pax and not pax[0].get("id"):
offer_pax = offer.get("passengers", [])
for i, p in enumerate(pax):
if i < len(offer_pax):
p["id"] = offer_pax[i]["id"]
summary = fmt_offer(offer)
try:
result = duffel_api.create_checkout(
offer_id, pax,
offer.get("total_amount"), offer.get("total_currency"),
summary, svc_list,
)
except Exception as e:
return f"Checkout creation failed: {e}"
import os
base = os.environ.get("FLIGHTCLAW_API_URL", "").rstrip("/")
checkout_path = result.get("checkout_url", "")
url = f"{base}{checkout_path}"
fee = result.get("fee", 0)
return (
f"Checkout page created.\n\nURL: {url}\n"
f"Fee included: {offer.get('total_currency')} {fee:.2f}\n\n"
f"Send this URL to the passenger to complete card payment."
)
@mcp.tool(annotations={"readOnlyHint": True, "idempotentHint": True})
def duffel_check_alerts(order_id: str | None = None) -> str:
"""Check for airline-initiated changes or updates on orders.
Args:
order_id: Order ID to check (optional, omit for all recent)
"""
try:
result = duffel_api.get_webhook_alerts(order_id)
except Exception as e:
return f"Error: {e}"
alerts = result.get("alerts", [])
events = result.get("events", [])
items = alerts or events
if not items:
return "No alerts." if not order_id else f"No alerts for {order_id}."
lines = []
for item in items:
lines.append(f" {item.get('type', item.get('summary', '?'))} ({item.get('created_at', '?')})")
return f"{len(items)} alert(s):\n" + "\n".join(lines)
"""Booking fulfillment beyond Duffel.
Extends bookable coverage with Kiwi.com (Tequila API) when a key is configured,
and always offers an affiliate / deep-link hand-off as the universal fallback so
no recommended flight is ever a dead end.
Env:
KIWI_API_KEY Tequila API key — enables the Kiwi bookability check (search).
KIWI_AFFILID Kiwi affiliate id — used for monetised hand-off deep links.
Note: Kiwi *search* works with an API key; actually *booking* via Kiwi's API
needs a commercial deposit account. Until then, Kiwi-covered flights are handed
off via deep link.
"""
import json
import os
import urllib.parse
import urllib.request
GOOGLE_BOOKING_URL = "https://www.google.com/travel/flights/booking?tfs="
TEQUILA_SEARCH = "https://api.tequila.kiwi.com/v2/search"
def _digits(n):
return "".join(c for c in str(n) if c.isdigit())
def _kiwi_date(d):
"""YYYY-MM-DD -> dd/mm/yyyy (Tequila format)."""
try:
y, m, day = d.split("-")
return f"{day}/{m}/{y}"
except ValueError:
return d
def kiwi_configured():
return bool(os.environ.get("KIWI_API_KEY"))
def kiwi_index(origin, destination, date, return_date=None):
"""Look up what Kiwi can book on this route.
Returns {segments:set[(carrier,fnum)], deep_link:str|None} or None if Kiwi
isn't configured or the search fails.
"""
key = os.environ.get("KIWI_API_KEY")
if not key:
return None
params = {
"fly_from": origin,
"fly_to": destination,
"date_from": _kiwi_date(date),
"date_to": _kiwi_date(date),
"curr": "GBP",
"limit": 200,
"vehicle_type": "aircraft",
}
if return_date:
params["return_from"] = _kiwi_date(return_date)
params["return_to"] = _kiwi_date(return_date)
url = f"{TEQUILA_SEARCH}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers={"apikey": key, "accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=20) as r:
data = json.loads(r.read())
except Exception:
return None
segments = set()
itineraries = data.get("data", []) or []
for it in itineraries:
for leg in it.get("route", []):
c = leg.get("airline")
fn = _digits(leg.get("flight_no", ""))
if c and fn:
segments.add((c, fn))
deep = itineraries[0].get("deep_link") if itineraries else None
return {"segments": segments, "deep_link": deep}
def book_direct_link(origin, destination, date, google_token=None):
"""A tappable hand-off link for flights we can't book in-app.
Prefers a monetised Kiwi affiliate deep link, then the precise Google Flights
booking link, then a generic Skyscanner search.
"""
affil = os.environ.get("KIWI_AFFILID")
if affil:
q = urllib.parse.urlencode({
"from": origin, "to": destination,
"departure": date, "affilid": affil,
})
return f"https://www.kiwi.com/deep?{q}"
if google_token:
return GOOGLE_BOOKING_URL + urllib.parse.quote(google_token, safe="")
ymd = date.replace("-", "")[2:] # yymmdd
return f"https://www.skyscanner.net/transport/flights/{origin}/{destination}/{ymd}/"
"""Companion group tools for FlightClaw — backend (D1) backed.
Save named groups of travelers (e.g. 'family' = jack,jane,kid) so "who are you
travelling with?" is answered once and reused. Group members are traveler name
keys; pass the returned comma-separated names straight to booking tools.
"""
import profile_api
def register_group_tools(mcp):
"""Register companion-group tools on the MCP server."""
@mcp.tool()
def save_group(name: str, members: str) -> str:
"""Save or update a named travel group for reuse.
Args:
name: Group name, e.g. 'family' or 'work-trip'
members: Comma-separated traveler name keys (e.g. 'jack,jane'). Save each
traveler with save_traveler first.
"""
member_list = [m.strip().lower() for m in members.split(",") if m.strip()]
if not member_list:
return "Provide at least one member name."
# Warn about unknown members but still save (they can be added later).
try:
known = {t["name"] for t in profile_api.list_travelers()}
unknown = [m for m in member_list if m not in known]
result = profile_api.upsert_group(name, member_list)
except profile_api.ProfileError as e:
return f"Error: {e}"
out = f"Saved group '{result['name']}': {', '.join(result['members'])}."
if unknown:
out += f"\nWarning: not yet saved as travelers: {', '.join(unknown)} (use save_traveler)."
return out
@mcp.tool()
def list_groups() -> str:
"""List all saved travel groups."""
try:
groups = profile_api.get_groups()
except profile_api.ProfileError as e:
return f"Error: {e}"
if not groups:
return "No groups saved. Use save_group."
lines = [f" {g['name']}: {', '.join(g['members']) or '(empty)'}" for g in groups]
lines.append(f"\n{len(groups)} group(s).")
return "\n".join(lines)
@mcp.tool()
def get_group(name: str) -> str:
"""Get a group's members, ready to pass to booking/recommendation tools.
Returns the comma-separated traveler names (use directly as the `passengers`
argument of duffel_book_with_link / duffel_book_flight) plus a roster.
Args:
name: Group name (e.g. 'family')
"""
key = name.lower().strip()
try:
groups = profile_api.get_groups()
except profile_api.ProfileError as e:
return f"Error: {e}"
group = next((g for g in groups if g["name"] == key), None)
if not group:
return f"No group '{key}'. Use list_groups to see saved groups."
members = group["members"]
if not members:
return f"Group '{key}' has no members."
lines = [f"Group '{key}' — passengers: {','.join(members)}", "Roster:"]
for m in members:
try:
t = profile_api.get_traveler(m)
except profile_api.ProfileError:
t = {"error": "lookup failed"}
if "error" in t:
lines.append(f" {m}: (not found — save with save_traveler)")
else:
lines.append(f" {m}: {t['given_name']} {t['family_name']}")
return "\n".join(lines)
@mcp.tool()
def delete_group(name: str) -> str:
"""Delete a saved travel group.
Args:
name: Group name to delete
"""
try:
result = profile_api.delete_group(name)
except profile_api.ProfileError as e:
return f"Error: {e}"
if not result.get("ok"):
return f"No group '{name}' found."
return f"Deleted group '{name}'."
"""Shared helpers for FlightClaw - filters, formatting, data persistence."""
import json
import os
from datetime import datetime, timedelta
from itertools import product
from fli.core import (
build_date_search_segments,
build_flight_segments,
build_time_restrictions,
parse_airlines as fli_parse_airlines,
parse_cabin_class,
parse_emissions,
parse_max_stops,
parse_sort_by,
resolve_airport,
)
from fli.core.parsers import ParseError
from fli.models import (
BagsFilter,
DateSearchFilters,
FlightSearchFilters,
LayoverRestrictions,
PassengerInfo,
PriceLimit,
SortBy,
)
from search_utils import fmt_price
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
TRACKED_FILE = os.path.join(DATA_DIR, "tracked.json")
def load_tracked():
if os.path.exists(TRACKED_FILE):
with open(TRACKED_FILE, "r") as f:
return json.load(f)
return []
def save_tracked(tracked):
os.makedirs(DATA_DIR, exist_ok=True)
with open(TRACKED_FILE, "w") as f:
json.dump(tracked, f, indent=2)
def expand_routes(origins_str, destinations_str, date_str, date_to_str=None):
origins = [o.strip().upper() for o in origins_str.split(",")]
destinations = [d.strip().upper() for d in destinations_str.split(",")]
start = datetime.strptime(date_str, "%Y-%m-%d").date()
end = datetime.strptime(date_to_str, "%Y-%m-%d").date() if date_to_str else start
dates = []
current = start
while current <= end:
dates.append(current.strftime("%Y-%m-%d"))
current += timedelta(days=1)
return list(product(origins, destinations, dates))
def parse_airlines(airlines_str):
"""Parse comma-separated airline codes string into Airline enums."""
if not airlines_str:
return None
codes = [c.strip().upper() for c in airlines_str.split(",")]
try:
return fli_parse_airlines(codes)
except ParseError:
# Silently skip invalid codes for backwards compat
from fli.models import Airline
result = []
for code in codes:
try:
result.append(getattr(Airline, code))
except AttributeError:
pass
return result or None
def _build_departure_window(earliest_departure, latest_departure):
"""Convert individual hour ints to 'HH-HH' window string."""
if earliest_departure is not None and latest_departure is not None:
return f"{earliest_departure}-{latest_departure}"
if earliest_departure is not None:
return f"{earliest_departure}-23"
if latest_departure is not None:
return f"0-{latest_departure}"
return None
def build_filters(
orig_code, dest_code, date, return_date=None, cabin="ECONOMY", stops="ANY",
adults=1, children=0, infants_in_seat=0, infants_on_lap=0,
airlines=None, max_price=None, max_duration=None,
earliest_departure=None, latest_departure=None,
earliest_arrival=None, latest_arrival=None,
max_layover_duration=None, sort_by=None,
exclude_basic_economy=False, emissions="ALL",
checked_bags=0, carry_on=False, show_all_results=True,
):
if not 0 <= checked_bags <= 2:
raise ParseError("checked_bags must be between 0 and 2")
origin = resolve_airport(orig_code)
destination = resolve_airport(dest_code)
dep_window = _build_departure_window(earliest_departure, latest_departure)
arr_window = _build_departure_window(earliest_arrival, latest_arrival)
time_restrictions = build_time_restrictions(dep_window, arr_window)
segments, trip_type = build_flight_segments(
origin=origin,
destination=destination,
departure_date=date,
return_date=return_date,
time_restrictions=time_restrictions,
)
price_limit = PriceLimit(max_price=max_price) if max_price else None
layover = LayoverRestrictions(max_duration=max_layover_duration) if max_layover_duration else None
bags = BagsFilter(checked_bags=checked_bags, carry_on=carry_on) if checked_bags or carry_on else None
return FlightSearchFilters(
trip_type=trip_type,
passenger_info=PassengerInfo(
adults=adults, children=children,
infants_in_seat=infants_in_seat, infants_on_lap=infants_on_lap,
),
flight_segments=segments,
seat_type=parse_cabin_class(cabin),
stops=parse_max_stops(stops),
airlines=parse_airlines(airlines),
price_limit=price_limit,
max_duration=max_duration,
layover_restrictions=layover,
sort_by=parse_sort_by(sort_by) if sort_by else SortBy.BEST,
exclude_basic_economy=exclude_basic_economy,
emissions=parse_emissions(emissions),
bags=bags,
show_all_results=show_all_results,
)
def build_date_filters(
orig_code, dest_code, from_date, to_date,
duration=None, is_round_trip=False,
cabin="ECONOMY", stops="ANY",
adults=1, children=0, infants_in_seat=0, infants_on_lap=0,
airlines=None, max_price=None, max_duration=None,
earliest_departure=None, latest_departure=None,
earliest_arrival=None, latest_arrival=None,
emissions="ALL", checked_bags=0, carry_on=False,
):
if not 0 <= checked_bags <= 2:
raise ParseError("checked_bags must be between 0 and 2")
origin = resolve_airport(orig_code)
destination = resolve_airport(dest_code)
dep_window = _build_departure_window(earliest_departure, latest_departure)
arr_window = _build_departure_window(earliest_arrival, latest_arrival)
time_restrictions = build_time_restrictions(dep_window, arr_window)
segments, trip_type = build_date_search_segments(
origin=origin,
destination=destination,
start_date=from_date,
trip_duration=duration,
is_round_trip=is_round_trip,
time_restrictions=time_restrictions,
)
price_limit = PriceLimit(max_price=max_price) if max_price else None
bags = BagsFilter(checked_bags=checked_bags, carry_on=carry_on) if checked_bags or carry_on else None
return DateSearchFilters(
trip_type=trip_type,
passenger_info=PassengerInfo(
adults=adults, children=children,
infants_in_seat=infants_in_seat, infants_on_lap=infants_on_lap,
),
flight_segments=segments,
seat_type=parse_cabin_class(cabin),
stops=parse_max_stops(stops),
airlines=parse_airlines(airlines),
price_limit=price_limit,
max_duration=max_duration,
emissions=parse_emissions(emissions),
bags=bags,
from_date=from_date,
to_date=to_date,
duration=duration,
)
def format_duration(minutes):
h, m = divmod(minutes, 60)
return f"{h}h {m}m"
def format_flight(flight, currency, index=None):
cur = getattr(flight, "currency", None) or currency
prefix = f"Option {index}: " if index else ""
lines = [f"{prefix}{fmt_price(flight.price, cur)} | {format_duration(flight.duration)} | {flight.stops} stop(s)"]
for leg in flight.legs:
airline_code = leg.airline.name.lstrip("_")
lines.append(f" {airline_code} {leg.flight_number}: {leg.departure_airport.name} {leg.departure_datetime.strftime('%H:%M')} -> {leg.arrival_airport.name} {leg.arrival_datetime.strftime('%H:%M')}")
return "\n".join(lines)
"""Thin wrapper around the link-cli for agent-billing payment flows."""
import json
import subprocess
class LinkError(RuntimeError):
pass
def _run(args, timeout=600):
"""Run link-cli with --format json and return parsed output."""
cmd = ["link-cli", *args, "--format", "json"]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except FileNotFoundError:
raise LinkError("link-cli not installed. Install via `brew install link-cli`.")
except subprocess.TimeoutExpired:
raise LinkError(f"link-cli timed out after {timeout}s.")
if proc.returncode != 0:
msg = proc.stderr.strip() or proc.stdout.strip() or f"exit {proc.returncode}"
raise LinkError(f"link-cli failed: {msg}")
out = proc.stdout.strip()
if not out:
return None
try:
return json.loads(out)
except json.JSONDecodeError:
return out
def is_authenticated():
try:
result = _run(["auth", "status"], timeout=10)
except LinkError:
return False
if isinstance(result, list) and result:
return bool(result[0].get("authenticated"))
if isinstance(result, dict):
return bool(result.get("authenticated"))
return False
def list_payment_methods():
result = _run(["payment-methods", "list"], timeout=30)
return result if isinstance(result, list) else []
def first_payment_method_id():
methods = list_payment_methods()
if not methods:
raise LinkError(
"No payment methods configured. Run `link-cli payment-methods add`."
)
return methods[0].get("id")
def create_card_spend_request(
*,
payment_method_id,
amount_cents,
currency,
merchant_name,
merchant_url,
context,
line_items=None,
total=None,
test=False,
):
"""Create a card-credential spend request and poll until approved/denied/expired.
Returns the spend request dict (status will be one of approved/denied/expired/etc).
"""
if amount_cents <= 0:
raise LinkError("Amount must be > 0 cents.")
if amount_cents > 50000:
raise LinkError(
f"Amount {amount_cents}c exceeds Link's $500 spend-request limit."
)
if len(context) < 100:
raise LinkError("Context must be at least 100 characters.")
args = [
"spend-request", "create",
"--credential-type", "card",
"--payment-method-id", payment_method_id,
"--amount", str(amount_cents),
"--currency", currency.lower(),
"--merchant-name", merchant_name,
"--merchant-url", merchant_url,
"--context", context,
"--request-approval",
]
for li in line_items or []:
args += ["--line-item", li]
for t in total or []:
args += ["--total", t]
if test:
args.append("--test")
return _run(args, timeout=600)
def retrieve_with_card(spend_request_id):
"""Retrieve a spend request including the card credential."""
return _run(
["spend-request", "retrieve", spend_request_id, "--include", "card"],
timeout=30,
)
def extract_card(spend_request):
"""Pull a card credential out of a spend-request payload, if present."""
if not isinstance(spend_request, dict):
return None
card = spend_request.get("card") or spend_request.get("credential", {}).get("card")
if not card:
return None
return {
"number": card.get("number"),
"exp_month": card.get("exp_month") or card.get("expMonth"),
"exp_year": card.get("exp_year") or card.get("expYear"),
"cvc": card.get("cvc") or card.get("cvv"),
"holder_name": card.get("holder_name") or card.get("cardholder_name"),
"brand": card.get("brand"),
"last4": card.get("last4"),
}
"""Passenger profile storage for FlightClaw - save and reuse passenger details."""
import json
import os
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
PASSENGERS_FILE = os.path.join(DATA_DIR, "passengers.json")
def load_passengers():
if os.path.exists(PASSENGERS_FILE):
with open(PASSENGERS_FILE, "r") as f:
return json.load(f)
return []
def save_passengers(passengers):
os.makedirs(DATA_DIR, exist_ok=True)
with open(PASSENGERS_FILE, "w") as f:
json.dump(passengers, f, indent=2)
def register_passenger_tools(mcp):
"""Register passenger profile tools on the given MCP server instance."""
@mcp.tool(annotations={"idempotentHint": True})
def save_passenger(
name: str,
given_name: str,
family_name: str,
born_on: str,
gender: str,
title: str,
email: str,
phone_number: str,
passport_number: str | None = None,
passport_expiry: str | None = None,
passport_nationality: str | None = None,
loyalty_programmes: list | None = None,
) -> str:
"""Save or update a passenger profile for easy booking.
Args:
name: Short key for lookup (e.g. 'jack')
given_name: First/given name as on passport
family_name: Last/family name as on passport
born_on: Date of birth (YYYY-MM-DD)
gender: 'm' or 'f'
title: 'mr', 'mrs', 'ms', 'miss', 'dr'
email: Contact email address
phone_number: Phone number with country code (e.g. +447700000000)
passport_number: Passport number (optional)
passport_expiry: Passport expiry date YYYY-MM-DD (optional)
passport_nationality: Passport nationality ISO code (optional)
loyalty_programmes: List of {airline_iata_code, account_number} (optional)
"""
passengers = load_passengers()
profile = {
"name": name.lower().strip(),
"given_name": given_name,
"family_name": family_name,
"born_on": born_on,
"gender": gender.lower().strip(),
"title": title.lower().strip(),
"email": email,
"phone_number": phone_number,
"passport_number": passport_number,
"passport_expiry": passport_expiry,
"passport_nationality": passport_nationality,
"loyalty_programmes": loyalty_programmes or [],
}
existing = next((i for i, p in enumerate(passengers) if p["name"] == profile["name"]), None)
if existing is not None:
passengers[existing] = profile
save_passengers(passengers)
return f"Updated passenger profile '{profile['name']}'."
else:
passengers.append(profile)
save_passengers(passengers)
return f"Saved new passenger profile '{profile['name']}'."
@mcp.tool(annotations={"readOnlyHint": True})
def list_passengers() -> str:
"""List all saved passenger profiles."""
passengers = load_passengers()
if not passengers:
return "No passenger profiles saved. Use save_passenger to add one."
lines = []
for p in passengers:
line = f"{p['name']}: {p['given_name']} {p['family_name']} ({p['email']})"
if p.get("loyalty_programmes"):
programmes = ", ".join(
f"{lp['airline_iata_code']}: {lp['account_number']}"
for lp in p["loyalty_programmes"]
)
line += f" | Loyalty: {programmes}"
lines.append(line)
lines.append(f"\n{len(passengers)} passenger(s) saved.")
return "\n".join(lines)
@mcp.tool(annotations={"readOnlyHint": True})
def get_passenger(name: str) -> str:
"""Get a passenger profile by name. Returns JSON for use with duffel_book_flight.
Args:
name: The short name key of the passenger (e.g. 'jack')
"""
passengers = load_passengers()
key = name.lower().strip()
profile = next((p for p in passengers if p["name"] == key), None)
if not profile:
return f"No passenger profile found for '{key}'. Use list_passengers to see saved profiles."
return json.dumps(profile, indent=2)
@mcp.tool(annotations={"destructiveHint": True, "idempotentHint": True})
def delete_passenger(name: str) -> str:
"""Delete a saved passenger profile.
Args:
name: The short name key of the passenger to delete (e.g. 'jack')
"""
passengers = load_passengers()
key = name.lower().strip()
before = len(passengers)
passengers = [p for p in passengers if p["name"] != key]
if len(passengers) == before:
return f"No passenger profile found for '{key}'. Use list_passengers to see saved profiles."
save_passengers(passengers)
return f"Deleted passenger profile '{key}'. {len(passengers)} profile(s) remaining."
"""Travel preference tools for FlightClaw — backend (D1) backed.
Preferences are entered once and drive recommend_flights ranking. They also
accumulate 'learnings' over time from post-trip feedback (the learning loop).
"""
import json
import profile_api
_SCALAR_FIELDS = [
"preferred_cabin", "alliance", "seat", "depart_window", "max_stops",
"redeye_ok", "baggage", "meal", "budget_sensitivity",
]
def _fmt_preferences(prefs):
if not prefs:
return "No preferences set yet. Use set_preferences."
lines = ["Travel preferences:"]
if prefs.get("cabin_rules"):
cr = prefs["cabin_rules"]
lines.append(f" Cabin: shorthaul={cr.get('shorthaul', '?')}, longhaul={cr.get('longhaul', '?')}")
elif prefs.get("preferred_cabin"):
lines.append(f" Cabin: {prefs['preferred_cabin']}")
for f in ["preferred_airlines", "avoid_airlines"]:
if prefs.get(f):
lines.append(f" {f.replace('_', ' ').title()}: {', '.join(prefs[f])}")
for f in ["alliance", "seat", "depart_window", "max_stops", "baggage", "meal", "budget_sensitivity"]:
if prefs.get(f) is not None:
lines.append(f" {f.replace('_', ' ').title()}: {prefs[f]}")
if "redeye_ok" in prefs:
lines.append(f" Red-eye OK: {prefs['redeye_ok']}")
if prefs.get("notes"):
lines.append(" Notes:")
lines.extend(f" - {n}" for n in prefs["notes"])
if prefs.get("learnings"):
lines.append(" Learnings (from past trips):")
lines.extend(f" - {n}" for n in prefs["learnings"])
return "\n".join(lines)
def register_preferences_tools(mcp):
"""Register travel-preference tools on the MCP server."""
@mcp.tool()
def set_preferences(
shorthaul_cabin: str | None = None,
longhaul_cabin: str | None = None,
preferred_airlines: str | None = None,
avoid_airlines: str | None = None,
alliance: str | None = None,
seat: str | None = None,
depart_window: str | None = None,
max_stops: str | None = None,
redeye_ok: bool | None = None,
baggage: str | None = None,
meal: str | None = None,
budget_sensitivity: str | None = None,
notes: str | None = None,
) -> str:
"""Set the user's travel preferences (one-time onboarding; patch any subset later).
Only provided fields are updated. Notes are appended (kept as history).
Args:
shorthaul_cabin: Cabin for short-haul flights (ECONOMY/PREMIUM_ECONOMY/BUSINESS/FIRST)
longhaul_cabin: Cabin for long-haul flights (e.g. BUSINESS)
preferred_airlines: Comma-separated IATA codes preferred (e.g. BA,AA)
avoid_airlines: Comma-separated IATA codes to avoid (e.g. NK,F9)
alliance: Preferred alliance (oneworld, staralliance, skyteam)
seat: Seat preference (aisle, window, no preference)
depart_window: Preferred departure window 'HH-HH' (e.g. '8-20') or word (morning/afternoon/evening)
max_stops: Max stops tolerated (NON_STOP, ONE_STOP, ANY)
redeye_ok: Whether overnight/red-eye flights are acceptable
baggage: Baggage preference (e.g. 'carry-on only', 'one checked bag')
meal: Meal preference (e.g. 'vegetarian')
budget_sensitivity: cheapest | balanced | comfort (drives ranking weight)
notes: A free-form preference note to append
"""
patch = {}
cabin_rules = {}
if shorthaul_cabin:
cabin_rules["shorthaul"] = shorthaul_cabin.upper()
if longhaul_cabin:
cabin_rules["longhaul"] = longhaul_cabin.upper()
if cabin_rules:
patch["cabin_rules"] = cabin_rules
if preferred_airlines is not None:
patch["preferred_airlines"] = [a.strip().upper() for a in preferred_airlines.split(",") if a.strip()]
if avoid_airlines is not None:
patch["avoid_airlines"] = [a.strip().upper() for a in avoid_airlines.split(",") if a.strip()]
if alliance is not None:
patch["alliance"] = alliance.lower().strip()
if seat is not None:
patch["seat"] = seat.lower().strip()
if depart_window is not None:
patch["depart_window"] = depart_window.strip()
if max_stops is not None:
patch["max_stops"] = max_stops.upper().strip()
if redeye_ok is not None:
patch["redeye_ok"] = redeye_ok
if baggage is not None:
patch["baggage"] = baggage
if meal is not None:
patch["meal"] = meal
if budget_sensitivity is not None:
patch["budget_sensitivity"] = budget_sensitivity.lower().strip()
if notes:
patch["notes"] = [notes]
if not patch:
return "No preferences provided."
try:
result = profile_api.patch_preferences(patch)
except profile_api.ProfileError as e:
return f"Error: {e}"
return "Preferences updated.\n\n" + _fmt_preferences(result)
@mcp.tool()
def get_preferences() -> str:
"""Show the user's saved travel preferences and accumulated learnings."""
try:
prefs = profile_api.get_preferences()
except profile_api.ProfileError as e:
return f"Error: {e}"
return _fmt_preferences(prefs)
@mcp.tool()
def update_preferences(patch_json: str) -> str:
"""Patch preferences with a raw JSON object (advanced; merges into existing).
'notes' and 'learnings' arrays are appended; other keys overwrite.
Args:
patch_json: JSON object, e.g. {"seat":"window","preferred_airlines":["BA"]}
"""
try:
patch = json.loads(patch_json)
except json.JSONDecodeError as e:
return f"Invalid JSON: {e}"
if not isinstance(patch, dict):
return "patch_json must be a JSON object."
try:
result = profile_api.patch_preferences(patch)
except profile_api.ProfileError as e:
return f"Error: {e}"
return "Preferences updated.\n\n" + _fmt_preferences(result)
"""HTTP client for the flightclaw-api profile store (D1-backed).
Reuses the same FLIGHTCLAW_API_URL / FLIGHTCLAW_API_KEY config as duffel_api.py.
All profile data (travelers, preferences, cards/points, groups, trips) lives
server-side so it is shared across sessions and devices.
"""
import json
import os
import urllib.error
import urllib.parse
import urllib.request
class ProfileError(RuntimeError):
pass
# Tenant the client operates on. None → server default ('default', the single-user
# path). Set via set_tenant() or the FLIGHTCLAW_TENANT env var to scope to another
# tenant (e.g. when driving a specific user from a script).
_TENANT = os.environ.get("FLIGHTCLAW_TENANT") or None
def set_tenant(tenant):
"""Scope all subsequent calls to a tenant (None = server default)."""
global _TENANT
_TENANT = tenant or None
def is_configured():
return bool(
os.environ.get("FLIGHTCLAW_API_URL")
and os.environ.get("FLIGHTCLAW_API_KEY")
)
def _request(method, path, body=None, params=None):
base_url = os.environ.get("FLIGHTCLAW_API_URL", "").rstrip("/")
api_key = os.environ.get("FLIGHTCLAW_API_KEY", "")
if not base_url or not api_key:
raise ProfileError(
"FLIGHTCLAW_API_URL and FLIGHTCLAW_API_KEY must be set "
"(they point to your private flightclaw-api Worker)."
)
url = f"{base_url}{path}"
if params:
qs = "&".join(
f"{k}={urllib.parse.quote(str(v))}" for k, v in params.items() if v is not None
)
if qs:
url = f"{url}?{qs}"
data = json.dumps(body).encode() if body is not None else None
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"User-Agent": "flightclaw/1.0",
}
if _TENANT:
headers["X-Tenant"] = _TENANT
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
error_body = e.read().decode()
try:
msg = json.loads(error_body).get("error", error_body)
except json.JSONDecodeError:
msg = error_body
raise ProfileError(f"API error ({e.code}): {msg}")
except urllib.error.URLError as e:
raise ProfileError(f"Could not reach flightclaw-api: {e}")
# --- Whole profile ---
def get_profile():
return _request("GET", "/profile")
# --- Travelers ---
def list_travelers():
return _request("GET", "/profile/travelers").get("travelers", [])
def get_traveler(name):
return _request("GET", "/profile/traveler", params={"name": name})
def upsert_traveler(traveler):
return _request("POST", "/profile/traveler", traveler)
def delete_traveler(name):
return _request("POST", "/profile/traveler/delete", {"name": name})
# --- Me ---
def get_me():
return _request("GET", "/profile/me")
def set_me(me_traveler=None, account_email=None, home_airports=None):
body = {}
if me_traveler is not None:
body["me_traveler"] = me_traveler
if account_email is not None:
body["account_email"] = account_email
if home_airports is not None:
body["home_airports"] = home_airports
return _request("POST", "/profile/me", body)
# --- Preferences ---
def get_preferences():
return _request("GET", "/profile/preferences")
def patch_preferences(patch):
return _request("POST", "/profile/preferences", patch)
# --- Cards & points ---
def get_cards():
return _request("GET", "/profile/cards")
def upsert_card(card):
return _request("POST", "/profile/card", card)
def delete_card(card_id):
return _request("POST", "/profile/card/delete", {"id": card_id})
def set_points(program, balance):
return _request("POST", "/profile/points", {"program": program, "balance": balance})
# --- Groups ---
def get_groups():
return _request("GET", "/profile/groups").get("groups", [])
def upsert_group(name, members):
return _request("POST", "/profile/group", {"name": name, "members": members})
def delete_group(name):
return _request("POST", "/profile/group/delete", {"name": name})
# --- Trips ---
def list_trips():
return _request("GET", "/profile/trips").get("trips", [])
def get_trip(trip_id):
return _request("GET", "/profile/trip", params={"id": trip_id})
def upsert_trip(trip):
return _request("POST", "/profile/trip", trip)
def trips_followup():
return _request("GET", "/profile/trips/followup").get("trips", [])
def trip_feedback(trip_id, feedback, learnings=None):
body = {"id": trip_id, "feedback": feedback}
if learnings:
body["learnings"] = learnings
return _request("POST", "/profile/trip/feedback", body)
flightclaw
Track flight prices from Google Flights. Search routes, monitor prices over time, and get alerts when prices drop.
MCP Server
FlightClaw runs as a local MCP server, giving any MCP-compatible client (Claude Code, Claude Desktop, etc.) access to flight search and tracking tools.
Setup
# Install dependencies
pip install flights "mcp[cli]"
# Add to Claude Code
claude mcp add flightclaw -- python3 /path/to/flightclaw/server.pyOr in Claude Desktop, add to claude_desktop_config.json:
{
"mcpServers": {
"flightclaw": {
"command": "python3",
"args": ["/path/to/flightclaw/server.py"]
}
}
}Tools
| Tool | Description |
|---|---|
search_flights | Search Google Flights for prices on a route |
search_dates | Find cheapest dates to fly across a date range (calendar view) |
track_flight | Add a route to price tracking with optional target price |
check_prices | Check all tracked flights for price changes and alerts |
list_tracked | List all tracked flights with price history |
remove_tracked | Remove a route from tracking |
Search filters
All search tools support:
- Passengers - adults, children, infants (in seat or on lap)
- Airlines - filter to specific carriers (e.g.
BA,AA,DL) - Price limit - max price in USD
- Duration - max total flight time in minutes
- Times - earliest/latest departure and arrival hours
- Layovers - max layover duration in minutes
- Sorting - by BEST, CHEAPEST, DEPARTURE, ARRIVAL, or DURATION
- Multi-airport - comma-separated codes (e.g.
LHR,MAN) - Date ranges -
date_tofor searching each day in a range
Example prompts
- "Search flights from LHR to JFK on 2025-08-01 in business class"
- "Find nonstop BA or VS flights LHR to JFK departing after 8am"
- "What are the cheapest dates to fly LHR to JFK in July?"
- "Search for 2 adults and 1 child, LHR to JFK, under $500"
- "Track LHR to SFO on 2025-07-01 with a target price of $400"
- "Check my tracked flights for price drops"
CLI Scripts
The original CLI scripts are still available in scripts/:
# Search flights
python scripts/search-flights.py LHR JFK 2025-07-01 --cabin BUSINESS
# Multiple airports and date ranges
python scripts/search-flights.py LHR,MAN JFK,EWR 2025-07-01 --date-to 2025-07-05
# Track a route
python scripts/track-flight.py LHR JFK 2025-07-01 --target-price 400
# Check for price drops (good for cron)
python scripts/check-prices.py --threshold 5
# List tracked flights
python scripts/list-tracked.pyHow it works
- Queries Google Flights via the
flilibrary - Prices returned in user's local currency (auto-detected from IP)
- Price history persists in
data/tracked.json - Supports one-way and round trips, all cabin classes (economy to first)
- Filter by airline, price, duration, departure/arrival times, layover duration
- Multi-airport and date-range searches expand into all combinations
- Date search finds the cheapest day to fly across a range
Install (OpenClaw)
npx skills add jackculpan/flightclaw"""Preference-aware flight recommendation for FlightClaw.
Pulls the user's saved travel preferences from the backend, runs the existing
Google Flights search, then scores and ranks results against those preferences
and returns the few options that best fit *this* user — with reasons.
"""
import urllib.parse
from datetime import datetime
import duffel_api
import fulfillment
import profile_api
from helpers import build_filters, format_duration, format_flight
from search_utils import fmt_price, search_with_currency
BOOKING_BASE_URL = "https://www.google.com/travel/flights/booking?tfs="
# Budget sensitivity → (price, duration, stops) score weights.
_WEIGHTS = {
"cheapest": (0.7, 0.2, 0.1),
"balanced": (0.45, 0.3, 0.25),
"comfort": (0.2, 0.4, 0.4),
}
_LONGHAUL_MINUTES = 360 # 6h+ counts as long-haul for cabin rules
def _parse_window(window):
"""Return (earliest_hour, latest_hour) from 'HH-HH' or a word, else (None, None)."""
if not window:
return None, None
words = {"morning": (5, 12), "afternoon": (12, 18), "evening": (17, 23), "night": (20, 23)}
w = window.lower().strip()
if w in words:
return words[w]
if "-" in w:
try:
a, b = w.split("-", 1)
return int(a), int(b)
except ValueError:
return None, None
return None, None
def _flatten(result):
"""Normalize a search result (one-way flight or (outbound, return) tuple)."""
if isinstance(result, tuple) and len(result) == 2 and hasattr(result[0], "price"):
out, ret = result
flights = [out, ret]
else:
flights = [result[0] if isinstance(result, tuple) else result]
# Skip results missing price/duration (some Google Flights rows are partial).
if any(f.price is None or f.duration is None for f in flights):
return None
price = sum(f.price for f in flights)
duration = sum(f.duration for f in flights)
stops = sum((f.stops or 0) for f in flights)
legs = [leg for f in flights for leg in f.legs]
return {"flights": flights, "price": price, "duration": duration, "stops": stops, "legs": legs}
def _airline_codes(legs):
return {getattr(leg.airline, "name", "") for leg in legs}
def _is_redeye(legs):
for leg in legs:
dep = leg.departure_datetime
arr = leg.arrival_datetime
# Overnight if it crosses midnight or departs late and arrives early.
if arr.date() > dep.date():
return True
if dep.hour >= 22 or arr.hour <= 6:
return True
return False
def _resolve_cabin(prefs, cabin_arg, origin, destination, date, return_date, base_kwargs):
"""Pick cabin: explicit arg > preferred_cabin > cabin_rules (probe haul) > ECONOMY."""
if cabin_arg:
return cabin_arg.upper(), None
if prefs.get("preferred_cabin"):
return prefs["preferred_cabin"].upper(), None
rules = prefs.get("cabin_rules")
if not rules:
return "ECONOMY", None
# Probe in economy to estimate haul length.
try:
filters = build_filters(origin, destination, date, return_date, "ECONOMY", **base_kwargs)
probe, _cur = search_with_currency(filters, top_n=1)
except Exception:
probe = None
haul = "shorthaul"
if probe:
info = _flatten(probe[0][0])
if info and info["duration"] >= _LONGHAUL_MINUTES:
haul = "longhaul"
cabin = rules.get(haul) or rules.get("shorthaul") or rules.get("longhaul") or "ECONOMY"
return cabin.upper(), haul
def _norm_fnum(n):
"""Normalize a flight number to digits only for cross-source comparison."""
return "".join(c for c in str(n) if c.isdigit())
def _duffel_index(origin, destination, date, return_date, cabin, adults, children, infants):
"""One Duffel lookup for the same route → what's actually bookable there.
Returns {segments:set[(carrier,fnum)], owners:dict[code]->(price,offer_id,currency),
test_mode:bool} or None if Duffel isn't configured / the search fails.
"""
if not duffel_api.is_configured():
return None
try:
data = duffel_api.search(
origin, destination, date, return_date, cabin,
adults, children, infants, 1,
)
except Exception:
return None
offers = data.get("offers", [])
segments = set()
owners = {}
test_mode = False
for o in offers:
owner = o.get("owner", {})
code = owner.get("iata_code")
if owner.get("name") == "Duffel Airways" or code == "ZZ":
test_mode = True
try:
price = float(o.get("total_amount", "0"))
except (TypeError, ValueError):
price = 0.0
cur = o.get("total_currency", "")
if code and (code not in owners or price < owners[code][0]):
owners[code] = (price, o.get("id"), cur)
for s in o.get("slices", []):
for seg in s.get("segments", []):
mc = seg.get("marketing_carrier", {})
c = mc.get("iata_code")
fn = _norm_fnum(seg.get("marketing_carrier_flight_number", ""))
if c and fn:
segments.add((c, fn))
return {"segments": segments, "owners": owners, "test_mode": test_mode}
def _bookability(option, dindex, kindex, origin, destination, date):
"""Route one fli option to the best fulfilment path. Returns (kind, detail).
Precedence: exact flight on Duffel (in-app book+pay) > exact flight on Kiwi >
same airline available on Duffel > deep-link hand-off (never a dead end).
"""
option_segs = [
(leg.airline.name, _norm_fnum(leg.flight_number))
for f in option["flights"] for leg in f.legs
]
primary = option["flights"][0].legs[0].airline.name
if dindex and option_segs and all(seg in dindex["segments"] for seg in option_segs):
own = dindex["owners"].get(primary)
ptr = f" (Duffel {own[2]} {own[0]:.0f}, offer {own[1]})" if own else ""
return "duffel", f"✅ bookable in-app via Duffel{ptr}"
if kindex and option_segs and all(seg in kindex["segments"] for seg in option_segs):
return "kiwi", "✅ bookable via Kiwi (LCC/OTA coverage)"
if dindex and primary in dindex["owners"]:
own = dindex["owners"][primary]
return "same_airline", (
f"≈ {primary} on Duffel, different flight/time (from {own[2]} {own[0]:.0f}) "
f"— use duffel_search_flights"
)
link = fulfillment.book_direct_link(origin, destination, date, option.get("token"))
return "direct", f"↗ {primary} not in-app — book direct: {link}"
def register_recommend_tools(mcp):
"""Register the preference-aware recommendation tool."""
@mcp.tool()
def recommend_flights(
origin: str,
destination: str,
date: str,
return_date: str | None = None,
cabin: str | None = None,
adults: int = 1,
children: int = 0,
infants_in_seat: int = 0,
infants_on_lap: int = 0,
candidates: int = 12,
) -> str:
"""Recommend the best flights for THIS user by ranking search results against saved preferences.
Loads the user's travel preferences (set via set_preferences) and uses them to
choose cabin, filter out avoided airlines, and score each option on price,
duration, stops, preferred airlines, departure window and red-eye tolerance.
Returns the top 3 with a short "why this fits you" for each.
Args:
origin: Origin IATA code (e.g. LHR)
destination: Destination IATA code (e.g. JFK)
date: Departure date (YYYY-MM-DD)
return_date: Return date for round trips (YYYY-MM-DD)
cabin: Override cabin (else taken from preferences). ECONOMY/PREMIUM_ECONOMY/BUSINESS/FIRST
adults: Adults (default 1)
children: Children (default 0)
infants_in_seat: Infants in seat (default 0)
infants_on_lap: Infants on lap (default 0)
candidates: How many raw results to consider before ranking (default 12)
"""
origin = origin.strip().upper()
destination = destination.strip().upper()
prefs = {}
prefs_note = ""
if profile_api.is_configured():
try:
prefs = profile_api.get_preferences() or {}
except profile_api.ProfileError as e:
prefs_note = f"(preferences unavailable: {e})"
else:
prefs_note = "(backend not configured — ranking on price/duration/stops only)"
# Preference-derived search params.
max_stops_pref = prefs.get("max_stops")
earliest, latest = _parse_window(prefs.get("depart_window"))
base_kwargs = dict(
adults=adults, children=children,
infants_in_seat=infants_in_seat, infants_on_lap=infants_on_lap,
stops=max_stops_pref or "ANY",
earliest_departure=earliest, latest_departure=latest,
)
resolved_cabin, haul = _resolve_cabin(
prefs, cabin, origin, destination, date, return_date, base_kwargs
)
try:
filters = build_filters(
origin, destination, date, return_date, resolved_cabin, **base_kwargs
)
except Exception as e:
return f"Could not build search: {e}"
results, currency = search_with_currency(filters, top_n=candidates)
if not results:
return f"No flights found for {origin} -> {destination} on {date}."
avoid = {a.upper() for a in prefs.get("avoid_airlines", [])}
preferred = {a.upper() for a in prefs.get("preferred_airlines", [])}
weights = _WEIGHTS.get((prefs.get("budget_sensitivity") or "balanced").lower(), _WEIGHTS["balanced"])
redeye_ok = prefs.get("redeye_ok", True)
scored, dropped = [], 0
for result, token in results:
info = _flatten(result)
if not info:
continue
codes = _airline_codes(info["legs"])
if avoid and codes & avoid:
dropped += 1
continue
info["codes"] = codes
info["token"] = token
scored.append(info)
if not scored:
return (
f"All {len(results)} options were operated by airlines you avoid "
f"({', '.join(sorted(avoid))}). Loosen avoid_airlines to see them."
)
prices = [s["price"] for s in scored]
durs = [s["duration"] for s in scored]
stops = [s["stops"] for s in scored]
p_lo, p_hi = min(prices), max(prices)
d_lo, d_hi = min(durs), max(durs)
s_lo, s_hi = min(stops), max(stops)
def norm(v, lo, hi):
return 0.0 if hi == lo else (v - lo) / (hi - lo)
wp, wd, ws = weights
for s in scored:
score = (
wp * (1 - norm(s["price"], p_lo, p_hi))
+ wd * (1 - norm(s["duration"], d_lo, d_hi))
+ ws * (1 - norm(s["stops"], s_lo, s_hi))
)
reasons = []
if s["price"] == p_lo:
reasons.append("cheapest option")
if s["duration"] == d_lo:
reasons.append("fastest")
if s["stops"] == s_lo and s_lo == 0:
reasons.append("non-stop")
if preferred and s["codes"] & preferred:
score += 0.15
reasons.append(f"on preferred airline ({', '.join(sorted(s['codes'] & preferred))})")
dep_hour = s["legs"][0].departure_datetime.hour
if earliest is not None and latest is not None and earliest <= dep_hour <= latest:
score += 0.1
reasons.append("departs in your preferred window")
if not redeye_ok and _is_redeye(s["legs"]):
score -= 0.15
reasons.append("red-eye (you usually avoid)")
s["score"] = score
s["reasons"] = reasons
scored.sort(key=lambda s: s["score"], reverse=True)
top = scored[:3]
# Reconcile the picks against what's actually bookable: Duffel (in-app)
# first, then Kiwi (LCC/OTA coverage), then a deep-link hand-off.
dindex = _duffel_index(
origin, destination, date, return_date, resolved_cabin,
adults, children, infants_in_seat + infants_on_lap,
)
kindex = fulfillment.kiwi_index(origin, destination, date, return_date)
header = f"Recommended for you: {origin} -> {destination} on {date}"
if return_date:
header += f" (return {return_date})"
header += f" — {resolved_cabin}"
if haul:
header += f" [{haul} per your cabin rule]"
lines = [header]
if prefs_note:
lines.append(prefs_note)
lines.append("")
labels = ["Best for you", "Runner-up", "Also worth it"]
for i, s in enumerate(top):
label = labels[i] if i < len(labels) else f"Option {i+1}"
lines.append(f"{label}: {fmt_price(s['price'], currency)} | "
f"{format_duration(s['duration'])} | {s['stops']} stop(s)")
for f in s["flights"]:
for leg in f.legs:
lines.append(
f" {leg.airline.name} {leg.flight_number}: "
f"{leg.departure_airport.name} {leg.departure_datetime.strftime('%H:%M')} -> "
f"{leg.arrival_airport.name} {leg.arrival_datetime.strftime('%H:%M')}"
)
if s["reasons"]:
lines.append(f" Why: {'; '.join(s['reasons'])}")
_kind, detail = _bookability(s, dindex, kindex, origin, destination, date)
if detail:
lines.append(f" {detail}")
lines.append("")
if dropped:
lines.append(f"({dropped} option(s) hidden — operated by airlines you avoid.)")
if dindex and dindex.get("test_mode"):
lines.append("⚠️ Duffel is in TEST/sandbox mode — bookings won't be real until a live token is set.")
if dindex is None:
lines.append("(Duffel bookability check skipped — Duffel not configured.)")
if not fulfillment.kiwi_configured():
lines.append("(Kiwi coverage off — set KIWI_API_KEY to extend bookable airlines.)")
lines.append(
"To book a ✅ Duffel option, use duffel_search_flights then duffel_book_with_link."
)
return "\n".join(lines)
#!/usr/bin/env python3
"""Check all tracked flights for price changes. Designed for cron/scheduled use."""
import argparse
import json
import os
import sys
from datetime import datetime, timezone
from fli.models import (
Airport,
FlightSearchFilters,
FlightSegment,
MaxStops,
PassengerInfo,
SeatType,
TripType,
)
from fli.models.airline import Airline
from search_utils import fmt_price, search_with_currency
SEAT_MAP = {
"ECONOMY": SeatType.ECONOMY,
"PREMIUM_ECONOMY": SeatType.PREMIUM_ECONOMY,
"BUSINESS": SeatType.BUSINESS,
"FIRST": SeatType.FIRST,
}
STOPS_MAP = {
"ANY": MaxStops.ANY,
"NON_STOP": MaxStops.NON_STOP,
"ONE_STOP": MaxStops.ONE_STOP_OR_FEWER,
"TWO_STOPS": MaxStops.TWO_OR_FEWER_STOPS,
}
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
TRACKED_FILE = os.path.join(DATA_DIR, "tracked.json")
def load_tracked():
if not os.path.exists(TRACKED_FILE):
return []
with open(TRACKED_FILE, "r") as f:
return json.load(f)
def save_tracked(tracked):
with open(TRACKED_FILE, "w") as f:
json.dump(tracked, f, indent=2)
def parse_args():
parser = argparse.ArgumentParser(description="Check tracked flight prices")
parser.add_argument("--threshold", type=float, default=10, help="Percentage drop to alert on (default: 10)")
return parser.parse_args()
def check_route(entry):
origin = Airport[entry["origin"]]
destination = Airport[entry["destination"]]
segments = [FlightSegment(departure_airport=[[origin, 0]], arrival_airport=[[destination, 0]], travel_date=entry["date"])]
trip_type = TripType.ONE_WAY
if entry.get("return_date"):
segments.append(FlightSegment(departure_airport=[[destination, 0]], arrival_airport=[[origin, 0]], travel_date=entry["return_date"]))
trip_type = TripType.ROUND_TRIP
# Build airline filter if preferred_airline is specified
airlines = None
preferred_airline = entry.get("preferred_airline")
if preferred_airline:
try:
airlines = [Airline[preferred_airline]]
except KeyError:
print(f" Warning: unknown airline code '{preferred_airline}', ignoring filter", file=sys.stderr)
filters = FlightSearchFilters(
trip_type=trip_type,
passenger_info=PassengerInfo(adults=1),
flight_segments=segments,
seat_type=SEAT_MAP.get(entry.get("cabin", "ECONOMY"), SeatType.ECONOMY),
stops=STOPS_MAP.get(entry.get("stops", "ANY"), MaxStops.ANY),
airlines=airlines,
)
target_out = entry.get("outbound_flight_number")
target_ret = entry.get("return_flight_number")
exclude_basic = entry.get("exclude_basic", False)
# Flight-number matching happens after the search, so a specific pair could
# sit beyond the default window. Search a wider set when tracking exact flights.
top_n = 50 if (target_out or target_ret) else 10
results, currency = search_with_currency(filters, top_n=top_n, exclude_basic_economy=exclude_basic)
if not results:
return None, None, None, currency
# Optional time-window filters (format: "HH:MM" 24h, e.g. "08:00")
depart_after = entry.get("depart_after")
depart_before = entry.get("depart_before")
return_after = entry.get("return_after")
return_before = entry.get("return_before")
flights = []
for r in results:
# search_with_currency returns (flight_data, booking_token) tuples
flight_data = r[0] if isinstance(r, tuple) else r
# For round-trip: flight_data is (outbound, ret); for one-way: single Flight
if isinstance(flight_data, tuple):
outbound, ret = flight_data
else:
outbound, ret = flight_data, None
if not outbound.legs:
continue
leg = outbound.legs[0]
ret_leg = ret.legs[0] if ret and ret.legs else None
# Filter by specific flight numbers if set
if target_out and leg.flight_number != target_out:
continue
# Reject results lacking the tracked return leg (e.g. a one-way result
# mixed in) so specific-return tracking never matches a missing leg.
if target_ret and (not ret_leg or ret_leg.flight_number != target_ret):
continue
# Filter by time windows if no specific flight numbers
if not target_out and depart_after and leg.departure_datetime:
h, m = map(int, depart_after.split(":"))
if leg.departure_datetime.hour * 60 + leg.departure_datetime.minute < h * 60 + m:
continue
if not target_out and depart_before and leg.departure_datetime:
h, m = map(int, depart_before.split(":"))
if leg.departure_datetime.hour * 60 + leg.departure_datetime.minute > h * 60 + m:
continue
if not target_ret and return_after and ret_leg and ret_leg.departure_datetime:
h, m = map(int, return_after.split(":"))
if ret_leg.departure_datetime.hour * 60 + ret_leg.departure_datetime.minute < h * 60 + m:
continue
if not target_ret and return_before and ret_leg and ret_leg.departure_datetime:
h, m = map(int, return_before.split(":"))
if ret_leg.departure_datetime.hour * 60 + ret_leg.departure_datetime.minute > h * 60 + m:
continue
# For round-trip results, use ret.price (the price of the specific
# outbound+return combination from the second API call) instead of
# outbound.price (which is the cheapest round-trip for that outbound,
# potentially with a different return flight). This matters when
# filtering for a specific return flight number that isn't the cheapest
# return option — outbound.price would understate the actual cost.
price = round(ret.price if ret is not None else outbound.price, 2)
flights.append({
"price": price,
"airline": leg.airline.name,
"flight_number": leg.flight_number,
"departs": leg.departure_datetime.strftime("%I:%M %p") if leg.departure_datetime else "?",
"arrives": leg.arrival_datetime.strftime("%I:%M %p") if leg.arrival_datetime else "?",
"return_flight_number": ret_leg.flight_number if ret_leg else None,
"return_airline": ret_leg.airline.name if ret_leg else None,
"return_departs": ret_leg.departure_datetime.strftime("%I:%M %p") if ret_leg and ret_leg.departure_datetime else None,
"return_arrives": ret_leg.arrival_datetime.strftime("%I:%M %p") if ret_leg and ret_leg.arrival_datetime else None,
})
if not flights:
carrier = (preferred_airline + " ") if preferred_airline else "flight "
not_found_msg = f"{carrier}{target_out}" if target_out else "any flight"
if target_ret:
not_found_msg += f" / {carrier}{target_ret}"
print(f" ⚠️ No results for {not_found_msg} — flight may not be operating or sold out")
return None, None, None, currency
best = min(flights, key=lambda f: f["price"])
return best["price"], best["airline"], flights, currency
def main():
args = parse_args()
tracked = load_tracked()
if not tracked:
print("No flights being tracked. Use track-flight.py to add routes.")
sys.exit(0)
now = datetime.now(timezone.utc).isoformat()
alerts = []
for entry in tracked:
route = entry.get("label") or f"{entry['origin']} -> {entry['destination']} on {entry['date']}"
currency = entry.get("currency", "USD")
print(f"Checking {route}...")
try:
price, airline, all_flights, detected_currency = check_route(entry)
currency = detected_currency or currency
except Exception as e:
print(f" Error: {e}", file=sys.stderr)
continue
if price is None:
print(f" No results found")
continue
entry["price_history"].append({
"timestamp": now,
"best_price": price,
"airline": airline,
})
entry["currency"] = currency
# Print all flight options, deduped by outbound flight (show best price per outbound)
if all_flights:
has_return = any(f.get("return_flight_number") for f in all_flights)
# Group by outbound carrier + flight number (so DL123 and UA123 stay
# distinct), keeping the lowest price per outbound.
seen = {}
for f in all_flights:
key = (f["airline"], f["flight_number"])
if key not in seen or f["price"] < seen[key]["price"]:
seen[key] = f
for f in sorted(seen.values(), key=lambda x: (x["price"], x["departs"])):
marker = "★" if f["price"] == price else " "
fn = f"{f['airline']} {f['flight_number']}"
if has_return and f.get("return_flight_number"):
ret_fn = f"{f.get('return_airline') or ''} {f['return_flight_number']}".strip()
print(f" {marker} {fmt_price(f['price'], currency):>8} {fn} {f['departs']}→{f['arrives']} / ret {ret_fn} {f['return_departs']}→{f['return_arrives']}")
else:
print(f" {marker} {fmt_price(f['price'], currency):>8} {fn} {f['departs']}→{f['arrives']}")
prev_prices = [p["best_price"] for p in entry["price_history"][:-1] if p["best_price"]]
if prev_prices:
last_price = prev_prices[-1]
change = price - last_price
pct = (change / last_price) * 100
if change < 0:
print(f" → Best: {fmt_price(price, currency)} - DOWN {fmt_price(abs(change), currency)} ({abs(pct):.1f}%)")
if abs(pct) >= args.threshold:
alerts.append(f"PRICE DROP: {route} is now {fmt_price(price, currency)} (was {fmt_price(last_price, currency)}, down {abs(pct):.1f}%)")
elif change > 0:
print(f" → Best: {fmt_price(price, currency)} - up {fmt_price(change, currency)} ({pct:.1f}%)")
else:
print(f" → Best: {fmt_price(price, currency)} - no change")
else:
print(f" → Best: {fmt_price(price, currency)} - first price recorded")
if entry.get("target_price") and price <= entry["target_price"]:
alerts.append(f"TARGET REACHED: {route} is {fmt_price(price, currency)} (target: {fmt_price(entry['target_price'], currency)})")
save_tracked(tracked)
if alerts:
print(f"\n{'='*60}")
print("ALERTS:")
for alert in alerts:
print(f" {alert}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""List all tracked flights with price history."""
import json
import os
import sys
from search_utils import fmt_price
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
TRACKED_FILE = os.path.join(DATA_DIR, "tracked.json")
def main():
if not os.path.exists(TRACKED_FILE):
print("No flights being tracked. Use track-flight.py to add routes.")
sys.exit(0)
with open(TRACKED_FILE, "r") as f:
tracked = json.load(f)
if not tracked:
print("No flights being tracked. Use track-flight.py to add routes.")
sys.exit(0)
for entry in tracked:
route = f"{entry['origin']} -> {entry['destination']}"
cabin = entry.get("cabin", "ECONOMY")
currency = entry.get("currency", "USD")
print(f"\n{'='*60}")
print(f"{route} | {entry['date']} | {cabin} | {currency}")
if entry.get("return_date"):
print(f" Return: {entry['return_date']}")
if entry.get("target_price"):
print(f" Target: {fmt_price(entry['target_price'], currency)}")
history = entry.get("price_history", [])
if not history:
print(" No price data yet")
continue
first_price = next((p["best_price"] for p in history if p["best_price"]), None)
last = history[-1]
current_price = last.get("best_price")
if current_price and first_price:
change = current_price - first_price
pct = (change / first_price) * 100
direction = "down" if change < 0 else "up"
print(f" Current: {fmt_price(current_price, currency)} ({last.get('airline', '?')})")
print(f" Original: {fmt_price(first_price, currency)} | {direction} {fmt_price(abs(change), currency)} ({abs(pct):.1f}%)")
elif current_price:
print(f" Current: {fmt_price(current_price, currency)} ({last.get('airline', '?')})")
print(f" Checks: {len(history)} | Since: {entry.get('added_at', '?')[:10]}")
print(f"\n{len(tracked)} flight(s) tracked.")
if __name__ == "__main__":
main()
"""Search wrapper that extracts currency from Google Flights API response."""
import base64
import json
import re
import urllib.parse
from copy import deepcopy
from fli.models import FlightSearchFilters
from fli.models.google_flights.base import TripType
from fli.search import SearchFlights
from fli.search.client import get_client
BASE_URL = "https://www.google.com/_/FlightsFrontendUi/data/travel.frontend.flights.FlightsFrontendService/GetShoppingResults"
# Google Flights API ticket type constants.
# [1][28]=1 → Any (includes Basic Economy, default)
# [1][28]=2 → Standard (excludes Basic Economy)
_TICKET_TYPE_ANY = 1
_TICKET_TYPE_STANDARD = 2 # excludes Basic Economy
def _encode_with_ticket_type(filters: FlightSearchFilters, ticket_type: int) -> str:
"""Encode filters with an explicit ticket type parameter.
The fli library doesn't expose ticket type natively. We reverse-engineered
that position [1][28] in the formatted payload controls this:
1 = Any (includes Basic Economy)
2 = Standard (excludes Basic Economy)
This is intentionally defensive and should be updated if
FlightSearchFilters.format() changes. Tested with fli 0.8.0.
"""
formatted = filters.format()
if not isinstance(formatted, list) or len(formatted) < 2 or not isinstance(formatted[1], list):
raise ValueError("Unexpected payload structure from FlightSearchFilters.format()")
while len(formatted[1]) <= 28:
formatted[1].append(None)
formatted[1][28] = ticket_type
formatted_json = json.dumps(formatted, separators=(",", ":"))
if json.loads(formatted_json)[1][28] != ticket_type:
raise ValueError("Encoded FlightSearchFilters.format() payload lost ticket type")
wrapped = [None, formatted_json]
return urllib.parse.quote(json.dumps(wrapped, separators=(",", ":")))
CURRENCY_SYMBOLS = {
"USD": "$", "GBP": "\u00a3", "EUR": "\u20ac", "THB": "\u0e3f",
"JPY": "\u00a5", "CNY": "\u00a5", "KRW": "\u20a9", "INR": "\u20b9",
"AUD": "A$", "CAD": "C$", "SGD": "S$", "HKD": "HK$", "NZD": "NZ$",
"TWD": "NT$", "MYR": "RM", "PHP": "\u20b1", "IDR": "Rp", "VND": "\u20ab",
"BRL": "R$", "MXN": "MX$", "CHF": "CHF", "SEK": "kr", "NOK": "kr",
"DKK": "kr", "PLN": "z\u0142", "CZK": "K\u010d", "HUF": "Ft",
"TRY": "\u20ba", "ZAR": "R", "AED": "AED", "SAR": "SAR", "QAR": "QAR",
"KWD": "KD", "BHD": "BD", "OMR": "OMR", "ILS": "\u20aa",
}
def _extract_currency(token_b64):
"""Extract 3-letter currency code from base64 booking token."""
try:
decoded = base64.b64decode(token_b64)
match = re.search(rb"\x1a\x03([A-Z]{3})", decoded)
if match:
return match.group(1).decode("ascii")
except Exception:
pass
return None
def currency_symbol(code):
"""Get currency symbol for a currency code."""
return CURRENCY_SYMBOLS.get(code, code)
def fmt_price(price, code):
"""Format a price with currency symbol."""
return f"{currency_symbol(code)}{price:,.0f}"
def _raw_search(filters, exclude_basic_economy: bool = False):
"""Make raw API call and return parsed response data."""
client = get_client()
if exclude_basic_economy:
encoded = _encode_with_ticket_type(filters, _TICKET_TYPE_STANDARD)
else:
encoded = filters.encode()
response = client.post(
url=BASE_URL,
data=f"f.req={encoded}",
impersonate="chrome",
allow_redirects=True,
)
response.raise_for_status()
parsed = json.loads(response.text.lstrip(")]}'"))[0][2]
if not parsed:
return None
return json.loads(parsed)
def _extract_booking_token(item):
"""Extract booking token from item[8] (flight detail protobuf for tfs URL param)."""
try:
if len(item) > 8 and isinstance(item[8], str):
parsed = json.loads(item[8])
if isinstance(parsed, list) and parsed:
return parsed[0]
except Exception:
pass
return None
def search_with_currency(filters: FlightSearchFilters, top_n: int = 5, exclude_basic_economy: bool = False):
"""Search flights and detect currency from the raw API response.
Returns (results, currency_code) where:
- results is a list of (flight_or_pair, booking_token) tuples
- booking_token is the tfs protobuf for Google Flights booking URLs
- currency_code is e.g. 'THB', 'USD', 'GBP'
Makes a single API call.
Args:
filters: Flight search filters
top_n: Max results to return
exclude_basic_economy: If True, filters out Basic Economy fares (Standard ticket type)
"""
data = _raw_search(filters, exclude_basic_economy=exclude_basic_economy)
if data is None:
return None, "USD"
# Extract currency and booking tokens from flights
currency = None
flights_data = []
booking_tokens = []
for i in [2, 3]:
if i < len(data) and isinstance(data[i], list):
for item in data[i][0]:
flights_data.append(item)
# Extract currency from item[1][1]
if currency is None and len(item[1]) > 1 and isinstance(item[1][1], str):
currency = _extract_currency(item[1][1])
# Extract booking token from item[8] (flight detail protobuf)
booking_tokens.append(_extract_booking_token(item))
# Parse flights using fli's parser
results = [SearchFlights._parse_flights_data(flight) for flight in flights_data]
if filters.trip_type == TripType.ONE_WAY or filters.flight_segments[0].selected_flight is not None:
paired = list(zip(results, booking_tokens))
return paired, currency or "USD"
# Round-trip: get return flights with tokens via raw API
flight_pairs = []
for selected_flight in results[:top_n]:
selected_filters = deepcopy(filters)
selected_filters.flight_segments[0].selected_flight = selected_flight
return_data = _raw_search(selected_filters, exclude_basic_economy=exclude_basic_economy)
if return_data is None:
continue
for ri in [2, 3]:
if ri < len(return_data) and isinstance(return_data[ri], list):
for item in return_data[ri][0]:
ret_flight = SearchFlights._parse_flights_data(item)
flight_pairs.append(
((selected_flight, ret_flight), _extract_booking_token(item))
)
return flight_pairs, currency or "USD"
#!/usr/bin/env python3
"""Search Google Flights for a route and date."""
import argparse
import sys
from datetime import datetime, timedelta
from itertools import product
from fli.models import (
Airport,
FlightSearchFilters,
FlightSegment,
MaxStops,
PassengerInfo,
SeatType,
TripType,
)
from search_utils import fmt_price, search_with_currency
SEAT_MAP = {
"ECONOMY": SeatType.ECONOMY,
"PREMIUM_ECONOMY": SeatType.PREMIUM_ECONOMY,
"BUSINESS": SeatType.BUSINESS,
"FIRST": SeatType.FIRST,
}
STOPS_MAP = {
"ANY": MaxStops.ANY,
"NON_STOP": MaxStops.NON_STOP,
"ONE_STOP": MaxStops.ONE_STOP_OR_FEWER,
"TWO_STOPS": MaxStops.TWO_OR_FEWER_STOPS,
}
def parse_args():
parser = argparse.ArgumentParser(description="Search Google Flights")
parser.add_argument("origin", help="Origin airport IATA code(s), comma-separated (e.g. LHR or LHR,MAN)")
parser.add_argument("destination", help="Destination airport IATA code(s), comma-separated (e.g. JFK or JFK,EWR)")
parser.add_argument("date", help="Departure date (YYYY-MM-DD)")
parser.add_argument("--date-to", help="End of date range (YYYY-MM-DD). Searches each day from date to date-to inclusive.")
parser.add_argument("--return-date", help="Return date for round trips (YYYY-MM-DD)")
parser.add_argument("--cabin", default="ECONOMY", choices=SEAT_MAP.keys(), help="Cabin class")
parser.add_argument("--stops", default="ANY", choices=STOPS_MAP.keys(), help="Max stops")
parser.add_argument("--results", type=int, default=5, help="Number of results")
parser.add_argument("--exclude-basic", action="store_true", help="Exclude Basic Economy fares (Standard ticket type only)")
return parser.parse_args()
def expand_routes(origins_str, destinations_str, date_str, date_to_str=None):
origins = [o.strip().upper() for o in origins_str.split(",")]
destinations = [d.strip().upper() for d in destinations_str.split(",")]
start = datetime.strptime(date_str, "%Y-%m-%d").date()
if date_to_str:
end = datetime.strptime(date_to_str, "%Y-%m-%d").date()
else:
end = start
dates = []
current = start
while current <= end:
dates.append(current.strftime("%Y-%m-%d"))
current += timedelta(days=1)
return list(product(origins, destinations, dates))
def format_duration(minutes):
h, m = divmod(minutes, 60)
return f"{h}h {m}m"
def format_results(results, currency, is_round_trip=False):
if not results:
print("No flights found.")
return
for i, result in enumerate(results, 1):
# search_with_currency yields (flight_data, booking_token); flight_data is
# an (outbound, ret) pair for round trips or a single Flight for one-ways.
flight_data = result[0] if isinstance(result, tuple) else result
if is_round_trip and isinstance(flight_data, tuple):
outbound, ret = flight_data
# ret.price is the full round-trip total for this outbound+return pair
# (the return-leg search returns the combined price, not a per-leg fare),
# so it is the price for the whole option — not something to add to
# outbound.price, which is the cheapest total for that outbound.
print(f"\n{'='*60}")
print(f"Option {i}: {fmt_price(ret.price, currency)} total")
print(f" Outbound: {format_duration(outbound.duration)} | {outbound.stops} stop(s)")
for leg in outbound.legs:
print(f" {leg.airline.name} {leg.flight_number}: {leg.departure_airport.name} {leg.departure_datetime.strftime('%H:%M')} -> {leg.arrival_airport.name} {leg.arrival_datetime.strftime('%H:%M')}")
print(f" Return: {format_duration(ret.duration)} | {ret.stops} stop(s)")
for leg in ret.legs:
print(f" {leg.airline.name} {leg.flight_number}: {leg.departure_airport.name} {leg.departure_datetime.strftime('%H:%M')} -> {leg.arrival_airport.name} {leg.arrival_datetime.strftime('%H:%M')}")
else:
flight = flight_data
print(f"\n{'='*60}")
print(f"Option {i}: {fmt_price(flight.price, currency)} | {format_duration(flight.duration)} | {flight.stops} stop(s)")
for leg in flight.legs:
print(f" {leg.airline.name} {leg.flight_number}: {leg.departure_airport.name} {leg.departure_datetime.strftime('%H:%M')} -> {leg.arrival_airport.name} {leg.arrival_datetime.strftime('%H:%M')}")
def main():
args = parse_args()
combos = expand_routes(args.origin, args.destination, args.date, args.date_to)
total_results = 0
for orig_code, dest_code, date in combos:
try:
origin = Airport[orig_code]
destination = Airport[dest_code]
except KeyError as e:
print(f"Unknown airport code: {e}", file=sys.stderr)
continue
segments = [FlightSegment(departure_airport=[[origin, 0]], arrival_airport=[[destination, 0]], travel_date=date)]
trip_type = TripType.ONE_WAY
if args.return_date:
segments.append(FlightSegment(departure_airport=[[destination, 0]], arrival_airport=[[origin, 0]], travel_date=args.return_date))
trip_type = TripType.ROUND_TRIP
filters = FlightSearchFilters(
trip_type=trip_type,
passenger_info=PassengerInfo(adults=1),
flight_segments=segments,
seat_type=SEAT_MAP[args.cabin],
stops=STOPS_MAP[args.stops],
)
label = f"\nSearching {orig_code} -> {dest_code} on {date}"
if args.exclude_basic:
label += " (excluding Basic Economy)"
print(label + "...")
results, currency = search_with_currency(filters, top_n=args.results, exclude_basic_economy=args.exclude_basic)
if results:
print(f"Prices in {currency}")
format_results(results, currency, is_round_trip=bool(args.return_date))
print(f"\n{len(results)} result(s) found.")
total_results += len(results)
else:
print("No flights found.")
if len(combos) > 1:
print(f"\n{'='*60}")
print(f"Searched {len(combos)} route/date combination(s). {total_results} total result(s).")
if __name__ == "__main__":
main()
#!/bin/bash
set -e
echo "Installing flightclaw dependencies..."
# Pin fli to a released version for reproducible installs. flights 0.9.0 provides
# the date-search / emissions / bags / basic-economy APIs this server imports.
# fastmcp backs the FastMCP fallback in server.py (fli dropped the FliMCP base class).
pip install "flights==0.9.0" "mcp[cli]" fastmcp
mkdir -p "$(dirname "$0")/data"
echo "Done. flightclaw is ready to use."
Related skills
How it compares
Pick flightclaw for agent-driven Google Flights research and ongoing route tracking; use dedicated travel booking apps when you only need a one-off purchase UI.
FAQ
How does flightclaw get flight prices?
flightclaw queries Google Flights through the Python flights pip library. Search and tracking tools return fares in the user's local currency auto-detected from IP, with ongoing history stored in data/tracked.json.
What MCP tools does flightclaw expose?
flightclaw registers 6 MCP tools: search_flights, search_dates, track_flight, check_prices, list_tracked, and remove_tracked. Claude Code or any MCP client can call them after pip install flights mcp[cli].
Can flightclaw search multiple airports and date ranges?
flightclaw accepts comma-separated airport codes like LHR,MAN and date_to ranges that expand into per-day searches. Filters include airline, max USD price, duration, layovers, and passenger counts.
Is Flightclaw safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.