关于 Google Flights Search (Real-Time Fares)
托管式 MCP 服务,可按日期范围和目的地搜索 Google Flights 实时机票。面向旅行 Agent 和开发者,结果包含 Google 的低价、常规或高价判断及预订链接;服务只搜索和报价,不执行预订。
主要功能
- 01Google Flights 实时票价
- 02按日期范围和机场搜索
- 03Google 价格水平判断
- 04往返行程总价
- 05Google Flights 预订链接
适用场景
- 01在出行日期范围内寻找低价
- 02比较目的地与票价水平
- 03查询指定停留时长的往返价格
MCP 相关
在 https://flights.flightpowers.com/mcp 向 MCP 客户端提供航班搜索工具。客户端可使用 Google 登录授权或提供 RapidAPI 密钥。
配置
远程服务无需本地安装。选择你使用的客户端,复制配置或使用一键安装。
https://flights.flightpowers.com/mcpclaude mcp add --transport http com-flightpowers-google-flights https://flights.flightpowers.com/mcp{"mcpServers":{"com-flightpowers-google-flights":{"type":"http","url":"https://flights.flightpowers.com/mcp"}}}在 Cursor 中打开{"servers":{"com-flightpowers-google-flights":{"type":"http","url":"https://flights.flightpowers.com/mcp"}}}在 VS Code 中打开{"mcpServers":{"com-flightpowers-google-flights":{"type":"http","url":"https://flights.flightpowers.com/mcp"}}}{"mcpServers":{"com-flightpowers-google-flights":{"serverUrl":"https://flights.flightpowers.com/mcp"}}}命令在终端中运行;JSON 粘贴到对应客户端的 MCP 配置后重启。需要授权的服务会在首次连接时提示登录。
https://flights.flightpowers.com/mcp需要授权工具
2| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| limit | integer | 否 | 合并并排序后要返回的航班数量上限。 |
| sort_by | string | 否 | "best"、"price" 或 "duration"。适用于所有结果。 |
| verbose | boolean | 否 | 返回上游针对每个票价发送的所有字段,以及所有入选票价,不限制行数。默认关闭:结果会按 sort_by 返回最优行,并采用紧凑格式(日期、目的地、航空公司、经停次数、时长、价格、行程长度和预订链接)。否则,按整月和多个目的地进行宽范围搜索会产生数 MB 的 JSON,有些宿主根本无法将其放入对话中。需要抵达时间、每段航程的经停次数、原始经停详情,或多于 results_returned 的行数时,请开启此选项;results_total 始终显示结果总数。 |
| currency | string | 否 | ISO 货币代码,默认值为 "usd"。 |
| max_price | any | 否 | 仅返回价格不高于此值的航班。 |
| max_stops | any | 否 | 每个航班的最大经停次数。0 表示仅限直飞。 |
| seat_type | any | 否 | 1 经济舱,2 高端经济舱,3 商务舱,4 头等舱。 |
| passengers | any | 否 | 每位旅客提供一个条目,而非人数:1 成人,2 儿童(2–11 岁),3 怀抱婴儿,4 占座婴儿。例如,两名成人和一名儿童可填写 [1, 1, 2]。至少需要一名成人;每名怀抱婴儿都需要一名成人陪同;最多 9 人。单名成人可省略此项。也支持人数列表 [成人数, 儿童数, 婴儿数],例如 [2, 1, 0],或表示两名成人的简单列表 [2];搜索前会将其转换为代码。若列表符合代码格式,则按代码搜索。 |
| to_airport | any | 是 | 目的地机场。单个 IATA 代码("BCN")、以逗号分隔的多个代码("BCN,LIS,ATH")或列表(["BCN","LIS","ATH"])均可——所有格式都会被接受,并在同一次搜索中比较各目的地。 |
| from_airport | string | 是 | 出发地 IATA 代码,例如 "TLV"。每次搜索只能指定一个出发地;指定第二个出发地会被拒绝,而不会进行搜索。 |
| max_searches | any | 否 | 此调用可产生的付费请求数上限或下限。留空时采用正常行为:当请求合理时,限额会覆盖整个请求(整月、三种行程时长为 93 个组合;整月 × 3 晚 × 3 个目的地为 279 个组合;两种情况都会完整执行),超过限额的部分则会在整个范围内均匀抽样,而不是提前截断。设置较低值可减少宽范围搜索消耗的套餐配额;也可设置较高值,硬性上限为 300,以支持范围更大的网格搜索。 |
| use_fallback | any | 否 | 保持未设置。将搜索切换到第二个独立航班数据源,而不是通常读取 Google Flights 页面。未设置时,搜索重试失败后会自动切换到该数据源一次。设为 true 时,每次尝试都会直接使用该数据源——速度慢得多,也可能超时。设为 false 时会完全禁用该数据源,包括自动重试。 |
| airline_codes | any | 否 | 仅限这些航空公司代码,例如 ["LY"]。 |
| departure_date | any | 否 | 单个出发日期,"YYYY-MM-DD"。 |
| arrival_time_max | any | 否 | 最晚抵达小时,0–23。 |
| arrival_time_min | any | 否 | 最早抵达小时,0–23。 |
| departure_date_to | any | 否 | 出发日期范围的最后一天。 |
| departure_time_max | any | 否 | 最晚出发小时,0–23。 |
| departure_time_min | any | 否 | 最早出发小时,0–23。 |
| departure_date_from | any | 否 | 出发日期范围的起始日期。 |
| exclude_airline_codes | any | 否 | 排除这些航空公司代码。 |
详细介绍
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 订阅,因此密钥需要随请求一起传递。
- 订阅 Google Flights Live API: https://rapidapi.com/mtnrabi/api/google-flights-live-api
- 复制你的
x-rapidapi-key。 - 通过以下三种方式中的任意一种将其传给服务端。
如果缺少密钥,且你无法使用免费额度,工具不会静默失败,也不会产生任何费用。它们会返回 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 页面可通过登录取代上述所有方式:
- 打开 **https://google-flights-mcp.flightpowers.com/connect**(酒店服务: **https://hotels.flightpowers.com/connect**),然后使用 Google 登录。
- 将 RapidAPI 密钥粘贴到表单中,通过 TLS 传输。
- 按下 显示 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_ID | Google Cloud Console → Credentials → OAuth client ID,类型为 Web application。以 .apps.googleusercontent.com 结尾。 |
GOOGLE_OAUTH_CLIENT_SECRET | 同一客户端的密钥(GOCSPX-…)。 |
MCP_KEY_MASTER | 32 字节,base64 编码:openssl rand -base64 32。用于加密已存储的密钥(AES-256-GCM),并派生 Cookie 和令牌签名密钥。 |
DATABASE_URL | Neon 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/oauth | RFC 9728 |
GET /.well-known/oauth-authorization-server and …/mcp/oauth | RFC 8414 |
POST /oauth/register | RFC 7591,动态客户端注册,开放 |
GET /connect/authorize · POST /connect/authorize | RFC 6749 §4.1,必须使用 PKCE S256 |
POST /oauth/token | authorization_code and refresh_token |
POST /oauth/revoke | RFC 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 描述的是我们的处理流程,不是对酒店的猜测:
reason | search_status | 含义 |
|---|---|---|
ok | ok | 已定价 |
no_availability | empty | 已搜索并收到响应,但没有结果 |
no_price | empty | 返回了酒店,但没有一家提供价格(available: false 的情况归入此项) |
search_failed | degraded | 搜索出错,因此无法确定任何信息——不代表“没有房间” |
not_searched | not_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 记录。这是预期行为,不是错误:任何持有任意有效密钥的人都能访问一个不计量的来源,相当于由我们付费的网关。
实现遵循的四条规则
- 没有密钥的来源绝不会使用我们的密钥调用。 它会列在
providers_skipped中,并附带reason(no_key、not_subscribed、key_rejected)和订阅地址。每个房源都需要单独订阅,因此 Hub 返回403表示“未订阅该房源”,而不是“密钥错误”。 - 来源降级时会返回一条标明来源的记录,而不是留空。
search_status: "degraded"、count: null,不含价格——同时仍会返回另一个来源的记录。“Booking 未响应”和“Booking 没有结果”是截然相反的答案,必须能用不同的句子表达。 cheapest_total和median_total仅统计带有价格的记录,count就是该数量。绝不会将price_string解析为数字。- 不进行跨币种运算。 每个来源收到的查询都使用相同币种;如果两个来源返回的币种不同,每条记录会保留自己的币种,并在
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_CALL | 30 | 每次调用的扇出上限。最高限制为 60。 |
MAX_CONCURRENT_SEARCHES | 10 | 扇出的并发数。 |
MAX_HTTP_CONNECTIONS | 60 | 连接池上限;无服务器实例共用一个文件描述符池。 |
REQUEST_TIMEOUT_SECONDS | 75 | 上游函数的 Timeout(60)加上 15 秒的边缘中继缓冲时间,确保本服务不会在答案仍在生成时提前放弃。 |
DEFAULT_RESULT_LIMIT | 10 | 每次单独的上游搜索所请求的结果数。 |
MCP_PRODUCTS | both | 此部署提供的产品:flights、hotels 或 both。用于选择工具集、服务说明、服务名称、政策页面,以及当调用方没有密钥或未订阅时要引导其前往的 RapidAPI 商品页。采用默认值的酒店部署会将自身介绍为航班服务。 |
SIGNUP_URL | 与 MCP_PRODUCTS 匹配的商品页 | 返回给未携带密钥用户的链接。若为 both,酒店工具始终提供 Booking 商品页:一个 URL 无法同时充当两个 API 的订阅按钮。 |
MCP_PRODUCTS_BY_HOST | default | 指定每个主机名提供的产品,这样一个部署就能承载两个付费域名,而且每个商品页仍可获得各自专属的工具集。default 是内置的 flightpowers.com 别名映射;off 会完全禁用主机路由(无需改代码即可回滚);也可以指定显式的 host=product,… 映射。未映射的主机名会回退到 MCP_PRODUCTS。 |
MCP_PUBLIC_URL | http://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”仅用于描述公开数据来源。票价由上游提供商提供,随时可能变化,且不作保证。购买前请务必在航空公司或订票网站确认价格。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。