Skip to content

MCP ​

AirnodeHub serves its catalogue as an MCP server, so an agent finds a listing, calls it and checks what came back as tools rather than as HTTP you wire up yourself:

https://airnodehub.api3.org/mcp

It is a streamable HTTP server. There is no account and no key, so a client needs only the URL.

Connecting ​

Clients differ in where the configuration lives, but they all want the same two values, the transport and the URL:

json
{
  "mcpServers": {
    "airnodehub": {
      "type": "http",
      "url": "https://airnodehub.api3.org/mcp"
    }
  }
}

Some spell the transport as "transport": "streamable-http", and some ask for the URL in a settings pane or as a CLI flag instead of a file. There is nothing else to supply either way. Ask the connected client to search the listings, and a catalogue coming back is the setup confirmed.

Calling it without a client ​

The server answers a tools/call on its own, with no initialize handshake and no session header, so every example below runs as it stands. MCP requires a caller to accept both media types, which is the one header easy to leave off:

sh
curl -X POST https://airnodehub.api3.org/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": { "name": "search-listings", "arguments": { "query": "crypto" } }
  }'

The reply is a single server-sent event, so the JSON-RPC body arrives on a data: line. Every tool declares an output schema in tools/list. A successful result carries text under content for a model to read, and the same facts as JSON under structuredContent for a script to read. For resolve-intent and call-operation, the text is that JSON as a string.

Every tool also carries annotations. All but call-operation are marked read-only. A call that carries a payment settles it, which spends your funds and cannot be undone, so call-operation is marked destructive, even though no operation we list changes anything upstream. It is also the one tool that reaches outside the Hub, so it is the one with openWorldHint set.

A tool that cannot do what you asked answers with isError set and a sentence saying why, rather than a JSON-RPC error. That is deliberate: a client's retry logic tends to absorb a transport-level failure before the model ever sees it, and the model is who needs to know that the slug was wrong.

The tools ​

Two routes lead to a call. The surer one starts with the whole catalogue: list-listings returns every listing with its operation ids, small enough to read whole, so you choose by what a listing does rather than by which words it happens to use, and get-listing-details then describes the operations of the ones you pick. When you already know a listing or one of its tags, a search with that word is a shortcut that returns the matching listings with their operations described. Read the catalogue before you conclude that nothing serves a request. Both routes end the same way: read the operations you chose with get-operation-details, which says what values each parameter takes, then call them with call-operation.

list-listings ​

The whole catalogue: every listing with what it covers, its tags and its operation ids, without prices. It takes no arguments. It is the surer way to find what serves a request than a search, since matching words misses synonyms and abbreviations.

json
{
  "name": "list-listings",
  "arguments": {}
}

Each listing is one line, such as - coingecko: Crypto prices, … Tags: crypto, coins. Operations: trendingSearch, simplePrice, coinsMarkets., and the structuredContent holds the same listings under listings, each operation with only its id.

search-listings ​

What the Hub can answer for a word. A query is a keyword shortcut: it returns the listings matching it, best first, with each operation described by what it does, what a call costs and the names of the parameters it takes, where an operation with no price is free. An empty query returns the same as list-listings.

A query also says what matched. Each listing names the query terms it matched and the operations whose own name or description matched, which points you at the likely target without hiding the lookups beside it. A term no listing matched is named, and the result says how many listings matched when limit cut them.

ParameterTypeDefaultNotes
querystring""One to three concrete terms. An empty string returns the same as list-listings.
limitinteger5Between 1 and 10. How many listings a non-empty query returns, ignored for "".

Explore and MCP search use the same BM25 ranking over listing titles, slugs, operators, descriptions and tags, each API's own description, operation names and descriptions, and parameter names and descriptions. Operation names also match as separate words. A small curated alias set includes bitcoin/BTC and ethereum/ETH for CoinGecko. Words match by stem, so stock finds stocks and driving finds drive. The last term also matches as the start of a word, so weath finds weather, while earlier terms must match a whole word. Common request words such as api, get and latest are ignored.

Every listing carries up to five tags, such as crypto, food or defi, naming the subjects it specializes in, so a tag is usually the quickest search. The query description lists every tag in use, so your client can pick one before its first search. A word few listings use counts for more than one many of them use, and a match in a title or tag counts for more than one in a parameter description. A result need not match every term or fulfil the complete request, so read its operations before choosing. No matches means no keyword matches, not proof that the capability is absent: try another subject term, read the whole catalogue with list-listings, or use resolve-intent for a natural-language request.

json
{
  "name": "search-listings",
  "arguments": {
    "query": "bitcoin"
  }
}

A listing in the result looks like this, and the name in parentheses is the slug the other tools take. The line after its tags says what it matched, and the last line is its passthroughUrl, the provider's own API:

Listings matching "bitcoin", best first.

## CoinGecko (coingecko)
Crypto prices, market caps and trending coins, attested back to the source rather than scraped.
Tags: crypto, coins.
Third-party.
Matched terms: bitcoin.
- trendingSearch: The coins, NFTs and categories trending on CoinGecko right now. Takes no parameters.
- simplePrice: The current price of one or more coins in one or more currencies. Required: ids, vs_currencies. Optional: include_market_cap, include_24hr_change.
- coinsMarkets: Ranked market data for coins in one currency. Required: vs_currency. Optional: ids, order, per_page, page.
The provider's own API, signed: https://airnode-coingecko.fly.dev/api

When a term matches no listing, a line under the first one says so, such as No listing matched "swap". When more listings match than limit allows, the first line says how many, such as Listings matching "crypto", best 5 of 9.

The structuredContent holds the same listings under listings. Each operation carries its description, its priceUsd when it costs money, its price per call in dollars, and the names of every parameter it takes, split into required and optional. Each listing also carries matchedTerms and matchedOperations. Beside listings are unmatchedTerms and totalMatches, how many listings matched before limit. Here it is cut to one operation:

json
{
  "listings": [
    {
      "slug": "coingecko",
      "title": "CoinGecko",
      "description": "Crypto prices, market caps and trending coins, attested back to the source rather than scraped.",
      "tags": ["crypto", "coins"],
      "firstParty": false,
      "passthroughUrl": "https://airnode-coingecko.fly.dev/api",
      "operations": [
        {
          "id": "simplePrice",
          "description": "The current price of one or more coins in one or more currencies.",
          "required": ["ids", "vs_currencies"],
          "optional": ["include_market_cap", "include_24hr_change"]
        }
      ],
      "matchedTerms": ["bitcoin"],
      "matchedOperations": []
    }
  ],
  "unmatchedTerms": [],
  "totalMatches": 1
}

A query that matches nothing gives an empty listings, and unmatchedTerms names its terms.

get-listing-details ​

The overview a search gives, for the listings you name: what each operation does, what a call costs and the names of its parameters. Use it for listings picked from the whole catalogue.

ParameterTypeRequiredNotes
slugsarray of stringyesOne to five slugs. Ask for everything you need at once.
json
{
  "name": "get-listing-details",
  "arguments": {
    "slugs": ["coingecko", "tickerlayer"]
  }
}

A slug that matches nothing is named back to you rather than skipped, and a call where none of them match is an error.

Each listing looks exactly as in a search, in the text and in structuredContent, and a slug asked for twice is described once. missing names the slugs that match no listing.

get-operation-details ​

Everything a call to the operations you name needs: each parameter with its description and constraints, what a call costs, what it returns, the same call in the provider's own form under the listing's passthrough, and the address the listing signs with.

ParameterTypeRequiredNotes
operationsarray of objectyesOne to ten { listing, operation } pairs. Ask for everything you need at once.
json
{
  "name": "get-operation-details",
  "arguments": {
    "operations": [
      { "listing": "coingecko", "operation": "simplePrice" },
      { "listing": "tickerlayer", "operation": "quote" }
    ]
  }
}

An operation that matches nothing is named back to you rather than skipped, and a call where none of them match is an error.

The structuredContent has listings and missing. Each listing appears once and carries its tags, its address, its passthroughUrl, and only the operations you asked for, as the listing's GET / describes them: each parameter with its required flag, its location and its JSON schema, which carries its type and description, the priceUsd, and the route under the passthrough. A listing that prices an operation also carries payment.accepts, with the network, asset, symbol and decimals of each way to pay. missing lists the { listing, operation } pairs that match nothing.

resolve-intent ​

The finding and the parameter filling in one step, for a caller that would rather ask once than search and choose. A model reads the registry and answers with candidate operations ranked best first, each with its parameters filled in.

ParameterTypeRequiredNotes
intentstringyesWhat you need, in plain English.
json
{
  "name": "resolve-intent",
  "arguments": {
    "intent": "current USD price of ETH"
  }
}

The result carries a structuredContent of intent, candidates and unresolvedInputs. Every candidate is checked back against the registry before you see it, so the parameter contract, the price and the signing address come from the API's own document rather than from the model. A candidate also carries passthrough: the example as the provider documents the call, with the method, the url and any body to send to the listing's passthrough as they are, which is the form for a client that speaks the provider's interface, and what verify-attestation below takes back as its passthrough binding. unresolvedInputs names what the resolver would not guess at, which is your cue to ask the user rather than to invent a value. With no candidates at all it says which kind of dead end you reached: unsupported means nothing listed serves this and asking differently will not help, noIntent means the message named no fact to fetch.

This is the one rate limited tool, since each call costs us inference to serve. Over the limit it answers with an error pointing you at list-listings and search-listings, which are not limited and read the same registry.

call-operation ​

Performs an operation through its Airnode's passthrough and returns the attestation as an envelope. The passthrough itself answers as the provider does.

ParameterTypeDefaultNotes
listingstringrequiredThe slug, as search-listings gives it.
operationstringrequiredThe operation id, exactly as the listing document declares it.
parametersobject{}The parameters for that operation.
responseProjectionobjectnoneThe fields to sign, instead of the whole response.
json
{
  "name": "call-operation",
  "arguments": {
    "listing": "coingecko",
    "operation": "simplePrice",
    "parameters": { "ids": "ethereum", "vs_currencies": "usd" }
  }
}

The structuredContent is the attestation itself. data is the provider's body exactly as it arrived, as one string, because that is what the signature covers:

json
{
  "airnode": "0x896B7a7F8872639e45F4Fd5F975b1031Ad57bfba",
  "requestHash": "0x500d56d398255a116354a5e21295080fb02b799bcc930700026954144742a227",
  "timestamp": "1788165461",
  "data": "{\"ethereum\":{\"usd\":2437.97}}",
  "signature": "0xe4893ec2…"
}

A response is often far larger than the part you asked for, and you pay for every token of it. responseProjection maps a name you choose to an RFC 6901 JSON Pointer, and the Airnode signs those fields instead of the whole body. data is then that object rather than a string, since the Airnode wrote those bytes itself as compact JSON:

json
{
  "name": "call-operation",
  "arguments": {
    "listing": "coingecko",
    "operation": "simplePrice",
    "parameters": { "ids": "ethereum", "vs_currencies": "usd" },
    "responseProjection": { "price": "/ethereum/usd" }
  }
}

data then reads { "price": 2437.97 }. The projection is part of the signed request rather than a filter applied afterwards, so the attestation states which fields you asked for, and trimming the response yourself instead would leave you holding data the signature no longer covers. Read what an operation returns before projecting it: a pointer the response does not have is refused rather than signed. Asking for only the fields you need has the rest.

An unknown slug, an operation the listing does not declare, a parameter it does not take or a required one left out is refused here without a request going out. Beyond that the provider's own answer comes back through the passthrough with its status, so a 429 is the provider's rate limit and a 502 is the Airnode failing to reach it. A call is given thirty seconds before it is abandoned.

A 400, 404 or 422 usually means the provider rejected a value, so that error also carries what the operation takes: its price, its route under the passthrough, what it returns and its parameters, as get-operation-details describes them, without the listing around it. When that runs past 8,000 characters, the error names get-operation-details instead.

A priced operation settles over the x402 MCP transport. Called without a payment, it answers with isError set and the x402 v2 payment requirements, under structuredContent and as JSON in the first content block. Sign one entry of accepts with your wallet and call again with the payment payload under _meta["x402/payment"]. The server refuses a payload that is not in the x402 v2 shape without sending it, and hands any other to the Airnode, which verifies it, performs the call and settles it, and the receipt comes back under _meta["x402/payment-response"]. The server holds no wallet and never pays for you. A client that does not speak the MCP transport can pay the Airnode URL in the error text directly instead. See paying.

verify-attestation ​

Checks that a response was signed by the listing you called, and, when you name the call, that the signature is bound to that exact request rather than replayed from another one.

ParameterTypeRequiredNotes
attestationobjectyesThe whole body call-operation returned.
listingstringyesThe slug you called.
operationstringnoThe operation you called. Give it with parameters.
parametersobjectnoThe parameters you sent. Give it with operation.
responseProjectionobjectnoThe projection you sent, if you sent one.
passthroughobjectnoThe method, url and body you sent through /api, in place of operation and parameters.

operation and parameters go together or not at all. One without the other is refused, because half a request silently answers a weaker question than the caller asked. Pass responseProjection alongside them whenever you sent one, since it is part of the request the signature is bound to and a projected response verifies only against the projection that produced it.

A response from a listing's passthrough verifies the same way. Build attestation from the response headers, X-Airnode-Address as airnode, X-Airnode-Request-Hash as requestHash, X-Airnode-Timestamp as timestamp and X-Airnode-Signature as signature, with the body exactly as received, as one string, as data. Then give passthrough with the method, the full URL you called and the body you sent, in place of operation and parameters. A projected response verifies the same way, since the projection is read from the URL you called.

json
{
  "name": "verify-attestation",
  "arguments": {
    "attestation": {
      "airnode": "0x896B…",
      "requestHash": "0x500d…",
      "timestamp": "1788165461",
      "data": {},
      "signature": "0xe489…"
    },
    "listing": "coingecko",
    "operation": "simplePrice",
    "parameters": { "ids": "ethereum", "vs_currencies": "usd" }
  }
}

Three things are checked in turn. The signer has to be the address that listing publishes, and a response signed by some other listing in the catalogue is named as such rather than merely rejected, since an operation id can belong to more than one listing and a genuine answer from the wrong one is the mistake worth catching. The request hash is re-derived from the operation and parameters you pass rather than read off the response, or the signature would only say that the Airnode signed something. It is derived as the passthrough request call-operation sends for them, and as the envelope form too, for a response you posted to / yourself. Then the signature has to recover to the address the response names.

Passing without operation says who signed and not what they answered, and the result says so in as many words. The structuredContent gives the same verdict as airnode, the address the signature recovers to, listing, and bound, which is false when you named no request to bind the signature to. A failed check has isError set and no structuredContent.

A listing in place of its provider ​

A listing's passthroughUrl is the provider's own API, signed. You can send it the request the provider documents, and the provider's status and body come back untouched, with the attestation in X-Airnode-* headers. A client built for the provider needs only that base URL and no API key, since the Airnode adds the provider's credential. It gets only the routes the listing declares, which get-operation-details gives as the route of each operation. The HTTP interface has the rest.

sh
curl --include 'https://airnode-coingecko.fly.dev/api/simple/price?ids=ethereum&vs_currencies=usd'

To compare a listing with its provider, call the passthroughUrl. call-operation wraps the same response in an envelope.

Verifying is still yours ​

That the Hub will check an attestation for you is not a contradiction of the Airnode having no verify endpoint. The Hub checking an Airnode is a different thing from an Airnode grading its own work. Doing the check yourself is stronger still, and attestation documents the algorithm so you can.

The tools read the same registry that the listing pages and /resolve read, so a parameter contract, a price or a signing address one of them reports comes from the API's own document rather than from a copy that could drift.

What it costs ​

Discovery is free by design, so searching, reading a listing and resolving cost nothing. A listing that prices its operations charges for the call itself, which call-operation settles only with a payment you signed. See paying for how that works and what is priced today.

We record what an intent resolved to and how the resolver got there, the same way over MCP as over /resolve. See finding an API for what that covers.