mcpField
B
Booking.com Hotel Search (Live Prices)

Booking.com Hotel Search (Live Prices)

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

关于 Booking.com Hotel Search (Live Prices)

托管式 MCP 服务,提供 Booking.com 实时酒店房价,并支持按国家比较价格以监测价格一致性。面向旅行 Agent 和开发者,可搜索与报价,但不负责预订。

主要功能

  1. 01Booking.com 实时房价
  2. 02按国家比较价格
  3. 03日期范围搜索
  4. 04结果附预订链接

适用场景

  1. 01监测不同国家的价格一致性
  2. 02比较旅行日期的酒店价格

MCP 相关

通过托管端点向 MCP 客户端提供 Booking.com 酒店房价搜索工具。客户端可连接 https://hotels.flightpowers.com/mcp。

配置

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

服务地址https://hotels.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://hotels.flightpowers.com/mcp需要授权

工具

3
参数类型必填说明
adultsany否成年住客人数。省略时使用上游默认值。
nightsany否入住晚数,可以是数字(3)或列表([2, 3, 7]),用于查询多个住宿时长的价格。根据每个入住日期计算退房日期,因此会替代 checkout_date,而不是与其组合使用。
filtersany否要应用的住宿设施筛选条件,例如 ["free_cancellation", "breakfast_included"]。如果名称无效,会拒绝请求并列出有效选项,而不是忽略该条件。
childrenany否与住客同住的儿童人数。
currencyany否返回价格所用的 ISO 货币代码,例如 "usd"。
providersany否用于为此住宿报价的来源,例如 ["booking"]、["airbnb"] 或 ["booking", "airbnb"]。默认为 ["booking"]。每个来源都需要单独的 RapidAPI 订阅,因此没有密钥的来源不会被调用;它会在 providers_skipped 中列出,并附上原因和订阅地址,而不是悄然缺失。filters 和 price_as_seen_from 仅适用于 booking;airbnb 则使用 price_min、price_max 和 room_types,其结果行带有 rating_scale 5,而 booking 的结果行带有 10。
destinationstring是住宿地点,用自然语言描述,例如 "Rome" 或 "Tokyo Shibuya"。城市、街区、地标或地区均可;无需内部地点 ID。
checkin_dateany否入住的第一晚,格式为 "YYYY-MM-DD"。查询一次住宿时,请与 checkout_date 一起提供;查询入住日期范围时,请改用 checkin_date_from 和 checkin_date_to。
max_searchesany否此调用最多可产生的计费请求数,可调高或调低。一次住宿算一个请求,因此 30 天范围内查询两种住宿时长为 60 个请求,一个月范围内查询三种时长则为 93 个请求;上限会自动提高,以覆盖该请求本身的规模。调低此值可减少支出,调高则可扩大查询网格(硬上限为 200);无论哪种情况,都会在整个日历范围内均匀抽样,而不是从前端截断。
checkout_dateany否离店日期,格式为 "YYYY-MM-DD"。必须晚于 checkin_date。请提供此项或 nights,不能同时提供。
checkin_date_toany否日期范围内的最后一个入住日期,格式为 "YYYY-MM-DD"。
budget_per_nightany否仅返回每晚价格不高于此金额的住宿,币种为 `currency`。
checkin_date_fromany否范围内的第一个入住日期,格式为 "YYYY-MM-DD"。需要同时提供 checkin_date_to 和 nights,并按范围内的每个入住日期为一次住宿报价——相当于航班日期范围查询。
price_as_seen_fromany否两字母国家/地区代码,例如 "de"。通过该国家/地区的住宅网络连接查询住宿价格,因此结果反映居住在当地的旅客会看到的报价。检查价格一致性时,请固定一家指定住宿并对每个国家/地区多次调用,因为价格会在调用间变动;每个国家/地区只调用一次可能会显示并不存在的差异。不提供此项则使用中性价格。

详细介绍

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 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。