google-flights-mcp
This server lets an agent search live Google Flights fares (one-way and round-trip) and Booking.com hotels, with flexible date ranges, multiple destinations, historical price insights, and honest spend/coverage reporting.
Search one-way flights across a date range or multiple destinations in one call, with filters for stops, airlines, departure/arrival times, max price, seat class, and passengers; results include price, airline, duration, stops, Google's historical price range (low/high/verdict), and a booking link.
Search round-trip flights priced as paired legs, using a fixed return date or trip lengths in nights (e.g. 5,6,7), with per-leg airline/stops/duration filters and a single buy link.
Search hotels by free-text destination and dates, with filters like free cancellation and breakfast, budget-per-night, currency, guest counts, and country-specific pricing via
price_as_seen_from; returns nightly prices, review scores, room type, and booking links.Find one hotel by name for a quick single-property lookup with dates and optional country-specific pricing, no internal property ID needed.
See whether a fare is actually good via Google's
price_insights_low/highandlow/typical/highverdicts.Know what was searched and what it cost: each response reports
search_coverage(which dates/destinations were actually searched) andapi_usage(billed requests and remaining quota), with gracefulneeds_api_keyandquota_exhaustedreplies.Run through your own RapidAPI key (via header, query param, or client API-key field), with no server-side key required and no ads on the paid endpoint.
Provides real-time flight search and fare comparison using Google Flights, including one-way and round-trip searches, price insights, and booking links.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@google-flights-mcpFind the cheapest round-trip flights from New York to Paris for 5-7 nights in May"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Google Flights MCP: real-time fares your agent can search across a whole date range, ad-free
Real-time Google Flights fares for agents. Three things people do with this server. Scan for deals: one call takes a date range and a list of destination airports, expands every combination server side, and returns each fare with Google's own low, typical or high verdict. Put live search in your app: flat JSON with a bookable link on every result, and round trips priced as paired legs. Run a 24/7 AI travel agent: add the server, sign in with Google, and schedule it. No ads, no sponsored content. You bring your own RapidAPI key, so every search is billed to your plan and never to anyone else's.
One URL. Sign in with Google, nothing to paste into your client:
claude mcp add --transport http google-flights https://flights.flightpowers.com/mcpYour client hits the URL, gets a 401 with the sign-in details, and shows a Sign in button. You sign in with Google and paste your RapidAPI key once on the /connect page. Nothing goes in your client config.
Same URL with your key, for scripts, CI and clients without a sign-in button:
claude mcp add --transport http google-flights https://flights.flightpowers.com/mcp --header "x-rapidapi-key: YOUR_RAPIDAPI_KEY"A headless run cannot open a browser, so it sends the key instead. There is no second endpoint to switch to.
Hosted. Nothing to clone, nothing to build. Listed in the official MCP Registry as com.flightpowers/google-flights. Health check: /health.
Need a key? Subscribe to the Google Flights Live API on RapidAPI, free tier available, and copy your x-rapidapi-key: https://rapidapi.com/mtnrabi/api/google-flights-live-api
Cursor Marketplace
Once listed, install directly from the Cursor Marketplace. Until then, add the remote MCP server manually:
# In Cursor, add via Settings → MCP Servers → Add Server
# URL: https://flights.flightpowers.com/mcp
# Header: x-rapidapi-key: YOUR_RAPIDAPI_KEYOr use the mcp.json at the root of this repo, which references ${RAPIDAPI_KEY} as a plugin
variable.
Related MCP server: whentofly
What your agent gets
Two tools that answer a fare question, not a date lookup.
Ask open-ended questions. "Cheapest one-way to Sri Lanka anywhere in October", "5 to 7 nights in Rome sometime in May, from Tel Aviv or Larnaca": each is one tool call. Both tools take a departure date range, a list of destination airports, and (round-trip) a
nightsvalue instead of a fixed return date, and expand them internally.Say whether a price is actually good. Every result carries Google's own historical range for that route and period:
price_insights_low,price_insights_high, and aprice_range_in_relation_to_other_periodsverdict oflow/typical/high. That is what lets an agent answer "$209 is typical here, don't rush" instead of just quoting a number.Book, not just browse. Every result includes a
buy_linkto Google Flights.Know what it spent. Every response carries
api_usage: requests used by this call, and what is left on the caller's plan. See Spend reporting.Know what it searched. Every response carries
search_coverage, so the model can say honestly which dates and destinations the answer is based on.
Results are live fares. They go stale within minutes: never cache a fare or reuse an earlier result; search again and state when the data was fetched.
Get a key (free tier available)
The server holds no upstream credential of its own. Every search is billed to your RapidAPI subscription, which is why the key travels with the request.
Subscribe to the Google Flights Live API: https://rapidapi.com/mtnrabi/api/google-flights-live-api
Copy your
x-rapidapi-key.Pass it to the server in any one of the three ways below.
If a key is missing, the tools do not fail silently and do not spend anything. They return
needs_api_key: true with the signup URL and these instructions, phrased for the model to read
back to you.
Three ways to pass your key
Way | How | When to use it |
Header (preferred) |
| Anything that lets you set headers. Keys stay out of URLs, and therefore out of proxy and access logs. |
Query parameter |
| Hosts that only let you paste a URL: claude.ai's custom-connector dialog is the case that matters. |
Client API-key field | Paste the key into the client's own "API key" box | Hosts that send |
First non-empty source wins, in that order. The key is never logged, never echoed into an error message, and never returned in a tool response.
Gemini CLI
Install via the Gemini extensions CLI:
gemini extensions install https://github.com/mtnrabi/google-flights-mcpThe installer will prompt for your RapidAPI key. Subscribe at
https://rapidapi.com/mtnrabi/api/google-flights-live-api (free tier available) and copy your
x-rapidapi-key.
Sign in once at /connect
This is the page the sign-in URL at the top of this README sends you to. A client that speaks MCP authorization walks you through it on its own; the steps below are the same thing done by hand.
Where a deployment has it enabled (check connect_enabled on /health), there is a page at
/connect that replaces all of the above with a sign-in:
Open https://flights.flightpowers.com/connect (hotels: https://hotels.flightpowers.com/connect) and sign in with Google.
Paste your RapidAPI key once, into a form, over TLS.
Press Reveal the URL and copy the connect URL,
…/mcp?fp_token=fpk_…, and use that as the server URL in your MCP client. Clients that let you set headers can send the same token asAuthorization: Bearer fpk_…instead. (It is hidden until you ask for it: that URL is a 90-day bearer credential for your plan, and a page that prints one by default puts it in every screenshot and screen share.)
If your client signed you in itself -- Claude, Cursor, ChatGPT and anything else that speaks
MCP authorization -- there is no URL to copy. /connect says so: it shows the key you
connected and tells you to go back to your assistant. There is a link on it for the case where a
second client cannot sign in and does need a connect URL.
What that buys you: your RapidAPI key is not in your client config, not in a URL, and not in
whatever logs that URL passes through. What it costs: the server stores your key, encrypted, and
knows your Google account id and email address. Disconnect on the same page deletes the record
and kills every connect token for your account, immediately. The full description is
section 2a of the privacy policy.
Some details worth knowing:
Saving runs one check. The key is validated against the listing before it is stored, so a typo fails on the page rather than in your client an hour later. That check costs at most one request from your own plan: on the free BASIC plan (10 a month), one of ten. A key that RapidAPI rejects at the gateway costs nothing.
A key on the request always wins. If you send an
x-rapidapi-keyheader (or any of the other channels above) and carry a connect token, the request's own key is used. Nothing you already have set up changes behaviour because you signed in.The token is not your key and cannot be turned back into it. It is valid for 90 days, and it stops resolving the moment you disconnect. A call carrying a token whose key has been disconnected gets a
needs_api_keyreply telling you to reconnect. It never falls back to somebody else's subscription and never spends anything.BASIC is free. Google Flights Live API · Booking Live API. One RapidAPI key covers whichever of the two you have subscribed to; you connect it once.
Running /connect on your own deployment
Off unless all four of these are set. A half-configured deployment registers none of the
routes and serves keyed callers exactly as before; /health reports connect_enabled so that is
visible rather than guessed.
Variable | What it is |
| Google Cloud Console → Credentials → OAuth client ID, type Web application. Ends |
| The same client's secret ( |
| 32 bytes, base64: |
| Neon Postgres, pooled endpoint ( |
Optional: MCP_CONNECT_VALIDATE=0 stores a pasted key without checking it first.
Authorised redirect URIs to register on the Google client, one per product origin, exactly:
https://flights.flightpowers.com/connect/callback
https://hotels.flightpowers.com/connect/callbackThe alias google-flights-mcp.flightpowers.com needs no entry. /connect and
/connect/start bounce an alias to the canonical origin before the sign-in starts, because
cookies are per-host and Google compares redirect_uri literally: an alias that started its own sign-in would come back to a host with no
state cookie and fail with a message that reads like a Google misconfiguration.
Also on the OAuth consent screen: scopes openid and .../auth/userinfo.email, and nothing else.
Rotating MCP_KEY_MASTER logs everybody out and invalidates every stored key. That is
deliberate: after a rotation nothing is left holding a token that resolves to a key nobody can
read. Users see "connect again", not a failed search. key_version on the table is there so a
staged rotation is possible later without a flag day.
Verifying the flow end to end
Migration first, once per database:
psql "$DATABASE_URL" -f migrations/001_mcp_user_keys.sqlThen, after deploying:
# 1. The feature is actually on.
curl -s https://flights.flightpowers.com/health | grep connect_enabled
# 2. The page renders for an anonymous visitor.
curl -sI https://flights.flightpowers.com/connect # 200
curl -sI https://flights.flightpowers.com/connect/start # 302 to accounts.google.com
# 3. Sign in in a browser, paste a key, press Reveal and copy the connect URL.
# 4. MCP Inspector against that URL -- list the tools, then run one real search.
npx @modelcontextprotocol/inspector
# Transport: Streamable HTTP
# URL: https://flights.flightpowers.com/mcp?fp_token=fpk_...
# 5. Claude Code, the same URL.
claude mcp add --transport http flightpowers \
"https://flights.flightpowers.com/mcp?fp_token=fpk_..."
claude mcp list # shows it connected
# then, in a session: ask for a fare and check the result is real
# 6. Cursor: Settings -> MCP -> Add, same URL. Or in ~/.cursor/mcp.json:
# { "mcpServers": { "flightpowers": {
# "url": "https://flights.flightpowers.com/mcp?fp_token=fpk_..." } } }
# 7. Hotels, the other hostname, with the same token.
# https://hotels.flightpowers.com/mcp?fp_token=fpk_...
# 8. Press Disconnect on /connect, then re-run step 4. The tool must answer
# needs_api_key with a "connect again" message -- not a search, and not a
# generic "get a key" reply.A tools/list that succeeds proves nothing about any of this: a token is only consulted when a
tool actually runs. Step 4 has to be a real search.
Sign in from inside your MCP client
/connect works by hand, but a client only starts a sign-in on its own when a request comes
back 401 with a WWW-Authenticate: Bearer resource_metadata=… header. /mcp does exactly that
now, and only when it has to:
Request to | What it gets |
Carries a credential: an | Served, the way it always was. No 401, no sign-in. |
Carries nothing |
|
https://hotels.flightpowers.com/mcp behaves the same way for hotels. One URL per product,
whichever way you authenticate, and there is nothing to choose between when you add the server.
The older …/mcp/oauth address still answers as the always-challenge alias, for connectors saved
on it before 2026-09-09; nothing new needs it.
What it is like to use. Paste the URL into your client. It registers itself,
opens a browser, you sign in with Google, and you approve that client by name on a page that
says exactly what it will be able to do: run searches billed to your own RapidAPI plan, nothing
else. The client never sees your RapidAPI key. If you have not connected one yet, the approval
still works and the first search comes back telling you to paste a key at /connect, with the
URL.
What you can revoke, and how. Press Disconnect on /connect: the stored key is deleted
and every OAuth token for that Google account is dropped in the same action. A client that was
connected stops working immediately. Individually, a client can call /oauth/revoke (RFC 7009).
Client setup
# Claude Code
claude mcp add --transport http flightpowers \
"https://flights.flightpowers.com/mcp"
claude mcp list # shows "needs authentication" until you sign in
/mcp # in a session: pick the server, follow the sign-in
# Cursor -- Settings -> MCP -> Add, URL above. Or ~/.cursor/mcp.json:
# { "mcpServers": { "flightpowers": {
# "url": "https://flights.flightpowers.com/mcp" } } }
# Cursor discovers the 401, registers itself and opens the browser.
# ChatGPT -- Settings -> Connectors -> Create. It asks for:
# MCP server URL: https://flights.flightpowers.com/mcp
# Authentication: OAuth
# Leave client id and secret EMPTY: this server supports dynamic client
# registration, so ChatGPT registers itself. Nothing else has to be filled in.
# MCP Inspector -- the quickest way to watch the whole handshake.
npx @modelcontextprotocol/inspector
# Transport: Streamable HTTP
# URL: https://flights.flightpowers.com/mcp
# Auth: OAuth 2.0 -> "Guided OAuth Flow" walks metadata -> DCR ->
# authorize -> token, and shows each response. Then run ONE real search.The protocol surface
Route | Spec |
| RFC 9728 |
| RFC 8414 |
| RFC 7591, dynamic client registration, open |
| RFC 6749 §4.1, PKCE S256 required |
|
|
| RFC 7009 |
The authorization endpoint is under /connect on purpose: the sign-in session cookie is scoped
Path=/connect so it can never be attached to a /mcp request, and putting authorize anywhere
else would mean either widening that cookie or making the user sign in twice.
Codes live 10 minutes and are single-use (DELETE … RETURNING, so two concurrent exchanges race
on one row and exactly one wins). Access tokens live 1 hour, refresh tokens 30 days with rotation.
Everything is opaque and stored as a SHA-256 hash, so a dump of the database contains nothing
that can be replayed. Tokens are not JWTs, deliberately: a signed token stays valid until it
expires whatever we decide afterwards, and Disconnect has to mean disconnect.
An access token is checked against the resource it was approved for before it is accepted, not
only when it is issued. Both products are the same deployment, the same database and the same
stored RapidAPI key per user, so without that check a token approved on the flights consent
page, which says "search live flight fares" and nothing else, would be accepted on the hotels
hostname and spend the user's hotels plan. The check is strict about the host and forgiving about
the path, because clients in the wild send the origin, /mcp and /mcp/oauth for the same
server. tests/test_oauth.py::TestATokenIsBoundToTheResourceItWasApprovedFor pins both halves.
Running it on your own deployment
No new environment variable. It comes on wherever /connect is configured, because it reuses
that Google sign-in and that key store. It needs one more table in the same database:
psql "$DATABASE_URL" -f migrations/002_mcp_oauth.sql/health then reports oauth_enabled: true and oauth_mcp_endpoint; that URL is what goes in
a directory listing. MCP_OAUTH=off disables it while leaving /connect running; that is the
rollback that needs no code change.
Nothing has to change on the Google OAuth client. The redirect URI is still
…/connect/callback, because the MCP client's OAuth flow ends at our authorize page, and only
that page talks to Google.
Verifying it end to end
BASE=https://flights.flightpowers.com
# 1. On, and advertising itself.
curl -s $BASE/health | python3 -m json.tool | grep oauth_
# 2. The challenge, on the one URL, with nothing presented. This is the
# whole feature in one response.
curl -si -X POST $BASE/mcp -H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' | head -20
# HTTP/2 401
# www-authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource/mcp"
# 3. Discovery, at the scoped and the bare path.
curl -s $BASE/.well-known/oauth-protected-resource/mcp | python3 -m json.tool
curl -s $BASE/.well-known/oauth-authorization-server | python3 -m json.tool
# 4. Dynamic registration answers.
curl -s -X POST $BASE/oauth/register -H 'content-type: application/json' \
-d '{"client_name":"probe","redirect_uris":["http://127.0.0.1:9999/cb"],
"token_endpoint_auth_method":"none"}' | python3 -m json.tool
# 5. The real test: MCP Inspector, Guided OAuth Flow, then ONE real search.
# A tools/list proves nothing -- the token is only consulted when a tool runs.
# 6. Hotels, the other hostname, same walk: https://hotels.flightpowers.com/mcp
# 7. A keyed call on the same URL. This must still work, with no 401 anywhere:
curl -s -X POST $BASE/mcp -H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H "x-rapidapi-key: $REAL_KEY" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}'
# 8. Disconnect on /connect, then re-run step 5's search: it must answer
# needs_api_key, and the client must be logged out.Keeping the registration table honest
/oauth/register is open, because the MCP spec requires it and because a client_id on its own
authorises nothing: every flow through one still ends at a consent page a signed-in human has to
press a button on. Open is not the same as unlimited, so three things stand behind it.
Mechanism | Where it lives | What it stops |
Rate limit | in memory, per instance | a burst: 10 registrations per address per 10 minutes, 60 token requests per minute, 10 |
Daily caps | Postgres, so every instance agrees | a slow drip: 30 registrations per address per day. The global cap is 5,000 a day -- a backstop against unbounded rows, not a defence: a global number set near real traffic is a lever an attacker pulls to refuse every new Claude or Cursor user for a day. Crossing 500 in a day logs and refuses nothing. |
Sweep | on | the litter: expired codes and tokens, and registrations that never became an authorization within 7 days. A client with a live token, an outstanding code, or a consent page that has been rendered for it is never swept. |
The rate limit is per INSTANCE. On Vercel that means N warm instances allow up to N times those numbers between them, and a cold start starts the counters at zero. That is why the durable caps exist as well: they are counted in the database, where the number is the same everywhere.
The address every one of these is keyed on comes from x-real-ip first -- Vercel sets it to the
peer it accepted -- and otherwise from the LAST usable entry of x-forwarded-for, skipping hops
that can only be internal. Never entry 0: proxies append on the right, so the left-most entry is
whatever the caller wrote, and reading it would make every limit here one header away from being
bypassed. A request with neither header shares one bucket named unknown, which is rate limited
as a single caller and is not subject to the durable per-address cap (one missing header on the
edge must not lock the whole server out for a day).
A registration is stamped as in-use when its consent page is RENDERED, not only when the human
presses Approve. MCP clients commonly register when they are installed and authorize days later,
and the sweep must not delete a row while its consent page is on screen -- there is no foreign key
from codes or tokens back to the client, so the exchange that followed would fail
invalid_client with nothing naming the cause.
Requires one migration:
psql "$DATABASE_URL" -f migrations/003_mcp_oauth_hygiene.sqlRefresh tokens rotate, and a replay revokes the family
A refresh token is single-use: exchanging it issues a new pair and retires the one presented.
The retired row is kept and stamped, not deleted, because a deleted row and a token that was
never issued look identical -- and telling those apart is the point. Presenting an
already-rotated refresh token means either a client that lost the response or a copy in somebody
else's hands, and OAuth 2.1 §4.14.2 says to assume the second: the answer is invalid_grant, and
every token descended from that authorization is deleted. The honest client signs in again; the
thief's access token stops working at the same moment.
With one deliberate exception, for the case that is almost always the innocent one: the FIRST replay of the token we just rotated, from the same client, within 10 seconds, is answered with the pair that rotation already issued. It is an idempotent retry -- nothing new is created -- and it means a client whose response was lost to a dropped connection is not silently signed out. A second replay, or one after the window, is the real thing and still kills the family. The window is per instance and in memory, so a miss simply falls through to the conservative answer.
Revoking a refresh token through /oauth/revoke takes its access tokens with it, for the same
reason (RFC 7009 §2.1) -- and only if the token was issued to the client asking, which is the
other half of that section. A client presenting somebody else's token still gets 200 (§2.2) and
nothing is revoked.
A client_id can be a URL
client_id_metadata_document_supported: true is advertised in
/.well-known/oauth-authorization-server. A client may use an https URL as its client_id;
the document at that URL lists its redirect_uris, and we fetch and check it per flow instead of
writing a registration row. Smithery asks for this before it will proxy a remote OAuth server.
What is checked, every time: https only, a public hostname (no IP literals, no localhost, no
credentials in the URL), the hostname resolved and every address it answers with required to
be public unicast (a name is not a control: 127.0.0.1.nip.io has a dot in it and points at
loopback), no redirect followed, a 5-second timeout, a 64 KB cap enforced while the body is read
rather than after it is buffered, a client_id inside the document that matches the URL if it is
present, and -- the one that matters -- the redirect_uri in the request must be listed in the
document. The lookup is rate limited on its own (60 per address per 10 minutes), because it is
the only outbound fetch in this server that a caller can trigger before signing in. Dynamic registration is unchanged
and still the default: a client_id that is not an https URL is looked up in the table exactly as
before.
Tools
Tool | What it does |
| Real-time one-way fares. Input: origin IATA, destination IATA or a list, and either one departure date or a date range. Returns price, airline, duration, stops, |
| Real-time round-trip fares priced as paired legs, not two one-ways. Input: origin, destination(s), a departure date or range, and either a |
The hotels deployment serves search_hotels, find_hotel_by_name and compare_hotel_rates instead; see Hotels: providers and compare_hotel_rates.
search_oneway_flights
search_oneway_flights(
from_airport: str, # origin IATA, e.g. "TLV"
to_airport: str | list[str], # destination IATA, or a list to compare
departure_date: str | None = None, # "YYYY-MM-DD"
departure_date_from: str | None = None,# first date of a range
departure_date_to: str | None = None, # last date of a range
max_stops: int | None = None, # 0 = non-stop only
airline_codes: list[str] | None = None,
exclude_airline_codes: list[str] | None = None,
departure_time_min: int | None = None, # hour, 0-23
departure_time_max: int | None = None,
arrival_time_min: int | None = None,
arrival_time_max: int | None = None,
currency: str = "usd",
max_price: int | None = None,
seat_type: int | None = None, # 1 economy, 2 premium economy, 3 business, 4 first
passengers: list[int] | None = None, # [adults, children, infants]
sort_by: str = "best", # "best" | "price" | "duration"
limit: int = 10, # results returned after merge + sort
max_searches: int | None = None, # cap the billed requests this call may make
use_fallback: bool | None = None, # leave unset: accepted upstream, currently inert
)search_roundtrip_flights
search_roundtrip_flights(
from_airport: str,
to_airport: str | list[str],
departure_date: str | None = None,
departure_date_from: str | None = None,
departure_date_to: str | None = None,
return_date: str | None = None, # use this OR nights, not both
nights: int | list[int] | None = None, # e.g. 7, or [5, 6, 7]
max_departure_stops: int | None = None,
max_return_stops: int | None = None,
departure_airline_codes: list[str] | None = None,
return_airline_codes: list[str] | None = None,
currency: str = "usd",
max_price: int | None = None,
seat_type: int | None = None,
passengers: list[int] | None = None,
sort_by: str = "best",
limit: int = 10,
max_searches: int | None = None,
use_fallback: bool | None = None,
)sort_by is applied by this server across the merged result set from every search it ran, so it
is predictable regardless of how many combinations were expanded.
A worked example
User: "I'm in Tel Aviv. Cheapest week-long trip to Rome or Athens, leaving any day in the first half of May."
One call:
{
"name": "search_roundtrip_flights",
"arguments": {
"from_airport": "TLV",
"to_airport": ["FCO", "ATH"],
"departure_date_from": "2026-05-01",
"departure_date_to": "2026-05-15",
"nights": 7,
"sort_by": "price",
"limit": 5
}
}That expands to 15 dates × 2 destinations = 30 combinations, which is exactly the per-call cap. The response shape (field names are real; the values below are illustrative, not a quote, run the call to get live fares):
{
"results": [
{
"from_airport": "Tel Aviv (TLV)",
"to_airport": "Rome (FCO)",
"departure_date": "2026-05-05",
"return_date": "2026-05-12",
"total_price": "$XXX",
"total_price_as_number": 0,
"total_duration_seconds": 0,
"total_stops": 0,
"price_range_in_relation_to_other_periods": "low",
"price_insights_low": 0,
"price_insights_high": 0,
"departure_flight_airline": "...",
"departure_flight_departure_description": "...",
"departure_flight_arrival_description": "...",
"departure_flight_duration": "...",
"departure_flight_stops": 0,
"departure_stops_info": [],
"return_flight_airline": "...",
"return_flight_departure_description": "...",
"return_flight_arrival_description": "...",
"return_flight_duration": "...",
"return_flight_stops": 0,
"return_stops_info": [],
"buy_link": "https://www.google.com/travel/flights?tfs=..."
}
],
"result_count": 5,
"search_coverage": {
"requested_combinations": 30,
"searched_combinations": 30,
"truncated": false,
"max_searches_per_request": 30,
"departure_dates_searched": ["2026-05-01", "..."],
"destinations_searched": ["ATH", "FCO"]
},
"api_usage": {
"requests_used_by_this_call": 30,
"plan_requests_remaining": 0,
"plan_requests_limit": 0,
"note": "This search used 30 of your RapidAPI plan's requests; ... remain in the current period. Each date and destination combination is one billed request."
}
}Other response shapes to expect, all of them normal:
No flights on those dates.
results: []with amessage: Google Flights genuinely returns nothing for some route/date combinations. Not an error. Try nearby dates or a nearby airport.use_fallbackwill not change this and is left unset by default: the backend accepts the field, but the second flight-data source it selects is gated behindUSE_FALLBACK_FLI(fallback_available()), which is not switched on for this API, so none of its three values has any observable effect on a search today. The automatic retries the backend does on an unreadable page are unconditional and are not affected by it.Some searches failed. A
partialfield says how many of the executed searches failed, and the results cover the rest.Range too wide.
search_coverage.truncated: trueplus anote. The range is sampled evenly across the whole window (first and last kept), not cut short, so the sample is representative, not the first N days. Raisemax_searchesor narrow the range for fuller coverage.No key / rejected key.
needs_api_key: true, zero spend, with the fix. A valid RapidAPI key that is not subscribed to this API is the most common cause.Plan exhausted.
quota_exhausted: truewithapi_usage, plus a reminder that narrowing the range makes remaining quota go further.
Hotels: a check-in range
POST /search prices exactly one stay, so "cheapest three nights in Rome in May" used to be
31 tool calls -- or, in practice, one call on a date the model picked and an answer presented as
the cheapest. Both hotel search tools now take the flights shape instead:
search_hotels(
destination: str,
checkin_date: str | None = None, # one stay: this plus checkout_date
checkout_date: str | None = None,
checkin_date_from: str | None = None, # or a range: this, checkin_date_to and nights
checkin_date_to: str | None = None,
nights: int | list[int] | None = None, # 3, or [2, 3, 7] to price several lengths
max_searches: int | None = None, # cap the billed requests this call may make
...
)Same machinery as the flights fan-out (src/fanout.py): one backend call per stay, capped at
max_searches_per_tool_call (30, hard max 60), sampled evenly across the range when it does
not fit, and reported in search_coverage. nights derives each check-out date, so it replaces
checkout_date rather than joining it. A fixed checkout_date against a range of check-in dates
is allowed and means "out on the 4th, whenever I arrive"; the impossible pairs are dropped.
The response is bounded on purpose. Every property of every stay is ~25 KB per stay (measured: 25,892 bytes for 25 properties, 18,989 of them URLs), so each stay reports its cheapest property, its per-night rate and its median, and the full property list comes back for the cheapest stay only:
{
"results": [ /* every property of the CHEAPEST stay, upstream rows untouched */ ],
"result_count": 18,
"results_for_stay": {"checkin_date": "2026-05-12", "checkout_date": "2026-05-15", "nights": 3},
"stays": [
{
"checkin_date": "2026-05-01", "checkout_date": "2026-05-04", "nights": 3,
"search_status": "ok", "reason": "ok",
"property_count": 22, "priced_count": 19,
"cheapest_total": 411.0, "price_per_night": 137.0, "median_total": 690.0,
"currency": "USD",
"cheapest": { /* the row, minus its image URL */ }
},
{"checkin_date": "2026-05-02", "search_status": "degraded", "reason": "search_failed",
"property_count": null, "priced_count": null, "cheapest": null},
{"checkin_date": "2026-05-03", "search_status": "not_searched", "reason": "not_searched",
"property_count": null, "cheapest": null}
],
"cheapest_overall": {"checkin_date": "2026-05-12", "total": 305.0, "price_per_night": 101.67,
"currency": "USD", "property": { /* ... */ }},
"search_status": "partial",
"search_coverage": {
"requested_combinations": 31,
"searched_combinations": 15,
"truncated": true,
"max_searches_per_request": 30,
"stays_searched": [{"checkin_date": "2026-05-01", "checkout_date": "2026-05-04"}, "..."],
"checkin_dates_searched": ["2026-05-01", "..."],
"note": "This request expanded to 31 stays, above the ..."
},
"api_usage": {"requests_used_by_this_call": 15, "note": "... Each stay -- one check-in date paired with one length -- is one billed request."}
}reason on a stay is a fact about our pipeline, never a guess about the hotel:
|
| Means |
|
| priced |
|
| searched, answered, nothing came back |
|
| properties came back, none carried a price ( |
|
| the search errored, so nothing is known -- not "no rooms" |
|
| the cap sampled it away |
Counts are null rather than 0 on the last two: zero reads as "nothing there", and neither case
knows that. Top-level search_status is ok / partial / empty / degraded over the stays;
every stay failing raises instead of answering with an empty list.
Two deliberate refusals: a check-in range with providers naming more than one source (a fan-out
times a per-source fan-out, billed to two subscriptions, that neither search_coverage nor
api_usage can describe honestly today), and max_searches on a single stay, which would silently
do nothing.
A single stay is byte-identical to what it was before this existed -- same request body, same
response keys, no stays, no search_coverage, no search_status. Asserted in
tests/test_hotel_date_range.py::TestTheOldShapeIsUntouched against a frozen expectation captured
from the previous code.
Hotels: providers and compare_hotel_rates
The hotels deployment (hotels.flightpowers.com, the same code selected by the Host header)
serves three tools. search_hotels and find_hotel_by_name also take a check-in range (above);
search_hotels gained one optional argument for sources and there is one new tool.
Tool | What it does |
| Live rates for a destination and dates, or for every stay a check-in range expands to. |
| One named property, Booking.com only. Airbnb's room page carries no price, so a name lookup there would resolve to something that cannot be priced. |
| The same stay priced on every source you have a key for, one row per source: cheapest total, median total, how many places were priced, currency, and when the rows were read. |
The default did not move
search_hotels with no providers argument sends the same upstream request it always sent, to the
same host, and answers with the same keys. So does providers: ["booking"]. Both are asserted in
tests/test_providers.py::TestTheDefaultDidNotMove, upstream request body included -- a default is
only a default if keeping it costs nothing.
Naming a second source changes the response shape, and only then:
{
"results": [ /* every source's rows, each carrying "provider" and "rating_scale" */ ],
"result_count": 3,
"providers": [ /* one row per source that was CALLED */ ],
"providers_skipped":[ /* one row per source that was NOT, with a subscribe_url */ ],
"caveats": [ /* what to read before calling one source cheaper */ ],
"api_usage": { "requests_used_by_this_call": 2 }
}Where each source is called
bookinggoes straight tobooking-live-api.p.rapidapi.comon the caller's key, billed by RapidAPI to their own subscription. Unchanged.airbnbgoes through our own front door,POST https://api.flightpowers.com/v1/hotels/searchwithprovider: "airbnb"in the body, because the Airbnb backend is not on the RapidAPI edge. The call carries the caller's key asx-rapidapi-keyand identifies itself withX-FP-Client: mcp-hotels/<version>-- the front overwrites body attribution with its own conclusion, so the header is the only thing that says who called (rule 11). Override the origin withAPI_FRONT_BASE_URLfor a preview front; it holds no credential.
Airbnb is available when the Airbnb listing launches on RapidAPI. It does not exist there yet, and
the front ships with the provider off, so today a real providers: ["airbnb"] call comes back as a
degraded row saying so. That is the
designed answer, not a bug: an unmetered source reachable by anyone with any valid key is a gateway
we pay for.
Four rules the implementation holds
A source you have no key for is never called on ours. It is named in
providers_skippedwithreason(no_key,not_subscribed,key_rejected) and the URL where you subscribe. Each listing is a separate subscription, so a403from the Hub means "not subscribed to that listing", not "bad key".A degraded source is a named row, not a hole.
search_status: "degraded",count: null, no prices -- and the other source's rows still come back. "Booking did not answer" and "Booking had nothing" are opposite answers and must be sayable as different sentences.cheapest_totalandmedian_totalcover only the rows that carry a price, andcountis that number. Aprice_stringis never parsed into a number.No cross-currency arithmetic. Every source is asked for the same currency; if two answer in different ones, each row keeps its own and
caveatssays the totals are not comparable. Nothing is converted.
Keys, per source
Almost everybody has one RapidAPI key subscribed to several listings, and that key is used for every source with no extra configuration. A caller who genuinely holds two can name one per source, and the source-scoped name wins:
x-rapidapi-key-airbnb: <key> # header
?rapidapi_key_airbnb=<key> # query
?config=<base64 {"rapidApiKeyAirbnb": "<key>"}> # Smithery blobThe order is the one src/credentials.py already documents -- source-scoped names, then the
unscoped specific names, then the config blob, then generic names last and skipped entirely when a
config parameter is present, because a gateway's own key under a generic name is not ours.
filters and price_as_seen_from are Booking-only
On a mixed search they are sent to Booking and not to the front. On an Airbnb-only search they are refused, not dropped: a silently discarded filter returns more properties than you asked for and nothing says so.
Structured output (outputSchema, structuredContent, isError)
Every tool declares an outputSchema, and every result carries the payload
twice: once as structuredContent, once as the serialized JSON in a text
content block. The MCP spec asks for the duplicate --
For backwards compatibility, a tool that returns structured content SHOULD also return the serialized JSON in a TextContent block.
-- and it is load-bearing here rather than ceremonial, because clients that predate structured output read the text block and nothing else. (Verified against spec revision 2026-07-28; structured output arrived in 2025-06-18.)
The schemas are deliberately additionalProperties: true with only results
required. The spec puts the obligation on the server -- "Servers MUST provide
structured results that conform to this schema" -- and these tools have
several legitimate exits that carry different keys -- a zero-result answer, the keyless
needs_api_key reply and the quota_exhausted reply. A tighter
schema would look better and would make the server non-conformant on a path
it ships on purpose.
search_status, and why degraded is an error
Flight results carry search_status, mirroring the backend's own
X-Search-Status vocabulary:
value | meaning |
| every combination searched returned results |
| the search completed; Google genuinely has no itineraries. A real answer |
| some combinations returned results, some failed. The list is incomplete |
| every combination failed. The search did not happen; an empty list means nothing |
A degraded result is also flagged isError: true. It is the only one
that is. The spec classifies "API failures" as tool execution errors and says
clients "SHOULD provide tool execution errors to language models to enable
self-correction", while nothing in the spec obliges a host to show
structuredContent to the model at all. A failure carried only by a field
inside the payload is therefore a failure the model may never see, which was
the whole problem search_status was added to solve.
The payload still rides along with the error -- structuredContent and the
text block are both present, so nothing is lost. api_usage in particular: a degraded
search still spent the caller's own RapidAPI requests, and hiding that would
hide a charge they have to pay. empty and
partial are not errors: one is a true negative and the other carries
results a caller can use.
Spend reporting (api_usage)
The money is yours, so the meter is visible. Every successful response carries:
Field | Meaning |
| Billed upstream requests this one tool call consumed. |
| What is left on your RapidAPI plan this period. |
| Your plan's limit for the period. |
| The same thing in a sentence, so the model can relay it to you before you ask. |
plan_requests_remaining and plan_requests_limit come from the upstream response and are
omitted when upstream does not report them; the note adapts. The rule the model should state
out loud: one date × one destination = one billed request.
Cost control knobs, in order of bluntness: max_searches per call (lower it to spend less on a
wide question), a narrower date range, a shorter destination list.
One call vs thirty
The underlying REST API takes exactly one (origin, destination, date) tuple per call. Against a
one-date-per-call passthrough, "cheapest to Sri Lanka anywhere in October" is 31 separate tool
calls: 31 round trips through the model, 31 chances to lose the thread, and a bill the user only
discovers afterwards.
Here it is one tool call. The fan-out happens server-side, concurrently, capped, evenly
sampled, deduplicated on buy_link, merged, sorted by your sort_by, and reported honestly in
search_coverage and api_usage.
This server | |
Fan-out per call | 30 (hard max 60; raise or lower per call with |
Ads | none |
Key | your own RapidAPI key |
Spend reporting |
|
This server carries no ads at all, not by taste but by constraint: Anthropic's connector directory policy and OpenAI's app guidelines both prohibit advertising and sponsored content in tool results.
Local development
git clone <this repo> && cd mcp_server_paid
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp example.env .env # fill it in; leave RAPIDAPI_KEY empty
set -a && . .env && set +a
.venv/bin/python -m src # streamable HTTP on http://localhost:8000/mcpPoint a client at the local process the same way:
claude mcp add --transport http google-flights-local http://localhost:8000/mcp --header "x-rapidapi-key: YOUR_RAPIDAPI_KEY"Tests (791 passing, verified):
.venv/bin/python -m pytest -qConfiguration lives in example.env; every variable is documented there. The ones that matter:
Variable | Default | Why it matters |
|
| Per-call fan-out cap. Clamped to a hard maximum of 60. |
|
| Concurrency of the fan-out. |
|
| Connection-pool ceiling; serverless instances share a file-descriptor pool. |
|
| The upstream function's |
|
| Results requested per individual upstream search. |
|
| Which product this deployment serves: |
| listing matching | Quoted back to users who arrive without a key. On |
|
| Which product each hostname serves, so one deployment can carry both paid domains and each listing still gets exactly its own tool set. |
|
| Reported by |
| (empty) | Leave empty in production. If set, every keyless caller is served on, and billed to, that subscription. The server logs a warning at startup and |
| (empty) | When set, |
| (empty) | Empty disables the file sink; stdout |
Operational routes: GET /health (public, unauthenticated: registries poll it),
GET /metrics, GET /metrics/calls?hours=24,
GET /.well-known/mcp/server-card.json (see below).
The static server card
GET /.well-known/mcp/server-card.json returns this deployment's metadata as a standalone
JSON document: serverInfo, description, transport, capabilities, authentication,
instructions, and the full tools and prompts lists exactly as tools/list serialises
them. Public, unauthenticated, Cache-Control: public, max-age=3600, open CORS.
It exists because /mcp answers 401 to a scanner that brings no credential, so an
automated scanner cannot read the tool list off the wire. Smithery's publish
page names this document as the way out: "If automatic scanning can't complete (auth wall,
required configuration, or other issues), you can provide server metadata manually via a
static server card at /.well-known/mcp/server-card.json". The field list follows
SEP-1649.
Two things worth knowing:
The tool list is read from the LIVE registry at first request, never written out by hand, so it cannot drift from what
tools/listreturns.tests/test_server_card.pycompares the two on both products.It is per hostname, like everything else here:
hotels.flightpowers.comreturns the hotel card, both flights hostnames return the flights card, and every URL inside is on that product's own origin.transport.endpointis/mcp, the single endpoint, with the always-challenge/mcp/oauthalias listed under_meta.
curl -s https://flights.flightpowers.com/.well-known/mcp/server-card.json | python3 -m json.tool
curl -s https://hotels.flightpowers.com/.well-known/mcp/server-card.json | python3 -m json.toolDeployment target is Vercel via api/index.py (FastAPI wrapper handing FastMCP its lifespan,
stateless_http=True). The canonical MCP path is /mcp, no trailing slash.
Never commit a real key. example.env ships with placeholders; keep it that way.
Run it in a container
The hosted server needs nothing installed. This is for self-hosting, and it is what lets Glama run its build test and cut a release.
docker build -t flightpowers-mcp .
docker run --rm -p 8000:8000 flightpowers-mcp
curl http://localhost:8000/healthThe container serves streamable HTTP on ${PORT}/mcp, the same transport as the hosted
deployment, via python -m src. api/index.py is the Vercel wrapper and is not used here.
No secret is baked into the image. Every search is billed to the caller's own RapidAPI
subscription and their key travels with the request as x-rapidapi-key. RAPIDAPI_KEY is
optional and is a server-side fallback: when set, a caller who sends no key of their own is
served on, and billed to, that subscription. Leave it unset unless that is what you want;
/health reports server_side_key_configured either way.
# optional, local development only
docker run --rm -p 8000:8000 -e RAPIDAPI_KEY=your_key flightpowers-mcpEvery variable in the table above works as -e NAME=value. HOST defaults to 0.0.0.0 and
PORT to 8000 inside the image. The HEALTHCHECK polls /health on $PORT, so overriding
PORT keeps working.
Non-affiliation
This is an independent API 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 public data source. Fares are supplied by the upstream provider, change constantly, and are not guaranteed. Always confirm the price on the airline or booking site before purchase.
Available Tools
4 toolsfind_hotel_by_nameFlightPowers: find one hotel by nameARead-onlyInspect
FlightPowers single-property lookup: live Booking.com availability and pricing for one named property. Input: the hotel name a person would type (adding the city helps when a chain has many properties) plus check-in and check-out dates -- no internal property ID needed, the resolution is done for you. Returns the property's price, review score, room type and a booking link. Use it to check one specific hotel, or to track a single property's price over time.
price_as_seen_from prices the stay as a shopper resident in that country would see it. Gaps are real but usually modest and property-dependent, and rates move between calls, so call each country a few times on this same property before reporting a gap.
Rates go stale within minutes: never reuse an earlier result.
Requires the caller's own RapidAPI key for the Booking Live API. Get one (free tier available) at https://rapidapi.com/mtnrabi/api/booking-live-api, then pass it as an x-rapidapi-key header (preferred), a ?rapidapi_key= query parameter on the server URL, or your client's own API key field -- first non-empty wins. Usage counts against the caller's own RapidAPI plan, not ours; every response reports what it spent and what is left in api_usage.
| Name | Required | Description | Default |
|---|---|---|---|
| adults | No | Number of adult guests. | |
| children | No | Number of children sharing the room. | |
| currency | No | ISO currency code for the prices returned, e.g. "usd". | |
| hotel_name | Yes | The property name a person would type, e.g. "Hotel Artemide". Adding the city ("Hotel Artemide Rome") disambiguates a chain with many properties. No internal property ID is needed. | |
| checkin_date | Yes | First night of the stay, "YYYY-MM-DD". | |
| checkout_date | Yes | Departure morning, "YYYY-MM-DD". Must be after checkin_date. | |
| price_as_seen_from | No | Two-letter country code, e.g. "de". Prices the stay as a shopper resident in that country would see it. Call each country a few times on this same property before reporting a gap, because rates move between calls and gaps are usually modest and property-dependent. |
Output Schema
| Name | Required | Description |
|---|---|---|
| message | No | |
| results | Yes | The itineraries or properties found, already sorted and deduplicated. An empty array is only meaningful when search_status is 'empty'. |
| api_usage | No | What this call cost the caller's own RapidAPI plan, and what remains on it. Present on every response that reached the upstream, including a degraded one -- a search that failed was still billed. |
| signup_url | No | Where the caller subscribes or changes plan. |
| result_count | No | |
| needs_api_key | No | True when no usable RapidAPI key arrived with the call, or the upstream rejected the one that did. No search was run and nothing was billed; signup_url and message say how to fix it. |
| applied_filters | No | Which of the requested filters the upstream actually applied. Untyped: the shape is the upstream's, echoed through. |
| quota_exhausted | No | True when the caller's RapidAPI plan has no requests left for the current period. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description fully discloses live external API behavior, the need for an API key, usage costs, and that rates can change between calls. It also warns 'never reuse an earlier result,' which is meaningful behavioral context beyond the readOnly/Idempotent annotations.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is organized into clear, purposeful sections: the core lookup behavior, the price_as_seen_from caveat, freshness warning, and API key instructions. While a bit long, every section adds operational value and the structure makes it easy to scan.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
It covers what the tool returns, authentication requirements, cost attribution, freshness expectations, and a specific pricing-locale caveat. That is sufficient for correct invocation, though the exact output schema shape is not spelled out in the description.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The input schema already has 100% coverage with detailed parameter descriptions, including disambiguation guidance and checkout-after-checkin constraints. The prose mostly repeats these details rather than adding new parameter-level meaning, so it meets the baseline but does not go beyond it.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with 'FlightPowers single-property lookup' and says it returns availability, pricing, review score, room type, and a booking link. This clearly defines the tool's exact function and distinguishes it from broad hotel search or flight search siblings.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description explicitly says to use it 'to check one specific hotel, or to track a single property's price over time,' which gives clear usage guidance. It does not explicitly mention the sibling search_hotels tool as the alternative for broad searches, but the 'single-property' framing makes that distinction clear.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
search_hotelsFlightPowers: search hotelsARead-onlyInspect
FlightPowers hotel search: live Booking.com availability and nightly prices for a destination and date range. Input: a free-text destination the way a person would say it ("Rome", "Tokyo Shibuya"), plus check-in and check-out dates. Returns each property's price, review score, room type, location and a booking link.
Set price_as_seen_from to a two-letter country code to price the same stay the way a shopper resident in that country would see it, which no other travel tool here can do. Gaps are real but usually modest and property-dependent, and rates move between calls, so hold one named property fixed, call each country a few times, and never read one call per country as a gap.
Rates go stale within minutes: never reuse an earlier result, search again.
Requires the caller's own RapidAPI key for the Booking Live API. Get one (free tier available) at https://rapidapi.com/mtnrabi/api/booking-live-api, then pass it as an x-rapidapi-key header (preferred), a ?rapidapi_key= query parameter on the server URL, or your client's own API key field -- first non-empty wins. Usage counts against the caller's own RapidAPI plan, not ours; every response reports what it spent and what is left in api_usage.
| Name | Required | Description | Default |
|---|---|---|---|
| adults | No | Number of adult guests. Defaults to the upstream default when omitted. | |
| filters | No | Property filters to apply, e.g. ["free_cancellation", "breakfast_included"]. An unknown name is rejected with the list of valid ones rather than being ignored. | |
| children | No | Number of children sharing the room. | |
| currency | No | ISO currency code for the prices returned, e.g. "usd". | |
| destination | Yes | Where to stay, in free text the way a person would say it, e.g. "Rome" or "Tokyo Shibuya". A city, district, landmark or region all work; no internal location ID is needed. | |
| checkin_date | Yes | First night of the stay, "YYYY-MM-DD". | |
| checkout_date | Yes | Departure morning, "YYYY-MM-DD". Must be after checkin_date. | |
| budget_per_night | No | Only return properties at or below this nightly price, in `currency`. | |
| price_as_seen_from | No | Two-letter country code, e.g. "de". Prices the stay through a residential connection in that country, so the result is what a shopper resident there would be quoted. For a rate-parity check hold one named property fixed and call each country a few times, because rates move between calls and one call per country can show a gap that is not there. Omit it for a neutral price. |
Output Schema
| Name | Required | Description |
|---|---|---|
| message | No | |
| results | Yes | The itineraries or properties found, already sorted and deduplicated. An empty array is only meaningful when search_status is 'empty'. |
| api_usage | No | What this call cost the caller's own RapidAPI plan, and what remains on it. Present on every response that reached the upstream, including a degraded one -- a search that failed was still billed. |
| signup_url | No | Where the caller subscribes or changes plan. |
| result_count | No | |
| needs_api_key | No | True when no usable RapidAPI key arrived with the call, or the upstream rejected the one that did. No search was run and nothing was billed; signup_url and message say how to fix it. |
| applied_filters | No | Which of the requested filters the upstream actually applied. Untyped: the shape is the upstream's, echoed through. |
| quota_exhausted | No | True when the caller's RapidAPI plan has no requests left for the current period. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Beyond the readOnlyHint annotation, the description discloses several important behaviors: results are live and go stale within minutes, unknown filters are rejected rather than ignored, API usage is charged to the caller's own plan, and the first non-empty API key source wins. This goes well beyond what the annotations alone convey.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is somewhat long, but nearly every sentence carries necessary operational or behavioral information. The repetition around rate-parity checks is slightly verbose but serves an important warning purpose, so the length is justified overall.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
The description covers what the tool returns, how to supply inputs, how pricing and filtering behave, how rate-parity checks should be performed, and how API keys and usage accounting work. Combined with the output schema, an agent has everything needed to invoke and interpret this tool correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The schema already covers 100% of parameters with meaningful descriptions, and the tool description adds further value by explaining the semantics of price_as_seen_from, including the country-code behavior and how to interpret rate-parity results. Parameter meaning is fully clear.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states that this is a hotel search tool for live Booking.com availability and nightly prices by destination and date range. It is immediately distinguishable from the flight-search siblings, and the title reinforces the purpose.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description gives direct guidance on when to use the tool and how to use it correctly, including the rate-parity workflow with price_as_seen_from, the need to hold one property fixed, and the warning that rates move between calls. It also explains API key handling and billing, leaving no ambiguity about operational usage.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
search_oneway_flightsFlightPowers: search one-way flightsARead-onlyInspect
FlightPowers one-way fare search: live prices read from Google Flights. Input: origin and destination IATA codes -- the destination may be several codes, as "BCN,LIS,ATH" or ["BCN","LIS","ATH"] -- plus either one departure date or a date range. Returns each flight's price, airline, duration, stops, a bookable buy_link, and Google's historical price range (price_insights_low / price_insights_high) so you can say whether a fare is actually a good deal.
Use it for any one-way fare question, including open-ended ones. For a flexible search make ONE call with a date range and/or several destinations -- do NOT call it once per date. 'Cheapest flight to Sri Lanka anywhere in October' is one call, not thirty.
Each date/destination combination is one billed request; the count and the plan's remaining quota come back in api_usage.
by_destination carries one entry per destination you asked for -- empty ones included, each with a reason -- so read it before telling a user a destination has no flights.
Requires the caller's own RapidAPI key for the Google Flights Live API. Get one (free tier available) at https://rapidapi.com/mtnrabi/api/google-flights-live-api, then pass it as an x-rapidapi-key header (preferred), a ?rapidapi_key= query parameter on the server URL, or your client's own API key field -- first non-empty wins. Usage counts against the caller's own RapidAPI plan, not ours; every response reports what it spent and what is left in api_usage.
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | Maximum flights to return, after merging and sorting. | |
| sort_by | No | "best", "price", or "duration". Applied across all results. | best |
| currency | No | ISO currency code, default "usd". | usd |
| max_price | No | Only return flights at or below this price. | |
| max_stops | No | Maximum stops per flight. 0 means non-stop only. | |
| seat_type | No | 1 economy, 2 premium economy, 3 business, 4 first. | |
| passengers | No | Passenger counts as [adults, children, infants]. | |
| to_airport | Yes | Destination airport. One IATA code ("BCN"), several separated by commas ("BCN,LIS,ATH"), or a list (["BCN","LIS","ATH"]) -- every shape is accepted and the destinations are compared in the same search. | |
| from_airport | Yes | Origin IATA code, e.g. "TLV". One origin per search; a second one is refused rather than searched. | |
| max_searches | No | Cap the billed requests this call may make. Lower it to spend less of the plan's quota on a wide search; the range is then sampled evenly rather than cut short. | |
| use_fallback | No | Leave unset. Switches the search to a second, independent flight data source instead of the usual Google Flights page read. Unset already escalates to that source once, automatically, after a search's retries have failed. true forces it inline on every attempt -- much slower, and it can time out. false disables it entirely, that automatic retry included. | |
| airline_codes | No | Restrict to these airline codes, e.g. ["LY"]. | |
| departure_date | No | Single departure date, "YYYY-MM-DD". | |
| arrival_time_max | No | Latest arrival hour, 0-23. | |
| arrival_time_min | No | Earliest arrival hour, 0-23. | |
| departure_date_to | No | Last date of a departure range. | |
| departure_time_max | No | Latest departure hour, 0-23. | |
| departure_time_min | No | Earliest departure hour, 0-23. | |
| departure_date_from | No | First date of a departure range. | |
| exclude_airline_codes | No | Exclude these airline codes. |
Output Schema
| Name | Required | Description |
|---|---|---|
| message | No | Present when there is something the model must relay to the user rather than silently absorb -- no results, a degraded search, a missing key or a spent quota. |
| partial | No | Present when some searches failed but others succeeded. Plain text saying how much of the request the results cover. |
| results | Yes | The itineraries or properties found, already sorted and deduplicated. An empty array is only meaningful when search_status is 'empty'. |
| api_usage | No | What this call cost the caller's own RapidAPI plan, and what remains on it. Present on every response that reached the upstream, including a degraded one -- a search that failed was still billed. |
| signup_url | No | Where the caller subscribes or changes plan. |
| result_count | No | |
| needs_api_key | No | True when no usable RapidAPI key arrived with the call, or the upstream rejected the one that did. No search was run and nothing was billed; signup_url and message say how to fix it. |
| search_status | No | Whether the underlying search actually completed, read from the backend's X-Search-Status header. 'ok': every combination searched returned results. 'empty': the search completed and Google genuinely has no itineraries for it -- a real answer, not a failure. 'partial': some combinations returned results and some failed, so the list is incomplete. 'degraded': every combination failed, so the search did not happen and an empty list means nothing; this case is also flagged with isError: true and is safe to retry. |
| by_destination | No | One entry per destination the REQUEST asked for, in request order, present whether or not that destination has any flights in `results`. A destination with an empty `rows` array is a hole in the answer, and `reason` says which kind of hole: 'no_flights' (searched, answered, Google has nothing), 'search_failed' (searched and the search errored, so nothing is known), 'not_in_limit' (searched, found flights, none fitted in `limit`) or 'not_searched' (never searched -- the per-call fan-out cap sampled it away). 'ok' means it has rows. Read this rather than inferring coverage from `results`: a destination missing from `results` looks identical to one that has no flights, and they are not the same answer. `rows` are the same row objects that are in `results`, in the same order -- nothing here is data the answer does not already contain. |
| quota_exhausted | No | True when the caller's RapidAPI plan has no requests left for the current period. |
| search_coverage | No | What was actually searched. Present whether or not the request was truncated, so a model can state honestly what its answer rests on. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description discloses return fields (price, airline, duration, stops, buy_link, price_insights) and billing reporting via api_usage. It further explains fallback source behavior, max_searches sampling, and that usage counts against the caller's own RapidAPI plan, all beyond the annotations.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Although lengthy, each paragraph serves a distinct purpose—result contents, invocation advice, billing, and API-key setup—and includes concrete examples without redundant filler. The structure is logical and easy to scan.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
The description covers key operational concerns an external agent needs: API key requirements, quota reporting, multi-destination/date handling, and fallback behavior. Since an output schema is present, no additional return-value documentation is necessary.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
All 20 parameters have schema descriptions, and the description adds practical semantics such as accepted shapes for to_airport, origin singularity, date-range versus single-date usage, and seat_type/passenger mappings. This goes well beyond the schema's basic property descriptions.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
Title and first sentence explicitly state 'one-way fare search' and 'live prices read from Google Flights,' clearly distinguishing it from sibling roundtrip/hotel tools. The verb 'search' plus resource 'flights' makes the purpose unambiguous.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description gives explicit guidance: 'Use it for any one-way fare question' and 'do NOT call it once per date,' with a concrete example of a single flexible search. It also explains API-key requirements, billing consequences, and that a second origin is refused rather than searched.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
search_roundtrip_flightsFlightPowers: search round-trip flightsARead-onlyInspect
FlightPowers round-trip fare search: live prices read from Google Flights, priced as paired legs rather than two separate one-ways. Input: origin and destination IATA codes -- the destination may be several codes, as "BCN,LIS,ATH" or ["BCN","LIS","ATH"] -- a departure date or range, and either a return date or a trip length in nights. Returns the total price for both legs, per-leg airline, stops and duration, and a single bookable buy_link for the trip.
Use it for any return-trip fare question. For a flexible search make ONE call: pass departure_date_from / departure_date_to for the outbound range and nights instead of return_date to compare trip lengths -- '5 to 7 nights in Rome sometime in May' is one call.
Each date/destination combination is one billed request; the count and the plan's remaining quota come back in api_usage.
by_destination carries one entry per destination you asked for -- empty ones included, each with a reason -- so read it before telling a user a destination has no flights.
Requires the caller's own RapidAPI key for the Google Flights Live API. Get one (free tier available) at https://rapidapi.com/mtnrabi/api/google-flights-live-api, then pass it as an x-rapidapi-key header (preferred), a ?rapidapi_key= query parameter on the server URL, or your client's own API key field -- first non-empty wins. Usage counts against the caller's own RapidAPI plan, not ours; every response reports what it spent and what is left in api_usage.
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | Maximum trips to return, after merging and sorting. | |
| nights | No | Trip length in nights; a number, or a list like [5, 6, 7]. The return date is derived from each departure date. | |
| sort_by | No | "best", "price", or "duration". Applied across all results. | best |
| currency | No | ISO currency code, default "usd". | usd |
| max_price | No | Only return trips at or below this total price. | |
| seat_type | No | 1 economy, 2 premium economy, 3 business, 4 first. | |
| passengers | No | Passenger counts as [adults, children, infants]. | |
| to_airport | Yes | Destination airport. One IATA code ("BCN"), several separated by commas ("BCN,LIS,ATH"), or a list (["BCN","LIS","ATH"]) -- every shape is accepted and the destinations are compared in the same search. | |
| return_date | No | Fixed return date. Use this OR nights, not both. | |
| from_airport | Yes | Origin IATA code, e.g. "TLV". One origin per search; a second one is refused rather than searched. | |
| max_searches | No | Cap the billed requests this call may make. Lower it to spend less of the plan's quota on a wide search; the range is then sampled evenly rather than cut short. | |
| use_fallback | No | Leave unset. Switches the search to a second, independent flight data source instead of the usual Google Flights page read. Unset already escalates to that source once, automatically, after a search's retries have failed. true forces it inline on every attempt -- much slower, and it can time out. false disables it entirely, that automatic retry included. | |
| departure_date | No | Single outbound date, "YYYY-MM-DD". | |
| max_return_stops | No | Maximum stops on the return leg. | |
| departure_date_to | No | Last date of an outbound range. | |
| departure_date_from | No | First date of an outbound range. | |
| max_departure_stops | No | Maximum stops on the outbound leg. | |
| return_airline_codes | No | Restrict the return leg to these airlines. | |
| departure_airline_codes | No | Restrict the outbound leg to these airlines. |
Output Schema
| Name | Required | Description |
|---|---|---|
| message | No | Present when there is something the model must relay to the user rather than silently absorb -- no results, a degraded search, a missing key or a spent quota. |
| partial | No | Present when some searches failed but others succeeded. Plain text saying how much of the request the results cover. |
| results | Yes | The itineraries or properties found, already sorted and deduplicated. An empty array is only meaningful when search_status is 'empty'. |
| api_usage | No | What this call cost the caller's own RapidAPI plan, and what remains on it. Present on every response that reached the upstream, including a degraded one -- a search that failed was still billed. |
| signup_url | No | Where the caller subscribes or changes plan. |
| result_count | No | |
| needs_api_key | No | True when no usable RapidAPI key arrived with the call, or the upstream rejected the one that did. No search was run and nothing was billed; signup_url and message say how to fix it. |
| search_status | No | Whether the underlying search actually completed, read from the backend's X-Search-Status header. 'ok': every combination searched returned results. 'empty': the search completed and Google genuinely has no itineraries for it -- a real answer, not a failure. 'partial': some combinations returned results and some failed, so the list is incomplete. 'degraded': every combination failed, so the search did not happen and an empty list means nothing; this case is also flagged with isError: true and is safe to retry. |
| by_destination | No | One entry per destination the REQUEST asked for, in request order, present whether or not that destination has any flights in `results`. A destination with an empty `rows` array is a hole in the answer, and `reason` says which kind of hole: 'no_flights' (searched, answered, Google has nothing), 'search_failed' (searched and the search errored, so nothing is known), 'not_in_limit' (searched, found flights, none fitted in `limit`) or 'not_searched' (never searched -- the per-call fan-out cap sampled it away). 'ok' means it has rows. Read this rather than inferring coverage from `results`: a destination missing from `results` looks identical to one that has no flights, and they are not the same answer. `rows` are the same row objects that are in `results`, in the same order -- nothing here is data the answer does not already contain. |
| quota_exhausted | No | True when the caller's RapidAPI plan has no requests left for the current period. |
| search_coverage | No | What was actually searched. Present whether or not the request was truncated, so a model can state honestly what its answer rests on. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Discloses external RapidAPI dependency, caller-provided key requirements, billing/quota consumption, retry and fallback behavior, and the fact that each destination/date combination is a billed request. This goes beyond the read-only annotation and gives accurate expectations of side effects.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is thorough but somewhat long, with some details repeated across paragraphs and parameter descriptions. However, the content is organized into clear topical paragraphs and nearly every sentence carries practical guidance, making the length justified overall.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Covers external API integration, authentication, billing, fallback source switching, result composition, and the meaning of api_usage and by_destination. Given the tool's complexity and external dependencies, the description contains all necessary context for correct invocation and interpretation.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Every schema parameter has a meaningful description, and the description adds important semantic details such as single-origin refusal, accepted destination formats, nights vs return_date exclusivity, and even sampling behavior for max_searches. The prose supplements the schema rather than merely repeating it.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
Clearly identifies the tool as a round-trip flight fare search reading live prices from Google Flights and pricing paired legs, not separate one-ways. This distinguishes it from the sibling one-way search tool without ambiguity.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicitly directs use for any return-trip fare question and gives concrete guidance for flexible date ranges, multi-destination searches, and quota control via max_searches. The instructions about return_date vs nights and fallback behavior leave little room for misuse.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
4 tool updates
v1.0.0- First observed
find_hotel_by_name - First observed
search_hotels - First observed
search_oneway_flights - First observed
search_roundtrip_flights
TDQS
Scored across 4 tools
Each tool targets a clearly distinct query type: one-way flights, round-trip flights, general hotel search, and specific hotel lookup. Even the two hotel tools are easy to differentiate because one is broad destination search and the other is single-property lookup.
Three tools follow the search_<subject> pattern: search_oneway_flights, search_roundtrip_flights, and search_hotels. find_hotel_by_name breaks the pattern by using find_ instead of search_, though it remains readable and predictable.
Four tools is a well-scoped set for a travel-search server. Each tool earns its place and the count avoids both bloat and thinness.
The core search workflows are covered: one-way flights, round-trip flights, hotel search, and named-hotel lookup. Obvious gaps include multi-city flight search and airport/place code resolution, but agents can work around these with external knowledge or by combining existing tools.
Maintenance
Related MCP Connectors
Google Flights search data: fares, routes, stops, and price insights via a hosted MCP server.
Flight Intelligence MCP — search, cheapest dates, multi-city, airline compare via Google Flights
Live flight prices and working booking links for AI agents and travel apps.
Free, no-login flight search with real-time pricing from multiple airlines.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables AI clients to explore cheapest destinations, optimize multi-leg flight itineraries, and reference airport/region data via MCP tools and resources.MIT
- AlicenseNot gradedqualityBmaintenanceFlight search for AI agents: flexible-date cheapest round-trips with a good-price verdict from historical data. Hosted remote MCP, free, no key.3MIT
- AlicenseNot gradedqualityDmaintenanceProvides live flight prices, booking links, and airport lookup via a hosted MCP server. Enables search for flights and direct booking URL retrieval.1MIT
- AlicenseAqualityAmaintenanceEnables MCP clients to search one-way, round-trip, and multi-city itineraries and retrieve fares, flight legs, carbon emissions and price history as structured JSON.214876MIT