FlightPowers Google Flights MCP
MCP hospedado para tarifas ao vivo do Google Flights com o veredito próprio do Google de baixo/típico/alto, viagens de ida e volta combinadas e cobrança via RapidAPI. Sem anúncios. Traga sua própria chave RapidAPI.
Documentação
Google Flights MCP: tarifas em tempo real que seu agente pode pesquisar em um intervalo de datas inteiro, sem anúncios
Uma URL, de qualquer forma:
claude mcp add --transport http google-flights https://flights.flightpowers.com/mcp
Em um cliente que suporta autorização MCP, um botão Entrar aparece: você entra com o
Google e cola sua chave RapidAPI uma vez na página /connect, e nada vai para a configuração
do seu cliente. Em um cliente que não suporta, traga a chave você mesmo:
claude mcp add --transport http google-flights https://flights.flightpowers.com/mcp --header "x-rapidapi-key: YOUR_RAPIDAPI_KEY"
Mesma URL, nas duas vezes. Uma requisição com qualquer tipo de credencial é atendida; uma requisição
sem nada é respondida 401 com os metadados OAuth, que é o que faz o botão Entrar
aparecer. https://flights.flightpowers.com/mcp/oauth ainda está ativo e exige o login na
primeira requisição, para clientes cujo modo de autenticação é fixo quando um servidor é adicionado.
Hospedado. Nada para clonar, nada para compilar. Listado no Registro Oficial MCP como
com.flightpowers/google-flights-mcp. Verificação de saúde:
/health.
Precisa de uma chave? Assine a API Google Flights Live no RapidAPI, com plano gratuito disponível,
e copie sua x-rapidapi-key: https://rapidapi.com/mtnrabi/api/google-flights-live-api
Ainda não tem chave? Comece com o servidor gratuito, mesma pesquisa, sem cadastro:**
claude mcp add --transport http google-flights-free https://google-flights-lulu.flightpowers.com/mcp
(com suporte a anúncios: um cartão patrocinado divulgado por resultado, limite de fan-out em 15, e clientes que
não conseguem renderizar o cartão patrocinado podem ter limite ainda menor.) Volte aqui quando os anúncios, o
limite de 15 pesquisas ou essas restrições de cliente atrapalharem.
O que seu agente recebe
Duas ferramentas que respondem a uma pergunta de tarifa, não a uma consulta de data.
- Faça perguntas abertas. "Passagem só de ida mais barata para o Sri Lanka em qualquer data de outubro", "5 a 7
noites em Roma em algum momento de maio, saindo de Tel Aviv ou Larnaca": cada uma é uma chamada de ferramenta. Ambas
as ferramentas aceitam um intervalo de data de partida, uma lista de aeroportos de destino e (para ida e volta) um
valor
nightsem vez de uma data fixa de retorno, e expandem isso internamente. - Diga se um preço é realmente bom. Cada resultado traz a faixa histórica do próprio Google
para aquela rota e período:
price_insights_low,price_insights_highe um vereditoprice_range_in_relation_to_other_periodsdelow/typical/high. É isso que permite que um agente responda "$209 é típico aqui, não se apresse" em vez de apenas citar um número. - Reserve, não apenas navegue. Cada resultado inclui um
buy_linkpara o Google Flights. - Saiba o que gastou. Cada resposta traz
api_usage: requisições usadas por esta chamada e o que resta no plano de quem chamou. Veja Relatório de gastos. - Saiba o que pesquisou. Cada resposta traz
search_coverage, para que o modelo possa dizer honestamente em quais datas e destinos a resposta se baseia.
Os resultados são tarifas ao vivo. Elas ficam desatualizadas em minutos: nunca armazene em cache uma tarifa nem reutilize um resultado anterior; pesquise novamente e informe quando os dados foram obtidos.
Obtenha uma chave (plano gratuito disponível)
O servidor não guarda nenhuma credencial upstream própria. Cada pesquisa é cobrada na sua assinatura RapidAPI, e é por isso que a chave viaja junto com a requisição.
- Assine a API Google Flights Live: https://rapidapi.com/mtnrabi/api/google-flights-live-api
- Copie sua
x-rapidapi-key. - Passe-a para o servidor de qualquer uma das três formas abaixo.
Se uma chave estiver faltando, as ferramentas não falham silenciosamente e não gastam nada. Elas retornam
needs_api_key: true com a URL de cadastro e estas instruções, escritas para o modelo ler
de volta para você.
Três formas de passar sua chave
| Forma | Como | Quando usar |
|---|---|---|
| Cabeçalho (preferido) | --header "x-rapidapi-key: YOUR_RAPIDAPI_KEY" | Qualquer coisa que permita definir cabeçalhos. As chaves ficam fora das URLs e, portanto, fora dos logs de proxy e acesso. |
| Parâmetro de consulta | https://google-flights-mcp.flightpowers.com/mcp?rapidapi_key=YOUR_RAPIDAPI_KEY | Hosts que só permitem colar uma URL: o diálogo de conector personalizado do claude.ai é o caso que importa. |
| Campo de chave de API do cliente | Cole a chave na própria caixa "chave de API" do cliente | Hosts que enviam authorization: Bearer <key> ou x-api-key. O formulário de configuração salva do Smithery (config.rapidApiKey=) também é aceito. |
A primeira fonte não vazia vence, nessa ordem. A chave nunca é registrada, nunca é ecoada em uma mensagem de erro e nunca é retornada em uma resposta de ferramenta.
Uma quarta forma: entre uma vez em /connect
Esta é a página para onde a URL de login no topo deste README envia você. Um cliente que fala autorização MCP guia você por ela sozinho; os passos abaixo são a mesma coisa feita manualmente.
Onde uma implantação tiver isso habilitado (verifique connect_enabled em /health), há uma página em
/connect que substitui tudo acima por um login:
- Abra https://google-flights-mcp.flightpowers.com/connect (hotéis: https://hotels.flightpowers.com/connect) e entre com o Google.
- Cole sua chave RapidAPI uma vez, em um formulário, via TLS.
- Pressione Revelar a URL e copie a URL de conexão,
…/mcp?fp_token=fpk_…, e use-a como a URL do servidor no seu cliente MCP. Clientes que permitem definir cabeçalhos podem enviar o mesmo token comoAuthorization: Bearer fpk_…em vez disso. (Ela fica oculta até você pedir: essa URL é uma credencial de portador de 90 dias para o seu plano, e uma página que a imprime por padrão a coloca em cada captura de tela e compartilhamento de tela.)
Se o seu cliente fez o login por conta própria — Claude, Cursor, ChatGPT e qualquer outra coisa que fale
autorização MCP — não há URL para copiar. /connect diz isso: mostra a chave que você
conectou e diz para voltar ao seu assistente. Há um link nela para o caso de um
segundo cliente não conseguir entrar e precisar de uma URL de conexão.
O que isso traz: sua chave RapidAPI não fica na configuração do cliente, nem em uma URL, nem em
quaisquer logs pelos quais essa URL passe. O custo: o servidor armazena sua chave, criptografada, e
sabe seu ID de conta do Google e endereço de e-mail. Disconnect na mesma página exclui o registro
e invalida todos os tokens de conexão da sua conta, imediatamente. A descrição completa está na
seção 2a da política de privacidade.
Alguns detalhes que vale a pena saber:
- Salvar executa uma verificação. A chave é validada contra a listagem antes de ser armazenada, então um erro de digitação falha na página em vez de no seu cliente uma hora depois. Essa verificação custa no máximo uma requisição do seu próprio plano: no plano BASIC gratuito (10 por mês), uma de dez. Uma chave que o RapidAPI rejeita no gateway não custa nada.
- Uma chave na requisição sempre vence. Se você enviar um cabeçalho
x-rapidapi-key(ou qualquer um dos outros canais acima) e carregar um token de conexão, a chave da própria requisição é usada. Nada do que você já configurou muda de comportamento porque você entrou. - O token não é sua chave e não pode ser convertido de volta nela. Ele é válido por 90 dias e
para de resolver no momento em que você desconecta. Uma chamada com um token cuja chave foi
desconectada recebe uma resposta
needs_api_keydizendo para reconectar. Nunca cai na assinatura de outra pessoa e nunca gasta nada. - BASIC é gratuito. API Google Flights Live · API Booking Live. Uma chave RapidAPI cobre qualquer uma das duas que você assinou; você a conecta uma vez.
Executando /connect na sua própria implantação
Desligado a menos que todos os quatro estejam definidos. Uma implantação meio configurada não registra nenhuma das
rotas e atende chamadas com chave exatamente como antes; /health reporta connect_enabled para que isso seja
visível em vez de adivinhado.
| Variável | O que é |
|---|---|
GOOGLE_OAUTH_CLIENT_ID | Google Cloud Console → Credenciais → ID do cliente OAuth, tipo Aplicativo web. Termina em .apps.googleusercontent.com. |
GOOGLE_OAUTH_CLIENT_SECRET | O segredo do mesmo cliente (GOCSPX-…). |
MCP_KEY_MASTER | 32 bytes, base64: openssl rand -base64 32. Criptografa chaves armazenadas (AES-256-GCM) e deriva as chaves de assinatura de cookie e token. |
DATABASE_URL | Neon Postgres, endpoint pooled (…-pooler…). Esquema: migrations/001_mcp_user_keys.sql. |
Opcional: MCP_CONNECT_VALIDATE=0 armazena uma chave colada sem verificá-la antes.
URIs de redirecionamento autorizadas para registrar no cliente Google, uma por origem de produto, exatamente:
https://google-flights-mcp.flightpowers.com/connect/callback
https://hotels.flightpowers.com/connect/callback
flights.flightpowers.com não precisa de entrada. /connect e /connect/start redirecionam um alias para
a origem canônica antes de o login começar, porque cookies são por host e o Google compara
redirect_uri literalmente: um alias que iniciasse seu próprio login voltaria para um host sem
cookie de estado e falharia com uma mensagem que parece uma configuração errada do Google.
Também na tela de consentimento OAuth: escopos openid e .../auth/userinfo.email, e nada mais.
Rotacionar MCP_KEY_MASTER desconecta todos e invalida todas as chaves armazenadas. Isso é
deliberado: após uma rotação, nada fica segurando um token que resolva para uma chave que ninguém pode
ler. Os usuários veem "conecte novamente", não uma pesquisa falha. key_version na tabela está lá para que
uma rotação em etapas seja possível depois sem um dia de mudança brusca.
Verificando o fluxo de ponta a ponta
Migração primeiro, uma vez por banco de dados:
psql "$DATABASE_URL" -f migrations/001_mcp_user_keys.sql
Depois, após a implantação:
# 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.
Um tools/list que tenha sucesso não prova nada sobre isso: um token só é consultado quando uma
ferramenta realmente executa. O passo 4 tem que ser uma pesquisa real.
Uma quinta forma: entre de dentro do seu cliente MCP
Um cliente MCP só inicia um login quando uma requisição volta 401 com um
cabeçalho WWW-Authenticate: Bearer resource_metadata=…. Nada mais no fio diz a ele que um está
disponível. Até 2026-09-09 /mcp nunca enviou esse cabeçalho, então um chamador sem chave recebia um 200
cujo corpo dizia needs_api_key: JSON correto, invisível para o mecanismo de autenticação de todos os clientes, e
o usuário via "a ferramenta falhou" sem onde clicar.
Agora /mcp desafia, mas apenas um chamador que não trouxe nada, e apenas em uma chamada que gastaria algo:
| URL | Comportamento |
|---|---|
https://flights.flightpowers.com/mcp | A que deve ser usada. Uma chave RapidAPI em um cabeçalho, na string de consulta ou em um blob de configuração do Smithery, um token de conexão fpk_ ou um token de acesso fpo_: todos atendidos exatamente como antes. Nada: initialize, tools/list e o resto do handshake somente leitura ainda são respondidos, e tools/call recebe 401 + o desafio, então seu cliente oferece um botão Entrar. |
https://hotels.flightpowers.com/mcp | O mesmo, para hotéis. |
…/mcp/oauth | O mesmo servidor com o login exigido na primeira requisição. Para clientes cujo modo de autenticação é fixo quando um servidor é adicionado, e para conectores salvos nessa URL antes da mudança. |
Mesmas ferramentas, mesmo roteamento por produto e hostname, tudo o mais igual. O alias é o registro de ferramentas idêntico atrás de uma verificação de token, não uma segunda cópia do servidor.
A propriedade que protege integrações pagantes: uma requisição com credencial nunca é
desafiada, e a ordem de credenciais não muda (cabeçalho, consulta, blob de configuração, depois identidade
OAuth, depois token de conexão, depois o fallback de env RAPIDAPI_KEY). Uma chave errada também não é
"nada": ela chega às ferramentas e volta como o erro exato que o RapidAPI deu,
porque substituir isso por um prompt de login seria uma resposta pior. Uma implantação com
RAPIDAPI_KEY definido atende chamadores sem chave do próprio plano de propósito e nunca é desafiada.
MCP_REQUIRE_AUTH=off desliga o desafio completamente, sem implantação.
A descoberta permanece aberta, e isso foi aprendido da maneira mais difícil. Por algumas horas em 2026-09-09, o
desafio também cobriu initialize e tools/list. A Glama verifica cada conector a cada hora
abrindo uma conexão MCP e listando suas ferramentas, sem credenciais; ambas as listagens pagas foram
marcadas como não saudáveis e rebaixadas na mesma noite, e a verificação de lançamento da Smithery, mcpservers.org
e M8ven sondam da mesma forma. Então a linha é traçada no gasto, não na conexão: initialize,
notifications/initialized, ping, tools/list, prompts/list e resources/list são servidos
para qualquer pessoa (src/discovery.py), e todo o resto precisa de uma chave ou de um login. Um lote com um
tools/call nele, um corpo superdimensionado e um que não pode ser analisado são todos desafiados — a lista de permissões
falha de forma fechada. /mcp/oauth ainda desafia tudo, que é a URL para dar a um diretório
que quer um servidor sempre exigindo autenticação, e o público, não autenticado
/.well-known/mcp/server-card.json ainda está lá para um scanner que lê um cartão em vez disso.
Como é usar. Cole a URL /mcp/oauth no seu cliente. Ele se registra,
abre um navegador, você faz login com o Google e aprova esse cliente pelo nome em uma página que
diz exatamente o que ele poderá fazer: executar buscas cobradas no seu próprio plano RapidAPI, nada
mais. O cliente nunca vê sua chave RapidAPI. Se você ainda não conectou uma, a aprovação
ainda funciona e a primeira busca retorna dizendo para você colar uma chave em /connect, com a
URL.
O que você pode revogar, e como. Pressione Desconectar em /connect: a chave armazenada é excluída
e todo token OAuth para essa conta do Google é descartado na mesma ação. Um cliente que estava
conectado para de funcionar imediatamente. Individualmente, um cliente pode chamar /oauth/revoke (RFC 7009).
Configuração do cliente
# 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.
A superfície do protocolo
| Rota | Especificação |
|---|---|
GET /.well-known/oauth-protected-resource e …/mcp/oauth | RFC 9728 |
GET /.well-known/oauth-authorization-server e …/mcp/oauth | RFC 8414 |
POST /oauth/register | RFC 7591, registro dinâmico de cliente, aberto |
GET /connect/authorize · POST /connect/authorize | RFC 6749 §4.1, PKCE S256 obrigatório |
POST /oauth/token | authorization_code e refresh_token |
POST /oauth/revoke | RFC 7009 |
O endpoint de autorização está sob /connect de propósito: o cookie de sessão de login tem escopo
Path=/connect para que nunca possa ser anexado a uma solicitação /mcp, e colocar authorize em qualquer outro
lugar significaria alargar esse cookie ou fazer o usuário fazer login duas vezes.
Os códigos duram 10 minutos e são de uso único (DELETE … RETURNING, então duas trocas concorrentes disputam
uma linha e exatamente uma vence). Os tokens de acesso duram 1 hora, os tokens de atualização 30 dias com rotação.
Tudo é opaco e armazenado como um hash SHA-256, então um despejo do banco de dados não contém nada
que possa ser reproduzido. Os tokens não são JWTs, deliberadamente: um token assinado permanece válido até
expirar, independentemente do que decidirmos depois, e Desconectar tem que significar desconectar.
Um token de acesso é verificado contra o recurso para o qual foi aprovado antes de ser aceito, não
apenas quando é emitido. Ambos os produtos são a mesma implantação, o mesmo banco de dados e a mesma
chave RapidAPI armazenada por usuário, então sem essa verificação, um token aprovado na página de consentimento
de voos, que diz "buscar tarifas de voos ao vivo" e nada mais, seria aceito no hostname
de hotéis e gastaria o plano de hotéis do usuário. A verificação é estrita quanto ao host e tolerante quanto ao
caminho, porque clientes no mundo real enviam a origem, /mcp e /mcp/oauth para o mesmo
servidor. tests/test_oauth.py::TestATokenIsBoundToTheResourceItWasApprovedFor fixa ambas as metades.
Executando na sua própria implantação
Nenhuma nova variável de ambiente. Ele vem ativado onde /connect está configurado, porque reutiliza
esse login do Google e esse armazenamento de chaves. Ele precisa de mais uma tabela no mesmo banco de dados:
psql "$DATABASE_URL" -f migrations/002_mcp_oauth.sql
/health então relata oauth_enabled: true e oauth_mcp_endpoint; essa URL é o que vai em
uma listagem de diretório. MCP_OAUTH=off o desativa enquanto deixa /connect em execução; essa é a
reversão que não precisa de mudança de código.
Nada precisa mudar no cliente OAuth do Google. O URI de redirecionamento ainda é
…/connect/callback, porque o fluxo OAuth do cliente MCP termina na nossa página de autorização, e somente
essa página fala com o Google.
Verificando de ponta a ponta
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.
Mantendo a tabela de registro honesta
/oauth/register é aberto, porque a especificação MCP exige e porque um client_id por si só
não autoriza nada: todo fluxo através de um ainda termina em uma página de consentimento na qual um humano conectado tem que
pressionar um botão. Aberto não é o mesmo que ilimitado, então três coisas ficam por trás disso.
| Mecanismo | Onde vive | O que impede |
|---|---|---|
| Limite de taxa | em memória, por instância | uma rajada: 10 registros por endereço a cada 10 minutos, 60 solicitações de token por minuto, 10 /connect/save por hora. Acima do limite é 429 com Retry-After. |
| Limites diários | Postgres, então toda instância concorda | um gotejamento lento: 30 registros por endereço por dia. O limite global é 5.000 por dia — uma salvaguarda contra linhas ilimitadas, não uma defesa: um número global definido perto do tráfego real é uma alavanca que um atacante puxa para recusar todo novo usuário do Claude ou Cursor por um dia. Cruzar 500 em um dia registra e não recusa nada. MCP_OAUTH_DCR_MAX_PER_IP_PER_DAY, MCP_OAUTH_DCR_MAX_PER_DAY e MCP_OAUTH_DCR_WARN_PER_DAY os movem sem uma implantação. |
| Varredura | em /oauth/register, no máximo uma vez a cada 15 minutos por instância | o lixo: códigos e tokens expirados, e registros que nunca se tornaram uma autorização dentro de 7 dias. Um cliente com um token ativo, um código pendente ou uma página de consentimento que foi renderizada para ele nunca é varrido. |
O limite de taxa é por INSTÂNCIA. No Vercel, isso significa que N instâncias ativas permitem até N vezes esses números entre elas, e uma inicialização a frio começa os contadores em zero. É por isso que os limites duráveis também existem: eles são contados no banco de dados, onde o número é o mesmo em todos os lugares.
O endereço no qual cada um deles é baseado vem de x-real-ip primeiro — o Vercel o define para o
par que aceitou — e, caso contrário, da ÚLTIMA entrada utilizável de x-forwarded-for, pulando saltos
que só podem ser internos. Nunca a entrada 0: proxies anexam à direita, então a entrada mais à esquerda é
o que o chamador escreveu, e lê-la faria cada limite aqui estar a um cabeçalho de distância de ser
contornado. Uma solicitação sem nenhum dos cabeçalhos compartilha um bucket chamado unknown, que é limitado por taxa
como um único chamador e não está sujeito ao limite durável por endereço (um cabeçalho ausente na
borda não deve bloquear o servidor inteiro por um dia).
Um registro é carimbado como em uso quando sua página de consentimento é RENDERIZADA, não apenas quando o humano
pressiona Aprovar. Clientes MCP comumente registram quando são instalados e autorizam dias depois,
e a varredura não deve excluir uma linha enquanto sua página de consentimento está na tela — não há chave estrangeira
de códigos ou tokens de volta ao cliente, então a troca que se seguiu falharia
invalid_client sem nada nomeando a causa.
Requer uma migração:
psql "$DATABASE_URL" -f migrations/003_mcp_oauth_hygiene.sql
Tokens de atualização rotacionam, e uma reprodução revoga a família
Um token de atualização é de uso único: trocá-lo emite um novo par e aposenta o apresentado.
A linha aposentada é mantida e carimbada, não excluída, porque uma linha excluída e um token que nunca foi
emitido parecem idênticos — e distinguir entre eles é o ponto. Apresentar um
token de atualização já rotacionado significa ou um cliente que perdeu a resposta ou uma cópia nas mãos
de outra pessoa, e OAuth 2.1 §4.14.2 diz para assumir a segunda: a resposta é invalid_grant, e
todo token descendente dessa autorização é excluído. O cliente honesto faz login novamente; o
token de acesso do ladrão para de funcionar no mesmo momento.
Com uma exceção deliberada, para o caso que quase sempre é o inocente: a PRIMEIRA reprodução do token que acabamos de rotacionar, do mesmo cliente, dentro de 10 segundos, é respondida com o par que a rotação já emitiu. É uma nova tentativa idempotente — nada de novo é criado — e significa que um cliente cuja resposta foi perdida em uma conexão interrompida não é silenciosamente desconectado. Uma segunda reprodução, ou uma após a janela, é a coisa real e ainda mata a família. A janela é por instância e em memória, então uma falha simplesmente cai na resposta conservadora.
Revogar um token de atualização através de /oauth/revoke leva seus tokens de acesso junto, pela mesma
razão (RFC 7009 §2.1) — e somente se o token foi emitido para o cliente que está pedindo, que é a
outra metade dessa seção. Um cliente apresentando o token de outra pessoa ainda recebe 200 (§2.2) e
nada é revogado.
Um client_id pode ser uma URL
client_id_metadata_document_supported: true é anunciado em
/.well-known/oauth-authorization-server. Um cliente pode usar uma URL https como seu client_id;
o documento nessa URL lista seus redirect_uris, e nós o buscamos e verificamos por fluxo em vez de
escrever uma linha de registro. A Smithery pede isso antes de fazer proxy de um servidor OAuth remoto.
O que é verificado, toda vez: apenas https, um hostname público (sem literais de IP, sem localhost, sem
credenciais na URL), o hostname resolvido e cada endereço com o qual ele responde obrigado a
ser unicast público (um nome não é um controle: 127.0.0.1.nip.io tem um ponto e aponta para
loopback), nenhum redirecionamento seguido, um timeout de 5 segundos, um limite de 64 KB aplicado enquanto o corpo é lido
em vez de depois que é armazenado em buffer, um client_id dentro do documento que corresponde à URL se estiver
presente, e — o que importa — o redirect_uri na solicitação deve estar listado no
documento. A consulta é limitada por taxa por conta própria (60 por endereço a cada 10 minutos), porque é
a única busca de saída neste servidor que um chamador pode acionar antes de fazer login. O registro dinâmico permanece inalterado
e ainda é o padrão: um client_id que não é uma URL https é consultado na tabela exatamente como
antes.
Ferramentas
| Ferramenta | O que faz |
|---|---|
search_oneway_flights | Tarifas de ida em tempo real. Entrada: IATA de origem, IATA de destino ou uma lista, e uma data de partida ou um intervalo de datas. Retorna preço, companhia aérea, duração, paradas, buy_link e o intervalo de preços histórico do Google para você julgar a tarifa. Use para qualquer pergunta de ida, incluindo as de data aberta: uma chamada com um intervalo, nunca uma chamada por data. |
search_roundtrip_flights | Tarifas de ida e volta em tempo real precificadas como trechos pareados, não duas idas. Entrada: origem, destino(s), uma data de partida ou intervalo, e um return_date ou uma duração de viagem em nights (um número ou uma lista como [5,6,7]). Retorna preço total, companhia aérea/paradas/duração por trecho e um buy_link para a viagem. |
A implantação de hotéis serve search_hotels, find_hotel_by_name e compare_hotel_rates em vez disso; veja Hotels: providers e 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, # [adults, children, infants]
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 é aplicado por este servidor no conjunto de resultados mesclado de cada busca que executou, então é
previsível independentemente de quantas combinações foram expandidas.
Um exemplo prático
Usuário: "Estou em Tel Aviv. Viagem de uma semana mais barata para Roma ou Atenas, saindo em qualquer dia na primeira quinzena de maio."
Uma chamada:
{
"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
}
}
Isso expande para 15 datas × 2 destinos = 30 combinações, que é exatamente o limite por chamada. O formato da resposta (os nomes dos campos são reais; os valores abaixo são ilustrativos, não uma citação, execute a chamada para obter tarifas ao vivo):
{
"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."
}
}
Outros formatos de resposta a esperar, todos normais:
- Nenhum voo nessas datas.
results: []com ummessage: o Google Flights genuinamente não retorna nada para algumas combinações de rota/data. Não é um erro. Tente datas próximas ou um aeroporto próximo.use_fallbacknão mudará isso e fica não definido por padrão: o backend aceita o campo, mas a segunda fonte de dados de voo que ele seleciona está bloqueada atrás deUSE_FALLBACK_FLI(fallback_available()), que não está ativado para esta API, então nenhum dos seus três valores tem qualquer efeito observável em uma busca hoje. As novas tentativas automáticas que o backend faz em uma página ilegível são incondicionais e não são afetadas por isso. - Algumas buscas falharam. Um campo
partialinforma quantas das buscas executadas falharam, e os resultados cobrem o restante. - Intervalo muito amplo.
search_coverage.truncated: truemais umnote. O intervalo é amostrado uniformemente em toda a janela (primeiro e último mantidos), não encurtado, então a amostra é representativa, não os primeiros N dias. Aumentemax_searchesou reduza o intervalo para uma cobertura mais completa. - Sem chave / chave rejeitada.
needs_api_key: true, zero gasto, com a correção. Uma chave RapidAPI válida que não está assinada para esta API é a causa mais comum. - Plano esgotado.
quota_exhausted: truecomapi_usage, além de um lembrete de que reduzir o intervalo faz a cota restante render mais.
Hotéis: um intervalo de check-in
POST /search precifica exatamente uma estadia, então "três noites mais baratas em Roma em maio" costumava ser
31 chamadas de ferramenta — ou, na prática, uma chamada em uma data que o modelo escolhia e uma resposta apresentada como
a mais barata. Ambas as ferramentas de busca de hotéis agora assumem o formato de voos:
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
...
)
Mesma mecânica do fan-out de voos (src/fanout.py): uma chamada de backend por estadia, limitada a
max_searches_per_tool_call (30, máximo rígido de 60), amostrada uniformemente no intervalo quando não
cabe, e relatada em search_coverage. nights deriva cada data de check-out, então substitui
checkout_date em vez de se juntar a ele. Um checkout_date fixo contra um intervalo de datas de check-in
é permitido e significa "saída no dia 4, quando eu chegar"; os pares impossíveis são descartados.
A resposta é limitada de propósito. Cada propriedade de cada estadia é ~25 KB por estadia (medido: 25.892 bytes para 25 propriedades, 18.989 delas URLs), então cada estadia relata sua propriedade mais barata, sua tarifa por noite e sua mediana, e a lista completa de propriedades volta apenas para a estadia mais barata:
{
"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 em uma estadia é um fato sobre nosso pipeline, nunca um palpite sobre o hotel:
reason | search_status | Significado |
|---|---|---|
ok | ok | precificado |
no_availability | empty | buscado, respondido, nada voltou |
no_price | empty | propriedades voltaram, nenhuma trouxe preço (available: false cai aqui) |
search_failed | degraded | a busca errou, então nada é conhecido — não "sem quartos" |
not_searched | not_searched | o limite o amostrou para fora |
As contagens são null em vez de 0 nos dois últimos: zero lê como "nada lá", e nenhum caso
sabe disso. O search_status de nível superior é ok / partial / empty / degraded sobre as estadias;
cada estadia falhando levanta erro em vez de responder com uma lista vazia.
Duas recusas deliberadas: um intervalo de check-in com providers nomeando mais de uma fonte (um fan-out
vezes um fan-out por fonte, cobrado em duas assinaturas, que nem search_coverage nem
api_usage podem descrever honestamente hoje), e max_searches em uma única estadia, que silenciosamente
não faria nada.
Uma estadia única é byte-idêntica ao que era antes disso existir — mesmo corpo de requisição, mesmas
chaves de resposta, sem stays, sem search_coverage, sem search_status. Verificado em
tests/test_hotel_date_range.py::TestTheOldShapeIsUntouched contra uma expectativa congelada capturada
do código anterior.
Hotéis: providers e compare_hotel_rates
A implantação de hotéis (hotels.flightpowers.com, o mesmo código selecionado pelo cabeçalho Host)
serve três ferramentas. search_hotels e find_hotel_by_name também aceitam um intervalo de check-in (acima);
search_hotels ganhou um argumento opcional para fontes e há uma nova ferramenta.
| Ferramenta | O que faz |
|---|---|
search_hotels | Tarifas ao vivo para um destino e datas, ou para cada estadia que um intervalo de check-in expande. providers nomeia as fontes para precificar: ["booking"] (o padrão), ["airbnb"], ou ambas. |
find_hotel_by_name | Uma propriedade nomeada, somente Booking.com. A página do quarto da Airbnb não traz preço, então uma busca por nome lá resolveria para algo que não pode ser precificado. |
compare_hotel_rates | A mesma estadia precificada em cada fonte para a qual você tem uma chave, uma linha por fonte: total mais barato, total mediano, quantos lugares foram precificados, moeda e quando as linhas foram lidas. |
O padrão não mudou
search_hotels sem argumento providers envia a mesma requisição upstream que sempre enviou, para o
mesmo host, e responde com as mesmas chaves. O mesmo faz providers: ["booking"]. Ambos são verificados em
tests/test_providers.py::TestTheDefaultDidNotMove, corpo da requisição upstream incluído — um padrão só é
um padrão se mantê-lo não custa nada.
Nomear uma segunda fonte muda a forma da resposta, e só então:
{
"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 }
}
Onde cada fonte é chamada
bookingvai direto parabooking-live-api.p.rapidapi.comna chave do chamador, cobrado pela RapidAPI na própria assinatura deles. Inalterado.airbnbpassa pela nossa própria porta da frente,POST https://api.flightpowers.com/v1/hotels/searchcomprovider: "airbnb"no corpo (flight_rabbi#472), porque o backend da Airbnb não está na borda da RapidAPI. A chamada carrega a chave do chamador comox-rapidapi-keye se identifica comX-FP-Client: mcp-hotels/<version>— a porta da frente sobrescreve a atribuição do corpo com sua própria conclusão, então o cabeçalho é a única coisa que diz quem chamou (regra 11). Substitua a origem comAPI_FRONT_BASE_URLpara uma porta de visualização; ela não guarda credencial.
A listagem da Airbnb ainda não existe na RapidAPI, e a porta da frente é enviada com o provedor desligado, então
hoje uma chamada real de providers: ["airbnb"] volta como uma linha de degraded dizendo isso. Essa é a
resposta projetada, não um bug: uma fonte sem medição acessível por qualquer pessoa com qualquer chave válida é um gateway
pelo qual pagamos.
Quatro regras que a implementação mantém
- Uma fonte para a qual você não tem chave nunca é chamada na nossa. Ela é nomeada em
providers_skippedcomreason(no_key,not_subscribed,key_rejected) e a URL onde você assina. Cada listagem é uma assinatura separada, então um403do Hub significa "não assinado para aquela listagem", não "chave ruim". - Uma fonte degradada é uma linha nomeada, não um buraco.
search_status: "degraded",count: null, sem preços — e as linhas da outra fonte ainda voltam. "Booking não respondeu" e "Booking não tinha nada" são respostas opostas e devem ser dizíveis como frases diferentes. cheapest_totalemedian_totalcobrem apenas as linhas que carregam um preço, ecounté esse número. Umprice_stringnunca é analisado em um número.- Sem aritmética entre moedas. Cada fonte é solicitada na mesma moeda; se duas responderem em
moedas diferentes, cada linha mantém a sua e
caveatsdiz que os totais não são comparáveis. Nada é convertido.
Chaves, por fonte
Quase todo mundo tem uma chave RapidAPI assinada para várias listagens, e essa chave é usada para cada fonte sem configuração extra. Um chamador que realmente tem duas pode nomear uma por fonte, e o nome com escopo de fonte vence:
x-rapidapi-key-airbnb: <key> # header
?rapidapi_key_airbnb=<key> # query
?config=<base64 {"rapidApiKeyAirbnb": "<key>"}> # Smithery blob
A ordem é a que src/credentials.py já documenta — nomes com escopo de fonte, depois os
nomes específicos sem escopo, depois o blob de configuração, depois nomes genéricos por último e ignorados completamente quando um
parâmetro config está presente, porque a chave própria de um gateway sob um nome genérico não é nossa.
filters e price_as_seen_from são somente Booking
Em uma busca mista, eles são enviados para Booking e não para a porta da frente. Em uma busca somente Airbnb, eles são recusados, não descartados: um filtro silenciosamente descartado retorna mais propriedades do que você pediu e nada diz isso.
Saída estruturada (outputSchema, structuredContent, isError)
Cada ferramenta declara um outputSchema, e cada resultado carrega o payload
duas vezes: uma vez como structuredContent, uma vez como o JSON serializado em um bloco de
conteúdo de texto. A especificação MCP pede a duplicata —
Para compatibilidade reversa, uma ferramenta que retorna conteúdo estruturado DEVE também retornar o JSON serializado em um bloco TextContent.
— e isso é essencial aqui, não cerimonial, porque clientes que antecedem a saída estruturada leem o bloco de texto e nada mais. (Verificado contra a revisão da especificação 2026-07-28; a saída estruturada chegou em 2025-06-18.)
Os esquemas são deliberadamente additionalProperties: true com apenas results
obrigatório. A especificação coloca a obrigação no servidor — "Servidores DEVEM fornecer
resultados estruturados que estejam em conformidade com este esquema" — e essas ferramentas têm
várias saídas legítimas que carregam chaves diferentes — uma resposta de zero resultados, a resposta sem chave
needs_api_key e a resposta quota_exhausted. Um esquema mais
restrito pareceria melhor e tornaria o servidor não conforme em um caminho
que ele envia de propósito.
search_status, e por que degraded é um erro
Os resultados de voos carregam search_status, espelhando o próprio vocabulário
X-Search-Status do backend:
| valor | significado |
|---|---|
ok | toda combinação buscada retornou resultados |
empty | a busca foi concluída; o Google genuinamente não tem itinerários. Uma resposta real |
partial | algumas combinações retornaram resultados, algumas falharam. A lista está incompleta |
degraded | toda combinação falhou. A busca não aconteceu; uma lista vazia não significa nada |
Um resultado degraded é também sinalizado como isError: true. É o único que
é. A especificação classifica "falhas de API" como erros de execução de ferramenta e diz
que clientes "DEVEM fornecer erros de execução de ferramenta aos modelos de linguagem para permitir
autocorreção", enquanto nada na especificação obriga um host a mostrar
structuredContent ao modelo. Uma falha carregada apenas por um campo
dentro do payload é, portanto, uma falha que o modelo pode nunca ver, que era
todo o problema que search_status foi adicionado para resolver.
O payload ainda acompanha o erro — structuredContent e o
bloco de texto estão ambos presentes, então nada é perdido. api_usage em particular: uma busca degradada
ainda gastou as requisições RapidAPI do próprio chamador, e esconder isso esconderia
uma cobrança que eles têm que pagar. empty e
partial não são erros: um é um negativo verdadeiro e o outro carrega
resultados que um chamador pode usar.
Relatório de gastos (api_usage)
O dinheiro é seu, então o medidor é visível. Cada resposta bem-sucedida carrega:
| Campo | Significado |
|---|---|
requests_used_by_this_call | Requisições upstream cobradas que esta única chamada de ferramenta consumiu. |
plan_requests_remaining | O que resta no seu plano RapidAPI neste período. |
plan_requests_limit | O limite do seu plano para o período. |
note | A mesma coisa em uma frase, para o modelo poder transmitir a você antes de você perguntar. |
plan_requests_remaining e plan_requests_limit vêm da resposta upstream e são
omitidos quando o upstream não os relata; o note se adapta. A regra que o modelo deve declarar
em voz alta: uma data × um destino = uma requisição cobrada.
Controles de custo, em ordem de contundência: max_searches por chamada (reduza para gastar menos em uma
pergunta ampla), um intervalo de datas mais estreito, uma lista de destinos mais curta.
Uma chamada vs trinta
A API REST subjacente aceita exatamente uma tupla (origin, destination, date) por chamada. Em um
encaminhamento direto de uma data por chamada, "mais barato para o Sri Lanka em qualquer dia de outubro" são 31 chamadas
de ferramenta separadas: 31 idas e voltas pelo modelo, 31 chances de perder o fio da meada e uma conta que o usuário
só descobre depois.
Aqui, é uma chamada de ferramenta. A distribuição acontece no lado do servidor, de forma concorrente, limitada, com
amostragem uniforme, deduplicada em buy_link, mesclada, ordenada pelo seu sort_by e relatada com transparência em
search_coverage e api_usage.
| Este servidor (pago) | Servidor gratuito | |
|---|---|---|
| Distribuição por chamada | 30 (máximo rígido de 60; aumente ou diminua por chamada com max_searches) | 15 |
| Anúncios | nenhum | um card patrocinado divulgado por resultado |
| Chave | sua própria chave RapidAPI | nenhuma necessária |
| Relatório de gastos | api_usage em cada resposta | n/d |
| Listável em diretório | sim | não |
Este servidor não exibe nenhum anúncio, não por preferência, mas por restrição: a política do diretório de conectores da Anthropic e as diretrizes de aplicativos da OpenAI proíbem publicidade e conteúdo patrocinado em resultados de ferramentas, então um servidor com anúncios nunca pode ser listado lá, e este pode.
Desenvolvimento local
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
Aponte um cliente para o processo local da mesma forma:
claude mcp add --transport http google-flights-local http://localhost:8000/mcp --header "x-rapidapi-key: YOUR_RAPIDAPI_KEY"
Testes (620 passando, verificados):
.venv/bin/python -m pytest -q
A configuração fica em example.env; cada variável está documentada lá. As que importam:
| Variável | Padrão | Por que importa |
|---|---|---|
MAX_SEARCHES_PER_TOOL_CALL | 30 | Limite de distribuição por chamada. Limitado a um máximo rígido de 60. |
MAX_CONCURRENT_SEARCHES | 10 | Concorrência da distribuição. |
MAX_HTTP_CONNECTIONS | 60 | Teto do pool de conexões; instâncias serverless compartilham um pool de descritores de arquivo. |
REQUEST_TIMEOUT_SECONDS | 75 | O Timeout da função upstream (60) mais uma margem de 15s de retransmissão de borda, para que este lado nunca desista de uma resposta que ainda está chegando. |
DEFAULT_RESULT_LIMIT | 10 | Resultados solicitados por busca upstream individual. |
MCP_PRODUCTS | both | Qual produto esta implantação atende: flights, hotels ou both. Seleciona o conjunto de ferramentas, as instruções do servidor, o nome do serviço, as páginas de política e a listagem RapidAPI para a qual um chamador sem chave ou sem assinatura é enviado. Uma implantação de hotéis deixada no padrão se apresenta como um servidor de voos. |
SIGNUP_URL | listagem correspondente a MCP_PRODUCTS | Citado de volta para usuários que chegam sem chave. Em both, as ferramentas de hotéis citam a listagem Booking independentemente: uma URL não pode ser o botão de Assinar para duas APIs. |
MCP_PRODUCTS_BY_HOST | default | Qual produto cada hostname atende, para que uma implantação possa carregar ambos os domínios pagos e cada listagem ainda receba exatamente seu próprio conjunto de ferramentas. default é o mapa integrado de aliases de flightpowers.com; off desativa o roteamento por host completamente (o rollback sem código); ou um mapa host=product,… explícito. Um hostname não mapeado cai no padrão MCP_PRODUCTS. |
MCP_PUBLIC_URL | http://localhost:8000/mcp | Relatado por /health e a origem de todo link de página de política. MCP_PUBLIC_URL_FLIGHTS / MCP_PUBLIC_URL_HOTELS o substituem por produto em uma implantação que atende ambos. Sem eles, o hostname de hotéis anunciaria o de voos. SIGNUP_URL_FLIGHTS / SIGNUP_URL_HOTELS funcionam da mesma forma. |
RAPIDAPI_KEY | (vazio) | Deixe vazio em produção. Se definido, todo chamador sem chave é atendido e cobrado nessa assinatura. O servidor registra um aviso na inicialização e /health relata server_side_key_configured. |
METRICS_TOKEN | (vazio) | Quando definido, /metrics exige um cabeçalho x-metrics-token. |
LOG_PATH | (vazio) | Vazio desativa o coletor de arquivos; as linhas MCP_CALL do stdout permanecem como registro. Correto em serverless. |
Rotas operacionais: GET /health (pública, sem autenticação: registros a consultam),
GET /metrics, GET /metrics/calls?hours=24,
GET /.well-known/mcp/server-card.json (veja abaixo).
O card estático do servidor
GET /.well-known/mcp/server-card.json retorna os metadados desta implantação como um documento
JSON independente: serverInfo, description, transport, capabilities, authentication,
instructions e as listas completas de tools e prompts exatamente como tools/list as serializa.
Público, sem autenticação, Cache-Control: public, max-age=3600, CORS aberto.
Ele existe porque a URL que publicamos em diretórios é /mcp/oauth, que sempre responde
401, então um scanner automatizado apontado para ESSA url não consegue ler a lista de ferramentas diretamente da conexão.
(Desde 2026-09-09, um scanner apontado para /mcp consegue: a descoberta somente leitura é servida sem
credencial — veja src/discovery.py.) A página de publicação do Smithery
nomeia este documento como o caminho de saída: "Se a varredura automática não puder ser concluída (barreira de autenticação,
configuração necessária ou outros problemas), você pode fornecer metadados do servidor manualmente via um
card estático do servidor em /.well-known/mcp/server-card.json". A lista de campos segue
SEP-1649.
Duas coisas que valem a pena saber:
- A lista de ferramentas é lida do registro AO VIVO na primeira solicitação, nunca escrita manualmente,
então não pode divergir do que
tools/listretorna.tests/test_server_card.pycompara os dois em ambos os produtos. - É por hostname, como tudo aqui:
hotels.flightpowers.comretorna o card de hotéis, ambos os hostnames de voos retornam o card de voos, e toda URL dentro está na origem do próprio produto.transport.endpointé/mcp/oauthonde OAuth está configurado, com o endpoint/mcpcom chave listado sob_metacomo alternativa.
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
O alvo de implantação é Vercel via api/index.py (wrapper FastAPI entregando o ciclo de vida do FastMCP,
stateless_http=True). O caminho MCP canônico é /mcp, sem barra final.
Nunca faça commit de uma chave real. example.env acompanha com placeholders; mantenha assim.
Execute em um contêiner
O servidor hospedado não precisa de nada instalado. Isso é para auto-hospedagem, e é o que permite que o Glama execute seu teste de build e faça um lançamento.
docker build -t flightpowers-mcp .
docker run --rm -p 8000:8000 flightpowers-mcp
curl http://localhost:8000/health
O contêiner serve HTTP transmissível em ${PORT}/mcp, o mesmo transporte da implantação
hospedada, via python -m src. api/index.py é o wrapper Vercel e não é usado aqui.
Nenhum segredo é embutido na imagem. Cada busca é cobrada na assinatura RapidAPI do próprio chamador
e a chave dele viaja com a solicitação como x-rapidapi-key. RAPIDAPI_KEY é
opcional e é um fallback do lado do servidor: quando definido, um chamador que não envia chave própria é
atendido e cobrado nessa assinatura. Deixe sem definir, a menos que seja isso que você queira;
/health relata server_side_key_configured de qualquer forma.
# optional, local development only
docker run --rm -p 8000:8000 -e RAPIDAPI_KEY=your_key flightpowers-mcp
Toda variável na tabela acima funciona como -e NAME=value. HOST tem como padrão 0.0.0.0 e
PORT tem como padrão 8000 dentro da imagem. O HEALTHCHECK consulta /health em $PORT, então substituir
PORT continua funcionando.
Não afiliação
Esta é uma API independente que retorna preços de voos publicamente disponíveis. Ela não é afiliada à, endossada por ou patrocinada pelo Google. "Google Flights" é usado apenas para descrever a fonte de dados pública. As tarifas são fornecidas pelo provedor upstream, mudam constantemente e não são garantidas. Sempre confirme o preço no site da companhia aérea ou de reservas antes da compra.