FlightPowers Google Flights MCP
MCP alojado para tarifas en vivo de Google Flights con el veredicto propio de Google de bajo/típico/alto, viajes redondos emparejados y facturación de RapidAPI. Sin anuncios. Trae tu propia clave de RapidAPI.
Documentación
Google Flights MCP: tarifas en tiempo real que tu agente puede buscar en un rango completo de fechas, sin anuncios
Una URL, de cualquier manera:
claude mcp add --transport http google-flights https://flights.flightpowers.com/mcp
En un cliente que soporta autorización MCP, aparece un botón de Iniciar sesión: inicias sesión con
Google y pegas tu clave de RapidAPI una vez en la página de /connect, y nada va en la configuración
de tu cliente. En un cliente que no lo soporta, trae la clave tú mismo:
claude mcp add --transport http google-flights https://flights.flightpowers.com/mcp --header "x-rapidapi-key: YOUR_RAPIDAPI_KEY"
Misma URL, ambas veces. Una solicitud que lleva una credencial de cualquier tipo es atendida; una solicitud que no
lleva nada es respondida 401 con los metadatos de OAuth, que es lo que hace que aparezca el botón de
Iniciar sesión. https://flights.flightpowers.com/mcp/oauth sigue activo y exige el inicio de sesión en la
primera solicitud, para clientes cuyo modo de autenticación está fijado cuando se añade un servidor.
Alojado. Nada que clonar, nada que construir. Listado en el Registro oficial de MCP como
com.flightpowers/google-flights-mcp. Verificación de estado:
/health.
¿Necesitas una clave? Suscríbete a la API en vivo de Google Flights en RapidAPI, con plan gratuito disponible,
y copia tu x-rapidapi-key: https://rapidapi.com/mtnrabi/api/google-flights-live-api
¿Aún no tienes clave? Comienza con el servidor gratuito, la misma búsqueda, sin registro:
claude mcp add --transport http google-flights-free https://google-flights-lulu.flightpowers.com/mcp
(con soporte de anuncios: una tarjeta patrocinada divulgada por resultado, con un límite de expansión de 15, y los clientes que
no pueden mostrar la tarjeta patrocinada pueden tener un límite aún mayor). Vuelve aquí cuando los anuncios, el
límite de 15 búsquedas o esas restricciones de cliente te estorben.
Lo que obtiene tu agente
Dos herramientas que responden a una pregunta de tarifa, no a una consulta de fecha.
- Haz preguntas abiertas. "Vuelo de ida más barato a Sri Lanka en cualquier momento de octubre", "5 a 7
noches en Roma en algún momento de mayo, desde Tel Aviv o Larnaca": cada una es una llamada de herramienta. Ambas
herramientas aceptan un rango de fecha de salida, una lista de aeropuertos de destino y (en viaje redondo) un
valor de
nightsen lugar de una fecha de regreso fija, y los expanden internamente. - Di si un precio es realmente bueno. Cada resultado lleva el rango histórico propio de Google
para esa ruta y período:
price_insights_low,price_insights_high, y un veredicto deprice_range_in_relation_to_other_periodsdelow/typical/high. Eso es lo que permite a un agente responder "$209 es típico aquí, no te apresures" en lugar de solo citar un número. - Reserva, no solo navega. Cada resultado incluye un
buy_linka Google Flights. - Sabe cuánto gastó. Cada respuesta lleva
api_usage: solicitudes usadas por esta llamada, y lo que queda en el plan del llamante. Consulta Informe de gastos. - Sabe qué buscó. Cada respuesta lleva
search_coverage, para que el modelo pueda decir honestamente en qué fechas y destinos se basa la respuesta.
Los resultados son tarifas en vivo. Se vuelven obsoletos en minutos: nunca almacenes en caché una tarifa ni reutilices un resultado anterior; busca de nuevo e indica cuándo se obtuvieron los datos.
Obtén una clave (plan gratuito disponible)
El servidor no guarda ninguna credencial upstream propia. Cada búsqueda se factura a tu suscripción de RapidAPI, por eso la clave viaja con la solicitud.
- Suscríbete a la API en vivo de Google Flights: https://rapidapi.com/mtnrabi/api/google-flights-live-api
- Copia tu
x-rapidapi-key. - Pásala al servidor de cualquiera de las tres maneras siguientes.
Si falta una clave, las herramientas no fallan silenciosamente y no gastan nada. Devuelven
needs_api_key: true con la URL de registro y estas instrucciones, redactadas para que el modelo las lea
en voz alta.
Tres maneras de pasar tu clave
| Manera | Cómo | Cuándo usarla |
|---|---|---|
| Encabezado (preferido) | --header "x-rapidapi-key: YOUR_RAPIDAPI_KEY" | Cualquier cosa que te permita configurar encabezados. Las claves se mantienen fuera de las URLs y, por tanto, fuera de los registros de proxy y acceso. |
| Parámetro de consulta | https://google-flights-mcp.flightpowers.com/mcp?rapidapi_key=YOUR_RAPIDAPI_KEY | Hosts que solo te permiten pegar una URL: el diálogo de conector personalizado de claude.ai es el caso que importa. |
| Campo de clave de API del cliente | Pega la clave en el cuadro de "clave de API" del propio cliente | Hosts que envían authorization: Bearer <key> o x-api-key. El formulario de configuración guardada de Smithery (config.rapidApiKey=) también se acepta. |
La primera fuente no vacía gana, en ese orden. La clave nunca se registra, nunca se repite en un mensaje de error y nunca se devuelve en una respuesta de herramienta.
Una cuarta manera: inicia sesión una vez en /connect
Esta es la página a la que te envía la URL de inicio de sesión al principio de este README. Un cliente que habla autorización MCP te guía a través de ella por sí solo; los pasos siguientes son lo mismo hecho a mano.
Donde una implementación lo tenga habilitado (consulta connect_enabled en /health), hay una página en
/connect que reemplaza todo lo anterior con un inicio de sesión:
- Abre https://google-flights-mcp.flightpowers.com/connect (hoteles: https://hotels.flightpowers.com/connect) e inicia sesión con Google.
- Pega tu clave de RapidAPI una vez, en un formulario, a través de TLS.
- Pulsa Revelar la URL y copia la URL de conexión,
…/mcp?fp_token=fpk_…, y úsala como la URL del servidor en tu cliente MCP. Los clientes que te permiten configurar encabezados pueden enviar el mismo token comoAuthorization: Bearer fpk_…en su lugar. (Está oculta hasta que la pidas: esa URL es una credencial de portador de 90 días para tu plan, y una página que la imprime por defecto la pone en cada captura de pantalla y en cada pantalla compartida).
Si tu cliente te ha iniciado sesión por sí mismo -- Claude, Cursor, ChatGPT y cualquier otra cosa que hable
autorización MCP -- no hay URL que copiar. /connect lo dice: muestra la clave con la que te
conectaste y te dice que vuelvas a tu asistente. Hay un enlace en ella para el caso de que un
segundo cliente no pueda iniciar sesión y necesite una URL de conexión.
Lo que obtienes con esto: tu clave de RapidAPI no está en la configuración de tu cliente, ni en una URL, ni en
los registros por los que pase esa URL. Lo que cuesta: el servidor almacena tu clave, cifrada, y
conoce tu ID de cuenta de Google y tu correo electrónico. Disconnect en la misma página elimina el registro
y mata todos los tokens de conexión de tu cuenta, inmediatamente. La descripción completa está en
la sección 2a de la política de privacidad.
Algunos detalles que vale la pena conocer:
- Guardar ejecuta una verificación. La clave se valida contra el listado antes de almacenarse, así que un error tipográfico falla en la página en lugar de en tu cliente una hora después. Esa verificación cuesta como máximo una solicitud de tu propio plan: en el plan BASIC gratuito (10 al mes), una de diez. Una clave que RapidAPI rechace en la puerta de enlace no cuesta nada.
- Una clave en la solicitud siempre gana. Si envías un encabezado
x-rapidapi-key(o cualquiera de los otros canales anteriores) y llevas un token de conexión, se usa la clave de la propia solicitud. Nada de lo que ya tengas configurado cambia de comportamiento porque hayas iniciado sesión. - El token no es tu clave y no se puede convertir de nuevo en ella. Es válido durante 90 días, y
deja de resolverse en el momento en que te desconectas. Una llamada que lleva un token cuya clave ha sido
desconectada recibe una respuesta de
needs_api_keyque te dice que te reconectes. Nunca recurre a la suscripción de otra persona y nunca gasta nada. - BASIC es gratuito. API en vivo de Google Flights · API en vivo de reservas. Una clave de RapidAPI cubre cualquiera de los dos a los que te hayas suscrito; la conectas una vez.
Ejecutar /connect en tu propia implementación
Desactivado a menos que las cuatro estén configuradas. Una implementación a medio configurar no registra ninguna de las
rutas y atiende a los llamantes con clave exactamente como antes; /health informa connect_enabled para que sea
visible en lugar de adivinado.
| Variable | Qué es |
|---|---|
GOOGLE_OAUTH_CLIENT_ID | Consola de Google Cloud → Credenciales → ID de cliente OAuth, tipo Aplicación web. Termina en .apps.googleusercontent.com. |
GOOGLE_OAUTH_CLIENT_SECRET | El secreto del mismo cliente (GOCSPX-…). |
MCP_KEY_MASTER | 32 bytes, en base64: openssl rand -base64 32. Cifra las claves almacenadas (AES-256-GCM) y deriva las claves de firma de cookies y tokens. |
DATABASE_URL | Neon Postgres, endpoint agrupado (…-pooler…). Esquema: migrations/001_mcp_user_keys.sql. |
Opcional: MCP_CONNECT_VALIDATE=0 almacena una clave pegada sin verificarla primero.
URIs de redirección autorizadas para registrar en el cliente de Google, una por origen de producto, exactamente:
https://google-flights-mcp.flightpowers.com/connect/callback
https://hotels.flightpowers.com/connect/callback
flights.flightpowers.com no necesita ninguna entrada. /connect y /connect/start redirigen un alias a
el origen canónico antes de que comience el inicio de sesión, porque las cookies son por host y Google compara
redirect_uri literalmente: un alias que iniciara su propio inicio de sesión volvería a un host sin
cookie de estado y fallaría con un mensaje que parece una mala configuración de Google.
También en la pantalla de consentimiento de OAuth: los ámbitos openid y .../auth/userinfo.email, y nada más.
Rotar MCP_KEY_MASTER desconecta a todos y invalida cada clave almacenada. Eso es
deliberado: después de una rotación, nada queda sosteniendo un token que se resuelva a una clave que nadie puede
leer. Los usuarios ven "conéctate de nuevo", no una búsqueda fallida. key_version en la tabla está ahí para que
una rotación escalonada sea posible más adelante sin un día de transición.
Verificar el flujo de extremo a extremo
Migración primero, una vez por base de datos:
psql "$DATABASE_URL" -f migrations/001_mcp_user_keys.sql
Luego, después de implementar:
# 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.
Un tools/list que tenga éxito no prueba nada de esto: un token solo se consulta cuando una
herramienta realmente se ejecuta. El paso 4 tiene que ser una búsqueda real.
Una quinta manera: inicia sesión desde dentro de tu cliente MCP
Un cliente MCP solo inicia un inicio de sesión cuando una solicitud vuelve 401 con un
encabezado WWW-Authenticate: Bearer resource_metadata=…. Nada más en el cable le dice que hay uno
disponible. Hasta 2026-09-09 /mcp nunca envió ese encabezado, así que un llamante sin clave recibía un 200
cuyo cuerpo decía needs_api_key: JSON correcto, invisible para la maquinaria de autenticación de todos los clientes, y
el usuario veía "la herramienta falló" sin ningún lugar donde hacer clic.
Ahora /mcp desafía, pero solo a un llamante que no trajo nada, y solo en una llamada que gastaría algo:
| URL | Comportamiento |
|---|---|
https://flights.flightpowers.com/mcp | La que hay que usar. Una clave de RapidAPI en un encabezado, en la cadena de consulta o en un blob de configuración de Smithery, un token de conexión fpk_ o un token de acceso fpo_: todo se atiende exactamente como antes. Nada en absoluto: initialize, tools/list y el resto del protocolo de solo lectura se siguen respondiendo, y tools/call recibe 401 + el desafío, para que tu cliente ofrezca un botón de Iniciar sesión. |
https://hotels.flightpowers.com/mcp | Lo mismo, para hoteles. |
…/mcp/oauth | El mismo servidor con el inicio de sesión exigido en la primera solicitud. Para clientes cuyo modo de autenticación está fijado cuando se añade un servidor, y para conectores guardados en esa URL antes del cambio. |
Mismas herramientas, mismo enrutamiento de producto por hostname, mismo todo lo demás. El alias es el registro de herramientas idéntico detrás de una verificación de token, no una segunda copia del servidor.
La propiedad que protege las integraciones de pago: una solicitud que lleva una credencial nunca es
desafiada, y el orden de credenciales no cambia (encabezado, consulta, blob de configuración, luego identidad
OAuth, luego un token de conexión, luego el respaldo de entorno RAPIDAPI_KEY). Una clave incorrecta tampoco es
"nada": llega a las herramientas y vuelve como el error preciso que dio RapidAPI,
porque reemplazarlo con un aviso de inicio de sesión sería una respuesta peor. Una implementación con
RAPIDAPI_KEY configurado atiende a llamantes sin clave desde su propio plan a propósito y nunca es desafiada.
MCP_REQUIRE_AUTH=off desactiva el desafío por completo, sin implementación.
El descubrimiento permanece abierto, y eso se aprendió por las malas. Durante unas horas el 2026-09-09 el
desafío cubrió también initialize y tools/list. Glama revisa cada conector cada hora
abriendo una conexión MCP y listando sus herramientas, sin credenciales; ambos listados de pago fueron
marcados como no saludables y bajados en el ranking esa misma tarde, y el escaneo de lanzamiento de Smithery, mcpservers.org
y M8ven sondean de la misma manera. Así que la línea se traza en el gasto, no en la conexión: initialize,
notifications/initialized, ping, tools/list, prompts/list y resources/list se sirven
a cualquiera (src/discovery.py), y todo lo demás necesita una clave o un inicio de sesión. Un lote con un
tools/call dentro, un cuerpo sobredimensionado y uno que no se puede analizar son todos desafiados: la lista de permitidos
falla de forma segura. /mcp/oauth sigue desafiando todo, que es la URL que se le da a un directorio
que quiere un servidor que siempre requiera autenticación, y el público, sin autenticación
/.well-known/mcp/server-card.json sigue ahí para un escáner que lee una tarjeta en su lugar.
Cómo es usarlo. Pega la URL /mcp/oauth en tu cliente. Se registra solo,
abre un navegador, inicias sesión con Google y apruebas ese cliente por nombre en una página que
dice exactamente lo que podrá hacer: ejecutar búsquedas facturadas a tu propio plan de RapidAPI, nada
más. El cliente nunca ve tu clave de RapidAPI. Si aún no has conectado una, la aprobación
sigue funcionando y la primera búsqueda regresa diciéndote que pegues una clave en /connect, con la
URL.
Qué puedes revocar, y cómo. Pulsa Desconectar en /connect: la clave almacenada se elimina
y cada token OAuth para esa cuenta de Google se elimina en la misma acción. Un cliente que estaba
conectado deja de funcionar inmediatamente. Individualmente, un cliente puede llamar a /oauth/revoke (RFC 7009).
Configuración del 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.
La superficie del protocolo
| Ruta | Especificación |
|---|---|
GET /.well-known/oauth-protected-resource y …/mcp/oauth | RFC 9728 |
GET /.well-known/oauth-authorization-server y …/mcp/oauth | RFC 8414 |
POST /oauth/register | RFC 7591, registro dinámico de clientes, abierto |
GET /connect/authorize · POST /connect/authorize | RFC 6749 §4.1, PKCE S256 requerido |
POST /oauth/token | authorization_code y refresh_token |
POST /oauth/revoke | RFC 7009 |
El endpoint de autorización está bajo /connect a propósito: la cookie de sesión de inicio de sesión tiene un alcance
Path=/connect para que nunca pueda adjuntarse a una solicitud /mcp, y poner authorize en cualquier otro
lugar significaría ampliar esa cookie o hacer que el usuario inicie sesión dos veces.
Los códigos viven 10 minutos y son de un solo uso (DELETE … RETURNING, por lo que dos intercambios concurrentes compiten
por una fila y exactamente uno gana). Los tokens de acceso viven 1 hora, los tokens de actualización 30 días con rotación.
Todo es opaco y se almacena como un hash SHA-256, por lo que un volcado de la base de datos no contiene nada
que pueda reproducirse. Los tokens no son JWT, deliberadamente: un token firmado sigue siendo válido hasta que
expira, hagamos lo que hagamos después, y Desconectar tiene que significar desconectar.
Un token de acceso se verifica contra el recurso para el que fue aprobado antes de ser aceptado, no
solo cuando se emite. Ambos productos son el mismo despliegue, la misma base de datos y la misma
clave RapidAPI almacenada por usuario, por lo que sin esa verificación un token aprobado en la página de consentimiento
de vuelos, que dice "buscar tarifas de vuelos en vivo" y nada más, sería aceptado en el hostname
de hoteles y gastaría el plan de hoteles del usuario. La verificación es estricta con el host e indulgente con
la ruta, porque los clientes en el mundo real envían el origen, /mcp y /mcp/oauth para el mismo
servidor. tests/test_oauth.py::TestATokenIsBoundToTheResourceItWasApprovedFor fija ambas mitades.
Ejecutarlo en tu propio despliegue
Sin nueva variable de entorno. Se activa dondequiera que /connect esté configurado, porque reutiliza
ese inicio de sesión de Google y ese almacén de claves. Necesita una tabla más en la misma base de datos:
psql "$DATABASE_URL" -f migrations/002_mcp_oauth.sql
/health entonces reporta oauth_enabled: true y oauth_mcp_endpoint; esa URL es lo que va en
un listado de directorio. MCP_OAUTH=off lo desactiva mientras deja /connect ejecutándose; esa es la
retirada que no necesita ningún cambio de código.
Nada tiene que cambiar en el cliente OAuth de Google. La URI de redirección sigue siendo
…/connect/callback, porque el flujo OAuth del cliente MCP termina en nuestra página de autorización, y solo
esa página habla con Google.
Verificándolo de extremo a extremo
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.
Manteniendo honesta la tabla de registro
/oauth/register está abierto, porque la especificación MCP lo requiere y porque un client_id por sí solo
no autoriza nada: cada flujo a través de uno termina en una página de consentimiento donde un humano con sesión iniciada tiene que
pulsar un botón. Abierto no es lo mismo que ilimitado, por lo que tres cosas lo respaldan.
| Mecanismo | Dónde vive | Qué detiene |
|---|---|---|
| Límite de velocidad | en memoria, por instancia | una ráfaga: 10 registros por dirección por 10 minutos, 60 solicitudes de token por minuto, 10 /connect/save por hora. Superar el límite es 429 con Retry-After. |
| Límites diarios | Postgres, para que cada instancia esté de acuerdo | un goteo lento: 30 registros por dirección por día. El límite global es 5,000 por día: un respaldo contra filas ilimitadas, no una defensa: un número global cercano al tráfico real es una palanca que un atacante tira para rechazar a cada nuevo usuario de Claude o Cursor por un día. Cruzar 500 en un día registra y no rechaza nada. MCP_OAUTH_DCR_MAX_PER_IP_PER_DAY, MCP_OAUTH_DCR_MAX_PER_DAY y MCP_OAUTH_DCR_WARN_PER_DAY los mueven sin un despliegue. |
| Barrido | en /oauth/register, como máximo una vez cada 15 minutos por instancia | la basura: códigos y tokens expirados, y registros que nunca se convirtieron en una autorización dentro de 7 días. Un cliente con un token vivo, un código pendiente o una página de consentimiento que se ha renderizado para él nunca se barre. |
El límite de velocidad es por INSTANCIA. En Vercel eso significa que N instancias activas permiten hasta N veces esos números entre ellas, y un inicio en frío comienza los contadores en cero. Por eso existen también los límites duraderos: se cuentan en la base de datos, donde el número es el mismo en todas partes.
La dirección en la que se basa cada uno de estos proviene de x-real-ip primero: Vercel la establece al
par que aceptó — y de lo contrario de la ÚLTIMA entrada utilizable de x-forwarded-for, omitiendo saltos
que solo pueden ser internos. Nunca la entrada 0: los proxies añaden a la derecha, por lo que la entrada más a la izquierda es
lo que el llamador escribió, y leerla haría que cada límite aquí esté a un encabezado de ser
evadido. Una solicitud sin ninguno de los dos encabezados comparte un cubo llamado unknown, que tiene límite de velocidad
como un solo llamador y no está sujeto al límite duradero por dirección (un encabezado faltante en el
borde no debe bloquear todo el servidor por un día).
Un registro se marca como en uso cuando su página de consentimiento se RENDERIZA, no solo cuando el humano
pulsa Aprobar. Los clientes MCP comúnmente se registran cuando se instalan y autorizan días después,
y el barrido no debe eliminar una fila mientras su página de consentimiento está en pantalla: no hay clave foránea
de códigos o tokens de vuelta al cliente, por lo que el intercambio que siguió fallaría
invalid_client sin nada que nombre la causa.
Requiere una migración:
psql "$DATABASE_URL" -f migrations/003_mcp_oauth_hygiene.sql
Los tokens de actualización rotan, y una reproducción revoca la familia
Un token de actualización es de un solo uso: intercambiarlo emite un nuevo par y retira el presentado.
La fila retirada se mantiene y se marca, no se elimina, porque una fila eliminada y un token que nunca se
emitió se ven idénticos — y distinguirlos es el punto. Presentar un
token de actualización ya rotado significa o un cliente que perdió la respuesta o una copia en manos de
otra persona, y OAuth 2.1 §4.14.2 dice que se asuma lo segundo: la respuesta es invalid_grant, y
cada token descendiente de esa autorización se elimina. El cliente honesto inicia sesión de nuevo; el
token de acceso del ladrón deja de funcionar en el mismo momento.
Con una excepción deliberada, para el caso que casi siempre es el inocente: la PRIMERA reproducción del token que acabamos de rotar, del mismo cliente, dentro de 10 segundos, se responde con el par que esa rotación ya emitió. Es un reintento idempotente — no se crea nada nuevo — y significa que un cliente cuya respuesta se perdió por una conexión caída no se desconecta silenciosamente. Una segunda reproducción, o una después de la ventana, es lo real y aún mata a la familia. La ventana es por instancia y en memoria, por lo que un fallo simplemente cae en la respuesta conservadora.
Revocar un token de actualización a través de /oauth/revoke se lleva sus tokens de acceso, por la misma
razón (RFC 7009 §2.1) — y solo si el token fue emitido al cliente que pregunta, que es la
otra mitad de esa sección. Un cliente que presenta el token de otra persona aún recibe 200 (§2.2) y
nada se revoca.
Un client_id puede ser una URL
client_id_metadata_document_supported: true se anuncia en
/.well-known/oauth-authorization-server. Un cliente puede usar una URL https como su client_id;
el documento en esa URL lista sus redirect_uris, y lo obtenemos y verificamos por flujo en lugar de
escribir una fila de registro. Smithery pide esto antes de hacer proxy a un servidor OAuth remoto.
Lo que se verifica, cada vez: solo https, un hostname público (sin literales IP, sin localhost, sin
credenciales en la URL), el hostname resuelto y cada dirección con la que responde requerida para
ser unidifusión pública (un nombre no es un control: 127.0.0.1.nip.io tiene un punto y apunta a
loopback), sin redirección seguida, un tiempo de espera de 5 segundos, un límite de 64 KB aplicado mientras el cuerpo se lee
en lugar de después de que se almacena en búfer, un client_id dentro del documento que coincida con la URL si está
presente, y — el que importa — el redirect_uri en la solicitud debe estar listado en el
documento. La búsqueda tiene su propio límite de velocidad (60 por dirección por 10 minutos), porque es
la única búsqueda saliente en este servidor que un llamador puede activar antes de iniciar sesión. El registro dinámico no cambia
y sigue siendo el predeterminado: un client_id que no es una URL https se busca en la tabla exactamente como
antes.
Herramientas
| Herramienta | Qué hace |
|---|---|
search_oneway_flights | Tarifas de ida en tiempo real. Entrada: IATA de origen, IATA de destino o una lista, y ya sea una fecha de salida o un rango de fechas. Devuelve precio, aerolínea, duración, escalas, buy_link y el rango de precios histórico de Google para que puedas juzgar la tarifa. Úsala para cualquier pregunta de ida, incluidas las abiertas: una llamada con un rango, nunca una llamada por fecha. |
search_roundtrip_flights | Tarifas de ida y vuelta en tiempo real con precio como tramos emparejados, no dos idas. Entrada: origen, destino(s), una fecha de salida o rango, y ya sea un return_date o una duración de viaje en nights (un número o una lista como [5,6,7]). Devuelve precio total, aerolínea/escalas/duración por tramo y un buy_link para el viaje. |
El despliegue de hoteles sirve search_hotels, find_hotel_by_name y compare_hotel_rates en su lugar; ver Hotels: providers y 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 se aplica por este servidor en el conjunto de resultados fusionado de cada búsqueda que ejecutó, por lo que
es predecible independientemente de cuántas combinaciones se expandieron.
Un ejemplo trabajado
Usuario: "Estoy en Tel Aviv. Viaje de una semana más barato a Roma o Atenas, saliendo cualquier día en la primera mitad de mayo."
Una llamada:
{
"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
}
}
Eso se expande a 15 fechas × 2 destinos = 30 combinaciones, que es exactamente el límite por llamada. La forma de la respuesta (los nombres de los campos son reales; los valores a continuación son ilustrativos, no una cita, ejecuta la llamada para obtener tarifas en 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."
}
}
Otras formas de respuesta a esperar, todas normales:
- No hay vuelos en esas fechas.
results: []con unmessage: Google Flights genuinamente no devuelve nada para algunas combinaciones de ruta/fecha. No es un error. Prueba con fechas cercanas o un aeropuerto cercano.use_fallbackno cambiará esto y se deja sin establecer por defecto: el backend acepta el campo, pero la segunda fuente de datos de vuelos que selecciona está restringida detrás deUSE_FALLBACK_FLI(fallback_available()), que no está activado para esta API, por lo que ninguno de sus tres valores tiene un efecto observable en una búsqueda hoy. Los reintentos automáticos que el backend hace en una página ilegible son incondicionales y no se ven afectados por ello. - Algunas búsquedas fallaron. Un campo
partialindica cuántas de las búsquedas ejecutadas fallaron, y los resultados cubren el resto. - Rango demasiado amplio.
search_coverage.truncated: truemás unnote. El rango se muestrea uniformemente en toda la ventana (se conservan el primero y el último), no se recorta, por lo que la muestra es representativa, no los primeros N días. Aumentamax_searcheso reduce el rango para una cobertura más completa. - Sin clave / clave rechazada.
needs_api_key: true, cero gasto, con la solución. Una clave RapidAPI válida que no está suscrita a esta API es la causa más común. - Plan agotado.
quota_exhausted: trueconapi_usage, más un recordatorio de que reducir el rango hace que la cuota restante rinda más.
Hoteles: un rango de check-in
POST /search cotiza exactamente una estancia, por lo que "las tres noches más baratas en Roma en mayo" solía ser
31 llamadas a herramientas -- o, en la práctica, una llamada en una fecha que el modelo elegía y una respuesta presentada como
la más barata. Ambas herramientas de búsqueda de hoteles ahora adoptan la forma de vuelos:
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
...
)
Misma maquinaria que el fan-out de vuelos (src/fanout.py): una llamada al backend por estancia, limitada a
max_searches_per_tool_call (30, máximo duro 60), muestreada uniformemente en el rango cuando no
cabe, y reportada en search_coverage. nights deriva cada fecha de check-out, por lo que reemplaza
a checkout_date en lugar de unirse a él. Un checkout_date fijo contra un rango de fechas de check-in
está permitido y significa "salgo el día 4, llegue cuando llegue"; los pares imposibles se descartan.
La respuesta está limitada a propósito. Cada propiedad de cada estancia es ~25 KB por estancia (medido: 25,892 bytes para 25 propiedades, 18,989 de ellas URLs), por lo que cada estancia reporta su propiedad más barata, su tarifa por noche y su mediana, y la lista completa de propiedades solo se devuelve para la estancia más 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 en una estancia es un hecho sobre nuestro pipeline, nunca una suposición sobre el hotel:
reason | search_status | Significado |
|---|---|---|
ok | ok | cotizado |
no_availability | empty | buscado, respondido, no devolvió nada |
no_price | empty | llegaron propiedades, ninguna llevaba precio (available: false cae aquí) |
search_failed | degraded | la búsqueda dio error, por lo que no se sabe nada -- no "no hay habitaciones" |
not_searched | not_searched | el límite lo muestreó fuera |
Los conteos son null en lugar de 0 en los dos últimos: cero se lee como "no hay nada", y ninguno de los casos
lo sabe. El search_status de nivel superior es ok / partial / empty / degraded sobre las estancias;
cada estancia que falla genera un error en lugar de responder con una lista vacía.
Dos rechazos deliberados: un rango de check-in con providers nombrando más de una fuente (un fan-out
multiplicado por un fan-out por fuente, facturado a dos suscripciones, que ni search_coverage ni
api_usage pueden describir honestamente hoy), y max_searches en una sola estancia, que silenciosamente no haría
nada.
Una sola estancia es byte-idéntica a lo que era antes de que esto existiera -- mismo cuerpo de solicitud, mismas
claves de respuesta, sin stays, sin search_coverage, sin search_status. Verificado en
tests/test_hotel_date_range.py::TestTheOldShapeIsUntouched contra una expectativa congelada capturada
del código anterior.
Hoteles: providers y compare_hotel_rates
El despliegue de hoteles (hotels.flightpowers.com, el mismo código seleccionado por el encabezado Host)
sirve tres herramientas. search_hotels y find_hotel_by_name también aceptan un rango de check-in (arriba);
search_hotels ganó un argumento opcional para fuentes y hay una herramienta nueva.
| Herramienta | Qué hace |
|---|---|
search_hotels | Tarifas en vivo para un destino y fechas, o para cada estancia a la que se expande un rango de check-in. providers nombra las fuentes para cotizar: ["booking"] (el predeterminado), ["airbnb"], o ambas. |
find_hotel_by_name | Una propiedad nombrada, solo Booking.com. La página de habitación de Airbnb no lleva precio, por lo que una búsqueda por nombre allí resolvería a algo que no se puede cotizar. |
compare_hotel_rates | La misma estancia cotizada en cada fuente para la que tengas una clave, una fila por fuente: total más barato, total mediano, cuántos lugares se cotizaron, moneda y cuándo se leyeron las filas. |
El predeterminado no se movió
search_hotels sin argumento providers envía la misma solicitud upstream que siempre envió, al mismo
host, y responde con las mismas claves. Igual hace providers: ["booking"]. Ambos están verificados en
tests/test_providers.py::TestTheDefaultDidNotMove, incluido el cuerpo de la solicitud upstream -- un predeterminado solo
es un predeterminado si mantenerlo no cuesta nada.
Nombrar una segunda fuente cambia la forma de la respuesta, y solo entonces:
{
"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 }
}
Dónde se llama a cada fuente
bookingva directamente abooking-live-api.p.rapidapi.comcon la clave del llamante, facturado por RapidAPI a su propia suscripción. Sin cambios.airbnbpasa por nuestra propia puerta principal,POST https://api.flightpowers.com/v1/hotels/searchconprovider: "airbnb"en el cuerpo (flight_rabbi#472), porque el backend de Airbnb no está en el borde de RapidAPI. La llamada lleva la clave del llamante comox-rapidapi-keyy se identifica conX-FP-Client: mcp-hotels/<version>-- el front sobrescribe la atribución del cuerpo con su propia conclusión, por lo que el encabezado es lo único que dice quién llamó (regla 11). Sobrescribe el origen conAPI_FRONT_BASE_URLpara un front de vista previa; no contiene credenciales.
El listado de Airbnb aún no existe en RapidAPI, y el front se envía con el proveedor desactivado, por lo que
hoy una llamada real a providers: ["airbnb"] vuelve como una fila degraded que lo dice. Esa es la
respuesta diseñada, no un error: una fuente sin medir accesible por cualquiera con cualquier clave válida es una puerta de enlace
que pagamos.
Cuatro reglas que la implementación mantiene
- Una fuente para la que no tienes clave nunca se llama con la nuestra. Se nombra en
providers_skippedconreason(no_key,not_subscribed,key_rejected) y la URL donde te suscribes. Cada listado es una suscripción separada, por lo que un403del Hub significa "no suscrito a ese listado", no "clave mala". - Una fuente degradada es una fila nombrada, no un hueco.
search_status: "degraded",count: null, sin precios -- y las filas de la otra fuente aún vuelven. "Booking no respondió" y "Booking no tenía nada" son respuestas opuestas y deben poder decirse como oraciones diferentes. cheapest_totalymedian_totalcubren solo las filas que llevan un precio, ycountes ese número. Unprice_stringnunca se analiza como número.- Sin aritmética entre monedas. A cada fuente se le pide la misma moneda; si dos responden en
diferentes, cada fila mantiene la suya y
caveatsdice que los totales no son comparables. Nada se convierte.
Claves, por fuente
Casi todos tienen una clave RapidAPI suscrita a varios listados, y esa clave se usa para cada fuente sin configuración adicional. Un llamante que genuinamente tiene dos puede nombrar una por fuente, y el nombre con ámbito de fuente gana:
x-rapidapi-key-airbnb: <key> # header
?rapidapi_key_airbnb=<key> # query
?config=<base64 {"rapidApiKeyAirbnb": "<key>"}> # Smithery blob
El orden es el que src/credentials.py ya documenta -- nombres con ámbito de fuente, luego los
nombres específicos sin ámbito, luego el blob de configuración, luego nombres genéricos al final y omitidos por completo cuando hay un
parámetro config presente, porque la clave propia de una puerta de enlace bajo un nombre genérico no es nuestra.
filters y price_as_seen_from son solo de Booking
En una búsqueda mixta se envían a Booking y no al front. En una búsqueda solo de Airbnb se rechazan, no se descartan: un filtro descartado silenciosamente devuelve más propiedades de las que pediste y nada lo dice.
Salida estructurada (outputSchema, structuredContent, isError)
Cada herramienta declara un outputSchema, y cada resultado lleva el payload
dos veces: una como structuredContent, otra como el JSON serializado en un bloque de contenido
de texto. La especificación MCP pide el duplicado --
Para compatibilidad hacia atrás, una herramienta que devuelve contenido estructurado DEBE también devolver el JSON serializado en un bloque TextContent.
-- y es fundamental aquí en lugar de ceremonial, porque los clientes que predatan la salida estructurada leen el bloque de texto y nada más. (Verificado contra la revisión de especificación 2026-07-28; la salida estructurada llegó en 2025-06-18.)
Los esquemas son deliberadamente additionalProperties: true con solo results
requerido. La especificación pone la obligación en el servidor -- "Los servidores DEBEN
proporcionar resultados estructurados que se ajusten a este esquema" -- y estas herramientas tienen
varias salidas legítimas que llevan claves diferentes -- una respuesta de cero resultados, la respuesta sin clave
needs_api_key y la respuesta quota_exhausted. Un esquema más
estricto se vería mejor y haría que el servidor no fuera conforme en una ruta
que envía a propósito.
search_status, y por qué degraded es un error
Los resultados de vuelos llevan search_status, reflejando el propio vocabulario
X-Search-Status del backend:
| valor | significado |
|---|---|
ok | cada combinación buscada devolvió resultados |
empty | la búsqueda se completó; Google genuinamente no tiene itinerarios. Una respuesta real |
partial | algunas combinaciones devolvieron resultados, algunas fallaron. La lista está incompleta |
degraded | cada combinación falló. La búsqueda no ocurrió; una lista vacía no significa nada |
Un resultado degraded también está marcado como isError: true. Es el único que
lo está. La especificación clasifica los "fallos de API" como errores de ejecución de herramientas y dice
que los clientes "DEBEN proporcionar errores de ejecución de herramientas a los modelos de lenguaje para permitir
la autocorrección", mientras que nada en la especificación obliga a un host a mostrar
structuredContent al modelo en absoluto. Un fallo llevado solo por un campo
dentro del payload es, por lo tanto, un fallo que el modelo puede nunca ver, que era
todo el problema que search_status se añadió para resolver.
El payload aún viaja con el error -- structuredContent y el
bloque de texto están ambos presentes, por lo que nada se pierde. api_usage en particular: una búsqueda degradada
aún gastó las solicitudes RapidAPI propias del llamante, y ocultar eso ocultaría
un cargo que tienen que pagar. empty y
partial no son errores: uno es un negativo verdadero y el otro lleva
resultados que un llamante puede usar.
Reporte de gasto (api_usage)
El dinero es tuyo, por lo que el medidor es visible. Cada respuesta exitosa lleva:
| Campo | Significado |
|---|---|
requests_used_by_this_call | Solicitudes upstream facturadas que consumió esta llamada de herramienta. |
plan_requests_remaining | Lo que queda en tu plan RapidAPI este período. |
plan_requests_limit | El límite de tu plan para el período. |
note | Lo mismo en una oración, para que el modelo pueda transmitírtelo antes de que preguntes. |
plan_requests_remaining y plan_requests_limit vienen de la respuesta upstream y se
omiten cuando upstream no los reporta; el note se adapta. La regla que el modelo debería decir
en voz alta: una fecha × un destino = una solicitud facturada.
Controles de costo, en orden de contundencia: max_searches por llamada (bájalo para gastar menos en una
pregunta amplia), un rango de fechas más estrecho, una lista de destinos más corta.
Una llamada vs treinta
La API REST subyacente acepta exactamente una tupla (origin, destination, date) por llamada. Frente a un passthrough de una fecha por llamada, "lo más barato a Sri Lanka en cualquier día de octubre" son 31 llamadas de herramienta separadas: 31 idas y vueltas a través del modelo, 31 oportunidades de perder el hilo, y una factura que el usuario solo descubre después.
Aquí es una llamada de herramienta. El fan-out ocurre del lado del servidor, de forma concurrente, con límite, muestreo uniforme, deduplicado en buy_link, fusionado, ordenado por tu sort_by, y reportado honestamente en search_coverage y api_usage.
| Este servidor (de pago) | Servidor gratuito | |
|---|---|---|
| Fan-out por llamada | 30 (máximo duro 60; sube o baja por llamada con max_searches) | 15 |
| Anuncios | ninguno | una tarjeta patrocinada divulgada por resultado |
| Clave | tu propia clave RapidAPI | ninguna necesaria |
| Reporte de gasto | api_usage en cada respuesta | n/a |
| Listable en directorio | sí | no |
Este servidor no lleva anuncios en absoluto, no por gusto sino por restricción: la política del directorio de conectores de Anthropic y las pautas de aplicaciones de OpenAI prohíben publicidad y contenido patrocinado en resultados de herramientas, por lo que un servidor con anuncios nunca puede aparecer listado allí y este sí puede.
Desarrollo 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
Apunta un cliente al proceso local de la misma manera:
claude mcp add --transport http google-flights-local http://localhost:8000/mcp --header "x-rapidapi-key: YOUR_RAPIDAPI_KEY"
Pruebas (620 pasando, verificadas):
.venv/bin/python -m pytest -q
La configuración vive en example.env; cada variable está documentada allí. Las que importan:
| Variable | Predeterminado | Por qué importa |
|---|---|---|
MAX_SEARCHES_PER_TOOL_CALL | 30 | Límite de fan-out por llamada. Limitado a un máximo duro de 60. |
MAX_CONCURRENT_SEARCHES | 10 | Concurrencia del fan-out. |
MAX_HTTP_CONNECTIONS | 60 | Tope del pool de conexiones; las instancias serverless comparten un pool de descriptores de archivo. |
REQUEST_TIMEOUT_SECONDS | 75 | El Timeout de la función upstream (60) más un margen de 15s de relay de borde, para que este lado nunca se rinda ante una respuesta que aún está llegando. |
DEFAULT_RESULT_LIMIT | 10 | Resultados solicitados por búsqueda upstream individual. |
MCP_PRODUCTS | both | Qué producto sirve este despliegue: flights, hotels o both. Selecciona el conjunto de herramientas, las instrucciones del servidor, el nombre del servicio, las páginas de políticas y el listado de RapidAPI al que se envía a un llamador sin clave o sin suscripción. Un despliegue de hoteles que se deja en el predeterminado se presenta como un servidor de vuelos. |
SIGNUP_URL | listado que coincide con MCP_PRODUCTS | Citado de vuelta a los usuarios que llegan sin clave. En both, las herramientas de hoteles citan el listado de Booking independientemente: una URL no puede ser el botón de Suscribirse para dos APIs. |
MCP_PRODUCTS_BY_HOST | default | Qué producto sirve cada hostname, para que un despliegue pueda llevar ambos dominios de pago y cada listado reciba exactamente su propio conjunto de herramientas. default es el mapa integrado de alias de flightpowers.com; off desactiva el enrutamiento por host por completo (el rollback sin código); o un mapa explícito host=product,…. Un hostname no mapeado cae en MCP_PRODUCTS. |
MCP_PUBLIC_URL | http://localhost:8000/mcp | Reportado por /health y el origen de cada enlace de página de políticas. MCP_PUBLIC_URL_FLIGHTS / MCP_PUBLIC_URL_HOTELS lo anulan por producto en un despliegue que sirve ambos. Sin ellos, el hostname de hoteles anunciaría el de vuelos. SIGNUP_URL_FLIGHTS / SIGNUP_URL_HOTELS funcionan igual. |
RAPIDAPI_KEY | (vacío) | Déjalo vacío en producción. Si se establece, cada llamador sin clave es atendido y facturado a esa suscripción. El servidor registra una advertencia al inicio y /health reporta server_side_key_configured. |
METRICS_TOKEN | (vacío) | Cuando se establece, /metrics requiere un encabezado x-metrics-token. |
LOG_PATH | (vacío) | Vacío desactiva el sumidero de archivos; las líneas de stdout MCP_CALL siguen siendo el registro. Correcto en serverless. |
Rutas operativas: GET /health (pública, sin autenticación: los registros la consultan),
GET /metrics, GET /metrics/calls?hours=24,
GET /.well-known/mcp/server-card.json (ver abajo).
La tarjeta de servidor estática
GET /.well-known/mcp/server-card.json devuelve los metadatos de este despliegue como un documento JSON independiente:
serverInfo, description, transport, capabilities, authentication,
instructions, y las listas completas de tools y prompts exactamente como tools/list las serializa.
Pública, sin autenticación, Cache-Control: public, max-age=3600, CORS abierto.
Existe porque la URL que publicamos en directorios es /mcp/oauth, que siempre responde
401, por lo que un escáner automatizado apuntado a ESA url no puede leer la lista de herramientas del cable.
(Desde 2026-09-09 un escáner apuntado a /mcp puede: el descubrimiento de solo lectura se sirve sin
credencial — ver src/discovery.py.) La página de publicación de Smithery
nombra este documento como la salida: "Si el escaneo automático no puede completarse (muro de autenticación,
configuración requerida u otros problemas), puedes proporcionar metadatos del servidor manualmente a través de una
tarjeta de servidor estática en /.well-known/mcp/server-card.json". La lista de campos sigue
SEP-1649.
Dos cosas que vale la pena saber:
- La lista de herramientas se lee del registro EN VIVO en la primera solicitud, nunca se escribe a mano,
por lo que no puede desviarse de lo que
tools/listdevuelve.tests/test_server_card.pycompara ambos en ambos productos. - Es por hostname, como todo lo demás aquí:
hotels.flightpowers.comdevuelve la tarjeta de hoteles, ambos hostnames de vuelos devuelven la tarjeta de vuelos, y cada URL dentro está en el propio origen de ese producto.transport.endpointes/mcp/oauthdonde OAuth está configurado, con el endpoint con clave/mcplistado bajo_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
El objetivo de despliegue es Vercel a través de api/index.py (wrapper de FastAPI que entrega a FastMCP su ciclo de vida,
stateless_http=True). La ruta MCP canónica es /mcp, sin barra final.
Nunca comprometas una clave real. example.env se envía con marcadores de posición; mantenlo así.
Ejecútalo en un contenedor
El servidor alojado no necesita nada instalado. Esto es para autoalojamiento, y es lo que permite a Glama ejecutar su prueba de compilación y publicar una versión.
docker build -t flightpowers-mcp .
docker run --rm -p 8000:8000 flightpowers-mcp
curl http://localhost:8000/health
El contenedor sirve HTTP transmisible en ${PORT}/mcp, el mismo transporte que el despliegue
alojado, a través de python -m src. api/index.py es el wrapper de Vercel y no se usa aquí.
Ningún secreto está horneado en la imagen. Cada búsqueda se factura a la suscripción RapidAPI del propio llamador
y su clave viaja con la solicitud como x-rapidapi-key. RAPIDAPI_KEY es
opcional y es un respaldo del lado del servidor: cuando se establece, un llamador que no envía clave propia es
atendido y facturado a esa suscripción. Déjalo sin establecer a menos que eso sea lo que quieras;
/health reporta server_side_key_configured de cualquier manera.
# optional, local development only
docker run --rm -p 8000:8000 -e RAPIDAPI_KEY=your_key flightpowers-mcp
Cada variable en la tabla anterior funciona como -e NAME=value. HOST predetermina a 0.0.0.0 y
PORT a 8000 dentro de la imagen. El HEALTHCHECK consulta /health en $PORT, por lo que anular
PORT sigue funcionando.
No afiliación
Esta es una API independiente que devuelve precios de vuelos disponibles públicamente. No está afiliada con, respaldada por, o patrocinada por Google. "Google Flights" se usa solo para describir la fuente de datos pública. Las tarifas son proporcionadas por el proveedor upstream, cambian constantemente y no están garantizadas. Siempre confirma el precio en el sitio de la aerolínea o de reserva antes de comprar.