aviasales-mcp
README.md
---
title: Aviasales MCP
emoji: "✈️"
colorFrom: blue
colorTo: indigo
sdk: docker
app_port: 7860
pinned: false
short_description: Flight price search over MCP
---
# aviasales-mcp
MCP server for flight price search via Aviasales / Travelpayouts Data API.
Thirteen read-only tools that let Claude Code, Claude Desktop, Cursor or any
other MCP client answer flight-price questions: fares by route and month, the
cheapest day to fly, flexible dates, budget and inspiration search, plus
airport/city/airline lookup.
Unlike Google-Flights-scraping MCP servers, place names do not have to be
guessed into IATA codes by the model: `lookup_cities` and `find_nearest_airports`
resolve them.
## Common prompts
Ask your agent in plain language — it picks the tool.
| Prompt | Tools it reaches for |
|--------|----------------------|
| "How much is a flight from Moscow to Istanbul in March?" | `lookup_cities` → `search_flights` |
| "What's the cheapest day to fly to Bangkok in September?" | `get_prices_calendar` |
| "I'm flying Berlin→Lisbon on 12 May, back on the 19th — would shifting a day either way be cheaper?" | `get_flexible_date_prices` |
| "Where can I fly from St Petersburg for under 30 000 ₽?" | `get_city_directions`, `search_by_price_range` |
| "Which airport should I fly into for Pattaya, and what does it cost from Dubai?" | `find_nearest_airports` → `search_flights` |
| "Novosibirsk to Rome in November — is it cheaper with a few days in Istanbul on the way?" | `plan_stopover_trip` |
| "Two weeks in Rome in November, and I would stop somewhere on the way out." | `plan_stopover_trip` with `trip_nights_min`/`trip_nights_max` |
| "Evening departures only, two adults and a child, business class." | `search_flights` with `depart_after`, `adults`, `children`, `trip_class` |
## Install
You need a free Travelpayouts API token:
<https://www.travelpayouts.com/programs/100/tools/api>
```bash
# Run without installing (what MCP client configs below use)
uvx aviasales-mcp
# Or install into the current environment
pip install aviasales-mcp
```
> The PyPI release is still pending. Until it lands, use the Docker invocation
> in [Setup](#setup) or run from a source checkout with
> `pip install .` in the repository root.
## MCP client configs
Take the block for your client and put your own token in. `AVIASALES_MARKET` is
optional but worth setting — see [Configuration](#configuration).
> **Keep the token out of anything you commit.** A project-level `.mcp.json` or
> `.cursor/mcp.json` is a normal thing to check into git, and a token pasted
> there goes with it. Prefer the user-level config file, or `.gitignore` the
> project one. On PyPI-less setups the Docker form reads `--env-file`, which
> keeps the secret in an ignored `.env`.
### Claude Code
One command, and the token lands in your user config rather than the repo:
```bash
claude mcp add aviasales --env AVIASALES_API_TOKEN=your-token-here -- uvx aviasales-mcp
```
Or add it to `~/.claude.json` (every project), or to `.mcp.json` in the project
root if you are happy to gitignore that file:
```json
{
"mcpServers": {
"aviasales": {
"command": "uvx",
"args": ["aviasales-mcp"],
"env": {
"AVIASALES_API_TOKEN": "your-token-here",
"AVIASALES_MARKET": "ru"
}
}
}
}
```
### Claude Desktop
`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS,
`%APPDATA%\Claude\claude_desktop_config.json` on Windows. A GUI app starts with
a trimmed `PATH` and often cannot find `uvx` by name — if the server fails to
start, replace `"uvx"` with its absolute path (`which uvx`):
```json
{
"mcpServers": {
"aviasales": {
"command": "uvx",
"args": ["aviasales-mcp"],
"env": {
"AVIASALES_API_TOKEN": "your-token-here",
"AVIASALES_MARKET": "ru"
}
}
}
}
```
### Cursor
`~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per project):
```json
{
"mcpServers": {
"aviasales": {
"command": "uvx",
"args": ["aviasales-mcp"],
"env": {
"AVIASALES_API_TOKEN": "your-token-here",
"AVIASALES_MARKET": "ru"
}
}
}
}
```
### Docker instead of uvx
Works in all three clients — swap the `command`/`args` for:
```json
{
"command": "docker",
"args": ["run", "-i", "--rm", "--env-file", "/path/to/.env", "aviasales-mcp"]
}
```
## Tools
### Flight prices
| Tool | Description |
|------|-------------|
| `search_flights` | Prices between two cities on a date or across a month (v3/prices_for_dates) |
| `get_prices_calendar` | Prices grouped by day or month — the cheapest day to fly |
| `get_flexible_date_prices` | Prices for the days around your dates — "would shifting a day be cheaper?" |
| `get_latest_prices` | Most recently found fares, optionally filtered by route |
| `get_popular_directions` | Where travellers reach a destination **from** |
| `get_city_directions` | Cheapest destinations reachable **from** a city — inspiration search |
| `get_alternative_directions` | Prices for nearby airports/cities |
| `search_by_price_range` | Flights inside a budget; omit the destination to search anywhere (no date filter — the endpoint ignores one) |
| `plan_stopover_trip` | Breaking the journey for a few days in a hub — one-way, or the whole round trip with `trip_nights_min`/`max` |
`search_flights`, `get_prices_calendar`, `get_flexible_date_prices` and
`get_latest_prices` accept `adults` (1–9), `children` (0–8), `infants` (0–8) and
`trip_class` (`economy`/`comfort`/`business`/`first`).
The Data API serves a cache of recent searches and takes no passenger
parameters, so the party is encoded into each ticket's `booking_link` instead —
the link opens Aviasales with the full party and class pre-filled and shows the
real total. **The prices themselves are always per adult in economy**, which is
what the `price_note` field on every price response spells out for the model.
`search_flights` also takes `depart_after` / `depart_before` (`HH:MM`, 24-hour)
to keep only departures in a time window; set `depart_after` later than
`depart_before` for a window that wraps midnight (red-eyes).
Tickets carry `duration_total` (door-to-door minutes), `duration_to` /
`duration_back` (flight time per direction) and `layover_minutes` (combined
ground time between connections). Every price response also carries a
`price_summary` (min/median/max) so the model can tell a good fare from a bad one
without a second search.
### Reference data
| Tool | Description |
|------|-------------|
| `lookup_airlines` | Airlines by name or IATA code |
| `lookup_airports` | Airports by name, IATA code, or city code |
| `lookup_cities` | Cities by name or IATA code — turn a city name into a code |
| `lookup_countries` | Countries by name or code |
| `find_nearest_airports` | Airports closest to a place or to lat/lon, by distance |
The `lookup_*` tools take `search`, `limit` (default 50, max 500) and `locale`,
and return `{status, total, returned, truncated, data}`. Always pass `search`:
the underlying datasets are ~10k airports and ~9.6k cities, which is far more
than any model can hold in context. Matches are ranked (exact code, then exact
city code, then name), and airports with no scheduled service sort last. Each
dataset is downloaded once per process and cached for 24 hours.
`find_nearest_airports` answers the question `lookup_airports` cannot: the
closest airport is rarely named after the town. It resolves `near="Pattaya"`
against the cached city and airport datasets and ranks by great-circle distance —
no third-party geocoder involved.
### Errors and empty results
Every response carries `status` (`"ok"` or `"error"`), so an empty `data` list is
never confused with a failure. Bad input is refused before the API call, with the
expected format spelled out (`"departure_at must be \"YYYY-MM-DD\" or
\"YYYY-MM\"…"`), and both error and empty responses carry a `hint` naming what to
try next. On a rejected argument the hint says what to substitute and which tool
resolves it — enough for the model to fix the call itself on the second attempt.
## Setup
Running it yourself, without `uvx`:
1. Get an API token at https://www.travelpayouts.com/programs/100/tools/api
2. Copy `.env.example` to `.env` and fill in your token
3. Build and run with Docker:
```bash
docker build -t aviasales-mcp .
docker run --env-file .env aviasales-mcp
```
## Remote connector (Hugging Face Spaces)
Claude's custom connectors reach the server from Anthropic's cloud, not from the
viewer's device, so it needs a public HTTPS URL. On individual plans the
connector UI takes OAuth credentials and offers no field for a static header,
which is why `MCP_AUTH_TOKEN` is not enough there and Google OAuth is.
**The allowlist is the security boundary, not the login.** Everyone has a Google
account, so `MCP_ALLOWED_EMAILS` is what separates "signed in" from "allowed to
spend the owner's Travelpayouts quota". The server refuses to start with OAuth
configured and the list empty.
1. Create a Google OAuth client (Google Cloud Console → APIs & Services →
Credentials → OAuth client ID → Web application). Authorized redirect URI:
`https://<user>-<space>.hf.space/auth/callback`.
2. Create a Docker Space and push this repository to it. The YAML block at the
top of this file is the Space's config; `app_port: 7860` matches the
Dockerfile.
3. In Space → Settings → Secrets, set `AVIASALES_API_TOKEN`,
`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `MCP_PUBLIC_URL`
(`https://<user>-<space>.hf.space`) and `MCP_ALLOWED_EMAILS`.
4. In Claude → Settings → Connectors → Add custom connector, give it
`https://<user>-<space>.hf.space/mcp` and sign in.
Two things to expect. The Space must be **public**: a private one demands a
Hugging Face token on every request, which Claude will not send — the OAuth
allowlist is what protects it, not the Space's visibility. And a free Space
**sleeps** when idle; the first request after that wakes it and may time out, so
ping it on a schedule if that becomes annoying.
## Configuration
| Variable | Required | Description |
|----------|----------|-------------|
| `AVIASALES_API_TOKEN` | Yes | Travelpayouts API token |
| `AVIASALES_PARTNER_ID` | No | Partner ID for booking links |
| `AVIASALES_DEFAULT_CURRENCY` | No | Default price currency (default: `rub`) |
| `AVIASALES_MARKET` | No | 2-letter market whose price cache to read (unset → `ru`) |
| `AVIASALES_LOCALE` | No | Language of reference data names (default: `en`) |
| `LOG_LEVEL` | No | Logging level (default: INFO) |
| `MCP_PORT` | No | Serve streamable-HTTP on this port instead of stdio (`PORT` also accepted) |
| `MCP_HOST` | No | Bind address for HTTP mode (default: `127.0.0.1`; use `0.0.0.0` in Docker) |
| `MCP_AUTH_TOKEN` | No | Shared secret required on every HTTP request |
| `MCP_AUTH_ALLOW_QUERY_TOKEN` | No | Also accept the token as `?token=` (default: false) |
| `MCP_ALLOW_INSECURE_HTTP` | No | Permit a non-loopback bind with no token (default: false) |
`AVIASALES_MARKET` is worth setting: the price cache is per market, and the same
route in the same currency comes back at a different price for `ru` and `us`.
## HTTP transport
By default the server speaks stdio, which is what local MCP clients expect.
Setting `MCP_PORT` switches it to streamable-HTTP so it can be reached remotely:
```bash
docker run --env-file .env \
-e MCP_PORT=8080 -e MCP_HOST=0.0.0.0 -e MCP_AUTH_TOKEN=<your-secret> \
-p 8080:8080 aviasales-mcp
```
The endpoint is then `http://<host>:8080/mcp`, and every request must present
the token as `Authorization: Bearer <token>`; anything else gets a 401.
Some MCP clients cannot set headers. `MCP_AUTH_ALLOW_QUERY_TOKEN=true` also
accepts `?token=<token>`, but note that uvicorn — and any proxy in front of it —
writes the full URL to its access log, so the secret ends up in logs. Prefer the
header.
There is no TLS here: terminate it at a reverse proxy if the port is reachable
from anywhere untrusted.
Without `MCP_AUTH_TOKEN` the port is unauthenticated and anyone who reaches it
can spend your Travelpayouts quota. Loopback binds are allowed (with a warning);
binding anything else refuses to start unless you also set
`MCP_ALLOW_INSECURE_HTTP=true`.
## Limitations
- **Prices are a cache of recent searches**, not live availability. A fare can be
gone by the time the link opens; `expires_at` says when the quote lapses.
- **Prices are always per adult in economy.** Passenger count and cabin change
the booking link, never the quoted number.
- **No booking.** This server searches and links out; it never holds or buys.
- **Cache coverage is uneven.** An empty result means nobody searched that route
recently, not that the route does not exist.
- **`get_latest_prices`, the matrices and nearby airports** come from the older
v2 response shape: they name the selling `agency` rather than the airline and
carry no flight number.
- **Rate limits are per endpoint** (600/min for most, 60/min for the week and
nearby matrices). The server retries 429s and warns when the published quota
runs low.
## Development
```bash
docker build --target dev -t aviasales-mcp-dev .
docker run --rm aviasales-mcp-dev pytest -q
docker run --rm -v "$(pwd)":/app -w /app aviasales-mcp-dev ruff check src/ tests/
```
CI runs the suite on Python 3.12 and 3.13 plus a Docker image build — see
`.github/workflows/ci.yml`.
## License
GPL-3.0-or-later — see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues