Skip to main content
Glama
mtnrabi

Google Flights MCP

Google Flights MCP — free, ad-supported

M8ven Score

Real-time one-way and round-trip flight search, and live Booking.com hotel rates, for any MCP client. No API key: sign in with Google and go. It is funded by one disclosed sponsored card attached to each result, not by charging you.

claude mcp add --transport http google-flights-free https://google-flights-lulu.flightpowers.com/mcp

Adding the server needs no credential at all. On /mcp a request with nothing attached is served for initialize, notifications/initialized, ping, tools/list, prompts/list and resources/list, so your client connects and reads the whole tool menu straight away. The first actual search is what needs an account: a credential-less tools/call answers 401 with the sign-in details and the same directions body, and your client shows a Sign in button. Sign in with Google and the four tools work. That is the whole setup: no key, no subscription, nothing else to configure.

/mcp/oauth is the same server with the challenge on everything, discovery included. That is the URL to hand a directory that wants "always requires auth", and it is what connectors saved before 2026-09-09 expect.

Fair use, counted against the account you signed in with: 150 searches a day and 2,000 a calendar month. One call spends one search per date × destination combination, so a wide search costs more than one, and a single call is capped at 15 combinations. Past either cap the tools answer with search_status: "rate_limited" and no results.


The four tools

Tool

What it does

search_oneway_flights

One-way search across a date range and a list of destinations

search_roundtrip_flights

Round-trip priced as paired legs, across a date range and a nights value

search_hotels

Live Booking.com availability and nightly prices for a destination and a stay

find_hotel_by_name

The same, for one named property

The two flight tools take a date range (departure_date_from / departure_date_to) and a list of destinations, and expand them server-side. The two hotel tools take one stay at a time. One user intent is one tool call — "cheapest flight to Sri Lanka anywhere in October" is a single call, not thirty.

search_oneway_flights(from_airport="TLV", to_airport="CMB",
                      departure_date_from="2026-10-01",
                      departure_date_to="2026-10-31")

  → 31 combinations requested, 15 searched, results merged and re-sorted

search_roundtrip_flights additionally takes nights — a number or a list like [5, 6, 7] — instead of a fixed return_date, so "5–7 nights in Rome sometime in May" is also one call.

What comes back

Each result carries price, duration, airline, stops, stop airports and layover durations, and a bookable buy_link. It also carries Google's own historical price range for that route and period — price_insights_low, price_insights_high, and a low / typical / high verdict in price_range_in_relation_to_other_periods.

That last part is the reason to prefer this over a plain price scrape: it lets an assistant say "$209 is typical for this route, no need to rush" rather than just quoting a number.

An empty result list means "no flights on this route and date" — it is a valid answer, not an error.

Every successful result also carries the compact usage receipt, so both the person and the model deciding whether to mention the paid server can see where the allowance stands:

{"used_today": 7, "day_cap": 150, "used_month": 40, "month_cap": 2000,
 "signed_in": true, "user": "traveller@example.com",
 "human": "7 of 150 searches today, 40 of 2,000 this month, on traveller@example.com."}

A richer warning shape, with a note and an upgrade object, takes over from 80% of a cap. The email is display only and never enters the counting key, so renaming a Google account does not reset an allowance.

Fares are live, and only live

Every call is a fresh search. Fares go stale within minutes, so results should never be cached, stored, or re-quoted later as if current. If an answer references a fare, it should say when it was fetched, and re-search rather than reuse.

All four tools declare a title and the four MCP tool annotations: readOnlyHint: true (none of them can book, hold, pay for or cancel anything), destructiveHint: false, idempotentHint: false and openWorldHint: true (they reach a live third-party API whose result set is not a closed domain). idempotentHint is the one worth spelling out: a host that reads true is entitled to serve a cached answer to a repeated call, which means quoting a price that has already moved.


Related MCP server: BreckenWander Travel

How it is free

Each successful result carries one clearly disclosed sponsored card. That card is the entire funding model — it is what pays for the backend search calls so that you do not have to bring a key or a credit card.

Being precise about it, because you should know what you are opting into:

  • One sponsored card per result, always labelled as sponsored.

  • Never attached to an error, and never to a zero-result answer. Beyond being obnoxious, an ad next to no substantive data reads as a prompt injection attempt — Lulu's own live testing had a model flag exactly that, 3 times out of 3.

  • Ads cannot take search down. The ad SDK is inert without credentials and fails open on every network error.

  • The ad is served through Lulu. This server does not send it your identity — it sends a slot request and renders what comes back.

What signing in is for. The allowance above has to belong to somebody, and before 2026-09-09 it belonged to a hash of an IP address and a user agent, which meant everybody behind one assistant shared one counter and a script that changed either got a fresh one. Signing in with Google fixes both ends. What is kept is your Google account id and your email address, nothing else: no RapidAPI key (there is none to give), no browsing history, no search history tied to your name beyond the counters that enforce the caps.

Your email gets a welcome note, and occasionally a cap note. The first sign-in sends a one-time welcome, and an account that keeps hitting the day or month cap can get one note pointing at the paid server, at most once every 30 days and never within 72 hours of the welcome. Both are transactional sends, not a newsletter, both are off unless the operator turns them on, and every one carries an unsubscribe link that goes through this server's own /email/unsubscribe route rather than a generic list-management page.

Because ads are involved, this server is not listed in the official MCP Registry or in the Anthropic and OpenAI directories: all three ban ad-carrying servers. That is a deliberate, known consequence of the free model, not an oversight.

One caveat worth stating up front

The sponsored card can only render inside assistants that display MCP widget frames. Callers are classified by source IP and, in enforcement mode, clients that cannot render the card may get a reduced fan-out cap or be refused. The default deployment mode is monitor — classify and log, serve everyone — but if you are wiring this into a headless script rather than an assistant, the paid server below is the better fit and is not restricted this way.


When you outgrow the free one

There is a paid, ad-free sibling with the same four tools and the same search behind it: https://flights.flightpowers.com/mcp for flights and https://hotels.flightpowers.com/mcp for hotels.

Nothing here is gated behind it — the free server is fully functional. The paid one is simply the right choice once ads, the daily cap, the 15-search fan-out cap, or the client restriction start getting in your way.

Free — this server

Paid — flights / hotels

Ads

one disclosed sponsored card per result

none

Credential

sign in with Google, no key

sign in with Google and your own RapidAPI key, or just the key

Searches

150 a day, 2,000 a calendar month, per account

whatever your RapidAPI plan holds

Fan-out cap per call

15 searches

30 searches (hard max 60; per-call max_searches override)

Spend reporting

n/a

api_usage on every response

Client restrictions

classified by tier; non-rendering clients may be capped or refused

none — any client, any transport

Listed in the MCP Registry

no (ads are banned there)

yes, as com.flightpowers/google-flights and com.flightpowers/booking

Cost

free

your own RapidAPI usage

The trade is straightforward: the paid server has no ads and no restrictions because you are paying the search costs with your own RapidAPI key, instead of a sponsor paying them.

Getting the key takes a minute. Subscribe to the Google Flights Live API on RapidAPI — there is a free tier — and copy your x-rapidapi-key:

https://rapidapi.com/mtnrabi/api/google-flights-live-api

Then point a client at the paid server:

claude mcp add --transport http google-flights https://flights.flightpowers.com/mcp --header "x-rapidapi-key: YOUR_RAPIDAPI_KEY"

The key can also be passed as a ?rapidapi_key= query parameter, or in a client API-key field where the client offers one, or pasted once on the paid server's /connect page after signing in with Google. Every paid response includes api_usage with requests_used_by_this_call, plan_requests_remaining, and plan_requests_limit, so spend is visible before the next call rather than at the end of the month.


Truncation is always reported

When a request exceeds the cap, dates are sampled evenly across the range rather than truncated to the first N — fifteen dates spread across October answers "cheapest in October"; the first fifteen days does not. Every response carries search_coverage:

{"requested_combinations": 31, "searched_combinations": 15, "truncated": true,
 "departure_dates_searched": ["2026-10-01", "2026-10-03", "..."],
 "note": "This request expanded to 31 searches, above the 15-search limit..."}

A silently truncated search reads as a complete one, which is how a user ends up trusting a "cheapest" answer that never looked at half the month.

sort_type is not exposed

On the backend sort_type selects which search runs rather than post-sorting, it is silently dropped for one-way by the upstream API, and max_price overrides it (app.py:255). Since results from up to 15 searches have to be merged and re-sorted here anyway, the server always lets the backend default apply and sorts the merged set itself via sort_by (best / price / duration). That is predictable; passing sort_type through is not.


Non-affiliation

This is an independent service that returns publicly available flight pricing. It is not affiliated with, endorsed by, or sponsored by Google. "Google Flights" is used only to describe the source of the pricing data.


Running your own instance

Everything below is for operating this server yourself. If you only want to use it, the install line at the top is all you need.

MCP client ──HTTP──▶ this server ──POST──▶ upstream travel API ──▶ Google Flights
                          │
                          └──POST /slot──▶ ads.getlulu.dev

It calls the upstream travel API directly and is deliberately kept separate from the paid channels, which are billed per caller.

Quick start

cd mcp_server
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp example.env .env          # fill it in
set -a && . .env && set +a
.venv/bin/python -m src      # serves on :8000/mcp

Health check at /health, live counters at /metrics. /health probes the sign-in stores rather than reporting configuration. One SELECT 1 against each, run concurrently under a 2.5 s timeout, never raising:

{"status":"degraded","signin_store":"unreachable",
 "stores":{"oauth":"unreachable","free_users":"ok"}, "anon_mode":"challenge"}

signin_store is ok | unreachable | not_configured and status degrades with it. ?stores=0 keeps the cheap liveness answer with no database connection. Both /health and /metrics report anon_mode from the process actually answering, which is the only claim that settles whether a flipped env var reached the running code.

Configuration

BASE_LAMBDA_URL and RAPID_AUTH point this server at your own upstream travel API and authenticate to it. If you copy the values out of an env file that quotes them, note that the loader strips one layer of surrounding quotes — a value pasted with its quotes intact produces a 403 that looks like a wrong secret rather than a quoting mistake.

See example.env for every variable. The ones that matter most:

Variable

Why it matters

MCP_PUBLIC_URL

Must exactly match the URL clients connect to. See below.

MAX_BACKEND_CALLS_PER_TOOL_CALL

The cost cap. Default 15.

ENFORCEMENT_MODE

off / monitor / enforce. Default monitor.

GOOGLE_OAUTH_CLIENT_ID / _SECRET

Google sign-in. Redirect URI is <origin>/connect/callback, scopes openid and .../auth/userinfo.email.

DATABASE_URL

Neon Postgres, pooled endpoint. Schema: migrations/001_free_mcp_signin.sql, migrations/002_free_mcp_email.sql.

FAIR_USE_DAY_CAP / FAIR_USE_MONTH_CAP

The per-account allowance. 150 and 2,000.

RESEND_API_KEY

Sends the welcome and cap-note transactional emails. Empty means neither can send, regardless of the two flags below.

FREE_SIGNIN_WELCOME

on sends the one-time welcome note at first sign-in. Default off.

FREE_SIGNIN_CAPNOTE

on sends the cap note when an account keeps hitting the day/month cap. At most one per user per 30 days, never within 72h of the welcome. Default off.

Sign-in is all-or-nothing, and that is the rollback

The sign-in registers nothing unless GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET and DATABASE_URL are all set. A deployment missing any of them is exactly what this server was before: /mcp open to anyone, /mcp/oauth a 404. FREE_ANON_MODE=open is the softer rollback: it answers anonymous callers again at FREE_ANON_DAILY_CAP, and /mcp/oauth challenges whatever that says.

It is not "one env var, no deploy". Two things have to be true for the flip to change anything. On Vercel an env var is baked into a deployment, so set it, redeploy, then confirm "anon_mode": "open" on /health; if health still says challenge, the running code never saw it. And open grants FREE_ANON_DAILY_CAP against the same per-day counter those callers were already spending at the ordinary cap, so anyone already past it stays refused, which looks exactly like "the switch did nothing". To restore service rather than offer a taster, raise FREE_ANON_DAILY_CAP to the ordinary day cap in the same redeploy. In either mode initialize and tools/list are never refused by a cap, so a client can always connect and read the directions. Only tools/call is gated.

Run the migrations once per database, in order:

psql "$DATABASE_URL" -f migrations/001_free_mcp_signin.sql
psql "$DATABASE_URL" -f migrations/002_free_mcp_email.sql

Tables are free_mcp_* on purpose: a token minted here must never be one the paid server would accept.

MCP_PUBLIC_URL is a silent failure mode

Lulu derives Claude's _meta.ui.domain from this value by hashing it. Claude computes the same hash independently and rejects a frame whose domain does not match. A wrong URL means the sponsored card never renders, no error is raised anywhere, and CPM stays at zero.

The server logs the derived domain at startup so the value is checkable:

sponsored widget domain for https://.../mcp -> <hash>.claudemcpcontent.com

Why fan-out is internal

The backend takes exactly one (origin, destination, date) tuple per call, so "cheapest flight to Sri Lanka anywhere in October" is 31 backend calls. On the paid API that is fine — every call is revenue. Here, revenue is one rendered ad per tool call while every backend call is cost, so letting the model fire 31 tool calls would be 31× the cost for 1× the revenue.

So the model makes one call and the server fans out under a hard cap.

Cost control

Two independent guards:

  1. Per tool callMAX_BACKEND_CALLS_PER_TOOL_CALL, default 15.

  2. Rolling 24hDAILY_BACKEND_CALL_BUDGET (0 disables it). At BUDGET_DEGRADE_AT of the budget the per-call cap drops to a third; at 100% it drops to 1.

Degrading beats refusing: a hard stop at 80% would look like an outage to every user for the rest of the day.

The 24h window survives a restart — a crash loop must not hand the process a fresh budget. How it survives depends on the store: with Upstash the window lives in Redis and is shared across instances; with the in-process store it is rebuilt from the LOG_PATH file on startup. With neither — no Upstash and no writable log file — the budget resets on every cold start and is not enforceable. That is the default on Vercel, so configure Upstash there; /metrics reports budget.enforceable so you never have to guess.

Enforcement: what is and is not possible

The goal is "only usable where the sponsored card actually renders". The obvious approach does not work, so it is worth being precise.

clientInfo.name cannot gate anything. MCP spec revision 2026-07-28 says so normatively: those fields "are self-reported by the sender and are not verified by the protocol… SHOULD NOT rely on them for security decisions." A script sets "claude-ai" as easily as Claude does. It is recorded for reporting only.

Source IP can. It cannot be forged on a completed TLS handshake, and both hosts broker remote MCP from their own cloud rather than the user's machine:

Tier

How it is decided

Renders the widget?

llm_host

Anthropic 160.79.104.0/21, or OpenAI's published chatgpt-connectors.json prefixes

Yes — the only tier that can fire a beacon

local_client

Client name hint (Claude Code, Cursor, …)

No — CLI text card only, structurally $0 CPM

unknown

Everything else

No

In enforce mode, llm_host gets the full cap, other tiers get a third, and anything in BLOCKED_TIERS is refused. Two deliberate safety rules:

  • monitor is the default. Blocking in week one would destroy the traffic sample the POC exists to collect.

  • unknown is never blocked while OpenAI's range feed is unloaded, because without it every real ChatGPT caller looks unknown.

What this server cannot measure

The rendered-impression beacon fires from inside the Lulu widget frame straight to ads.getlulu.dev. It never touches this process, so this server cannot compute a true render rate on its own.

What it does is count slots served per tier. Reconcile that against Lulu's reported rendered impressions to get the real ratio, then put any tier that renders nothing into BLOCKED_TIERS. The mechanism is built; the input is a human decision informed by real numbers.

Counting Lambda requests

Every tool call emits exactly one line to stdout, prefixed MCP_CALL . The field names below are real; the values are illustrative — nothing here is a published latency or volume figure:

MCP_CALL {"iso":"2026-08-06T09:39:37Z","tool":"search_roundtrip_flights",
 "tier":"unknown","requested_combinations":3,"backend_calls":3,
 "results_returned":4,"ad_eligible":true,"duration_ms":0}

backend_calls is the Lambda request count for that call. Sum it across MCP_CALL lines for any period and you have total backend requests. stdout is the record because it is the one sink that works everywhere — Vercel captures it automatically, and the filesystem there is read-only.

One line per tool call, never per backend call: Vercel caps runtime logs at 256 lines and 1 MB per request.

LOG_PATH adds a second, file sink for container deploys. It disables itself if the path is not writable rather than appending into a void.

Metrics

GET /metrics (set METRICS_TOKEN to require an x-metrics-token header). Shape below, with illustrative values — not usage figures:

{"totals": {"tool_calls": 2, "backend_calls": 4, "ad_eligible_calls": 2},
 "backend_calls_per_tool_call": 2.0,
 "by_tier": {"llm_host": {"tool_calls": 2, "backend_calls": 4}},
 "budget": {"used_24h": 4, "budget": 1000, "remaining": 996,
            "enforceable": false},
 "durable_counters": false,
 "config": {"anon_mode": "challenge", "anon_day_cap": 0, "...": "..."},
 "notes": ["..."]}

GET /metrics/calls?hours=24 returns call counts per UTC hour.

backend_calls_per_tool_call is the number that decides whether this channel survives past the POC. Every backend call is cost; every tool call is at most one rendered ad. Multiply it by the real per-call Lambda + proxy cost and compare against the CPM the ad network actually reports.

durable_counters: false and budget.enforceable: false mean the totals cover one process only. On a container that is fine. On Vercel it means the real numbers are higher than shown and the spend guard is not actually armed — see below.

Counter storage

Counters live behind a small interface because the deployment target decides which implementation is correct:

Store

When

Durable

In-process dict

Container, local

No

Upstash Redis (REST)

Vercel, any serverless

Yes

On Vercel, Fluid compute shares one instance across concurrent invocations and scales instances freely, so in-process counters fragment and reset. They do not error — they just return a number smaller than the truth, which is the worst way for a spend guard to fail. So DAILY_BACKEND_CALL_BUDGET is only enforceable with a shared store, and the server says so at startup and in /metrics rather than pretending otherwise.

vercel install upstash   # injects UPSTASH_REDIS_REST_URL / _TOKEN

Upstash is reached over its REST API rather than the Redis wire protocol: short-lived invocations are the wrong shape for a pooling client, and it keeps redis out of the dependency list. Every store failure is logged and swallowed — a counter outage must never become an outage of flight search.

Ad wiring

Wired as widget + middleware separately rather than via the one-line enable_lulu_ads, because only the middleware path accepts is_error_result — which is what keeps ads off empty results.

Results render through Lulu's result widget, not its sponsored card. The sponsored card paints the ad but contains no rendered-impression beacon; the beacon lives only in the result widget's fixed SPONSORED strip. Serving the sponsored card alone earns click revenue and exactly $0 CPM, with nothing anywhere reporting a problem. The sponsored card is kept as a fallback for when result-widget registration fails, and that fallback is logged loudly.

The beacon is a 1×1 <img> to ads.getlulu.dev. MCP Apps hosts apply a default CSP of img-src 'self' data:, so the domain has to be declared on the widget resource — via app= for MCP Apps and openai/widgetCSP for ChatGPT. Undeclared looks identical to a working integration from the outside: card renders, strip shows, no impression, no error.

Register once, then set LULU_ADS_PUBLISHER_ID / LULU_ADS_API_KEY:

curl -X POST https://ads.getlulu.dev/publishers \
  -H 'content-type: application/json' \
  -d '{"name":"...","contact_email":"...","server_url":"..."}'

The API key is returned exactly once.

The billable field is imp_url (snake_case) on the /slot response. The TypeScript typings call it impUrl, but the Python SDK allowlists response keys and reads only imp_url (lulu_ads/client.py:210). Once live credentials exist, confirm real /slot responses actually carry it — without it the widget has no beacon to fire and there is no billable impression, however well everything else works.

Tests

.venv/bin/python -m pytest tests -q     # 803 tests

Backend and ad server are both stubbed, so the suite needs no network and no credentials. tests/test_ads.py and tests/test_stores.py run real stub servers rather than mocking the SDK, so they exercise the actual wire format.

Deploy to Vercel

api/index.py      entrypoint, exports `app`
vercel.json       maxDuration + no-store on /mcp
.python-version   3.13
requirements.txt

Vercel auto-discovers api/index.py and serves the whole app as one Fluid function receiving every path, so /mcp, /health and /metrics all land in the same process. No rewrites needed.

vercel link          # new project
vercel install upstash

# `vercel env add` takes ONE variable per invocation --
# `vercel env add <NAME> [environment]`. Each prompts for the value.
vercel env add BASE_LAMBDA_URL production
vercel env add RAPID_AUTH production
vercel env add MCP_PUBLIC_URL production
vercel env add LULU_ADS_PUBLISHER_ID production
vercel env add LULU_ADS_API_KEY production

vercel deploy --prod

Four Vercel-specific things this code already handles, each of which is a silent failure if you get it wrong:

Lifespan. FastMCP initialises its streamable-HTTP session manager in an ASGI lifespan startup event. Without it, every /mcp request fails with Task group is not initialized — a total outage of the MCP endpoint. Vercel's lifespan support (shipped 2025-12-09) is announced for "FastAPI apps"; whether that covers a bare Starlette app is not stated. So api/index.py wraps the FastMCP app in FastAPI and hands over lifespan=_mcp_app.lifespan, which is also the integration FastMCP documents. Do not "simplify" this away.

Stateless. stateless_http=True. Invocations are short-lived and not guaranteed to reach the same instance, so there is nowhere to keep a session.

Filesystem. Read-only outside an ephemeral /tmp. Leave LOG_PATH empty; stdout MCP_CALL lines are the record.

Counters. Configure Upstash, or the spend guard is decorative — see above.

Other limits that matter here: maxDuration defaults to 300s on every plan under Fluid (backend calls peak around 90s, so this fits); responses are streamed rather than buffered, because json_response=True would re-acquire the 4.5 MB body cap that a 15-way fan-out can approach; and the shared 1,024-file-descriptor pool is why MAX_HTTP_CONNECTIONS exists.

Log retention is 1 hour on Hobby, 1 day on Pro. If the MCP_CALL lines are your billing evidence for the POC, that argues for Pro plus a log drain, or for shipping the counters to Upstash and reading /metrics.

Deploy as a container

Dockerfile builds an always-on HTTP service, same shape as backend/Dockerfile (which deploys to Render). Set LOG_PATH and mount a volume at /app/logs so the spend guard's rolling window survives deploys.

Either way: after deploying, set MCP_PUBLIC_URL to the real URL — no trailing slash — and confirm the widget domain in the startup log.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    C
    maintenance
    Free, no-login MCP server for searching and comparing flights with real-time pricing from multiple airlines and booking platforms worldwide.
    1
    4
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    A keyless, read-only MCP connector for BreckenWander, an independent travel platform. It searches flights, hotels, and experiences at all-in prices — every mandatory tax and fee included — and returns a link to complete the booking on breckenwander.com.
    MIT
  • F
    license
    Not graded
    quality
    A
    maintenance
    Provides real travel search for AI assistants, enabling flight, hotel, car rental, and ground transportation searches through a single MCP tool, with no API keys required.
    75
    -