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 returns its result as text under content, and the two tools with a declared output schema repeat it as parsed JSON under structuredContent.

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

search-listings

What the Hub can answer. Returns one summary per listing: its title and slug, whether it is first-party, and every operation with its parameter names and types. That is enough to call one, so this is the normal starting point.

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

Matching is plain substring over listing and operation names and descriptions, so search the category rather than the instance: crypto finds the coin pricing, bitcoin finds nothing. A word that appears throughout the catalogue, such as api or latest, ranks nothing.

json
{
  "name": "search-listings",
  "arguments": {
    "query": "crypto",
    "limit": 3
  }
}

A summary looks like this, and the parenthesised name is the slug the other tools take:

## TickerLayer (tickerlayer)
Stocks, forex, crypto, commodities, indices, ETFs and sovereign bond yields, from one market API.
Third-party.
- quote(assetClass: string, symbol: string): The latest bid and ask for one instrument.
- lastTrade(assetClass: string, symbol: string): The price one instrument last traded at.

get-listing-details

The full document for one or more listings: every operation, its parameters with their descriptions, what a call costs, and the address the listing signs with. Reach for it when a parameter needs more explanation than its name and type give, since a search result already carries enough to make a call.

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.

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. unresolvedInputs names what the resolver would not guess at, which is your cue to ask the user rather than to invent a value.

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 search-listings, which is not limited and reads the same registry.

call-operation

Performs an operation against its Airnode and returns the attested response.

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:

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:

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 or an operation the listing does not declare is refused here without a request going out. Beyond that the Airnode's own answer is passed through, so a 400 for a mistyped parameter and a 502 for an upstream failure reach you with the status and the body. A call is given thirty seconds before it is abandoned.

A priced operation is refused rather than performed, since nothing in this server holds a wallet. The refusal carries the Airnode's x402 requirements and its URL, so a caller that does hold one can settle the call directly. 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.

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.

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. 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.

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 hands back to you rather than settling. 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.