mcpField
B
Booking.com Hotel Search (Live Prices)

Booking.com Hotel Search (Live Prices)

リモート
hotels.flightpowers.com地図・旅行1今日更新
リポジトリ公式サイト

Booking.com Hotel Search (Live Prices) について

Booking.comのホテル料金をリアルタイムで検索し、国別の価格比較で料金の同一性を監視できるホスト型MCPサーバーです。旅行エージェントや開発者向けで、検索と価格提示を行いますが予約はしません。

主な機能

  1. 01Booking.comのリアルタイム料金
  2. 02国別の料金比較
  3. 03日付範囲での検索
  4. 04結果への予約リンク

活用シーン

  1. 01国ごとの料金差の監視
  2. 02旅行日程に合わせたホテル料金比較

MCP 対応

ホスト型エンドポイントから、Booking.comのホテル料金検索ツールをMCPクライアントに提供します。接続先は 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" です。1 回の宿泊を指定する場合は checkout_date と併せて入力します。チェックイン日の範囲を指定する場合は、checkin_date_from と checkin_date_to を使用します。
max_searchesanyいいえこの呼び出しで発生する課金リクエスト数の上限です。上下に調整できます。1 回の宿泊は 1 リクエストとして数えられるため、30 日間の範囲で宿泊日数を 2 種類指定すると 60 件、1 か月の範囲で 3 種類指定すると 93 件になります。上限は、その規模のリクエストを単独でカバーできるよう自動的に引き上げられます。値を下げると支出を抑えられ、上げると(ハード上限 200)より広い組み合わせを検索できます。いずれの場合も、範囲の先頭から切り捨てるのではなく、カレンダー全体から均等にサンプリングします。
checkout_dateanyいいえ出発日の朝。形式は "YYYY-MM-DD" です。checkin_date より後の日付である必要があります。この項目または nights のどちらか一方を指定し、両方は指定しないでください。
checkin_date_toanyいいえ範囲内の最後のチェックイン日。形式は "YYYY-MM-DD" です。
budget_per_nightanyいいえ1 泊あたりの料金がこの金額以下の宿泊施設のみを返します。通貨は `currency` です。
checkin_date_fromanyいいえ範囲内の最初のチェックイン日。形式は "YYYY-MM-DD" です。checkin_date_to と nights が必要です。範囲内の各チェックイン日について 1 回の宿泊料金を検索します。航空券の日付範囲検索に相当します。
price_as_seen_fromanyいいえ2 文字の国コード。例:"de"。その国の住宅用接続を通じて宿泊料金を検索するため、現地居住者が提示される料金を確認できます。料金の同等性を確認する場合は、対象施設を 1 つに固定し、国ごとに数回呼び出してください。呼び出しごとに料金が変動するため、国ごとに 1 回だけだと実際には存在しない差が見えることがあります。中立的な料金を使う場合は省略します。

詳細

Google Flights MCP:エージェントが日付範囲全体を検索できるリアルタイム運賃

ライブの航空券価格とホテル料金を取得する MCP サーバーです。Claude、Claude Code、Cursor、Cline、その他あらゆる MCP クライアントから、ツールとしてリアルタイムの Google Flights 運賃と Booking.com の客室料金を利用できます。1 回の呼び出しで日付範囲と目的地の空港リストを検索でき、各運賃には Google 独自の「安い/標準的/高い」という評価と価格帯が表示されます。往復航空券は 1 回のリクエストで料金を算出します(往路と復路、合計金額、予約リンクをまとめて提示)。https://flights.flightpowers.com/mcp でホストされており(ホテルは https://hotels.flightpowers.com/mcp)、インストールは不要です。検索と料金算出を行いますが、予約は行いません。

FlightPowers が開発しています。開発者と AI エージェント向けの旅行データ API で、REST、MCP サーバー、n8n ノードを 1 つの RapidAPI キーで利用できます。Google でサインインすると、毎日最初の 10 回の検索が無料です。

キーの入手先: RapidAPI の Google Flights Live API と Booking Live API です。月 10 回の検索を含む無料プランがあり、その後は PRO が航空券検索 2,500 回で 10 ドルです。検索 1 回あたりの料金は SerpApi の約 6 分の 1 です(SerpApi の最安プランは 1,000 回で 25 ドルです)。

どちらの方法でも URL は 1 つです:

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 です。何らかの認証情報を含むリクエストは処理されます。何も含まないリクエストには OAuth メタデータとともに 401 が返され、これによってサインインボタンが表示されます。https://flights.flightpowers.com/mcp/oauth も引き続き利用可能です。サーバー追加時に認証モードが固定されるクライアント向けに、初回リクエストでサインインを要求します。

ホスト型です。クローンもビルドも不要です。公式 MCP Registry に com.flightpowers/google-flights-mcp として登録されています。ヘルスチェック: /health。

まだキーをお持ちでない方へ。 始めるのにキーは不要です。上記の URL を追加して Google でサインインすると、毎 UTC 日の最初の 10 回の検索は、何も貼り付けることなく当社のキーを使って実行されます。カウント対象となる正確な単位については、キーなしで試す(Google でサインイン)をご覧ください。

キーが必要ですか? 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 (サインイン済みアカウントごとに 1 日 50 回、月 250 回の検索、結果ごとにスポンサー枠を 1 件表示、ファンアウトは 15 件までに制限されています。スポンサー枠を表示できないクライアントでは、さらに制限される場合があります。)広告、15 件の検索上限、またはクライアントの制限が気になるようになったら、こちらに戻ってきてください。


エージェントができること

日付の検索ではなく、運賃についての質問に答える2つのツールです。

  • 自由形式の質問ができます。 「10月のどこかでスリランカ行きの最安片道航空券」「5月のどこかで、テルアビブまたはラルナカ発、ローマで5〜7泊」など、どれもツール呼び出しは1回です。どちらのツールも、出発日の範囲、目的地空港のリスト、往復の場合は固定の帰国日ではなくnightsの値を受け取り、内部で検索範囲を展開します。
  • 価格が本当にお得かどうかを伝えられます。 すべての結果には、その路線と期間に関するGoogle独自の過去の価格帯、price_insights_low、price_insights_high、およびlow/typical/highで示されるprice_range_in_relation_to_other_periodsの判定が含まれます。これにより、エージェントは単に価格を読み上げるのではなく、「この路線では209ドルは標準的な価格なので、急ぐ必要はありません」と回答できます。
  • 検索するだけでなく、予約できます。 すべての結果に、Google Flightsへのbuy_linkが含まれます。
  • 使用量を把握できます。 すべての応答に、今回の呼び出しで使用したリクエスト数と、利用プランに残っている数を示すapi_usageが含まれます。使用量の報告をご覧ください。
  • 検索対象を把握できます。 すべての応答にsearch_coverageが含まれるため、モデルは回答の根拠となった日付と目的地を正確に伝えられます。

結果はリアルタイムの運賃です。数分で古くなるため、最新の価格が必要なときは以前の結果を再利用せず、もう一度検索し、データの取得時刻を明示してください。

キーなしで試す:Googleでサインインする

サインインしたアカウントは、当社のキーを使ってUTCの1日あたり10回まで無料で検索できます。クライアントに何かを貼り付ける必要はありません。クライアントが提供する方法でサインインするか、https://flights.flightpowers.com/connectにアクセスして、運賃を検索してください。現在、ホスト型デプロイで設定されている上限は10回です。/healthに表示される実際の値trial_enabledとtrial_day_capを確認してください。(このコードでは、許可枠はデフォルトで無効です。有効にするにはPAID_TRIAL_DAY_CAPとPAID_TRIAL_RAPIDAPI_KEYの両方を意図的に設定する必要があるため、自分で実行するデプロイでは設定するまで許可枠はありません。)

検索回数は、日付と目的地の組み合わせ1つにつき1回です。そのため、3日間の範囲を指定した1回の呼び出しで、3回分を消費します。最初のリクエストを送る前にプラン全体の利用枠を確保し、展開後に送信しなかった分はすぐに返却されるため、同時に2回呼び出しても両方で利用枠を消費することはありません。すべての結果には、回数と上限を示すtrialブロックが含まれます。上限に達すると、ツールはsearch_status: "trial_exhausted"を返し、結果は返しません。エラーにはならず、再試行しても解決しません。利用枠は毎日00:00 UTCに更新されます。

無料検索で指定できる範囲は、その日に残っている利用枠までです。それを超える日付範囲やグリッド(3つの旅行期間で1か月全体を検索すると93回)を指定すると、実行前に拒否され、回数と代わりに指定できるより小さな検索内容が返されます。自分のキーを使う場合は、1か月分のリクエストをカバーできるよう上限が自動的に引き上げられます。1回の呼び出しで複数の情報源を比較する機能(compare_hotel_rates。有効にしたデプロイで利用可能)は利用枠の対象外で、自分のキーが必要です。

自分のRapidAPIキーを接続すると、上限がなくなります。次のセクションで説明します。

キーを取得する(無料プランあり)

サーバーは、上流サービス用の認証情報を独自に保持しません。無料利用枠を超える検索はすべてお客様のRapidAPIサブスクリプションに課金されるため、リクエストごとにキーを渡す必要があります。

  1. Google Flights Live APIを登録します: https://rapidapi.com/mtnrabi/api/google-flights-live-api
  2. x-rapidapi-keyをコピーします。
  3. 以下の3つの方法のいずれかで、サーバーに渡します。

キーがなく、利用枠も利用できない場合、ツールは何も通知せずに失敗したり、料金を発生させたりすることはありません。モデルがそのまま読み上げられるように記述された登録URLと手順とともに、needs_api_key: trueを返します。

キーを渡す3つの方法

方法手順使用する場面
ヘッダー(推奨)--header "x-rapidapi-key: YOUR_RAPIDAPI_KEY"ヘッダーを設定できる場合に使用します。キーは URL に含まれないため、プロキシやアクセスログにも記録されません。
クエリパラメーターhttps://google-flights-mcp.flightpowers.com/mcp?rapidapi_key=YOUR_RAPIDAPI_KEYURL を貼り付けることしかできないホストで使用します。該当するのは claude.ai のカスタムコネクターのダイアログです。
クライアントの API キーフィールドクライアント側の「API key」ボックスにキーを貼り付けますauthorization: Bearer <key> または x-api-key を送信するホストで使用します。Smithery の保存済み設定フォーム(config.rapidApiKey=)にも対応しています。

最初に見つかった空でない値が、この順番で使用されます。キーがログに記録されたり、エラーメッセージに表示されたり、ツールの応答として返されたりすることはありません。

4つ目の方法:/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. Reveal the URL を押して、接続 URL …/mcp?fp_token=fpk_… をコピーし、MCP クライアントのサーバー URL として使用します。ヘッダーを設定できるクライアントでは、同じトークンを Authorization: Bearer fpk_… として送信することもできます。(表示を要求するまでは URL は非表示です。この URL はプランに紐づく90日間有効なベアラー認証情報であり、初期状態から表示するページでは、スクリーンショットや画面共有のたびに URL が映ってしまいます。)

クライアント自体がサインインを済ませている場合(Claude、Cursor、ChatGPT など、MCP 認可に対応したもの)は、コピーする URL はありません。その場合、/connect にその旨が表示され、接続したキーを確認してアシスタントに戻るよう案内されます。2つ目のクライアントがサインインできず、接続 URL が必要な場合に備えて、ページにはリンクもあります。

これにより、RapidAPI キーはクライアントの設定にも URL にも、その URL が通過する各種ログにも含まれません。一方で、サーバーにはキーが暗号化された状態で保存され、Google アカウント ID とメールアドレスが記録されます。同じページの Disconnect を押すと、その記録が削除され、アカウントのすべての接続トークンが直ちに無効になります。詳しくはプライバシーポリシーのセクション2aをご覧ください。

知っておくとよい詳細:

  • 保存時にチェックが1回実行されます。 キーは保存前に API の掲載情報に対して検証されるため、入力ミスは1時間後にクライアントで発覚するのではなく、その場でページに表示されます。このチェックで消費されるのは、ご自身のプランから最大1リクエストです。無料の BASIC プラン(月10回)なら、10回中の1回です。RapidAPI にゲートウェイで拒否されたキーは、使用回数を消費しません。
  • リクエストに含まれるキーが常に優先されます。 x-rapidapi-key ヘッダー(または上記の別の方法)と接続トークンの両方を送信した場合は、リクエスト自体のキーが使われます。サインインしたことで、すでに設定済みの動作が変わることはありません。
  • トークンはキーではなく、元のキーに戻すことはできません。有効期間は90日で、接続を解除するとすぐに使えなくなります。キーが接続解除されたトークンを含む呼び出しには、再接続を促す needs_api_key 応答が返されます。他のユーザーのサブスクリプションに切り替わることはなく、利用回数も消費されません。
  • BASIC は無料です。 Google Flights Live API · Booking Live API。RapidAPI キー1つで、登録したどちらかの API を利用できます。接続は一度だけで済みます。
独自のデプロイ環境で /connect を実行する

以下の4つすべてが設定されていない限り、無効です。一部だけ設定されたデプロイ環境では、どのルートも登録されず、キーを使う呼び出し元にはこれまでどおり応答します。/health は connect_enabled を報告するため、推測せずに状態を確認できます。

変数内容
GOOGLE_OAUTH_CLIENT_IDGoogle Cloud Console → 認証情報 → OAuth クライアント ID。種類は ウェブアプリケーション。.apps.googleusercontent.com で終わります。
GOOGLE_OAUTH_CLIENT_SECRET同じクライアントのシークレット(GOCSPX-…)。
MCP_KEY_MASTER32 バイトの base64 値:openssl rand -base64 32。保存されたキーを暗号化(AES-256-GCM)し、Cookie とトークンの署名キーを導出します。
DATABASE_URLNeon Postgres のプール済みエンドポイント(…-pooler…)。スキーマ:migrations/001_mcp_user_keys.sql。

任意:MCP_CONNECT_VALIDATE=0 を設定すると、貼り付けられたキーを事前に検証せず保存します。

Google クライアントに登録する認証済みリダイレクト URIは、製品のオリジンごとに 1 つずつ、正確に次のとおりです。

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

flights.flightpowers.com の登録は不要です。サインインが始まる前に、/connect と /connect/start はエイリアスから正規のオリジンへリダイレクトします。Cookie はホストごとに管理され、Google は redirect_uri を文字どおりに比較するためです。エイリアスからサインインを開始すると、状態 Cookie のないホストに戻ることになり、Google の設定ミスのように見えるメッセージが表示されて失敗します。

また、OAuth 同意画面では、スコープとして openid と .../auth/userinfo.email のみを設定してください。

**MCP_KEY_MASTER をローテーションすると、全員がログアウトし、保存されたすべてのキーが無効になります。**これは意図的な動作です。ローテーション後に、誰も読み取れないキーを参照するトークンが残らないようにします。ユーザーには検索失敗ではなく、「再接続してください」と表示されます。テーブルの key_version は、将来的に一斉切り替えなしで段階的なローテーションを行えるようにするためのものです。

フローを最初から最後まで検証する

まず、データベースごとに 1 回だけマイグレーションを実行します。

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 では、実際に検索する必要があります。

5 つ目の方法:MCP クライアント内からサインインする

MCP クライアントがサインインを開始するのは、リクエストに対して 401 と WWW-Authenticate: Bearer resource_metadata=… ヘッダーが返された場合だけです。通信上、それ以外にサインインが利用可能だとクライアントに伝えるものはありません。2026-09-09 までは /mcp がこのヘッダーを送信しなかったため、キーを持たない呼び出し元には needs_api_key と書かれた本文を含む 200 が返っていました。JSON としては正しくても、どのクライアントの認証機能にも認識されず、ユーザーにはクリックできる場所もないまま「ツールに失敗しました」と表示されていました。

現在、/mcp はチャレンジを返します。ただし、何も持たずにアクセスした呼び出し元に対してのみ、かつ何かを消費する呼び出しに対してのみです。

URL動作
https://flights.flightpowers.com/mcp使用する URL はこちらです。 ヘッダー、クエリ文字列、Smithery の設定データに含まれる RapidAPI キー、fpk_ 接続トークン、fpo_ アクセストークンは、すべてこれまでどおり処理されます。何もない場合も、initialize、tools/list、その他の読み取り専用ハンドシェイクには引き続き応答し、tools/call には 401 とチャレンジを返すため、クライアントにサインインボタンが表示されます。
https://hotels.flightpowers.com/mcpホテルの場合も同様です。
…/mcp/oauth同じサーバーですが、最初のリクエストからサインインを要求します。サーバー追加時に認証モードが固定されるクライアントや、変更前にこの URL で保存されたコネクター向けです。

ツールも、ホスト名による製品の振り分けも、その他すべても同じです。このエイリアスは、トークンチェックの背後にある同一のツールレジストリであり、サーバーの別コピーではありません。

有料連携を保護する仕組み: 認証情報を含むリクエストには、チャレンジが返されることはありません。また、認証情報の確認順序も変わりません(ヘッダー、クエリ、設定データ、OAuth ID、接続トークン、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 および …/mcp/oauthRFC 9728
GET /.well-known/oauth-authorization-server および …/mcp/oauthRFC 8414
POST /oauth/registerRFC 7591、動的クライアント登録、オープン
GET /connect/authorize · POST /connect/authorizeRFC 6749 §4.1、PKCE S256 必須
POST /oauth/tokenauthorization_code および refresh_token
POST /oauth/revokeRFC 7009

認可エンドポイントを意図的に /connect 配下に置いています。サインインセッション Cookie のスコープを Path=/connect にして、/mcp リクエストに決して付与されないようにしているためです。また、認可エンドポイントを 別の場所に置くと、Cookie の範囲を広げるか、ユーザーに 2 回サインインしてもらう必要があります。

認可コードの有効期間は 10 分で、1 回だけ使用できます(DELETE … RETURNING によって、2 つの同時交換処理が 1 つの行を取り合い、必ず一方だけが成功します)。アクセストークンの有効期間は 1 時間、リフレッシュトークンは ローテーションを伴い 30 日です。すべて不透明な値で、SHA-256 ハッシュとして保存されるため、データベースの ダンプには再利用可能なものは何も含まれません。トークンに JWT は意図的に使用していません。署名付きトークンは、 その後に何を決めようと、有効期限が切れるまで有効なままです。切断は、確実に切断を意味する必要があります。

アクセストークンは、発行時だけでなく、受け入れる前にも、承認されたリソースと照合されます。両方の製品は同じ デプロイ、同じデータベース、ユーザーごとに保存された同じ RapidAPI キーを使用しています。そのため、この確認が なければ、「ライブの航空運賃を検索」とだけ記載されたフライトの同意ページで承認されたトークンが、ホテルの ホスト名でも受け入れられ、ユーザーのホテルプランを消費してしまいます。この確認ではホスト名を厳密に照合し、 パスには柔軟に対応します。実環境のクライアントは、同じサーバーに対してオリジン、/mcp、/mcp/oauth を 送るためです。tests/test_oauth.py::TestATokenIsBoundToTheResourceItWasApprovedFor で、この両方をテストしています。

独自のデプロイ環境での実行

新しい環境変数はありません。 /connect が設定されている場所では、同じ Google サインインとキー ストアを再利用するため、自動的に有効になります。同じデータベースにテーブルを 1 つ追加する必要があります。

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

/health は oauth_enabled: true と oauth_mcp_endpoint を返します。この URL をディレクトリの 掲載情報に指定します。MCP_OAUTH=off を設定すると、/connect を稼働させたまま無効にできます。 コードの変更なしでロールバックできます。

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 だけでは何も認可されないことが理由です。どのフローも最終的には、サインインした人がボタンを押す必要のある同意ページに行き着きます。公開されているからといって無制限ではありません。これを支える仕組みが 3 つあります。

仕組み保存場所防ぐもの
レート制限メモリ内、インスタンスごと短時間の集中アクセス:アドレスごとに 10 分間で登録 10 件、1 分間でトークンリクエスト 60 件、1 時間で /connect/save を 10 回までに制限します。上限を超えると Retry-After を伴う 429 を返します。
1 日あたりの上限Postgres。すべてのインスタンスで値を共有ゆっくりとした増加:アドレスごとに 1 日 30 件まで登録できます。全体の上限は 1 日 5,000 件です。これは行数が無制限に増えるのを防ぐためのセーフティネットであり、防御策ではありません。実際のトラフィックに近い全体数を設定すると、攻撃者がそれを悪用して、その日中は Claude や Cursor の新規ユーザー全員を拒否できます。1 日に 500 件を超えるとログに記録されますが、拒否はしません。MCP_OAUTH_DCR_MAX_PER_IP_PER_DAY、MCP_OAUTH_DCR_MAX_PER_DAY、MCP_OAUTH_DCR_WARN_PER_DAY で、デプロイせずに上限を変更できます。
クリーンアップ/oauth/register へのアクセス時、インスタンスごとに最大 15 分に 1 回不要なデータ:期限切れのコードとトークン、および 7 日以内に認可に至らなかった登録を削除します。有効なトークンがあるクライアント、未処理のコードがあるクライアント、または同意ページが表示されたクライアントは、決してクリーンアップされません。

レート制限はインスタンス単位です。Vercel では、ウォーム状態のインスタンスが N 個ある場合、合計でこれらの数値の最大 N 倍まで許可されます。また、コールドスタート時にはカウンターがゼロから始まります。そのため、データベースでカウントされ、どのインスタンスから見ても同じ値になる永続的な上限も設けています。

これらすべてでキーとして使うアドレスは、まず x-real-ip から取得します。この値は Vercel によって、接続を受け付けたピアのアドレスに設定されます。そうでない場合は、内部ホップしか含まれない部分を除外して、x-forwarded-for の最後の有効なエントリを使用します。決してエントリ 0 を使ってはいけません。プロキシは右側に値を追加するため、最も左のエントリは呼び出し元が書き込んだ値です。これを読むと、ここで設けているすべての制限が、ヘッダーを 1 つ変更するだけで回避されてしまいます。どちらのヘッダーもないリクエストは、unknown という 1 つのバケットを共有します。このバケットは単一の呼び出し元としてレート制限され、アドレスごとの永続的な上限の対象にはなりません(エッジでヘッダーが 1 つ欠けただけで、サーバー全体を 1 日ロックアウトしてはいけません)。

登録は、人が Approve を押したときではなく、同意ページが表示された時点で使用中として記録されます。MCP クライアントは、インストール時に登録し、数日後に認可することがよくあります。クリーンアップによって、同意ページの表示中に行が削除されてはいけません。コードやトークンからクライアントへの外部キーがないため、その後の交換処理は invalid_client で失敗し、原因も特定できなくなります。

次のマイグレーションが必要です。

psql "$DATABASE_URL" -f migrations/003_mcp_oauth_hygiene.sql
リフレッシュトークンはローテーションされ、リプレイされるとファミリーが失効します

リフレッシュトークンは一度だけ使用できます。これを交換すると、新しいペアが発行され、提示されたトークンは無効になります。 無効になった行は削除されず、記録が残されます。削除された行と、発行されたことのないトークンは同じように見えるためです。両者を見分けることが重要です。すでにローテーションされたリフレッシュトークンが提示された場合、クライアントが応答を受け取れなかったか、トークンが他人の手に渡ったことを意味します。OAuth 2.1 §4.14.2 に従い、後者とみなします。その場合、応答は invalid_grant となり、その認可から派生したすべてのトークンが削除されます。正規のクライアントは再度サインインし、盗まれたアクセストークンも同時に使えなくなります。

ただし、ほとんどの場合に問題のないケースについては、意図的な例外が1つあります。ローテーションしたばかりのトークンが、同じクライアントから10秒以内に初めてリプレイされた場合、ローテーション時にすでに発行されたペアを返します。これは冪等な再試行で、新しいものは作成されません。応答が接続切断によって失われたクライアントが、気づかないうちにサインアウトされるのを防ぎます。2回目のリプレイ、またはウィンドウを過ぎてからのリプレイは、本当のリプレイとしてファミリーを失効させます。このウィンドウはインスタンスごとにメモリ上で管理されるため、判定に失敗した場合は保守的な応答にフォールバックします。

/oauth/revoke を通じてリフレッシュトークンを失効させると、同じ理由(RFC 7009 §2.1)で、そのアクセストークンも失効します。ただし、失効するのは要求元のクライアントに発行されたトークンに限られます。これは同節のもう一つの要件です。他人のトークンを提示したクライアントにも 200(§2.2)を返しますが、何も失効しません。

client_id には URL を指定できます

client_id_metadata_document_supported: true は /.well-known/oauth-authorization-server で通知されます。クライアントは 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 またはリスト、および出発日1日または日付範囲。価格、航空会社、所要時間、経由地、buy_link、および運賃を判断するための Google の過去の価格帯を返します。期間を指定しない質問も含め、片道に関する質問にはすべて使用してください。日付ごとに1回ではなく、範囲を指定して1回呼び出します。
search_roundtrip_flights往復の組み合わせとして価格設定された、リアルタイムの往復運賃を検索します。片道2件としては扱いません。入力:出発地、到着地(複数可)、出発日または日付範囲、および 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月前半なら出発日はいつでもよいので、ローマまたはアテネへ1週間旅行する場合、最も安い日程を教えてください。」

1 回の呼び出し:

{
  "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 件の組み合わせに展開されます。これは 1 回の呼び出しあたりの上限ちょうどです。 レスポンスの形式(フィールド名は実際のものです。以下の値は説明用であり、見積もりではありません。実際の運賃を確認するには、呼び出しを実行してください):

{
  "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 を変更しても結果は変わりません。また、デフォルトでは未設定のままです。バックエンドはこのフィールドを受け付けますが、選択される 2 つ目のフライトデータソースは USE_FALLBACK_FLI(fallback_available())によって制御されており、この API では有効になっていないため、現時点ではこのフィールドに指定できる 3 つの値はいずれも検索結果に影響しません。バックエンドが読み取り不能なページに対して行う自動再試行は無条件で実行され、このフィールドの影響を受けません。
  • 一部の検索に失敗しました。 partial フィールドに、実行した検索のうち失敗した件数が示され、残りの検索結果が返されます。
  • 範囲が広すぎます。 search_coverage.truncated: true と note が返されます。範囲は途中で切り捨てられるのではなく、ウィンドウ全体にわたって均等にサンプリングされます(最初と最後は保持されます)。そのため、サンプルは先頭から N 日分だけではなく、範囲を代表するものになります。より広く網羅するには max_searches を増やすか、範囲を狭めてください。
  • キーがない、またはキーが拒否されました。 needs_api_key: true が返され、利用額はゼロで、修正方法も示されます。最もよくある原因は、この API へのサブスクリプションがない有効な RapidAPI キーです。
  • プランの上限に達しました。 quota_exhausted: true と api_usage が返されます。また、範囲を狭めると残りのクォータをより有効に使えることを知らせるメッセージも表示されます。

ホテル:チェックイン日の範囲

POST /search では1件の滞在のみを料金検索するため、「5月にローマで最も安い3泊」を調べるには、以前は31回のツール呼び出しが必要でした。実際には、モデルが選んだ日付で1回だけ呼び出し、その結果を最安値として提示していました。現在、どちらのホテル検索ツールもフライトと同じ形式でリクエストを受け付けます。

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)と同じ仕組みです。滞在ごとにバックエンドを1回呼び出し、max_searches_per_tool_call(上限は30、ハード上限は60)で制限します。範囲内のすべてを検索できない場合は範囲全体から均等にサンプリングし、その結果をsearch_coverageで報告します。nightsから各チェックアウト日を算出するため、checkout_dateと置き換わり、両方を併用することはできません。チェックイン日の範囲に固定のcheckout_dateを指定することは可能で、「到着日を問わず4日に出発」という意味になります。成立しない組み合わせは除外されます。

レスポンスは意図的に上限を設けています。滞在ごとにすべての宿泊施設を含めると約25 KBになります(実測値:宿泊施設25件で25,892バイト、そのうち18,989バイトがURL)。そのため、各滞在について最安値の宿泊施設、1泊あたりの料金、中央値を返し、宿泊施設の全リストは最安の滞在についてのみ返します。

{
  "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上限によるサンプリングで検索対象から外れました

最後の2つでは、件数は0ではなくnullになります。ゼロは「該当するものがない」と受け取られますが、どちらのケースもそのことは分かっていません。トップレベルのsearch_statusは、各滞在の結果に応じてok/partial/empty/degradedとなります。すべての滞在で失敗した場合は、空のリストを返すのではなくエラーになります。

意図的に拒否する指定が1つあります。1件の滞在に対するmax_searchesです。この指定を許すと、何も起きないまま処理されてしまいます。

1件の滞在の場合、この機能が追加される前とバイト単位で同一の結果になります。リクエスト本文とレスポンスのキーは同じで、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は、キーを持つすべての情報源で同じ滞在の料金を検索し、情報源ごとに1行を返します。各行には、最安の合計料金、合計料金の中央値、料金が取得できた施設数、通貨、行の読み取り時刻が含まれます。このツールはMCP_HOTEL_COMPARE=onの場合にのみ登録されます。公開デプロイでは無効のため、ホスト型ホテルサーバーに表示されるツールは2つで、説明内でも、このツールが提供されている環境でのみ言及されています。このセクションの残りでは、このツールを有効にしたデプロイについて説明します。

レスポンスは次のとおりです。

{
  "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 は、リクエスト本文に provider: "airbnb" を指定して(flight_rabbi #472)、POST https://api.flightpowers.com/v1/hotels/search で独自の窓口を経由します。これは Airbnb のバックエンドが RapidAPI のエッジ上にないためです。この呼び出しには呼び出し元のキーを x-rapidapi-key として含め、X-FP-Client: mcp-hotels/<version> で呼び出し元を識別します。窓口は本文の呼び出し元情報を独自の判断で上書きするため、誰が呼び出したかを示すのはヘッダーだけです(ルール 11)。プレビュー用の窓口を使うには、API_FRONT_BASE_URL でオリジンを上書きします。この変数に認証情報は含まれません。

Airbnb のリスティングはまだ RapidAPI 上に存在せず、窓口ではプロバイダーが無効になっているため、 現時点で providers: ["airbnb"] を指定して compare_hotel_rates を実際に呼び出すと、その旨を示す degraded の行が返されます。これは想定された結果であり、バグではありません。有効なキーさえあれば誰でも到達できる、使用量が計測されないソースは、こちらが費用を負担するゲートウェイとなるためです。

実装で守る 4 つのルール
  1. キーを持っていないソースをこちらのキーで呼び出すことはありません。 providers_skipped に reason(no_key、not_subscribed、key_rejected)と、サブスクライブ先の URL を記載します。リスティングごとにサブスクリプションは別なので、Hub からの 403 は「そのリスティングをサブスクライブしていない」という意味であり、「キーが無効」という意味ではありません。
  2. 応答が不完全なソースも、欠落ではなく名前付きの行として示します。 search_status: "degraded"、count: null、価格なしとなり、もう一方のソースの行はそのまま返されます。「Booking から応答がなかった」と「Booking に該当結果がなかった」は正反対の答えなので、異なる文として伝えられなければなりません。
  3. cheapest_total と median_total の対象は価格が付いている行だけで、count はその行数です。 price_string を数値として解析することはありません。
  4. 通貨をまたいだ計算は行いません。 すべてのソースに同じ通貨を指定します。異なる通貨で応答があった場合は、各行にそれぞれの通貨を保持し、合計を比較できないことを caveats に記載します。通貨の換算は行いません。
ソースごとのキー

ほとんどの場合、複数のリスティングをサブスクライブした 1 つの RapidAPI キーを持ち、追加の設定なしでそのキーをすべてのソースに使用します。本当に 2 つのキーを持っている呼び出し元は、ソースごとに 1 つを指定できます。ソース固有の名前が優先されます。

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

優先順位は src/credentials.py にすでに記載されている順序です。ソース固有の名前、次にソースを指定しない個別の名前、その次に設定 Blob、最後に汎用名です。config パラメーターが存在する場合は、ゲートウェイ自身のキーが汎用名として指定されていてもこちらのキーではないため、汎用名は完全にスキップされます。

filters と price_as_seen_from は比較時には Booking 専用です

compare_hotel_rates ではこれらは Booking に送信され、窓口には送信されません。Airbnb のみを対象とする比較では、拒否されます。フィルターが黙って無視されると、リクエストした以上の宿泊施設が返されるうえ、そのことも通知されないためです。

構造化出力(outputSchema、structuredContent、isError)

すべてのツールはoutputSchemaを宣言し、すべての結果はペイロードを2回含みます。1回はstructuredContentとして、もう1回はテキストコンテンツブロック内のシリアライズ済みJSONとしてです。MCP仕様では、この重複を求めています。

後方互換性のため、構造化コンテンツを返すツールは、シリアライズ済みJSONもTextContentブロックで返すべきです。

これは形式的なものではなく、ここでは重要な意味があります。構造化出力が登場する前のクライアントは、テキストブロックだけを読み取るためです。(仕様のリビジョン 2026-07-28 に基づき確認済み。構造化出力は2025-06-18に導入されました。)

スキーマでは意図的にadditionalProperties: trueとし、必須項目はresultsのみとしています。仕様ではサーバーに「サーバーはこのスキーマに適合する構造化された結果を提供しなければならない」という義務を課していますが、これらのツールには異なるキーを含む正当な終了パターンがいくつかあります。結果がゼロの応答、キーのないneeds_api_key応答、そしてquota_exhausted応答です。より厳密なスキーマは見栄えこそよくなりますが、意図的に提供している経路でサーバーが仕様に適合しなくなります。

search_statusと、degradedがエラーである理由

フライトの結果にはsearch_statusが含まれ、バックエンド独自のX-Search-Statusの用語を反映しています。

値意味
ok検索したすべての組み合わせで結果が返されました
empty検索は完了しました。Googleには実際に旅程がありません。これは正当な結果です
partial一部の組み合わせでは結果が返され、一部では失敗しました。リストは不完全です
degradedすべての組み合わせで失敗しました。検索は実行されておらず、空のリストには意味がありません

degradedの結果には**isError: trueも設定されます**。これが該当する唯一の値です。仕様では「APIの失敗」をツール実行エラーに分類し、クライアントは「自己修正を可能にするため、ツール実行エラーを言語モデルに提供すべき」としています。一方、ホストにstructuredContentをモデルに表示するよう義務付ける規定は仕様にありません。そのため、ペイロード内のフィールドだけで伝えられる失敗は、モデルが目にすることのない失敗になり得ます。これこそが、search_statusを追加して解決しようとした問題です。

エラーとともにペイロードも引き続き渡されます。structuredContentとテキストブロックの両方が含まれるため、何も失われません。特にapi_usageは重要です。degradedの検索でも呼び出し元自身の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はそれに合わせて変わります。モデルが明言すべきルールは、日付1つ × 目的地1つ = 課金対象リクエスト1件です。

コストを抑える設定を効果の大きい順に挙げると、呼び出しごとのmax_searches(広い質問での出費を抑えるには値を小さくします)、日付範囲を狭めること、目的地リストを短くすることです。

1 回の呼び出しと 30 回の呼び出し

基盤となる REST API では、1 回の呼び出しにつき (origin, destination, date) のタプルが 1 つだけ必要です。1 日分ずつ呼び出すだけのパススルー方式では、「10 月のいつでもいいのでスリランカ行きの最安値」を調べるには、ツールを 31 回呼び出すことになります。つまり、モデルとの往復が 31 回、話の流れを見失う機会が 31 回あり、料金は後になって初めてユーザーにわかります。

こちらならツールの呼び出しは 1 回です。ファンアウトはサーバー側で並行して行われ、上限が設けられ、均等にサンプリングされます。buy_link で重複を排除し、結果を統合して sort_by で並べ替え、search_coverage と api_usage で利用状況を正直に報告します。

このサーバー(有料)無料サーバー
1 回の呼び出しあたりのファンアウト30(上限は 60。max_searches で呼び出しごとに増減可能)15
広告なし結果ごとに、スポンサー付きカードを 1 件表示
キーご自身の 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_CALL301 回の呼び出しあたりのファンアウト上限です。上限は 60 で、これを超えないように制限されます。
MAX_CONCURRENT_SEARCHES10ファンアウトの並行処理数です。
MAX_HTTP_CONNECTIONS60接続プールの上限です。サーバーレスインスタンス間でファイルディスクリプタープールを共有します。
REQUEST_TIMEOUT_SECONDS75上流関数の Timeout(60)に、エッジリレーの余裕時間 15 秒を加えた値です。これにより、回答の処理がまだ続いているのにこちら側が諦めることはありません。
DEFAULT_RESULT_LIMIT10個々の上流検索で要求する結果数です。
MCP_PRODUCTSbothこのデプロイで提供する製品です。flights、hotels、both のいずれかを指定します。ツールセット、サーバーの説明、サービス名、ポリシーページ、およびキーを持たない、またはサブスクリプション未登録の呼び出し元に案内する RapidAPI の掲載ページを選択します。hotels のデプロイでデフォルト値のままにすると、フライトサーバーとして自己紹介してしまいます。
SIGNUP_URLMCP_PRODUCTS に対応する掲載ページキーを持たずにアクセスしたユーザーに提示する URL です。both の場合、ホテルのツールでは常に Booking の掲載ページを案内します。1 つの URL を 2 つの API の Subscribe ボタンにすることはできません。
MCP_PRODUCTS_BY_HOSTdefault各ホスト名が提供する製品を指定します。これにより、1 つのデプロイで両方の有料ドメインを提供しながら、それぞれの掲載ページには対応するツールセットだけを表示できます。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 に準拠しています。

知っておくべき点が 2 つあります。

  • ツールリストは初回リクエスト時に稼働中のレジストリから読み取られ、手作業で書き出されることはありません。 そのため、tools/list の戻り値との不一致は起こりません。tests/test_server_card.py は両方の製品で これらを比較します。
  • ここにある他のすべてと同様、ホスト名ごとに異なります。hotels.flightpowers.com はホテルのカードを返し、 2 つのフライト用ホスト名はどちらもフライトのカードを返します。また、カード内のすべての 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 を介して、ホスト版デプロイと同じトランスポートであるストリーミング可能な HTTP を ${PORT}/mcp で提供します。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 Flights」は、公開データソースを説明する目的でのみ使用しています。運賃は上流のプロバイダーから提供され、 常に変動しており、保証されません。購入前に必ず航空会社または予約サイトで料金を確認してください。

コメント

MCP は初めてですか?

プロトコルの概要から最初のサーバーの導入まで、よくある疑問にまとめて答えます。

AI アプリケーションが外部のツールやデータソースに統一的な方法で接続するためのオープンなプロトコルです。クライアントはモデルとの対話を担い、MCP サーバーはツール・リソース・プロンプトテンプレートを提供します。標準メッセージでやり取りするため、1 つのサーバーを複数のクライアントで再利用できます。