mcpField
G
Google Flights Search (Real-Time Fares)

Google Flights Search (Real-Time Fares)

远程服务
flights.flightpowers.com地图与出行1今天更新

关于 Google Flights Search (Real-Time Fares)

托管式 MCP 服务,可按日期范围和目的地搜索 Google Flights 实时机票。面向旅行 Agent 和开发者,结果包含 Google 的低价、常规或高价判断及预订链接;服务只搜索和报价,不执行预订。

主要功能

  1. 01Google Flights 实时票价
  2. 02按日期范围和机场搜索
  3. 03Google 价格水平判断
  4. 04往返行程总价
  5. 05Google Flights 预订链接

适用场景

  1. 01在出行日期范围内寻找低价
  2. 02比较目的地与票价水平
  3. 03查询指定停留时长的往返价格

MCP 相关

在 https://flights.flightpowers.com/mcp 向 MCP 客户端提供航班搜索工具。客户端可使用 Google 登录授权或提供 RapidAPI 密钥。

配置

远程服务无需本地安装。选择你使用的客户端,复制配置或使用一键安装。

服务地址https://flights.flightpowers.com/mcp
一键复制安装命令
Claude CodeTerminal
Cursor.cursor/mcp.json
VS Code.vscode/mcp.json
Claude Desktopclaude_desktop_config.json
Windsurf~/.codeium/windsurf/mcp_config.json

命令在终端中运行;JSON 粘贴到对应客户端的 MCP 配置后重启。需要授权的服务会在首次连接时提示登录。

全部服务地址
Streamable HTTPhttps://flights.flightpowers.com/mcp需要授权

工具

2
参数类型必填说明
limitinteger否合并并排序后要返回的航班数量上限。
sort_bystring否"best"、"price" 或 "duration"。适用于所有结果。
verboseboolean否返回上游针对每个票价发送的所有字段,以及所有入选票价,不限制行数。默认关闭:结果会按 sort_by 返回最优行,并采用紧凑格式(日期、目的地、航空公司、经停次数、时长、价格、行程长度和预订链接)。否则,按整月和多个目的地进行宽范围搜索会产生数 MB 的 JSON,有些宿主根本无法将其放入对话中。需要抵达时间、每段航程的经停次数、原始经停详情,或多于 results_returned 的行数时,请开启此选项;results_total 始终显示结果总数。
currencystring否ISO 货币代码,默认值为 "usd"。
max_priceany否仅返回价格不高于此值的航班。
max_stopsany否每个航班的最大经停次数。0 表示仅限直飞。
seat_typeany否1 经济舱,2 高端经济舱,3 商务舱,4 头等舱。
passengersany否每位旅客提供一个条目,而非人数:1 成人,2 儿童(2–11 岁),3 怀抱婴儿,4 占座婴儿。例如,两名成人和一名儿童可填写 [1, 1, 2]。至少需要一名成人;每名怀抱婴儿都需要一名成人陪同;最多 9 人。单名成人可省略此项。也支持人数列表 [成人数, 儿童数, 婴儿数],例如 [2, 1, 0],或表示两名成人的简单列表 [2];搜索前会将其转换为代码。若列表符合代码格式,则按代码搜索。
to_airportany是目的地机场。单个 IATA 代码("BCN")、以逗号分隔的多个代码("BCN,LIS,ATH")或列表(["BCN","LIS","ATH"])均可——所有格式都会被接受,并在同一次搜索中比较各目的地。
from_airportstring是出发地 IATA 代码,例如 "TLV"。每次搜索只能指定一个出发地;指定第二个出发地会被拒绝,而不会进行搜索。
max_searchesany否此调用可产生的付费请求数上限或下限。留空时采用正常行为:当请求合理时,限额会覆盖整个请求(整月、三种行程时长为 93 个组合;整月 × 3 晚 × 3 个目的地为 279 个组合;两种情况都会完整执行),超过限额的部分则会在整个范围内均匀抽样,而不是提前截断。设置较低值可减少宽范围搜索消耗的套餐配额;也可设置较高值,硬性上限为 300,以支持范围更大的网格搜索。
use_fallbackany否保持未设置。将搜索切换到第二个独立航班数据源,而不是通常读取 Google Flights 页面。未设置时,搜索重试失败后会自动切换到该数据源一次。设为 true 时,每次尝试都会直接使用该数据源——速度慢得多,也可能超时。设为 false 时会完全禁用该数据源,包括自动重试。
airline_codesany否仅限这些航空公司代码,例如 ["LY"]。
departure_dateany否单个出发日期,"YYYY-MM-DD"。
arrival_time_maxany否最晚抵达小时,0–23。
arrival_time_minany否最早抵达小时,0–23。
departure_date_toany否出发日期范围的最后一天。
departure_time_maxany否最晚出发小时,0–23。
departure_time_minany否最早出发小时,0–23。
departure_date_fromany否出发日期范围的起始日期。
exclude_airline_codesany否排除这些航空公司代码。

详细介绍

Google Flights MCP:可让 Agent 在整个日期范围内搜索实时票价

用于查询实时机票价格和酒店房价的 MCP 服务:Claude、Claude Code、Cursor、Cline 及任何其他 MCP 客户端都可以将实时 Google Flights 票价和 Booking.com 房价作为工具使用。一次调用即可搜索整个日期范围和一系列目的地机场;每个票价都附有 Google 自带的低价/通常/高价判定和价格范围;往返行程按一次请求计价(两段航程、一个总价、一个预订链接)。托管于 https://flights.flightpowers.com/mcp(酒店服务位于 https://hotels.flightpowers.com/mcp),无需安装任何内容。它负责查找和报价,不负责预订。

由 FlightPowers 构建,这是面向开发者和 AI Agent 的旅行数据 API:REST、MCP 服务和 n8n 节点共用一个 RapidAPI 密钥。使用 Google 登录,每天前 10 次搜索免费。

**获取密钥:**在 RapidAPI 上订阅 Google Flights Live API 和 Booking Live API。每月可免费搜索 10 次,之后 PRO 套餐为 $10,可搜索 2,500 次;每次搜索的价格约为 SerpApi 的六分之一(其最便宜的套餐为 $25,可搜索 1,000 次)。

一个 URL,两种接入方式:

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

在支持 MCP 授权的客户端中,会出现登录按钮:使用 Google 登录后,每天前 10 次搜索都使用我们的密钥免费进行,无需粘贴任何内容。想取消这一限制时,只需在 /connect 页面粘贴一次自己的 RapidAPI 密钥;无论哪种方式,都无需在客户端配置中填入任何内容。在不显示登录按钮的客户端中,请自行提供密钥:

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

两种方式使用的都是同一个 URL。携带任何凭据的请求都会得到处理;完全不携带凭据的请求会收到 401 和 OAuth 元数据,这正是登录按钮会出现的原因。https://flights.flightpowers.com/mcp/oauth 仍然可用,并会在首次请求时要求登录,适用于添加服务时就固定了授权模式的客户端。

托管服务。无需克隆,无需构建。已列入官方 MCP Registry,名称为 com.flightpowers/google-flights-mcp。健康检查: /health。

**还没有密钥?**开始使用不需要密钥。添加上方的 URL,使用 Google 登录,即可每天 UTC 时间前 10 次搜索免费使用我们的密钥,无需粘贴任何内容。具体的计数单位见无需密钥即可试用。

**需要密钥?**在 RapidAPI 上订阅 Google Flights Live API,可选免费套餐,然后复制你的 x-rapidapi-key:https://rapidapi.com/mtnrabi/api/google-flights-live-api

**想要更多免费搜索次数,而且不介意广告?**我们还提供独立的免费广告支持服务: claude mcp add --transport http google-flights-free https://free-trial.flightpowers.com/mcp (每个已登录账户每天可搜索 50 次、每月可搜索 250 次;每个结果显示一张明确标注的赞助卡片;扇出搜索上限为 15 次;无法显示赞助卡片的客户端可能会受到更严格的限制。)如果广告、15 次搜索上限或客户端限制影响了你的使用,请回到这里。


Agent 能获得什么

两个回答票价问题而非日期查询的工具。

  • 提出开放式问题。“10 月任意一天去斯里兰卡,最便宜的单程票价是多少?”“5 月某个时间从特拉维夫或拉纳卡出发,在罗马住 5 到 7 晚”:每个问题都只需调用一次工具。两个工具都接受出发日期范围、目的地机场列表,以及往返行程的 nights 值(而非固定返程日期),并在内部展开查询。
  • **判断价格是否真的划算。**每条结果都附带 Google 提供的该航线和时段的历史价格范围:price_insights_low、price_insights_high,以及 price_range_in_relation_to_other_periods 字段给出的 low / typical / high 判定。这样,Agent 就能回答“这里的 $209 属于常见价格,不用急着订”,而不只是报出一个数字。
  • **直接预订,而不只是浏览。**每条结果都包含一个指向 Google Flights 的 buy_link。
  • **了解花费。**每次响应都包含 api_usage:本次调用使用的请求数,以及调用者套餐剩余的请求数。请参阅用量报告。
  • **了解搜索范围。**每次响应都包含 search_coverage,让模型能如实说明答案涵盖了哪些日期和目的地。

结果是实时票价。几分钟内就会过期:需要当前价格时,请重新搜索,而不要重复使用之前的结果,并说明数据的获取时间。

无需密钥即可试用:使用 Google 登录

登录后,账号每天可使用我们的密钥免费搜索 10 次,无需向客户端粘贴任何内容。通过客户端提供的方式登录,或访问 https://flights.flightpowers.com/connect,然后查询票价。目前托管部署提供 10 次;/health 会报告实时值 trial_enabled 和 trial_day_cap,应以此为准。(此代码默认关闭免费额度;需明确设置 PAID_TRIAL_DAY_CAP 和 PAID_TRIAL_RAPIDAPI_KEY 才能启用。因此,除非自行配置,否则你运行的部署不会提供免费额度。)

一次搜索对应一个日期 x 目的地组合,因此,一次调用查询三天会消耗三次额度。系统会在发出第一个请求前预留整个计划所需的额度,并立即退还扇出查询未实际发送的部分,因此两个同时发起的调用无法重复消耗同一份额度。每条结果都包含一个 trial 区块,其中列出已用次数和上限。超过上限后,工具会返回 search_status: "trial_exhausted",且不提供结果——这不是错误,重试也没有用;额度会在每天 00:00 UTC 重置。

免费搜索的上限取决于当天剩余额度:如果日期范围或网格超过剩余额度(例如,整个月查询三种行程长度需要 93 次搜索),系统会在开始执行前拒绝请求,并告知所需次数以及可以改问的较小问题。使用自己的密钥时,上限会自动提高,以涵盖整月请求。一次调用中比较多个来源(在启用该功能的部署上使用 compare_hotel_rates)不属于免费额度范围,需使用自己的密钥。

连接自己的 RapidAPI 密钥即可完全取消上限。请见下一节。

获取密钥(提供免费套餐)

服务端不持有自己的上游凭证。超出免费额度的每次搜索都会计入你的 RapidAPI 订阅,因此密钥需要随请求一起传递。

  1. 订阅 Google Flights Live API: https://rapidapi.com/mtnrabi/api/google-flights-live-api
  2. 复制你的 x-rapidapi-key。
  3. 通过以下三种方式中的任意一种将其传给服务端。

如果缺少密钥,且你无法使用免费额度,工具不会静默失败,也不会产生任何费用。它们会返回 needs_api_key: true,并附上注册 URL 和这些说明,措辞适合让模型直接读给你听。

传递密钥的三种方式

方式方法适用场景
请求头(首选)--header "x-rapidapi-key: YOUR_RAPIDAPI_KEY"任何支持设置请求头的场景。密钥不会出现在 URL 中,因此也不会出现在代理和访问日志中。
查询参数https://google-flights-mcp.flightpowers.com/mcp?rapidapi_key=YOUR_RAPIDAPI_KEY只能粘贴 URL 的主机:claude.ai 的自定义连接器对话框就是这种情况。
客户端 API 密钥字段将密钥粘贴到客户端自己的“API 密钥”框中会发送 authorization: Bearer <key> 或 x-api-key 的主机也适用。Smithery 的已保存配置表单(config.rapidApiKey=)同样受支持。

按上述顺序,使用第一个非空来源。密钥绝不会被记录、写入错误消息或包含在工具响应中。

第四种方式:在 /connect 登录一次

本 README 顶部的登录 URL 会将你带到此页面。支持 MCP 授权的客户端会自动引导你完成操作;下面的步骤是手动完成同一操作的方法。

在启用了此功能的部署中(查看 /health 上的 connect_enabled),/connect 页面可通过登录取代上述所有方式:

  1. 打开 **https://google-flights-mcp.flightpowers.com/connect**(酒店服务: **https://hotels.flightpowers.com/connect**),然后使用 Google 登录。
  2. 将 RapidAPI 密钥粘贴到表单中,通过 TLS 传输。
  3. 按下 显示 URL,然后复制连接 URL …/mcp?fp_token=fpk_…,并将其用作 MCP 客户端中的服务 URL。支持设置请求头的客户端也可以改为发送相同的令牌,格式为 Authorization: Bearer fpk_…。(只有在你主动请求后才会显示:该 URL 是你套餐的 90 天承载凭证,默认显示此 URL 的页面会让它出现在每张截图和屏幕共享中。)

如果客户端本身已为你完成登录——例如 Claude、Cursor、ChatGPT,以及其他支持 MCP 授权的客户端——则无需复制 URL。/connect 会说明这一点:它会显示你连接的密钥,并提示你返回助手。如果第二个客户端无法登录且确实需要连接 URL,页面上也有相应链接。

这样做的好处是:RapidAPI 密钥不会出现在客户端配置、URL 或 URL 经过的任何日志中。代价是:服务会加密存储你的密钥,并知道你的 Google 账号 ID 和电子邮件地址。你可以在同一页面点击 Disconnect,删除记录并立即撤销你账号的所有连接令牌。完整说明请参见隐私政策第 2a 节。

以下几点值得注意:

  • 保存时会进行一次检查。 密钥会在存储前针对 API 列表进行验证,因此拼写错误会立即在页面上报出,而不是等一小时后在客户端中才出现。此检查最多会使用你套餐中的一次请求:对于每月 10 次请求的免费 BASIC 套餐,这会占用其中一次。若 RapidAPI 在网关处拒绝密钥,则不会产生费用。
  • 请求中携带的密钥始终优先。 如果你发送了 x-rapidapi-key 请求头(或使用上述其他方式),同时又携带了连接令牌,则会使用请求自身的密钥。登录不会改变你已设置的任何行为。
  • 令牌不是你的密钥,也无法还原为密钥。令牌有效期为 90 天,并会在你断开连接时立即失效。携带的令牌若对应的密钥已断开连接,调用会返回 needs_api_key,提示你重新连接。它绝不会回退到其他人的订阅,也不会消耗任何额度。
  • BASIC 免费。 Google Flights Live API · Booking Live API。一个 RapidAPI 密钥即可用于你已订阅的任一 API;只需连接一次。
在自己的部署中运行 /connect

默认关闭。只有同时设置了 以下四项 才会启用。配置不完整的部署不会注册任何路由,仍会像之前一样为带密钥的调用者提供服务;/health 会报告 connect_enabled,因此可以直接查看,而不必猜测。

变量含义
GOOGLE_OAUTH_CLIENT_IDGoogle Cloud Console → Credentials → OAuth client ID,类型为 Web application。以 .apps.googleusercontent.com 结尾。
GOOGLE_OAUTH_CLIENT_SECRET同一客户端的密钥(GOCSPX-…)。
MCP_KEY_MASTER32 字节,base64 编码:openssl rand -base64 32。用于加密已存储的密钥(AES-256-GCM),并派生 Cookie 和令牌签名密钥。
DATABASE_URLNeon Postgres 的连接池端点(…-pooler…)。Schema:migrations/001_mcp_user_keys.sql。

可选:MCP_CONNECT_VALIDATE=0 可在不先验证的情况下存储粘贴的密钥。

在 Google 客户端上注册的授权重定向 URI,每个产品源站一条,必须完全匹配:

https://google-flights-mcp.flightpowers.com/connect/callback
https://hotels.flightpowers.com/connect/callback

flights.flightpowers.com 无需添加。由于 Cookie 按主机隔离,且 Google 会逐字比较 redirect_uri,/connect 和 /connect/start 会在开始登录前将别名重定向到规范源站:如果别名自行开始登录,登录完成后会返回到没有状态 Cookie 的主机,并失败;错误消息看起来会像是 Google 配置错误。

OAuth 同意屏幕上还需设置范围 openid 和 .../auth/userinfo.email,不添加其他范围。

轮换 MCP_KEY_MASTER 会让所有人退出登录,并使所有已存储的密钥失效。 这是有意为之:轮换后,不会留下任何持有令牌却无法读取对应密钥的情况。用户会看到“重新连接”,而不是搜索失败。表中的 key_version 字段是为了今后能够分阶段轮换,而不必在同一时间切换所有内容。

端到端验证流程

先对每个数据库执行一次迁移:

psql "$DATABASE_URL" -f migrations/001_mcp_user_keys.sql

然后,在部署完成后:

# 1. The feature is actually on.
curl -s https://google-flights-mcp.flightpowers.com/health | grep connect_enabled

# 2. The page renders for an anonymous visitor.
curl -sI https://google-flights-mcp.flightpowers.com/connect        # 200
curl -sI https://google-flights-mcp.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://google-flights-mcp.flightpowers.com/mcp?fp_token=fpk_...

# 5. Claude Code, the same URL.
claude mcp add --transport http flightpowers \
  "https://google-flights-mcp.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://google-flights-mcp.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.

tools/list 成功并不能证明上述任何内容正常:只有工具实际运行时,才会使用令牌。第 4 步必须执行一次真实搜索。

第五种方式:在 MCP 客户端内登录

只有当请求返回 401,且带有 WWW-Authenticate: Bearer resource_metadata=… 标头时,MCP 客户端才会开始登录流程。网络传输中的其他任何内容都不会告知客户端可以登录。在 2026-09-09 之前,/mcp 从未发送该标头,因此没有密钥的调用者会收到状态码为 200、正文写着 needs_api_key 的响应:JSON 本身正确,但客户端的身份验证机制完全看不到,用户只会看到“工具失败了”,却无处点击。

现在,/mcp 会发出身份验证挑战,但仅针对没有提供任何凭据的调用者,且只在会产生费用的调用上:

URL行为
https://flights.flightpowers.com/mcp应该使用这个。 无论是标头中的 RapidAPI 密钥、查询字符串中的密钥、Smithery 配置块中的密钥、fpk_ 连接令牌还是 fpo_ 访问令牌,处理方式都与以前完全相同。如果完全没有凭据,仍会响应 initialize、tools/list 和其余只读握手请求;而 tools/call 会返回 401 和身份验证挑战,让客户端显示“登录”按钮。
https://hotels.flightpowers.com/mcp酒店服务的行为相同。
…/mcp/oauth同一个服务,但从第一个请求开始就要求登录。适用于添加服务器时身份验证模式固定的客户端,以及更改前保存了该 URL 的连接器。

工具相同、按主机名路由产品的方式相同,其他一切也都相同。这个别名是经过令牌检查的同一份工具注册表,而不是服务器的第二个副本。

保护付费集成的特性: 携带凭据的请求绝不会收到身份验证挑战,凭据的优先顺序也未改变(标头、查询字符串、配置块、OAuth 身份、连接令牌,最后是 RAPIDAPI_KEY 环境变量回退)。错误密钥也不等于“没有凭据”:请求仍会到达工具,并返回 RapidAPI 给出的准确错误,因为把它替换成登录提示会是更糟糕的处理方式。设置了 RAPIDAPI_KEY 的部署会有意让无密钥调用者使用自身套餐进行访问,并且绝不会发出身份验证挑战。设置 MCP_REQUIRE_AUTH=off 即可完全关闭身份验证挑战,无需重新部署。

发现服务的接口保持开放,这是我们付出代价才学到的。 2026-09-09,有几个小时,身份验证挑战也覆盖了 initialize 和 tools/list。Glama 每小时都会通过无凭据地建立 MCP 连接并列出工具,重新检查每个连接器;当晚,两个付费目录条目都被标记为不健康,排名也随之下降。Smithery 的发布扫描、mcpservers.org 和 M8ven 也采用相同的探测方式。因此,界限划在产生费用的操作上,而不是建立连接上:任何人都可以使用 initialize、notifications/initialized、ping、tools/list、prompts/list 和 resources/list(src/discovery.py),其他所有操作都需要密钥或登录。包含 tools/call 的批次、超大请求正文和无法解析的请求都会受到挑战——允许列表采用默认拒绝策略。/mcp/oauth 仍会对所有请求发出身份验证挑战;如果目录要求服务器始终进行身份验证,就应提供这个 URL。对于读取信息卡片的扫描器,公开且无需身份验证的 /.well-known/mcp/server-card.json 仍然可用。

使用体验。 将 /mcp/oauth URL 粘贴到客户端中。客户端会自行注册并打开浏览器;你使用 Google 登录后,在一个明确说明客户端权限的页面上批准这个具名客户端:只能运行搜索,费用记入你自己的 RapidAPI 套餐,仅此而已。客户端永远不会看到你的 RapidAPI 密钥。如果你还没有连接密钥,批准仍然有效;首次搜索会提示你前往 /connect 粘贴密钥,并附上 URL。

如何撤销,以及可以撤销什么。 在 /connect 页面点击断开连接:存储的密钥会被删除,该 Google 账号的所有 OAuth 令牌也会一并删除。已连接的客户端会立即停止工作。也可以单独让客户端调用 /oauth/revoke(RFC 7009)。

客户端设置
# Claude Code
claude mcp add --transport http flightpowers \
  "https://google-flights-mcp.flightpowers.com/mcp/oauth"
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://google-flights-mcp.flightpowers.com/mcp/oauth" } } }
# Cursor discovers the 401, registers itself and opens the browser.

# ChatGPT -- Settings -> Connectors -> Create. It asks for:
#   MCP server URL:  https://google-flights-mcp.flightpowers.com/mcp/oauth
#   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://google-flights-mcp.flightpowers.com/mcp/oauth
#   Auth: OAuth 2.0  ->  "Guided OAuth Flow" walks metadata -> DCR ->
#   authorize -> token, and shows each response. Then run ONE real search.
协议接口
路由规范
GET /.well-known/oauth-protected-resource and …/mcp/oauthRFC 9728
GET /.well-known/oauth-authorization-server and …/mcp/oauthRFC 8414
POST /oauth/registerRFC 7591,动态客户端注册,开放
GET /connect/authorize · POST /connect/authorizeRFC 6749 §4.1,必须使用 PKCE S256
POST /oauth/tokenauthorization_code and refresh_token
POST /oauth/revokeRFC 7009

授权端点有意放在 /connect 下:登录会话 Cookie 的作用范围限定为 Path=/connect,因此绝不会附加到 /mcp 请求上;如果把授权端点放在其他位置, 就只能扩大该 Cookie 的作用范围,或让用户登录两次。

授权码有效期为 10 分钟,且只能使用一次(通过 DELETE … RETURNING 实现,因此两个并发兑换请求会争用同一行,且恰好只有一个能成功)。访问令牌有效期为 1 小时,刷新令牌有效期为 30 天,并会轮换。 所有令牌都是不透明令牌,以 SHA-256 哈希形式存储,因此数据库转储中不包含任何可用于重放的内容。令牌并非 JWT,这是有意为之:签名令牌一旦签发,无论我们之后做出什么决定,都会一直有效直到过期;而“断开连接”必须真正意味着断开连接。

访问令牌在被接受前,会根据其获准访问的资源进行检查,而不仅仅是在签发时检查。这两款产品部署在同一环境中,共用同一数据库,并为每位用户存储同一个 RapidAPI 密钥;因此,如果没有这项检查,在航班同意页面上获准的令牌——该页面只说明“搜索实时航班票价”——就会被酒店主机名接受,并消耗用户的酒店套餐。检查会严格匹配主机名,但对路径较宽松,因为实际使用中的客户端会为同一服务发送来源地址、/mcp 和 /mcp/oauth。tests/test_oauth.py::TestATokenIsBoundToTheResourceItWasApprovedFor 对这两项行为都有固定测试。

在自己的部署中运行

无需新增环境变量。 配置 /connect 后就会启用,因为它复用了 Google 登录和密钥存储。它需要在同一个数据库中新增一张表:

psql "$DATABASE_URL" -f migrations/002_mcp_oauth.sql

此时,/health 会报告 oauth_enabled: true 和 oauth_mcp_endpoint;该 URL 应填写在目录条目中。MCP_OAUTH=off 可在保留 /connect 运行的同时禁用 OAuth;这是无需更改代码的回滚方式。

Google OAuth 客户端无需做任何更改。重定向 URI 仍为 …/connect/callback,因为 MCP 客户端的 OAuth 流程会结束于我们的授权页面,只有该页面会与 Google 通信。

端到端验证
BASE=https://google-flights-mcp.flightpowers.com

# 1. On, and advertising itself.
curl -s $BASE/health | python3 -m json.tool | grep oauth_

# 2. The challenge. This is the whole feature in one response.
curl -si $BASE/mcp/oauth | head -20
#   HTTP/2 401
#   www-authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource/mcp/oauth"

# 3. Discovery, at both the scoped and the bare path.
curl -s $BASE/.well-known/oauth-protected-resource/mcp/oauth | 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/oauth

# 7. /mcp is untouched. 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.
保持注册表的健康

/oauth/register 是开放的,因为 MCP 规范要求如此,而且单靠 client_id 无法授予任何权限:每个经过它的流程最终都会进入同意页面,必须由已登录的用户按下按钮。开放不等于没有限制,因此有三项机制为其把关。

机制所在位置防止的问题
速率限制内存中,按实例计算突发请求:每个地址每 10 分钟注册 10 次、每分钟请求令牌 60 次、每小时调用 /connect/save 10 次。超过限制时返回 429 和 Retry-After。
每日上限Postgres,因此所有实例的数据一致缓慢累积:每个地址每天注册 30 次。全局上限为每天 5,000 次——这是防止行数无限增长的后备措施,而非防御手段:若将全局数值设得接近实际流量,攻击者就能利用它,让所有新的 Claude 或 Cursor 用户整整一天都无法使用服务。单日超过 500 次时会记录日志,但不会拒绝请求。可通过 MCP_OAUTH_DCR_MAX_PER_IP_PER_DAY、MCP_OAUTH_DCR_MAX_PER_DAY 和 MCP_OAUTH_DCR_WARN_PER_DAY 修改这些上限,无需部署。
清理在 /oauth/register 上执行,每个实例最多每 15 分钟一次清理残留项:过期的代码和令牌,以及在 7 天内从未用于授权的注册记录。拥有有效令牌、未完成代码,或已为其呈现同意页面的客户端,绝不会被清理。

速率限制按实例计算。在 Vercel 上,这意味着 N 个热实例之间最多允许相当于这些数值 N 倍的请求,而冷启动会将计数器归零。这就是还需要持久化上限的原因:它们在数据库中计数,因此所有地方看到的数值都相同。

这些机制所使用的地址,首先取自 x-real-ip——Vercel 会将其设为它接受连接的对端地址;否则,取自 x-forwarded-for 中最后一个可用条目,并跳过只能是内部地址的跃点。绝不使用第 0 个条目:代理会从右侧追加,因此最左侧条目由调用方填写,读取它会让此处的所有限制都只需改一个请求头就能绕过。没有这两个请求头的请求会共用一个名为 unknown 的桶,作为单个调用方受到速率限制,但不受持久化的按地址上限约束(边缘节点缺少一个请求头,不应导致整个服务器被锁上一整天)。

注册记录会在同意页面呈现时标记为正在使用,而非仅在用户按下 Approve 后才标记。MCP 客户端通常在安装时注册,几天后才进行授权;清理任务不得在同意页面仍显示时删除记录——代码或令牌没有指向客户端的外键,因此后续的交换会以 invalid_client 失败,却没有任何信息说明原因。

需要执行一次迁移:

psql "$DATABASE_URL" -f migrations/003_mcp_oauth_hygiene.sql
刷新令牌会轮换,重放会撤销整个令牌族

刷新令牌只能使用一次:交换令牌会签发一对新令牌,并使所提交的令牌失效。 失效的行会保留并加上时间戳,而不是删除,因为被删除的行和从未签发过的令牌看起来完全一样——而区分两者正是关键。提交已轮换的刷新令牌,意味着客户端可能丢失了响应,也可能有人拿到了令牌副本;OAuth 2.1 §4.14.2 指出应假定是后者:返回 invalid_grant,并删除该授权所派生的所有令牌。诚实的客户端需要重新登录;窃贼的访问令牌也会同时失效。

只有一个刻意设定的例外,适用于几乎总是无辜的情况:刚刚轮换的令牌首次被重放,且来自同一客户端、发生在 10 秒内时,系统会返回轮换时已经签发的令牌对。这是一次幂等重试——不会创建任何新内容——这样,响应因连接中断而丢失的客户端就不会在不知情的情况下被登出。第二次重放,或超过时间窗口后的重放,都属于真正的重放,仍会导致整个令牌族失效。时间窗口按实例设置并保存在内存中,因此未命中时会直接采用保守处理方式。

通过 /oauth/revoke 撤销刷新令牌时,也会一并撤销其访问令牌,理由相同(RFC 7009 §2.1);而且只有在令牌是签发给发起请求的客户端时才会撤销,这是该章节的另一项要求。客户端提交他人的令牌时,仍会收到 200(§2.2),且不会撤销任何令牌。

client_id 可以是 URL

/.well-known/oauth-authorization-server 会公布 client_id_metadata_document_supported: true。客户端可以使用 https URL 作为其 client_id;该 URL 上的文档会列出其 redirect_uris,我们会在每个流程中获取并检查该文档,而不是写入注册记录。Smithery 要求支持这一功能,才会代理远程 OAuth 服务。

每次都会进行以下检查:仅允许 https;主机名必须公开(不允许 IP 字面量、localhost,或 URL 中包含凭据);对主机名进行解析,并要求其返回的每个地址都是公开单播地址(名称本身并不能作为限制:127.0.0.1.nip.io 名称中带有点号,但实际指向回环地址);不跟随重定向;超时时间为 5 秒;读取响应正文时就限制大小为 64 KB,而不是等正文缓冲完毕后再检查;若文档中包含 client_id,则必须与 URL 匹配;最重要的是,请求中的 redirect_uri 必须列在文档中。该查询有独立的速率限制(每个地址每 10 分钟 60 次),因为这是此服务中唯一一个可由调用者在登录前触发的出站请求。动态注册不变,仍是默认方式:非 https URL 的 client_id 会像之前一样在表中查询。

工具

工具功能
search_oneway_flights实时单程票价。输入:出发地 IATA 代码、目的地 IATA 代码或列表,以及单个出发日期或日期范围。返回价格、航空公司、时长、经停次数、buy_link,以及 Google 的历史价格区间,供你判断票价。适用于任何单程问题,包括出发日期不确定的情况:用一次调用查询日期范围,绝不按每个日期分别调用。
search_roundtrip_flights实时往返票价,按往返航段组合定价,而不是两张单程票。输入:出发地、目的地(可多个)、单个出发日期或日期范围,以及 return_date 或以 nights 表示的行程长度(可以是数字,也可以是类似 [5,6,7] 的列表)。返回总价、每段航程的航空公司/经停次数/时长,以及整个行程的一个 buy_link。

酒店部署提供 search_hotels 和 find_hotel_by_name,两者都仅使用 Booking.com;compare_hotel_rates 仅在 MCP_HOTEL_COMPARE=on 时注册(公共部署中为关闭),参见 酒店: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,   # one code per traveller: 1 adult, 2 child, 3 infant on lap, 4 infant in seat
    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,
    returns_per_outbound: int | None = None,  # 1-5 returns paired with each outbound; unset = 2
    max_searches: int | None = None,
    use_fallback: bool | None = None,
)

此服务会对所有搜索合并后的结果集应用 sort_by,因此无论扩展了多少种组合,排序结果都可预测。

完整示例

用户:“我在特拉维夫。想去罗马或雅典,找一趟为期一周、价格最便宜的旅行,5 月上半月任意一天出发。”

调用一次:

{
  "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
  }
}

这会扩展为 15 个日期 × 2 个目的地 = 30 种组合,恰好达到每次调用的上限。 响应的结构如下(字段名是真实的;下面的值仅作示例,并非报价,运行调用即可获取实时票价):

{
  "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."
  }
}

其他可能出现的响应结构,均属正常:

  • 这些日期没有航班。 results: [],并附带 message:对于某些航线/日期组合,Google Flights 确实不会返回结果。这不是错误。请尝试临近日期或附近机场。use_fallback 不会改变这一点,且默认不设置:后端接受此字段,但它选择的第二个航班数据源受 USE_FALLBACK_FLI(fallback_available())控制,而此 API 并未启用该功能,因此其三个取值目前都不会对搜索产生任何可观察的影响。后端在页面无法读取时执行的自动重试不受此字段影响,且始终会进行。
  • 部分搜索失败。 partial 字段会指出已执行的搜索中有多少次失败,其余搜索结果仍会返回。
  • 日期范围过宽。 search_coverage.truncated: true,并附带 note。系统会在整个时间窗口内均匀采样(保留首尾日期),而非只取前面的日期,因此样本具有代表性,不是前 N 天。提高 max_searches 或缩小日期范围,以获得更全面的结果。
  • 缺少密钥/密钥被拒绝。 needs_api_key: true,不会产生费用,并附有解决方法。最常见的原因是 RapidAPI 密钥有效,但未订阅此 API。
  • 套餐额度已用尽。 quota_exhausted: true,并附带 api_usage,同时提醒缩小日期范围可以更充分地利用剩余额度。

酒店:入住日期范围

POST /search 只能为一次住宿定价,因此“5 月在罗马住三晚,找最便宜的住宿”过去需要调用工具 31 次——或者在实际使用中,只会在模型选定的某个日期调用一次,然后把答案说成最便宜的住宿。现在,这两个酒店搜索工具都采用了与航班工具相同的形式:

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
    ...
)

使用与航班扇出相同的机制(src/fanout.py):每次住宿调用一次后端,数量上限为 max_searches_per_tool_call(30,硬上限 60);超出上限时,在整个日期范围内均匀抽样,并通过 search_coverage 报告。nights 会据此推算每个退房日期,因此它会取代 checkout_date,而不是与之组合。允许在入住日期范围内指定固定的 checkout_date,意思是“无论哪天入住,都在 4 日离开”;不可能的日期组合会被舍弃。

响应有意限制了大小。每次住宿中每家酒店的完整属性约占 25 KB(实测:25 家酒店为 25,892 字节,其中 18,989 字节是 URL),因此每次住宿只报告最便宜的酒店、每晚价格和中位数,只有最便宜住宿会返回完整酒店列表:

{
  "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 描述的是我们的处理流程,不是对酒店的猜测:

reasonsearch_status含义
okok已定价
no_availabilityempty已搜索并收到响应,但没有结果
no_priceempty返回了酒店,但没有一家提供价格(available: false 的情况归入此项)
search_faileddegraded搜索出错,因此无法确定任何信息——不代表“没有房间”
not_searchednot_searched因抽样上限而未搜索

最后两种情况中的数量为 null,而不是 0:零会被理解为“没有结果”,但这两种情况都无法确定这一点。顶层的 search_status 根据所有住宿条目汇总为 ok / partial / empty / degraded;如果每次住宿都失败,则会报错,而不是返回空列表。

有一项刻意拒绝的用法:在单次住宿搜索中使用 max_searches,因为它只会悄无声息地不起作用。

单次住宿的响应与此前完全逐字节一致——请求正文相同、响应键相同,不包含 stays、search_coverage 或 search_status。这一点在 tests/test_hotel_date_range.py::TestTheOldShapeIsUntouched 中通过与从旧代码捕获的固定预期进行比对来断言。

酒店:compare_hotel_rates(默认关闭)

search_hotels 和 find_hotel_by_name 只为 Booking.com 定价,且不接受 providers 参数。若调用方仍发送 providers(较早的架构中曾有此参数,但返回给调用方的始终只有 Booking.com 的结果),现在会收到验证错误,且不会消耗任何资源;请移除此参数。这一点在 tests/test_providers.py::TestTheDefaultDidNotMove 中进行了断言。

compare_hotel_rates——针对同一次住宿,使用所有已配置密钥的数据源分别定价,每个数据源返回一行:最低总价、中位总价、有价格的酒店数量、货币,以及读取结果的时间——仅在 MCP_HOTEL_COMPARE=on 时注册。它在公开部署中处于关闭状态,因此托管的酒店服务会列出两个工具,且只有在提供该工具的地方,说明中才会提及它。本节其余内容介绍的是启用了该工具的部署。

其响应:

{
  "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 }
}
每个来源分别调用的位置
  • booking 直接使用调用者的密钥调用 booking-live-api.p.rapidapi.com,费用由 RapidAPI 从调用者自己的订阅中扣除。保持不变。
  • airbnb 通过我们自己的入口调用 POST https://api.flightpowers.com/v1/hotels/search,并在请求体中传入 provider: "airbnb"(flight_rabbi #472),因为 Airbnb 后端不在 RapidAPI 边缘上。调用会携带调用者的密钥作为 x-rapidapi-key,并通过 X-FP-Client: mcp-hotels/<version> 标识自身——入口会用自己的判断覆盖请求体中的来源归属,因此只有标头能说明是谁发起了调用(规则 11)。可使用 API_FRONT_BASE_URL 覆盖入口地址以指向预览入口;它不包含凭据。

Airbnb 房源目前尚未在 RapidAPI 上架,且入口默认关闭该来源,因此目前使用 providers: ["airbnb"] 调用 compare_hotel_rates,会返回一条说明情况的 degraded 记录。这是预期行为,不是错误:任何持有任意有效密钥的人都能访问一个不计量的来源,相当于由我们付费的网关。

实现遵循的四条规则
  1. 没有密钥的来源绝不会使用我们的密钥调用。 它会列在 providers_skipped 中,并附带 reason(no_key、not_subscribed、key_rejected)和订阅地址。每个房源都需要单独订阅,因此 Hub 返回 403 表示“未订阅该房源”,而不是“密钥错误”。
  2. 来源降级时会返回一条标明来源的记录,而不是留空。 search_status: "degraded"、count: null,不含价格——同时仍会返回另一个来源的记录。“Booking 未响应”和“Booking 没有结果”是截然相反的答案,必须能用不同的句子表达。
  3. cheapest_total 和 median_total 仅统计带有价格的记录,count 就是该数量。绝不会将 price_string 解析为数字。
  4. 不进行跨币种运算。 每个来源收到的查询都使用相同币种;如果两个来源返回的币种不同,每条记录会保留自己的币种,并在 caveats 中说明合计金额不可比较。不进行币种换算。
密钥,按来源配置

几乎所有人都只有一个 RapidAPI 密钥,并订阅了多个房源;所有来源都会使用该密钥,无需额外配置。如果调用者确实持有两个密钥,可以为每个来源指定一个;来源专用名称优先:

x-rapidapi-key-airbnb: <key>        # header
?rapidapi_key_airbnb=<key>          # query
?config=<base64 {"rapidApiKeyAirbnb": "<key>"}>   # Smithery blob

顺序与 src/credentials.py 中已有的说明一致——先是来源专用名称,再是不限定来源的专用名称,然后是配置对象,最后是通用名称;如果提供了 config 参数,则完全跳过通用名称,因为网关自己的通用名称密钥并不属于我们。

filters 和 price_as_seen_from 在比价中仅适用于 Booking

在 compare_hotel_rates 中,这些参数会发送给 Booking,不会发送到入口。如果只比较 Airbnb,则会拒绝这些参数,而不是直接丢弃:静默丢弃筛选条件会返回比你要求更多的房源,却不作任何说明。

结构化输出(outputSchema、structuredContent、isError)

每个工具都会声明一个 outputSchema,每个结果都会携带两份载荷:一份是 structuredContent,另一份是文本内容块中的序列化 JSON。MCP 规范要求重复提供这份内容——

为了向后兼容,返回结构化内容的工具 SHOULD 也在 TextContent 块中返回序列化 JSON。

——这在这里是必要条件,而非形式主义,因为早于结构化输出的客户端只会读取文本块。(已根据规范修订版 2026-07-28 核实;结构化输出于 2025-06-18 引入。)

这些架构特意设置为 additionalProperties: true,且只要求 results。规范将义务放在服务端——“Servers MUST provide structured results that conform to this schema”——而这些工具有几种合理的退出情况,会携带不同的键——零结果的答复、不带键的 needs_api_key 答复,以及 quota_exhausted 答复。更严格的架构看起来会更好,但会导致服务端在它有意支持的路径上不符合规范。

search_status,以及为什么 degraded 是错误

航班结果会携带 search_status,与后端自身的 X-Search-Status 词汇保持一致:

值含义
ok搜索的每种组合均返回了结果
empty搜索已完成;Google 确实没有可用行程。这是真实的答复
partial部分组合返回了结果,部分失败。结果列表不完整
degraded所有组合均失败。搜索并未发生;空列表不代表任何信息

degraded 结果也会标记为 isError: true。这是唯一会这样处理的状态。规范将“API failures”归类为工具执行错误,并指出客户端“SHOULD provide tool execution errors to language models to enable self-correction”,但规范并未要求宿主必须向模型展示 structuredContent。因此,如果失败信息只放在载荷中的某个字段里,模型可能永远看不到,这正是添加 search_status 要解决的问题。

载荷仍会随错误一并返回——structuredContent 和文本块都会存在,因此不会丢失任何内容。尤其是 api_usage:降级搜索仍会消耗调用者自己的 RapidAPI 请求额度,隐藏这项信息就等于隐藏一笔他们必须支付的费用。empty 和 partial 不属于错误:前者是真正的否定结果,后者则包含调用者可以使用的结果。

费用报告(api_usage)

费用由你承担,因此用量计量信息会清晰展示。每个成功响应都会包含:

字段含义
requests_used_by_this_call此次工具调用消耗的上游计费请求数。
plan_requests_remaining当前周期内 RapidAPI 套餐剩余的请求数。
plan_requests_limit当前周期内套餐的请求上限。
note用一句话说明相同信息,方便模型在你询问之前告知你。

plan_requests_remaining 和 plan_requests_limit 来自上游响应;如果上游未报告这些值,就会省略;note 会相应调整。模型应明确说明的规则是:一个日期 × 一个目的地 = 一个计费请求。

按限制力度从弱到强排列的费用控制选项:每次调用的 max_searches(针对范围较广的问题,调低它以减少费用)、缩小日期范围、缩短目的地列表。

一次调用与三十次调用

底层 REST API 每次调用恰好接受一个 (origin, destination, date) 元组。与一次只传递一个日期的透传方式相比,“10 月任何日期去斯里兰卡的最低价”需要调用工具 31 次:模型要往返 31 次,可能丢失上下文的机会有 31 次,而且用户事后才会发现费用。

这里只需调用工具一次。扇出在服务端并发执行,有上限、均匀抽样,按 buy_link 去重,合并后按你的 sort_by 排序,并在 search_coverage 和 api_usage 中如实报告。

本服务(付费)免费服务
每次调用的扇出数30(硬上限为 60;可通过 max_searches 在每次调用中调高或调低)15
广告无每个结果中包含一张明确标注的赞助卡片
密钥你自己的 RapidAPI 密钥无需密钥
费用报告每个响应中都有 api_usage不适用
可列入目录是否

本服务完全不含广告,这并非出于偏好,而是受政策限制:Anthropic 的连接器目录政策和 OpenAI 的应用指南都禁止在工具结果中投放广告和赞助内容,因此,带广告的服务永远无法列入这些目录,而本服务可以。

本地开发

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/mcp

以相同方式将客户端指向本地进程:

claude mcp add --transport http google-flights-local http://localhost:8000/mcp --header "x-rapidapi-key: YOUR_RAPIDAPI_KEY"

测试(620 项通过,已验证):

.venv/bin/python -m pytest -q

配置位于 example.env;其中记录了每个变量的说明。重要变量如下:

变量默认值作用
MAX_SEARCHES_PER_TOOL_CALL30每次调用的扇出上限。最高限制为 60。
MAX_CONCURRENT_SEARCHES10扇出的并发数。
MAX_HTTP_CONNECTIONS60连接池上限;无服务器实例共用一个文件描述符池。
REQUEST_TIMEOUT_SECONDS75上游函数的 Timeout(60)加上 15 秒的边缘中继缓冲时间,确保本服务不会在答案仍在生成时提前放弃。
DEFAULT_RESULT_LIMIT10每次单独的上游搜索所请求的结果数。
MCP_PRODUCTSboth此部署提供的产品:flights、hotels 或 both。用于选择工具集、服务说明、服务名称、政策页面,以及当调用方没有密钥或未订阅时要引导其前往的 RapidAPI 商品页。采用默认值的酒店部署会将自身介绍为航班服务。
SIGNUP_URL与 MCP_PRODUCTS 匹配的商品页返回给未携带密钥用户的链接。若为 both,酒店工具始终提供 Booking 商品页:一个 URL 无法同时充当两个 API 的订阅按钮。
MCP_PRODUCTS_BY_HOSTdefault指定每个主机名提供的产品,这样一个部署就能承载两个付费域名,而且每个商品页仍可获得各自专属的工具集。default 是内置的 flightpowers.com 别名映射;off 会完全禁用主机路由(无需改代码即可回滚);也可以指定显式的 host=product,… 映射。未映射的主机名会回退到 MCP_PRODUCTS。
MCP_PUBLIC_URLhttp://localhost:8000/mcp由 /health 报告,并作为所有政策页面链接的来源。对于同时提供两种产品的部署,可分别用 MCP_PUBLIC_URL_FLIGHTS / MCP_PUBLIC_URL_HOTELS 覆盖各产品的值。若不设置,酒店主机名会公布航班主机名的 URL。SIGNUP_URL_FLIGHTS / SIGNUP_URL_HOTELS 的用法相同。
RAPIDAPI_KEY(空)**生产环境中请留空。**如果设置,所有未携带密钥的调用方都会使用此订阅,并由此订阅承担费用。服务启动时会记录警告,且 /health 会报告 server_side_key_configured。
METRICS_TOKEN(空)设置后,访问 /metrics 需要携带 x-metrics-token 标头。
LOG_PATH(空)留空时会禁用文件输出;标准输出中的 MCP_CALL 行仍会保留记录。适用于无服务器环境。

运维路由:GET /health(公开,无需身份验证:注册中心会轮询此路由)、 GET /metrics、GET /metrics/calls?hours=24、 GET /.well-known/mcp/server-card.json(见下文)。

静态服务卡片

GET /.well-known/mcp/server-card.json 会以独立 JSON 文档的形式返回此部署的元数据:serverInfo、description、transport、capabilities、authentication、instructions,以及与 tools/list 序列化结果完全一致的完整 tools 和 prompts 列表。公开、无需身份验证,使用 Cache-Control: public, max-age=3600,并开放 CORS。

之所以提供此文档,是因为我们在目录中发布的 URL 是 /mcp/oauth,它始终返回 401,因此,指向该 URL 的自动扫描器无法从网络响应中读取工具列表。(自 2026-09-09 起,扫描器可以指向 /mcp:无需凭据即可进行只读发现——参见 src/discovery.py。)Smithery 的发布页面指出,此文档可解决这一问题:“如果自动扫描无法完成(遇到身份验证墙、需要配置或其他问题),你可以通过位于 /.well-known/mcp/server-card.json 的静态服务卡片手动提供服务元数据。”字段列表遵循 SEP-1649。

有两点值得注意:

  • 首次收到请求时,工具列表会从实时注册表中读取,而不是手动写入,因此不会与 tools/list 返回的内容不一致。tests/test_server_card.py 会对两个产品进行比对。
  • 和这里的其他所有内容一样,它按主机名区分:hotels.flightpowers.com 返回酒店卡片,两个航班主机名都返回航班卡片,卡片中的每个 URL 都位于对应产品自己的源站上。配置了 OAuth 时,transport.endpoint 为 /mcp/oauth,而带密钥的 /mcp 端点则作为备用选项列在 _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.tool

部署目标是 Vercel,通过 api/index.py(将 FastMCP 的生命周期交给 FastAPI 包装器,设置 stateless_http=True)。规范 MCP 路径为 /mcp,末尾不得带斜杠。

切勿提交真实密钥。example.env 中提供的是占位符;请保持原样。

在容器中运行

托管服务无需安装任何内容。以下内容用于自托管,也是 Glama 能运行构建测试并发布版本的基础。

docker build -t flightpowers-mcp .
docker run --rm -p 8000:8000 flightpowers-mcp
curl http://localhost:8000/health

容器通过 python -m src 在 ${PORT}/mcp 上提供可流式传输的 HTTP 服务,使用与托管部署相同的传输方式。此处不会使用 api/index.py 这个 Vercel 包装器。

镜像中不会内置任何密钥。 每次搜索的费用都由调用方自己的 RapidAPI 订阅承担,调用方的密钥会随请求通过 x-rapidapi-key 传递。RAPIDAPI_KEY 是可选的服务器端备用密钥:设置后,如果调用方未提供自己的密钥,服务将使用该订阅处理请求并由该订阅承担费用。除非你有此需求,否则请不要设置;无论是否设置,/health 都会报告 server_side_key_configured。

# optional, local development only
docker run --rm -p 8000:8000 -e RAPIDAPI_KEY=your_key flightpowers-mcp

上表中的每个变量都可以通过 -e NAME=value 使用。镜像内的 HOST 默认值为 0.0.0.0,PORT 默认值为 8000。HEALTHCHECK 会在 $PORT 上轮询 /health,因此覆盖 PORT 后仍可正常工作。

无关联声明

这是一个独立的 API,提供公开可用的航班价格信息。它与 Google 无任何关联,也未获得 Google 的认可或赞助。“Google Flights”仅用于描述公开数据来源。票价由上游提供商提供,随时可能变化,且不作保证。购买前请务必在航空公司或订票网站确认价格。

评论

支持 Markdown

第一次接触 MCP?

从协议是什么,到在客户端里装上第一个服务,这几条回答覆盖了大多数入门问题。

一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。