Wiring an agent to a flight data API takes about ten minutes. Making it reliably answer questions about flights takes rather longer, and the gap between those two is where most aviation agent projects stall.

The connection itself is a config block, and if that's all you need, the Claude MCP server tutorial covers setup step by step. This post is about what comes after: the tool surface is bigger than you think, the responses are bigger than your context window likes, and an agent that gets a stale position will state it as fact without hesitating.

The connection, briefly

SkyLink exposes its v3.1 dataset over the Model Context Protocol, so any MCP client — Claude Desktop, Cursor, Cline, Windsurf — can call it as tools. On the direct channel it's a streamable HTTP endpoint, stateless, authenticated with the same header as the REST API:

{
  "mcpServers": {
    "skylink": {
      "url": "https://data.skylinkapi.com/mcp",
      "headers": { "x-api-key": "YOUR_API_KEY" }
    }
  }
}

The key travels as a transport header and never enters the prompt, which matters more than it sounds. A key pasted into a system prompt is one screenshot or one logged conversation away from being public, and models have been known to repeat their instructions when asked nicely.

Worth knowing when debugging: the endpoint returns server-sent events, so your Accept header must include both application/json and text/event-stream or you get a 406. JSON-RPC batching isn't supported — one request per call, or 400. GET and DELETE on /mcp return 405. Most clients handle all of this, but hand-rolled integrations trip on the Accept header constantly.

Your agent can see between 46 and 58 tools

This is the first real design problem, and it surprises people.

The tool list is filtered by plan — Basic sees 46 tools, Pro 52 with 90-day history, Ultra and Mega 58 with the full year. That's a lot of tools by any measure. Tool-selection accuracy degrades as the list grows; the model has to pick the right one from a few dozen similarly-named options, and names like search_airport_airports_search_get are generated from the underlying operation rather than written for a model to read.

Two practical consequences.

Call tools/list and look at what your key actually exposes before designing prompts around a tool you assume exists. Requesting one outside your plan returns MCP error -32602: Tool <name> not found, because it was never registered for your key rather than failing at call time. That's good behaviour — the agent can't waste a turn on something it can't have — but it means the tool surface differs between your dev key and production if the plans differ.

Consider narrowing the surface for a production agent. For a general-purpose assistant, all 46 tools is fine; the model browses. For a focused agent — a dispatch helper, a delay bot, a briefing assistant — you will usually get better behaviour by putting a thin layer in front that exposes five or six well-named, well-described tools mapped to the endpoints you actually use. The model picks correctly far more often from six options than from fifty-eight.

# A focused wrapper beats the full surface for a narrow agent
TOOLS = [
    {"name": "get_airport_weather",
     "description": "Current observed weather (METAR) at an airport. "
                    "Takes a 4-letter ICAO code such as KJFK or EGLL. "
                    "Returns an observation, not a forecast.",
     "endpoint": "/weather/metar/{icao}"},
    {"name": "get_airport_forecast",
     "description": "Terminal forecast (TAF) for an airport, valid 24-30 hours. "
                    "Not available at every airport; small fields often have none.",
     "endpoint": "/weather/taf/{icao}"},
    # ... four more, not fifty
]

Notice what the descriptions do. They say what the data is (observation versus forecast), what identifier format is expected, and where it might not exist. The model reads these, and vague descriptions are the most common cause of an agent calling the wrong tool.

Responses are sized for programs, not context windows

A REST client doesn't care that a bounding-box query returned 800 aircraft. An agent does, because every one of those records is tokens, and a few such calls will fill the window and push the actual question out of it.

The live ADS-B endpoint supports filters precisely so you don't have to return everything: bound the box tightly, use min_alt and max_alt, filter by airline or registration, and cap with limit. Set photos=false — it's the default, it's slower when enabled, and an agent has no use for a thumbnail URL.

A useful habit is to shape the response for the model rather than passing the API payload through untouched:

def summarise_for_agent(aircraft, limit=15):
    """Trim a position feed to what a language model can actually use."""
    out = []
    for a in aircraft[:limit]:
        out.append({
            "flight": a.get("callsign") or a.get("registration") or a["icao24"],
            "type": a.get("aircraft_type"),
            "airline": a.get("airline"),
            "altitude_ft": a.get("altitude"),
            "speed_kt": a.get("ground_speed"),
            "observed_at": a["last_seen"],      # keep this — see below
        })
    return {"count": len(aircraft), "showing": len(out), "aircraft": out}

Keeping count alongside showing matters. If you silently truncate, the model will answer "there are 15 aircraft over London" when there were 800, and it will sound certain.

An aircraft at a boarding gate

The stale data problem, which is the serious one

A language model has no sense of time. Hand it a position record and it will describe the aircraft's location in the present tense regardless of whether the observation is two seconds or four minutes old.

This is the failure mode that turns a demo into an incident. The model is not lying; it is reporting what it was given, with the confidence it applies to everything.

Three mitigations, in order of how much they help.

Always pass the observation time through. Every ADS-B record carries last_seen, and the snapshot carries a timestamp. Do not strip them to save tokens — they are the cheapest tokens in the payload.

Compute the age yourself and label it. Don't make the model do date arithmetic, because it is bad at it. Give it a number and a word:

from datetime import datetime, timezone

def annotate_age(record, snapshot_ts):
    seen = datetime.fromisoformat(record["last_seen"]).replace(tzinfo=timezone.utc)
    now = datetime.fromisoformat(snapshot_ts).replace(tzinfo=timezone.utc)
    age = (now - seen).total_seconds()
    record["age_seconds"] = round(age)
    record["freshness"] = ("live" if age < 30
                           else "recent" if age < 120
                           else "stale — position may have changed significantly")
    return record

A model given "freshness": "stale — position may have changed significantly" hedges appropriately. A model given a bare latitude does not.

Say so in the system prompt. Something as plain as "Position data is an observation with a timestamp, not a live feed. When reporting an aircraft's position, state when it was observed. If a record is more than two minutes old, say the position is approximate." The combination of a labelled field and an instruction works; either alone is unreliable.

The wider question of why positions differ between sources, and why "now" is doing a lot of work in that sentence, is covered in why flight data disagrees.

Identifiers are where agents quietly go wrong

Aviation has several code systems that look interchangeable and are not, and a model will cheerfully substitute one for another.

ICAO versus IATA. KJFK and JFK are the same airport in different systems. Most of the API takes ICAO; the tickets endpoint takes IATA. An agent that has seen both in one conversation will mix them. The fix is descriptions that state the format explicitly and a validation layer that rejects a three-letter code where four are required rather than passing it through to a 404.

Callsign versus flight number. BAW178 and BA178 refer to the same flight and are not the same string. Models routinely convert between them without being asked, which is fine when the mapping is the obvious one and wrong for the many cases where it isn't.

Registration versus hex. G-XWBA and 40621d identify the same airframe. The model will not invent the hex, but it may report one when asked for the other.

The cheap defence is to validate inputs before they reach the API and return a corrective error the agent can act on. An agent that gets "error: expected 4-letter ICAO code (e.g. KJFK), received 'JFK'" will retry correctly. One that gets a bare 404 will often conclude the airport doesn't exist and tell the user so.

An airliner on approach at night, runway threshold lights glowing through mist

Agents fan out unpredictably

A human-driven app has predictable traffic. An agent asked "compare weather at the five airports I might divert to" makes five calls in a second, and an agent in a loop can make fifty before anyone notices.

Two things worth doing before this bites.

Cache aggressively, by data type. Aircraft type specs never change and can be cached forever. METARs update hourly or half-hourly, so a five-minute cache costs nothing in accuracy. Positions should not be cached at all beyond a few seconds. Matching cache lifetime to the underlying update rate is the single biggest quota saving available, and it also makes the agent faster.

Cap the loop. Agents retry. When a call fails with a 422 because the model constructed a bad bounding box, retrying unchanged fails identically and spends quota each time — and on the direct channel, requests that authenticate count against your allowance even when they error. Return a descriptive error, and put a hard ceiling on tool calls per turn.

What actually works well

Having spent most of this post on failure modes, the parts that work are worth naming, because they're why people do this.

Multi-step reasoning across endpoints. "Is there anything at Heathrow that would delay a departure this afternoon?" is a question that needs NOTAMs, the METAR, the TAF, and system delay data, with judgement applied to the combination. That's genuinely hard to build as a fixed UI and it's close to trivial for an agent with the right tools.

Open-ended filtering. "Which of our aircraft are airborne and above 20,000 feet right now" maps cleanly onto the ADS-B filters, and the model composes the query from the sentence.

Explaining the data. Aviation formats are famously opaque. An agent that can fetch a raw METAR and explain it in plain English is doing something genuinely useful for a non-specialist, and it's a use case where a wrong tense matters much less than in an operational one.

What to be careful with: anything where the answer drives an operational decision. An agent that reads NOTAMs and tells a pilot a runway is open is in a different risk category from one that summarises the weather for a travel app. Keep a human between the model and the decision, and make the interface show the raw source alongside the summary so it can be checked.

Getting started

The MCP server documentation has the config for both channels, including the mcp-remote bridge if you're on RapidAPI rather than direct. The free trial is 1,000 requests a month, which is enough to build and test an agent — though be aware that agent traffic is lumpier than app traffic, and a single enthusiastic debugging session can spend a few hundred calls.

If you're still deciding which data an aviation agent needs at all, the aviation data stack for AI agents covers the categories rather than the engineering, and building an AI flight dispatcher works through a concrete example end to end.