API ВЕГА

Google Flights MCP: актуальные тарифы, которые агент может искать сразу за весь диапазон дат, без рекламы

FlightPowers — это API туристических данных для разработчиков и AI-агентов: актуальные тарифы Google Flights с собственным диапазоном низких, типичных и высоких цен от Google, а также стоимостью поездки туда и обратно, рассчитанной за один запрос. Кроме того, доступны актуальные цены на отели Booking.com. Работать с сервисом можно через REST, MCP-серверы и узел n8n, используя один ключ RapidAPI. Бесплатный тариф включает 10 поисков; PRO стоит $10 за 2 500 поисков авиабилетов — примерно в шесть раз дешевле SerpApi за один поиск (их самый доступный тариф стоит $25 за 1 000 поисков). Сервис лучше всего подходит для отслеживания цен, поиска по диапазонам дат и работы AI-агентов; бронирование не поддерживается. Без рекламы — при использовании собственного ключа RapidAPI. Диапазон дат и список направлений можно передать одним запросом.

Один URL — два способа подключения:

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

В клиенте с поддержкой авторизации MCP появится кнопка «Войти»: войдите через Google, и первые 10 поисков в день будут бесплатными и без рекламы за наш счёт — вводить ключ не нужно. Если хотите снять это ограничение, один раз укажите собственный ключ RapidAPI на странице /connect. В любом случае добавлять ключ в конфигурацию клиента не потребуется. Если в клиенте нет кнопки «Войти», укажите ключ самостоятельно:

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

В обоих случаях используется один и тот же URL. Запрос с любыми учётными данными будет обработан. Если запрос отправлен без них, сервер ответит 401 и вернёт метаданные OAuth — благодаря этому в клиенте появится кнопка «Войти». URL https://flights.flightpowers.com/mcp/oauth по-прежнему доступен и требует войти уже при первом запросе. Он предназначен для клиентов, в которых режим авторизации фиксируется при добавлении сервера.

Сервер размещён в облаке: ничего клонировать и собирать не нужно. Он включён в официальный MCP Registry под именем com.flightpowers/google-flights-mcp. Проверка состояния:

/health.

Нет ключа? Для начала он не нужен. Добавьте URL выше, войдите через Google — первые 10 поисков за день по UTC будут бесплатными и без рекламы за наш счёт. В разделе «Попробовать без ключа» описано, что именно считается поиском.

Нужен ключ? Подпишитесь на Google Flights Live API на RapidAPI: доступен бесплатный тариф. Затем скопируйте свой 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 поисков или ограничения клиента начнут мешать.


Что получает ваш агент

Два инструмента, которые отвечают на вопрос о тарифе, а не просто ищут по конкретной дате.

  • Задавайте вопросы в свободной форме. «Самый дешёвый билет в одну сторону на Шри-Ланку в любой день октября», «5–7 ночей в Риме когда-нибудь в мае, вылет из Тель-Авива или Ларнаки»: каждый запрос выполняется одним вызовом инструмента. Оба инструмента принимают диапазон дат вылета, список аэропортов назначения и (для поездки туда и обратно) значение nights вместо фиксированной даты обратного вылета, а затем сами перебирают возможные варианты.

  • Узнавайте, действительно ли цена выгодная. Для каждого результата приводится собственный исторический диапазон Google для этого маршрута и периода: price_insights_low, price_insights_high и оценка price_range_in_relation_to_other_periods со значением low / typical / high. Благодаря этому агент может сказать: «Здесь $209 — типичная цена, торопиться не стоит», а не просто назвать сумму.

  • Переходите к бронированию, а не только к поиску. Каждый результат содержит buy_link на Google Flights.

  • Следите за расходами. В каждом ответе есть api_usage: число запросов, израсходованных этим вызовом, и остаток по тарифу пользователя. Подробнее — в разделе «Учёт расходов».

  • Узнавайте, что именно было проверено. В каждом ответе есть search_coverage, чтобы модель могла точно указать, на какие даты и направления опирается результат.

Результаты содержат актуальные тарифы. Они устаревают за несколько минут: никогда не кэшируйте тарифы и не используйте повторно предыдущие результаты. Выполните новый поиск и укажите, когда были получены данные.

Попробовать без ключа: войдите через Google

После входа в аккаунт можно выполнять 10 бесплатных поисков в день по UTC за наш счёт — вводить ключ в клиент не нужно. Войдите одним из способов, доступных в вашем клиенте, или откройте https://flights.flightpowers.com/connect и запросите тариф. Сейчас размещённая версия сервера предоставляет именно 10 поисков. Текущие значения trial_enabled и trial_day_cap отображаются на странице /health — ориентируйтесь на них. (По умолчанию бесплатный доступ в этом коде отключён: для его включения нужно явно задать PAID_TRIAL_DAY_CAP и PAID_TRIAL_RAPIDAPI_KEY. Поэтому на самостоятельно развёрнутом сервере бесплатных поисков не будет, пока вы не настроите эти параметры.)

Один поиск — это одна комбинация даты и направления, поэтому вызов с диапазоном в три дня расходует три поиска. Весь лимит резервируется до отправки первого запроса, а неиспользованная часть сразу возвращается. Благодаря этому два одновременных вызова не смогут израсходовать один и тот же лимит. В каждом результате есть блок trial с количеством использованных поисков и лимитом. После исчерпания лимита инструменты возвращают search_status: "trial_exhausted" без результатов — это не ошибка, и повторный запрос не поможет. Лимит обновляется в 00:00 UTC.

Сравнение нескольких источников в одном вызове (compare_hotel_rates или search_hotels с несколькими значениями providers) не входит в бесплатный лимит и требует собственного ключа.

Подключите собственный ключ RapidAPI, чтобы полностью снять ограничение. Подробнее — в следующем разделе.

Получить ключ (доступен бесплатный тариф)

У сервера нет собственных учётных данных для доступа к внешнему API. Все поиски сверх бесплатного лимита оплачиваются по вашей подписке RapidAPI, поэтому ключ передаётся вместе с запросом.

  • Подпишитесь на Google Flights Live API:

https://rapidapi.com/mtnrabi/api/google-flights-live-api

  • Скопируйте свой x-rapidapi-key.

  • Передайте ключ серверу одним из трёх способов, описанных ниже.

Если ключ не указан и бесплатный лимит вам недоступен, инструменты не завершат работу без сообщения и не израсходуют средства. Они вернут needs_api_key: true, ссылку для регистрации и инструкции, сформулированные так, чтобы модель могла озвучить их вам.

Три способа передать ключ

СпособКак передатьКогда использовать
Заголовок (предпочтительный)--header "x-rapidapi-key: YOUR_RAPIDAPI_KEY"Если клиент позволяет задавать заголовки. Ключ не попадёт в URL, а значит, и в журналы прокси-сервера и доступа.
Параметр запросаhttps://google-flights-mcp.flightpowers.com/mcp?rapidapi_key=YOUR_RAPIDAPI_KEYЕсли хост позволяет указать только URL. В частности, этот способ подходит для диалога настройки пользовательского коннектора на claude.ai.
Поле API key в клиентеВставьте ключ в поле «API key» самого клиентаДля хостов, которые отправляют authorization: Bearer или x-api-key. Также поддерживается форма сохранения конфигурации Smithery (config.rapidApiKey=).

Используется первый непустой источник в указанном порядке. Ключ не записывается в журналы, не включается в сообщения об ошибках и не возвращается в ответах инструментов.

Четвёртый способ: войдите один раз на странице /connect

Именно на эту страницу ведёт ссылка для входа в начале README. Клиент с поддержкой авторизации MCP проведёт вас через этот процесс автоматически; ниже описаны те же действия, выполненные вручную.

Если эта функция включена в вашей среде (проверьте значение connect_enabled на странице /health), доступна страница /connect, которая заменяет все описанные выше действия входом в систему:

https://hotels.flightpowers.com/connect) и войдите через Google.

  • Один раз вставьте свой ключ RapidAPI в форму. Передача данных защищена TLS.

  • Нажмите Reveal the URL и скопируйте URL для подключения — …/mcp?fp_token=fpk_…. Укажите его как URL сервера в клиенте MCP. Если клиент позволяет задавать заголовки, вместо этого можно передать тот же токен в заголовке Authorization: Bearer fpk_…. (Токен скрыт, пока вы не запросите его: этот URL — учётные данные bearer-типа, действующие 90 дней и связанные с вашим тарифом. Если страница показывает его сразу, он попадёт на каждый скриншот и в каждую демонстрацию экрана.)

Если клиент выполнил вход за вас — например, Claude, Cursor, ChatGPT или любой другой клиент с поддержкой авторизации MCP, — копировать URL не нужно. Страница /connect сообщит об этом: она покажет подключённый ключ и предложит вернуться к ассистенту. Там же есть ссылка на случай, если второй клиент не может выполнить вход и ему нужен URL для подключения.

Преимущество этого способа: ключ RapidAPI не хранится в настройках клиента, не передаётся в URL и не попадает в журналы, через которые проходит этот URL. Обратная сторона: сервер хранит ваш ключ в зашифрованном виде и получает доступ к идентификатору и адресу электронной почты вашей учётной записи Google. Кнопка Disconnect на той же странице удаляет запись и сразу же отзывает все токены подключения для вашей учётной записи. Подробности см. в разделе 2a политики конфиденциальности.

Несколько важных деталей:

  • При сохранении выполняется одна проверка. Перед сохранением ключ проверяется по каталогу API. Если в нём опечатка, ошибка появится на странице, а не в клиенте час спустя. Проверка расходует не более одного запроса из вашего тарифа: на бесплатном тарифе BASIC (10 запросов в месяц) — один из десяти. Если RapidAPI отклонит ключ на шлюзе, запрос ничего не будет стоить.

  • Ключ, переданный в запросе, всегда имеет приоритет. Если вы отправляете заголовок x-rapidapi-key (или передаёте ключ одним из других описанных выше способов) и используете токен подключения, запрос будет выполнен с собственным ключом. Авторизация через Google никак не меняет работу уже настроенного клиента.

  • Токен — это не ваш ключ, и получить из него исходный ключ невозможно. Токен действует 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…). Схема: 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 отдельная запись не нужна. Перед началом входа страницы /connect и /connect/start перенаправляют запрос с псевдонима на канонический домен. Это необходимо, потому что cookie привязаны к хосту, а Google сравнивает redirect_uri буквально: если начать вход с псевдонима, Google вернёт пользователя на хост без cookie состояния, и появится ошибка, похожая на неверную настройку Google.

Также на экране согласия OAuth укажите области openid и .../auth/userinfo.email, не добавляя другие.

Смена MCP_KEY_MASTER завершает сеансы всех пользователей и делает недействительными все сохранённые ключи. Это сделано намеренно: после смены секрета не должно остаться токенов, связанных с ключами, которые сервер больше не может расшифровать. Пользователи увидят предложение подключиться заново, а не ошибку поиска. Столбец key_version в таблице оставляет возможность в будущем провести поэтапную ротацию без одновременного переключения всех пользователей.

Проверка работы всего процесса

Сначала выполните миграцию один раз для каждой базы данных:

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

Затем, после развёртывания:

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

# 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-клиента

MCP-клиент начинает процесс входа только в ответ на запрос, который получил статус 401 и заголовок

WWW-Authenticate: Bearer resource_metadata=…. Ничто другое в сетевом обмене не сообщает клиенту о возможности входа. До 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Тот же сервер, но вход запрашивается уже при первом обращении. Этот адрес подходит для клиентов, режим аутентификации которых задаётся при добавлении сервера, а также для коннекторов, сохранённых по этому адресу до изменения.

Набор инструментов, маршрутизация продуктов по доменам и всё остальное остаются прежними. Псевдоним ведёт к тому же реестру инструментов, защищённому проверкой токена, а не к отдельной копии сервера.

Свойство, защищающее платные интеграции: запрос с учётными данными никогда не получает запрос на аутентификацию, а порядок проверки учётных данных не меняется: заголовок, строка запроса, блок конфигурации, затем идентификатор 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 по-прежнему запрашивает аутентификацию для всех запросов. Этот адрес следует указать в каталоге, если сервер должен всегда требовать аутентификацию. Для сканеров, которые читают карточку сервера, также доступен общедоступный /.well-known/mcp/server-card.json, не требующий аутентификации.

Как это работает. Вставьте URL /mcp/oauth в клиент. Клиент зарегистрируется, откроет браузер, и вы войдёте через Google. Затем подтвердите доступ для конкретного клиента по его имени на странице, где точно указано, что ему будет разрешено: выполнять поисковые запросы, оплачиваемые за счёт вашего плана RapidAPI, и ничего больше. Клиент не получит ваш ключ RapidAPI. Если вы ещё не подключили ключ, подтверждение всё равно пройдёт успешно, а при первом поиске появится сообщение с просьбой вставить ключ на странице /connect и URL этой страницы.

Как отозвать доступ. Нажмите Отключить на странице /connect: сохранённый ключ будет удалён, а все OAuth-токены этой учётной записи Google — отозваны в рамках того же действия. Подключённый клиент сразу перестанет работать. Отдельный клиент может отправить запрос на /oauth/revoke (RFC 7009).

Настройка клиента

MCP -> Add, URL выше. Или ~/.cursor/mcp.json:

{ "mcpServers": { "flightpowers": {

"url": "https://google-flights-mcp.flightpowers.com/mcp/oauth" } } }

Cursor обнаруживает 401, регистрируется и открывает браузер.

ChatGPT -- Settings -> Connectors -> Create. Запрашиваются следующие данные:

MCP server URL: https://google-flights-mcp.flightpowers.com/mcp/oauth

Authentication: OAuth

Оставьте поля client id и secret ПУСТЫМИ: сервер поддерживает динамическую

регистрацию клиентов, поэтому ChatGPT зарегистрируется самостоятельно. Больше ничего заполнять не нужно.

MCP Inspector -- самый быстрый способ проследить весь процесс рукопожатия.

npx @modelcontextprotocol/inspector

Transport: Streamable HTTP

URL: https://google-flights-mcp.flightpowers.com/mcp/oauth

Auth: OAuth 2.0 -> «Guided OAuth Flow» проведёт через metadata -> DCR ->

authorize -> token и покажет каждый ответ. Затем выполните ОДИН реальный поиск.">

# Claude Code
claude mcp add --transport http flightpowers \
  "https://google-flights-mcp.flightpowers.com/mcp/oauth"
claude mcp list            # показывает "needs authentication", пока вы не войдёте
/mcp                       # в сессии: выберите сервер и выполните вход

# Cursor -- Settings -> MCP -> Add, URL выше. Или ~/.cursor/mcp.json:
#   { "mcpServers": { "flightpowers": {
#       "url": "https://google-flights-mcp.flightpowers.com/mcp/oauth" } } }
# Cursor обнаруживает 401, регистрируется и открывает браузер.

# ChatGPT -- Settings -> Connectors -> Create. Запрашиваются следующие данные:
#   MCP server URL:  https://google-flights-mcp.flightpowers.com/mcp/oauth
#   Authentication:  OAuth
# Оставьте поля client id и secret ПУСТЫМИ: сервер поддерживает динамическую
# регистрацию клиентов, поэтому ChatGPT зарегистрируется самостоятельно. Больше ничего заполнять не нужно.

# MCP Inspector -- самый быстрый способ проследить весь процесс рукопожатия.
npx @modelcontextprotocol/inspector
#   Transport: Streamable HTTP
#   URL: https://google-flights-mcp.flightpowers.com/mcp/oauth
#   Auth: OAuth 2.0  ->  «Guided OAuth Flow» проведёт через metadata -> DCR ->
#   authorize -> token и покажет каждый ответ. Затем выполните ОДИН реальный поиск.

Поверхность протокола

МаршрутСпецификация
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, либо заставить пользователя входить дважды.

Коды действуют 10 минут и используются однократно (DELETE … RETURNING: при двух одновременных обменах запросы конкурируют за одну строку, и успеха добивается ровно один). Срок действия токенов доступа — 1 час, токенов обновления — 30 дней; при обновлении токены ротируются.

Токены непрозрачны и хранятся в виде хешей SHA-256, поэтому из дампа базы данных нельзя извлечь данные, пригодные для повторного использования. Токены намеренно не являются JWT: подписанный токен остаётся действительным до истечения срока независимо от последующих решений, а отключение должно действительно отзывать доступ.

Перед принятием токена доступа сервер проверяет, соответствует ли он ресурсу, для которого был выдан. Проверка выполняется не только при выдаче токена. Оба продукта работают в одном развёртывании, используют одну базу данных и один сохранённый ключ RapidAPI пользователя. Без такой проверки токен, подтверждённый на странице согласия для авиабилетов, где указано только «поиск актуальных цен на авиабилеты», был бы принят и на домене отелей, что позволило бы расходовать тарифный план пользователя для отелей. Проверка строго учитывает домен, но допускает разные пути: реальные клиенты передают в качестве адреса одного и того же сервера origin, /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 менять не нужно. URI перенаправления по-прежнему имеет вид …/connect/callback, поскольку поток OAuth для MCP-клиента завершается на нашей странице авторизации, и только эта страница взаимодействует с Google.

Проверка работы от начала до конца

BASE=https://google-flights-mcp.flightpowers.com

# 1. Сервис запущен и сообщает о себе.
curl -s $BASE/health | python3 -m json.tool | grep oauth_

# 2. Проверка вызова. Весь функционал демонстрируется одним ответом.
curl -si $BASE/mcp/oauth | head -20
#   HTTP/2 401
#   www-authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource/mcp/oauth"

# 3. Получение метаданных: по scoped-пути и по базовому пути.
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. Проверка ответа на динамическую регистрацию.
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. Главная проверка: MCP Inspector, Guided OAuth Flow, затем ОДИН реальный поиск.
#    Вызов tools/list ничего не доказывает: токен проверяется только при запуске инструмента.

# 6. Отели: тот же сценарий на другом хосте — https://hotels.flightpowers.com/mcp/oauth

# 7. Путь /mcp не затронут. Он по-прежнему должен работать, не возвращая 401:
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. Отключитесь через /connect, затем повторите поиск из шага 5: он должен вернуть
#    needs_api_key, а пользователь должен выйти из системы.

Контроль таблицы регистраций

Путь /oauth/register открыт, поскольку этого требует спецификация MCP, а сам по себе client_id

ничего не разрешает: любой поток авторизации с ним всё равно заканчивается страницей согласия, где

вошедший в систему пользователь должен нажать кнопку. Однако открытый доступ не означает отсутствие

ограничений: его контролируют три механизма.

МеханизмГде действуетОт чего защищает
Ограничение частоты запросовВ памяти, отдельно для каждого экземпляраОт резкого всплеска запросов: 10 регистраций с одного адреса за 10 минут, 60 запросов к токену в минуту, 10 запросов к /connect/save в час. При превышении лимита возвращается 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; промежуточные узлы, которые могут быть только внутренними,

пропускаются. Не используйте первую запись: прокси добавляют адреса справа, поэтому самую левую

запись может указать вызывающая сторона. Если использовать её, все ограничения можно будет обойти,

изменив один заголовок. Запросы без обоих заголовков попадают в общую группу unknown, которая

ограничивается как один клиент, но не учитывается при применении постоянного лимита на адрес. Так

один запрос без заголовка на пограничном узле не сможет заблокировать весь сервер на сутки.

Регистрация помечается как используемая, когда отображается страница согласия, а не только после

нажатия пользователем кнопки Approve. Клиенты MCP часто регистрируются при установке, а проходят

авторизацию спустя несколько дней. Очистка не должна удалять запись, пока открыта страница согласия:

между кодами или токенами и клиентом нет внешнего ключа, поэтому последующий обмен завершится

ошибкой invalid_client, и причина ошибки останется неясной.

Требуется выполнить одну миграцию:

psql "$DATABASE_URL" -f migrations/003_mcp_oauth_hygiene.sql

Refresh tokens обновляются при каждом обмене, а повторное использование отзывает всю цепочку

Refresh token можно использовать только один раз: при обмене выдаётся новая пара токенов, а

предъявленный токен становится недействительным. Устаревшая запись сохраняется и помечается, а не

удаляется: удалённая запись неотличима от токена, который никогда не выдавался, а важно уметь

различать эти случаи. Повторное предъявление уже заменённого refresh token означает, что клиент либо

не получил ответ, либо токен оказался у постороннего. Согласно OAuth 2.1 §4.14.2, следует исходить из

второго варианта: возвращается invalid_grant, а все токены, выданные на основе этой авторизации,

удаляются. Добросовестному клиенту придётся войти снова; access token злоумышленника перестанет

работать в тот же момент.

Предусмотрено одно намеренное исключение для случая, который почти всегда оказывается безобидным:

при ПЕРВОМ повторном предъявлении только что заменённого токена тем же клиентом в течение 10 секунд

возвращается пара токенов, уже выданная при обмене. Это идемпотентный повторный запрос: новые

токены не создаются. Так клиент, не получивший ответ из-за разрыва соединения, не выйдет из системы

без предупреждения. Второй повторный запрос или запрос после истечения этого окна считается

настоящим повторным использованием и по-прежнему приводит к отзыву всей цепочки. Окно действует

отдельно для каждого экземпляра и хранится в памяти, поэтому при отсутствии записи применяется

более консервативный сценарий.

При отзыве refresh token через /oauth/revoke отзываются и связанные с ним access tokens по той же

причине (RFC 7009 §2.1), но только если токен был выдан клиенту, который запрашивает отзыв. Это

вторая часть требований данного раздела. Клиент, предъявивший чужой токен, всё равно получает ответ

200 (§2.2), но отзыв не выполняется.

В качестве client_id можно использовать URL

В /.well-known/oauth-authorization-server объявляется поддержка

client_id_metadata_document_supported: true. Клиент может указать в качестве client_id **URL с

протоколом https**. По этому URL размещается документ со списком redirect_uris. Для каждого

потока авторизации сервер получает и проверяет этот документ, не сохраняя запись о регистрации.

Smithery требует эту возможность, прежде чем проксировать удалённый OAuth-сервер.

При каждой проверке выполняются следующие условия: используется только https; имя хоста публичное

(запрещены IP-адреса, localhost и учётные данные в URL); имя хоста разрешается в адреса, и каждый

полученный адрес должен быть публичным unicast-адресом (наличие точки в имени ничего не гарантирует:

127.0.0.1.nip.io содержит точку, но указывает на loopback); перенаправления не выполняются;

установлен тайм-аут 5 секунд; размер документа ограничен 64 KB, причём ограничение применяется во

время чтения тела ответа, а не после его полной загрузки; если в документе указан client_id, он

должен совпадать с URL; и, что особенно важно, переданный в запросе redirect_uri должен быть в

списке документа. Для такого запроса действует отдельное ограничение частоты — 60 запросов с одного

адреса за 10 минут, поскольку это единственный исходящий запрос сервера, который вызывающая сторона

может инициировать до входа в систему. Динамическая регистрация не меняется и по-прежнему остаётся

стандартным способом: если client_id не является URL с протоколом https, он проверяется в таблице,

как и прежде.

Инструменты

ИнструментНазначение
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 и compare_hotel_rates. Подробнее см. раздел Hotels: providers and compare_hotel_rates.

search_oneway_flights

search_oneway_flights(
    from_airport: str,                     # origin IATA, e.g. "TLV"
    to_airport: str | list[str],           # destination IATA, or a list to compare
    departure_date: str | None = None,     # "YYYY-MM-DD"
    departure_date_from: str | None = None,# first date of a range
    departure_date_to: str | None = None,  # last date of a range
    max_stops: int | None = None,          # 0 = non-stop only
    airline_codes: list[str] | None = None,
    exclude_airline_codes: list[str] | None = None,
    departure_time_min: int | None = None, # hour, 0-23
    departure_time_max: int | None = None,
    arrival_time_min: int | None = None,
    arrival_time_max: int | None = None,
    currency: str = "usd",
    max_price: int | None = None,
    seat_type: int | None = None,          # 1 economy, 2 premium economy, 3 business, 4 first
    passengers: list[int] | None = None,   # 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,
    max_searches: int | None = None,
    use_fallback: bool | None = None,
)

Сервер применяет sort_by ко всему объединённому набору результатов всех выполненных поисков. Поэтому сортировка остаётся предсказуемой независимо от количества проверенных комбинаций.

Пример использования

Пользователь: «Я нахожусь в Тель-Авиве. Найди самую дешёвую поездку на неделю в Рим или Афины с вылетом в любой день первой половины мая».

Один вызов:

{
  "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 рассчитывает стоимость только одного проживания. Поэтому запрос «самые дешёвые три ночи в Риме в мае» раньше требовал

31 вызова инструмента — или, на практике, одного вызова для даты, которую выбрала модель, после чего результат представлялся

как самый дешёвый. Теперь оба инструмента поиска отелей принимают диапазон дат по тому же принципу, что и поиск авиабилетов:

search_hotels(
    destination: str,
    checkin_date: str | None = None,        # одно проживание: эта дата и checkout_date
    checkout_date: str | None = None,
    checkin_date_from: str | None = None,   # или диапазон: эта дата, checkin_date_to и nights
    checkin_date_to: str | None = None,
    nights: int | list[int] | None = None,  # 3 или [2, 3, 7], чтобы рассчитать стоимость проживания разной длительности
    max_searches: int | None = None,        # ограничивает число оплачиваемых запросов в рамках этого вызова
    ...
)

Используется тот же механизм параллельного поиска, что и для авиабилетов (src/fanout.py): отдельный вызов к серверной части для каждого проживания, не более

max_searches_per_tool_call (30, жёсткий предел — 60). Если все варианты не помещаются в этот лимит, даты

равномерно распределяются по диапазону; фактический охват указывается в search_coverage. Параметр nights определяет даты выезда,

поэтому он заменяет checkout_date, а не дополняет его. Можно указать фиксированный checkout_date вместе с диапазоном дат заезда:

это означает «выезд 4-го числа независимо от даты приезда». Невозможные сочетания исключаются.

Размер ответа намеренно ограничен. Список всех вариантов размещения для каждого проживания занимает около 25 КБ на проживание (по результатам измерений:

25 892 байта для 25 объектов, из них 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 в зависимости от результатов по всем вариантам проживания;

если поиск завершился ошибкой для каждого варианта, вместо ответа с пустым списком будет вызвано исключение.

Предусмотрены два случая, в которых запрос намеренно отклоняется: диапазон дат заезда с несколькими источниками в providers (параллельный поиск по диапазону, умноженный на параллельный поиск по источникам, тарифицируется по двум подпискам; на данный момент ни search_coverage, ни api_usage не могут достоверно описать такую схему) и max_searches для одного проживания, где этот параметр не дал бы никакого эффекта.

Ответ для одного проживания побайтно совпадает с ответом до появления этой функции — то же тело запроса, те же ключи в ответе, без stays, search_coverage и search_status. Это проверяется в

tests/test_hotel_date_range.py::TestTheOldShapeIsUntouched сравнением с зафиксированным ожидаемым результатом, полученным на предыдущей версии кода.

Отели: providers и compare_hotel_rates

Развёртывание для отелей (hotels.flightpowers.com, где выбирается тот же код по заголовку Host) предоставляет три инструмента. search_hotels и find_hotel_by_name также поддерживают диапазон дат заезда, описанный выше;

в search_hotels добавлен необязательный аргумент для выбора источников, а также появился новый инструмент.

ИнструментНазначение
search_hotelsАктуальные цены для направления и дат или для каждого варианта проживания, сформированного из диапазона дат заезда. Параметр providers задаёт источники для поиска цен: ["booking"] (по умолчанию), ["airbnb"] или оба источника.
find_hotel_by_nameПоиск одного объекта по названию — только на Booking.com. На странице номера Airbnb нет цены, поэтому поиск по названию там привёл бы к объекту, для которого нельзя получить цену.
compare_hotel_ratesСравнение стоимости одного и того же проживания по всем источникам, для которых у вас есть ключ. Для каждого источника возвращается отдельная строка: минимальная и медианная стоимость, количество объектов с указанной ценой, валюта и время получения данных.

Поведение по умолчанию не изменилось

Если вызвать search_hotels без аргумента providers, отправляется тот же запрос к той же серверной части, что и раньше, а ответ содержит те же ключи. Это же относится к providers: ["booking"]. Оба варианта проверяются в

tests/test_providers.py::TestTheDefaultDidNotMove, включая тело запроса к серверной части: значение по умолчанию действительно удобно только тогда, когда его использование ничего не меняет.

Если указать второй источник, меняется структура ответа — и только в этом случае:

{
  "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/ — точка входа заменяет атрибуцию в теле запроса собственной, поэтому заголовок остаётся единственным указанием на то, кто выполнил вызов (правило 11). Для тестовой точки входа можно переопределить адрес через API_FRONT_BASE_URL; этот адрес не содержит учётных данных.

Объявления Airbnb пока нет в RapidAPI, а поставщик по умолчанию отключён на нашей точке входа, поэтому сейчас реальный вызов с providers: ["airbnb"] возвращает строку со статусом degraded и соответствующим пояснением. Это ожидаемое поведение, а не ошибка: источник, доступный любому пользователю с действительным ключом и не учитывающий расход запросов, фактически превратил бы нас в платный шлюз.

Четыре правила, которым следует реализация

  • Мы никогда не отправляем запрос к источнику, для которого у вас нет ключа. Он указывается в providers_skipped с причиной (no_key, not_subscribed, key_rejected) и URL для оформления подписки. Для каждого объявления требуется отдельная подписка, поэтому ответ 403 от Hub означает «нет подписки на это объявление», а не «неверный ключ».

  • Для источника с ошибкой возвращается отдельная строка, а не пустое значение. В ней указываются search_status: "degraded" и count: null, цены отсутствуют, а данные другого источника при этом возвращаются. «Booking не ответил» и «на Booking ничего не нашлось» — противоположные результаты, и их нужно различать.

  • cheapest_total и median_total рассчитываются только по строкам с указанной ценой, а count показывает их количество. Значение price_string никогда не преобразуется в число.

  • Цены в разных валютах не сравниваются арифметически. Для всех источников запрашивается одна валюта. Если источники возвращают разные валюты, в каждой строке сохраняется своя валюта, а в caveats указывается, что сравнивать итоговые суммы нельзя. Конвертация не выполняется.

Ключи для каждого источника

У большинства пользователей один ключ RapidAPI, подключённый к нескольким API-предложениям. Этот ключ используется для всех источников без дополнительной настройки. Если у пользователя действительно есть два ключа, он может указать отдельный для каждого источника — приоритет будет у ключа, заданного для конкретного источника:

    # header

?rapidapi_key_airbnb= # query ?config="}> # Smithery blob">

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

При поиске по нескольким источникам эти параметры передаются в Booking, но не во frontend. При поиске только по Airbnb запрос с ними отклоняется, а не выполняется без учёта параметров: если молча отбросить фильтр, пользователь получит больше вариантов, чем запрашивал, и не узнает об этом.

Структурированный вывод (outputSchema, structuredContent, isError)

Для каждого инструмента объявлена схема outputSchema, а результат содержит данные в двух представлениях: в structuredContent и в виде сериализованного JSON в текстовом блоке содержимого. MCP требует дублировать данные:

Для обратной совместимости инструмент, возвращающий структурированное содержимое, ДОЛЖЕН также возвращать сериализованный JSON в блоке TextContent.

Это требование здесь важно не формально, а на практике: клиенты, появившиеся до поддержки структурированного вывода, читают только текстовый блок. (Проверено по редакции спецификации 2026-07-28; структурированный вывод появился в версии 2025-06-18.)

В схемах намеренно указано additionalProperties: true, а обязательным полем задано только results. Спецификация возлагает обязательство на сервер: «Серверы ДОЛЖНЫ предоставлять структурированные результаты, соответствующие этой схеме». При этом у этих инструментов есть несколько допустимых вариантов ответа с разными ключами: ответ без результатов, ответ needs_api_key без ключа и ответ quota_exhausted. Более строгая схема выглядела бы аккуратнее, но сделала бы сервер несоответствующим спецификации в одном из предусмотренных сценариев.

search_status и почему degraded — это ошибка

Результаты поиска рейсов содержат search_status, значения которого соответствуют словарю статусов самого backend: X-Search-Status.

значениеописание
okВсе выполненные комбинации поиска вернули результаты
emptyПоиск завершён; Google действительно не нашёл маршрутов. Это полноценный ответ
partialНекоторые комбинации вернули результаты, а некоторые завершились ошибкой. Список неполный
degradedНе выполнена ни одна комбинация. Поиск не состоялся; пустой список ничего не означает

Результат со статусом degraded также помечается как ошибка с помощью isError: true. Только в этом случае устанавливается такой флаг. Спецификация относит «ошибки API» к ошибкам выполнения инструмента и указывает, что клиенты «ДОЛЖНЫ передавать языковым моделям ошибки выполнения инструментов, чтобы те могли исправить ситуацию». При этом спецификация не обязывает хост вообще показывать модели 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) за вызов. Если использовать его напрямую, запрос «самый дешёвый билет на Шри-Ланку в любой день октября» потребует 31 отдельного вызова инструмента: 31 обмен данными с моделью, 31 шанс потерять контекст и счёт, о котором пользователь узнает только после выполнения запросов.

Здесь достаточно одного вызова инструмента. Сервер параллельно отправляет запросы по отдельным направлениям, ограничивает их количество, равномерно распределяет поиск по датам, удаляет дубликаты по buy_link, объединяет результаты, сортирует их согласно sort_by и точно указывает охват поиска и расходы в search_coverage и api_usage.

Этот сервер (платный)Бесплатный сервер
Количество запросов за вызов30 (максимум — 60; увеличьте или уменьшите значение для каждого вызова с помощью max_searches)15
Рекламанетодна явно обозначенная спонсорская карточка на результат
Ключваш собственный ключ RapidAPIне требуется
Отчёт о расходахapi_usage в каждом ответен/д
Доступен для размещения в каталогеданет

На этом сервере совсем нет рекламы — не из-за предпочтений, а из-за требований: правила каталога коннекторов Anthropic и рекомендации для приложений OpenAI запрещают рекламу и спонсорский контент в результатах инструментов. Поэтому сервер с рекламой не может попасть в эти каталоги, а этот — может.

Локальная разработка

&& cd mcp_server_paid python3 -m venv .venv && .venv/bin/pip install -r requirements.txt cp example.env .env # заполните файл; оставьте RAPIDAPI_KEY пустым set -a && . .env && set +a .venv/bin/python -m src # потоковый HTTP на http://localhost:8000/mcp">

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; там описана каждая переменная. Наиболее важные:

VariableDefaultWhy it matters
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_URLlisting matching MCP_PRODUCTSURL, который показывается пользователям без ключа. При значении both инструменты для отелей в любом случае указывают на страницу Booking: один URL не может быть кнопкой Subscribe сразу для двух API.
MCP_PRODUCTS_BY_HOSTdefaultОпределяет, какой продукт обслуживает каждый hostname. Благодаря этому одно развертывание может работать с обоими платными доменами, а каждый из них получает свой набор инструментов. Значение default задаёт встроенную карту доменов flightpowers.com; off полностью отключает маршрутизацию по hostname (откат без изменений кода); также можно указать карту в формате host=product,…. Для hostname, которого нет в карте, используется значение MCP_PRODUCTS.
MCP_PUBLIC_URLhttp://localhost:8000/mcpURL, который отображается в ответе /health и используется как источник ссылок на страницы с правилами. Для каждого продукта в развертывании с обоими продуктами можно задать отдельные значения с помощью MCP_PUBLIC_URL_FLIGHTS и MCP_PUBLIC_URL_HOTELS. Если их не указать, домен отелей будет публиковать URL домена авиарейсов. Переменные SIGNUP_URL_FLIGHTS и SIGNUP_URL_HOTELS работают аналогичным образом.
RAPIDAPI_KEY(empty)В production оставьте переменную пустой. Если задать ключ, все пользователи без собственного ключа будут обслуживаться за счёт этой подписки, и запросы будут списываться с неё. При запуске сервер выводит предупреждение, а /health сообщает server_side_key_configured.
METRICS_TOKEN(empty)Если переменная задана, для доступа к /metrics требуется заголовок x-metrics-token.
LOG_PATH(empty)Пустое значение отключает запись в файл; строки MCP_CALL по-прежнему выводятся в stdout. Для бессерверного режима это правильная настройка.

Служебные маршруты: 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 и prompts, сериализованные точно так же,

как в ответе tools/list. Доступ публичный, аутентификация не требуется. Заголовок 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 сравнивает

эти данные для обоих продуктов.

  • Карточка формируется отдельно для каждого hostname, как и всё остальное в этом сервисе: hotels.flightpowers.com возвращает карточку отелей,

оба домена авиарейсов — карточку авиарейсов, а все URL в ней относятся к домену соответствующего продукта. transport.endpoint — /mcp/oauth, если настроен OAuth; альтернативный endpoint /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 (обёртку FastAPI, которая передаёт FastMCP его жизненный цикл,

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

Контейнер предоставляет потоковый HTTP на ${PORT}/mcp — тот же транспорт, что и в размещённом

развертывании. Для запуска используется python -m src. 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 обращается к /health на порту $PORT, поэтому переопределение PORT не нарушает её работу.

Отсутствие аффилированности

Это независимый API, который возвращает общедоступные данные о ценах на авиабилеты. Он не связан с Google, не одобрен и не спонсируется компанией Google. Название «Google Flights» используется только для обозначения источника общедоступных данных. Тарифы предоставляет вышестоящий сервис; они постоянно меняются и не гарантируются. Перед покупкой всегда проверяйте цену на сайте авиакомпании или сервиса бронирования.