Appearance
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/mcpIt 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. call-operation is the one tool that reaches outside the Hub, so it is the one with openWorldHint set. It is also the one tool not marked read-only, since an operation such as dRPC's jsonRpc forwards any method you give it, a transaction broadcast included.
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, its tags, 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.
| Parameter | Type | Default | Notes |
|---|---|---|---|
query | string | "" | One to three concrete terms. An empty string returns the whole catalogue. |
limit | integer | 5 | Between 1 and 10. How many listings a non-empty query returns, ignored for "". |
Explore and MCP search use the same word matching over listing titles, slugs, operators, descriptions, tags and operation names/descriptions. Operation names also match as separate words. A small curated alias set includes bitcoin/BTC and ethereum/ETH for CoinGecko. Simple plural matches such as stock → stocks are supported, and the last term also matches as the start of a word, so weath finds weather. Earlier terms must match whole words, so package vulnerabilities does not find packaged. 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. Listings matching more query terms rank first, and among those matching as many, the ones tagged with a query term rank first. A result need not match every term or fulfil the complete request. Inspect its operations before calling. No matches means no keyword matches, not proof that the capability is absent: try another subject term, browse with an empty query, or use resolve-intent for a natural-language request.
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.
Tags: stocks, forex, commodities, bonds, markets.
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.
The provider's own API, signed: https://airnode-tickerlayer.fly.dev/apiThe last line is the listing's passthroughUrl, the provider's own API.
The structuredContent holds the same listings under listings, with every parameter of every operation rather than the first six. An operation that costs money carries priceUsd, the USDC it costs per call. A free operation has no priceUsd:
json
{
"listings": [
{
"slug": "tickerlayer",
"title": "TickerLayer",
"description": "Stocks, forex, crypto, commodities, indices, ETFs and sovereign bond yields, from one market API.",
"tags": ["stocks", "forex", "commodities", "bonds", "markets"],
"firstParty": false,
"passthroughUrl": "https://airnode-tickerlayer.fly.dev/api",
"operations": [
{
"id": "quote",
"description": "The latest bid and ask for one instrument.",
"parameters": [
{ "name": "assetClass", "type": "string", "required": true },
{ "name": "symbol", "type": "string", "required": true }
]
}
]
}
]
}An empty query gives every listing, and each operation has only id and priceUsd, since the whole catalogue with its parameters is too large to read in one result. A query that matches nothing gives an empty listings.
get-listing-details
The full document for one or more listings: every operation, its parameters with their descriptions, what a call costs, the same call in the provider's own form under the listing's passthrough, 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.
| Parameter | Type | Required | Notes |
|---|---|---|---|
slugs | array of string | yes | One 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.
The structuredContent has listings and missing. Each listing carries its tags, its address, its passthroughUrl, its payment network and asset when it prices an operation, and its operations as the listing's GET / describes them: each parameter with its description, location and JSON schema, the priceUsd, and the route under the passthrough. missing names the slugs that match no listing.
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.
| Parameter | Type | Required | Notes |
|---|---|---|---|
intent | string | yes | What 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 search-listings, which is not limited and reads 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.
| Parameter | Type | Default | Notes |
|---|---|---|---|
listing | string | required | The slug, as search-listings gives it. |
operation | string | required | The operation id, exactly as the listing document declares it. |
parameters | object | {} | The parameters for that operation. |
responseProjection | object | none | The 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 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.
| Parameter | Type | Required | Notes |
|---|---|---|---|
attestation | object | yes | The whole body call-operation returned. |
listing | string | yes | The slug you called. |
operation | string | no | The operation you called. Give it with parameters. |
parameters | object | no | The parameters you sent. Give it with operation. |
responseProjection | object | no | The projection you sent, if you sent one. |
passthrough | object | no | The 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-listing-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 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.