MCP: connecting an agent

The server hosts a Model Context Protocol endpoint beside its REST API. An agent that speaks MCP can quote, scan and read Solana wallets through it, and pay for what it reads inside the tool call with x402 (USDC on Solana). This page is the client-side view: the URL, how to authenticate, what the tools are, how a paid call goes, and how to configure the common hosts. The server-side design is docs/DESIGN.md §9 "MCP".

Authentication

Every request to the endpoint is resolved the same way a /v1 request is:

You send You are You can
nothing anonymous quote any wallet, scan and read by paying per call, reuse your receipts
Authorization: Bearer lm_… (MCP token) your account, with the token's scopes everything above, plus list_wallets / add_wallet, and free reads of wallets your account watches
Authorization: Bearer lk_… (API key) the same as an MCP token the same; the two kinds differ only in intent

Mint an MCP token on the portal's Keys page: pick kind mcp, tick the scopes (read for ledgers, positions and reports; scan to start paid scans), create it. The plaintext is shown once, right after creation, and the "Connect an agent" card on that page shows the Claude Code one-liner with it filled in. It is stored only as a hash; revoking it from the same page is immediate. A revoked or unknown bearer is refused with HTTP 401 before any tool runs, never silently downgraded to anonymous.

Anonymous is a first-class mode, not a fallback: an agent with a funded Solana keypair and no account can do everything but manage a watchlist. The ping tool answers with the principal a session resolves to, which is the quickest way to check what a token does.

OAuth discovery is served alongside the MCP token flow above, for a client that would rather run its own OAuth handshake than have you paste a token: GET /.well-known/oauth-protected-resource (RFC 9728), GET /.well-known/oauth-authorization-server (RFC 8414) and POST /register (RFC 7591, dynamic client registration). A bearer the endpoint cannot resolve gets a WWW-Authenticate challenge naming the protected-resource document, so an OAuth-capable client can find its way from a plain 401.

Connect with OAuth

This is what happens when you paste the endpoint URL into Claude.ai or Claude Code and pick "Connect" instead of minting a key yourself:

  1. The client registers itself against POST /register and opens /authorize in a browser.
  2. If you are not already signed in to this account, you land on the normal sign-in page (Google or a Solana wallet) and come straight back to /authorize once you are.
  3. You see a plain consent screen: which client is asking, what it will be able to do (read your watched wallets, reports and positions; start scans on your behalf — never account or billing changes), and which of your signed-in identities it is asking on behalf of. Approve or deny.
  4. Approving sends the client back with a code it exchanges for an access token and a refresh token. The client then calls /mcp exactly as it would with a pasted MCP token — same tools, same scopes (read, scan), same paywall.

Nothing about what the token can do differs from an MCP key you mint by hand: it is the same account, the same two scopes, never admin. The access token expires in an hour; the client refreshes it automatically with the refresh token, which is itself good for 30 days.

Every client you have connected this way, and recent activity on your account's OAuth clients and tokens, is visible on the Keys page.

Tools

Every tool answers a one-line summary as content[0].text (written for a model to read) and the JSON the matching REST endpoint would have sent as structuredContent. A refusal is isError: true with structuredContent: {code, message, …} in the REST error's shape. Tools marked paid are gated by the paywall described in the next section; the rest are free.

Tool Arguments Cost What it does
add_wallet address (the wallet's base58 public key), label? (a short label, at most 128 characters) free, account only Watch a wallet from your account, with an optional label. Watching unlocks free reads of the wallet's ledger, positions and reports for your account (owner reads). Free; needs an account.
create_snapshot address (the wallet's base58 public key), denominate? (usd (default) or sol), payment? (base64 X-PAYMENT for a read of the wallet, when your account cannot read it yet; name its quote in quote_id), quote_id? (the quoteId of the challenge the payment was built for), range? (the value chart's range the link opens on: 30d | 90d | 1y | all), receipt? (receipt token of a payment that covers reads of this wallet) free, account only Mint a public, read-only share link (/s/{token}) to a wallet's report: value chart, allocation, flows, ledger, positions. Your account must be able to read the wallet now (it watches it, or a receipt/payment covers the read); the link never grants more than that and only reads. Optional range (30d|90d|1y|all) and denominate (usd|sol) set the view it opens on. Links expire after the configured TTL unless revoked. Free; needs an account.
expand_graph address (the wallet to expand, a base58 public key), action_id (an idempotency key you choose for this expansion; retrying the same key is not charged twice), depth? (only 1 is supported), direction? (in, out or both (default both)), force? (raise a hub's cap to the server's hub maximum, never beyond), limit? (most counterparties to return, 1-100, ranked by volume), min_usd? (drop edges priced below this many USD; unpriced edges are kept), source? (ledger (a scanned wallet, default) or live (the wallet's recent transactions)), window_sigs? (for source live, how many recent signatures to read, 1-100) paid Follow the money: one hop of a wallet's counterparties as graph nodes and edges, for "what funded this wallet and where did its tokens go". source "ledger" (default) reads a scanned wallet; "live" fetches its most recent window_sigs signatures on the spot, for a wallet nobody has scanned. direction in|out|both (default both); limit 1-100 counterparties by volume (default from the server); min_usd drops priced edges below it. action_id is required: an idempotency key you choose per expansion, so a retry is not charged twice. Hubs (exchanges, programs, wallets with very many counterparties) return only the top counterparties with a hub warning; force raises that cap to the server's hub maximum and no further. Nodes carry cited registry entities and behavioural signals, which are evidence, never a verdict. Rate limited per account (rate_limited, too_many_expansions; retry later). Costs prepaid research credits, taken before the fetch: a live read that makes RPC calls is priced higher than a cached live read or a ledger read; a failed expansion is refunded, a partial live result is charged. Retrying the same action_id with the same arguments is not charged again (cost_atomic 0); the same action_id with different arguments, or after a refunded failure, is action_conflict. Too little credit is insufficient_credit (nothing is fetched or charged). The result carries cost_atomic and balance_atomic. Needs an account. Until the follow_the_money feature is on for you the call fails with not_found.
find_first_funder address (the wallet to look up, a base58 public key), action_id (an idempotency key you choose for this lookup; retrying the same key is not charged twice) paid Find the first inbound SOL transfer into a wallet, for "what funded this wallet". Pages the wallet's signatures back toward its oldest transaction, at most the server's page cap (graph.funder_max_pages pages of 1000), and reads the oldest transactions forward. status found returns first_inbound (sender, signature, slot, block_time, lamports), a node for the sender with any registry entity or your own label, and the label "first inbound transfer within the scanned window": evidence, never a verdict that the sender is a funder (it may be dust, a custodian or a program). status not_found_within_window means the cap or the RPC budget was reached before the wallet's beginning (complete false) or the whole history has no inbound SOL transfer; it never guesses. signatures_scanned and complete say how far the window went. action_id is required: an idempotency key you choose per lookup. Rate limited per account like expand_graph. Costs prepaid research credits, taken before any RPC: a lookup that makes RPC calls is charged the funder price whether or not it finds anything, a repeat served from cache the cached price; a failed lookup is refunded. Retrying the same action_id with the same address is not charged again (cost_atomic 0); the same action_id with a different address, or after a refunded failure, is action_conflict. Too little credit is insufficient_credit (nothing is fetched). Needs an account. Until the follow_the_money feature is on for you the call fails with not_found.
get_flows address (the wallet's base58 public key), from? (inclusive lower bound on block time: RFC 3339 or YYYY-MM-DD), mint? (keep only transfers of this mint (sol for native SOL)), payment? (base64 X-PAYMENT built for the payment_required challenge (x402 exact scheme); name its quote in quote_id), quote_id? (the quoteId of the challenge the payment was built for (structuredContent.x402.accepts[0].extra.quoteId)), receipt? (receipt token of the payment that covers reads of this wallet), to? (inclusive upper bound on block time: RFC 3339 or YYYY-MM-DD), top? (counterparties kept per direction before the rest collapse into other; default 10, 0 keeps all) paid cached read A scanned wallet's transfers aggregated by counterparty and mint as a graph: nodes (the wallet, each counterparty named from your labels, your watchlist as own_wallet, or the program registry; the rest external) and edges (source, target, mint, amount, usd when priced, legs). top (default 10) counterparties per direction are kept, the rest collapse into an other node. Optional from/to and mint. Paid cached read, paid for like get_ledger.
get_job job_id (the job id scan_wallet returned), receipt? (receipt of the payment the job was started on; without it only the job's own account (or an admin) can read it) free Read a scan job by id: status (running, completed, partial, failed, cancelled), progress, RPC cost, and the quote it was bought against. Free, but scoped: the job's own account, an admin key, or the receipt of the payment the scan was started on (the receipt argument); anything else is not_found.
get_ledger address (the wallet's base58 public key), cursor? (next_cursor from the previous page), exclude? (operation types to leave out, e.g. ["airdrop_spam"]), from? (inclusive lower bound on block time: RFC 3339 or YYYY-MM-DD), include_counterparties? (add each leg's counterparties: who paid the wallet or was paid by it (address, direction in|out, kind wallet|program|token_account)), limit? (page size, 1..1000; default 100), min_usd_value? (leave out legs worth less than this many USD), mint? (keep only legs in this mint), operation_type? (keep only this operation type), payment? (base64 X-PAYMENT built for the payment_required challenge (x402 exact scheme); name its quote in quote_id), quote_id? (the quoteId of the challenge the payment was built for (structuredContent.x402.accepts[0].extra.quoteId)), receipt? (receipt token of the payment that covers reads of this wallet), to? (inclusive upper bound on block time: RFC 3339 or YYYY-MM-DD) paid cached read One page of a scanned wallet's ledger (every leg: transfers, swaps, fees, rent, airdrops), newest first, with a next_cursor for the following page. Paid cached read: covered by a receipt from the wallet's scan, by your account watching the wallet, or by a payment against the challenge the tool returns as payment_required (retry with payment set to the base64 X-PAYMENT and quote_id set to the challenge's quoteId; the settled payment's receipt then covers later reads of the wallet).
get_positions address (the wallet's base58 public key), exclude_spam? (leave airdrop_spam legs out of the replay; default true), method? (fifo | lifo | hifo | average; default fifo), payment? (base64 X-PAYMENT built for the payment_required challenge (x402 exact scheme); name its quote in quote_id), quote_id? (the quoteId of the challenge the payment was built for (structuredContent.x402.accepts[0].extra.quoteId)), receipt? (receipt token of the payment that covers reads of this wallet) paid cached read A scanned wallet's positions under a cost-basis method (fifo default; lifo, hifo, average): open lots per mint, realized PnL summary, rent-locked SOL. exclude_spam (default true) leaves airdrop dust out. Paid cached read, paid for like get_ledger.
get_price_history from? (start, RFC 3339 or YYYY-MM-DD; default 30 days before to), granularity? (1d (default) or 1h), mint? (token mint; empty or sol means native SOL), to? (end, RFC 3339 or YYYY-MM-DD; default now) free USD price series of a mint. Free. mint empty or "sol" is native SOL; granularity 1d (default) or 1h; from/to default to the last 30 days. Returns points as [unix seconds, usd] pairs, oldest first, with coverage. Only mints with a configured price source are served (SOL out of the box, stablecoins flat at $1); others are not_found. A large gap is filled by a background job: complete is false, backfill_job_id names it, read again later.
get_prices mints? (token mints to price; empty means native SOL) free Current USD prices. Free. mints is a list of token mints; empty or omitted means native SOL. Each answer carries usd, change_24h_pct (when the source knows it), source (coinbase, jupiter, stablecoin, or none when nothing prices the mint) and at. Prices are cached briefly server-side; a report's meta.sol_usd_at_generation is this SOL price.
get_report wallets (base58 public keys, at most 20), format (json | csv | positions_csv | 8949 | koinly | cointracker | pdf), exclude_spam? (leave airdrop_spam out; default false), from? (inclusive lower bound on block time: RFC 3339 or YYYY-MM-DD), method? (fifo | lifo | hifo | average; default fifo), payment? (base64 X-PAYMENT built for the payment_required challenge (x402 exact scheme; one payment covers the one wallet its quote prices); name its quote in quote_id), quote_id? (the quoteId of the challenge the payment was built for (structuredContent.x402.accepts[0].extra.quoteId)), receipt? (receipt token of the payment that covers reads of these wallets), to? (inclusive upper bound on block time: RFC 3339 or YYYY-MM-DD) paid cached read A report over one or more scanned wallets. format is json (structuredContent holds the report), or a CSV family — csv (ledger), positions_csv, 8949 (IRS Form 8949), koinly, cointracker — returned as text, or as a resource link to GET /v1/reports when larger than 256 KiB or for pdf. Optional from/to (RFC 3339 or YYYY-MM-DD), method (fifo|lifo|hifo|average), exclude_spam. Every wallet is a paid cached read, paid for like get_ledger; one receipt covers every wallet it was paid for.
get_value_history address (the wallet's base58 public key), denominate? (usd (default) or sol; both values are always returned), from? (start, RFC 3339 or YYYY-MM-DD; default 90 days before to), granularity? (1d (default) or 1h), payment? (base64 X-PAYMENT built for the payment_required challenge (x402 exact scheme); name its quote in quote_id), quote_id? (the quoteId of the challenge the payment was built for (structuredContent.x402.accepts[0].extra.quoteId)), receipt? (receipt token of the payment that covers reads of this wallet), to? (end, RFC 3339 or YYYY-MM-DD; default now) paid cached read A scanned wallet's portfolio value at the close of every bucket in a range, in USD and SOL, with the holdings behind each point. granularity 1d (default) or 1h; from/to default to the last 90 days. Mints with no price series are listed in unpriced_mints and left out of the value (coverage_pct says how much of the ledger was priced); the last point may be valued at spot (estimated). Paid cached read, paid for like get_ledger.
list_wallets — free, account only The wallets your account watches, with each one's last scan and ledger size. Free; needs an account (an mcp or api key), not anonymous.
ping — free Answers ok with the principal this session's credential resolves to. Free.
quote_wallet address (the wallet's base58 public key), scope? (preview | full | incremental | refresh | read; default full) free Price a scan of a Solana wallet. Free. Returns the quote (quote_id, tier, price_usd, amount_atomic, expires_at) and the tier table. scope is "full" (whole history, default), "preview" (the most recent signatures only), "incremental" (only what is new since the last scan — the cheap way to keep an already-scanned wallet current, and near-free for a wallet that has not moved), "refresh" (re-lists the whole history and fetches only what is missing) or "read" (what a cached read of an already-scanned wallet costs). A quote expires; scan_wallet needs a live one.
revoke_snapshot token (the share link's token (the part after /s/)) free, account only Revoke one of your share links by token; the page, its reads and its card answer not_found from then on. Free; needs an account.
scan_wallet quote_id (the quote_id from quote_wallet (fixes the wallet and scope)), payment? (base64 X-PAYMENT built for this quote's payment_required challenge (x402 exact scheme)), receipt? (receipt token of an earlier payment whose entitlement covers this scan) paid Start the scan a quote_wallet quote priced. Paid: without an entitlement the tool fails with code payment_required and an x402 challenge in structuredContent.x402 — retry with payment set to the base64 X-PAYMENT built for that challenge (an X-PAYMENT header on the MCP POST works too), or pass the receipt of an earlier payment for this wallet and scope. A settled payment is reported in structuredContent.payment with the receipt to reuse. Returns the running job; poll get_job. The quote fixes the wallet and the scope, so an incremental quote starts an incremental scan. Reads of the wallet are covered by the scan's receipt while it lives.
set_address_label address (the address to name: a base58 public key), label (what to call it, at most 64 characters; empty removes the label), category? (one of the address categories (exchange, own_wallet, external, ...); default external) free, account only Name a counterparty address in your own reports: get_flows and the flow graph show your label and category on that node from then on. category is one of the server's address categories (exchange, own_wallet, external, …; default external), label at most 64 characters, and an empty label removes the name again. The label is your account's alone — no other account sees it, and the wallet's shared chain data is untouched. Free; needs an account.

"Cached read" means the wallet has already been scanned and you are reading what the server holds; the price is a flat per-wallet fee (pricing.cached_read_usd, $0.02 by default). A read is free when it is covered by:

A wallet that has never been scanned cannot be read at any price: quote it, scan it, poll the job, then read.

Paying inside a tool call

A paid tool with no covering credential does not fail; it answers with a challenge. The call is isError: true, structuredContent.code is payment_required, and structuredContent.x402 is the same body the REST endpoint would have sent with HTTP 402: the x402 accepts[] naming the asset, amount, recipient and fee payer, with the quote under extra.quoteId. The client builds an x402 payment for it and calls the same tool again with two extra arguments:

The server verifies the payment, settles it on chain, and answers the call. A success gains structuredContent.payment with settled: true, the settlement transaction, and a receipt: the token that covers later reads of the same wallet without paying again. Presenting the same payment twice never charges twice — the replay answers with its first receipt and replayed: true. A refused payment (wrong amount, stale blockhash, settlement failed) is payment_required again with structuredContent.error naming the reason and a fresh challenge; nothing was charged for it.

The transport also honours an X-PAYMENT (or X-Receipt) header on the HTTP POST that carries the call, for that call only. Prefer the arguments: a session is many POSTs and a header is never remembered across them.

A full exchange

Reading the ledger of a scanned wallet anonymously. The JSON-RPC envelope is what the client library sends; if you use a host like Claude Code you only see the tool result.

1. Call the tool.

{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{
  "name":"get_ledger",
  "arguments":{"address":"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU","limit":50}}}

2. The challenge. isError is true, the text spells the price out, the x402 body is under structuredContent.x402.

{"jsonrpc":"2.0","id":2,"result":{
  "isError":true,
  "content":[{"type":"text","text":"payment_required: Wallet reporting: read of 7xKXtg2C…gAsU. No refunds; a scan that fails on our side re-runs free with its receipt. The price is 0.02 (amount 20000 atomic units of asset EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v) on network solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d, paid to 3qkZ…treasury under the x402 exact scheme with fee payer 49YR…payer; this is quote_id q_01J9X4A7M3. The full challenge is structuredContent.x402. Two ways to pay: (1) retry this tool with payment=<base64 X-PAYMENT built for the challenge in x402> and quote_id=q_01J9X4A7M3 — an x402 client builds that value from structuredContent.x402 (an X-PAYMENT header on the MCP POST is accepted too); (2) without an x402 client, shell out: x402pay mcp-payment -keyfile <solana keypair> -challenge '<structuredContent.x402 as JSON>' prints the payment value; or x402pay mcp-call -keyfile <solana keypair> -url <this server's /mcp> -tool get_ledger -args '<the same arguments as JSON>' does the whole exchange (call, pay, retry) and prints the result. A receipt from an earlier payment for this wallet can be passed as the receipt argument instead of paying again."}],
  "structuredContent":{
    "code":"payment_required",
    "message":"payment required",
    "x402":{
      "x402Version":1,
      "accepts":[{
        "scheme":"exact",
        "network":"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d",
        "asset":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
        "amount":"20000",
        "payTo":"3qkZ…treasury",
        "maxTimeoutSeconds":60,
        "resource":"/v1/wallets/7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU/ledger",
        "description":"Wallet reporting: read of 7xKXtg2C…gAsU. No refunds; a scan that fails on our side re-runs free with its receipt.",
        "extra":{"quoteId":"q_01J9X4A7M3","decimals":"6","feePayer":"49YR…payer"}
      }]
    }}}}

3. Build the payment. With any x402 client library, or with the CLI that ships in this repo (go build ./backend/cmd/x402pay), fed the challenge and a solana-keygen keypair holding USDC:

$ x402pay mcp-payment -keyfile ~/.config/solana/agent.json \
    -challenge '{"x402Version":1,"accepts":[{"scheme":"exact", … }]}'
eyJ4NDAyVmVyc2lvbiI6MSwic2NoZW1lIjoiZXhhY3QiLCJuZXR3b3JrIjoic29sYW5hOjVleWt0…

The value on stdout is the payment: a transaction transferring the amount to payTo, carrying a Memo instruction whose data is the challenge's extra.quoteId, partially signed by your key, awaiting the fee payer's signature. Nothing has moved yet. A client that builds the transaction itself must write that memo (program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr, no accounts, data exactly the quote id): the server refuses a payment without it, or with one naming another quote, as wrong_quote, since the memo is what ties the payment to the quote it was priced for.

4. Call again, with the payment. Same arguments, plus payment and the challenge's quote.

{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{
  "name":"get_ledger",
  "arguments":{
    "address":"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU","limit":50,
    "payment":"eyJ4NDAyVmVyc2lvbiI6MSwic2NoZW1lIjoiZXhhY3QiLCJuZXR3b3JrIjoic29sYW5hOjVleWt0…",
    "quote_id":"q_01J9X4A7M3"}}}

5. The result. The server settled the payment on chain, then served the page. payment carries the settlement and the receipt.

{"jsonrpc":"2.0","id":3,"result":{
  "content":[{"type":"text","text":"50 ledger entries for 7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU; more follow: pass cursor MTcyNTc5…. Payment settled (transaction 5Ux2…sig). Receipt Qm9v…receipt can be reused: pass it as the receipt argument of later tools instead of paying again."}],
  "structuredContent":{
    "entries":[{"signature":"…","block_time":"2026-09-01T10:00:00Z","operation_type":"swap", … }, …],
    "next_cursor":"MTcyNTc5…",
    "payment":{
      "settled":true,
      "transaction":"5Ux2…sig",
      "receipt":"Qm9v…receipt",
      "network":"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d",
      "payer":"AgNt…yourkey"}}}}

6. Reuse the receipt. The next page, the positions, a report over the same wallet: pass the receipt and no payment. No challenge and no payment block: nothing was charged, so there is nothing to report.

{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{
  "name":"get_positions",
  "arguments":{"address":"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
               "method":"fifo","receipt":"Qm9v…receipt"}}}

Doing it in one command

x402pay mcp-call performs the whole exchange — initialize, call, pay the challenge, retry once — and prints the outcome line by line, the receipt among them, then the text and the JSON:

$ x402pay mcp-call -keyfile ~/.config/solana/agent.json \
    -url https://ledger.missionsix.net/mcp \
    -tool get_ledger -args '{"address":"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU","limit":50}'
payer: AgNt…yourkey
tool: get_ledger
paid: challenge answered with the payment argument
payment: settled=true replayed=false network=solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d payer=AgNt…yourkey
transaction: 5Ux2…sig
receipt: Qm9v…receipt
status: ok

50 ledger entries for 7xKX…gAsU; more follow: pass cursor MTcyNTc5…. Payment settled (transaction 5Ux2…sig). Receipt Qm9v…receipt can be reused: …

{"entries":[…],"next_cursor":"MTcyNTc5…","payment":{"settled":true,…}}

-bearer lm_… (or X402PAY_BEARER) adds the token; -H 'X-Receipt: …' presents a receipt as a header on every POST; without -keyfile a challenge is printed rather than paid (exit 1). This is the fallback the challenge text tells a model about: an agent whose host has no x402 support can shell out to it, and the payer: / receipt: lines are made to be captured by a script.

A payment tool that only pays a raw HTTP 402

Some hosts give an agent a payment primitive rather than an x402 client or a shell: something like "fetch this URL, and if it answers 402, sign and retry" against a key the host custodies, with no way to hand it a challenge it did not get from its own fetch. That tool can never pay an MCP call — the /mcp transport is JSON-RPC, the outer POST always answers 200, and the challenge travels inside the tool result (isError + structuredContent.x402), so there is no 402 for it to catch.

Point that tool at the REST route instead of the MCP tool. Every paid MCP tool has one: scan_wallet is POST /v1/scans, get_ledger is GET /v1/wallets/{address}/ledger, get_positions is GET /v1/wallets/{address}/positions, get_value_history is GET /v1/wallets/{address}/value-history, get_flows is GET /v1/wallets/{address}/flows, get_report is GET /v1/reports. GET /llms.txt marks each one paid, and GET /v1/openapi.json has its parameters. Those routes answer an unpaid request with a genuine HTTP 402 and the same x402 challenge body — exactly what a raw-HTTP payment tool is built to catch.

Scanning a wallet that has not been scanned

The same loop, one step earlier: quote_wallet (free) gives a quote_id that fixes the wallet, scope and price for fifteen minutes (pricing.quote_ttl); scan_wallet with that quote_id is challenged for the quote's price; the paid retry (no separate quote_id argument is needed, the quote is the argument) starts the job; get_job (free) is polled until status leaves running; the scan's receipt then covers every read of that wallet. A scan the server fails on its own side re-runs free with the same receipt; there are no refunds otherwise, as the challenge says.

Receipts

A receipt is an opaque token (32 random bytes, URL-safe base64) that names one settled payment. It is a payment credential, not an identity: whoever holds it can use what it bought, so treat it like the token it is. It covers:

A signed-in account can list its own payments, with quote, entitlement and receipt, at GET /v1/payments; an anonymous payer has only the payment block the tool answered with, so keep it.

Rate limits

Caller Limit
anonymous rpc.tenant_budget_rps.anon POSTs per second per client IP (3 by default), burst twice that
with a bearer not limited at the endpoint (per-tenant budgets are a separate concern)

Over the limit is HTTP 429 with a Retry-After in whole seconds and the /v1 error body (rate_limited). One tool call is up to three POSTs (initialize, the notification, the call), so an anonymous client calling twice in quick succession may be refused once; x402pay mcp-call waits out a Retry-After up to five times before giving up. Presenting a token is the way past it.

Cross-site browser requests (those a browser labels with Sec-Fetch-Site) are refused; the endpoint is for MCP clients, not fetch from a web page.

Client configuration

Use the server's real URL in configs; the name (ledger below) is yours to pick. Every host that sends a bearer wants an MCP token from the Keys page; leave the header out to connect anonymously.

Claude Code

The claude mcp add form for a remote Streamable HTTP server with a bearer header, per the Claude Code MCP docs (--transport http, --header; --scope user makes it available in every project, the default local in this one):

claude mcp add --transport http ledger https://ledger.missionsix.net/mcp \
  --header "Authorization: Bearer lm_..."

or as a project-shared .mcp.json, with the token taken from the environment so the file can be committed:

{
  "mcpServers": {
    "ledger": {
      "type": "http",
      "url": "https://ledger.missionsix.net/mcp",
      "headers": { "Authorization": "Bearer ${LEDGER_MCP_TOKEN}" }
    }
  }
}

claude mcp list shows the connection; /mcp inside a session lists the tools. Claude Code has no x402 client of its own, so a paid tool comes back as the challenge and the model follows its instructions: build x402pay (go build -o /usr/local/bin/x402pay ./backend/cmd/x402pay), put a funded keypair in X402PAY_KEYFILE, and it can run x402pay mcp-payment to produce the payment argument, or x402pay mcp-call to do the exchange end to end.

Claude Desktop

Claude Desktop (and claude.ai) connects to remote servers through Settings → Connectors → Add custom connector, which takes the URL and expects the server to run an OAuth flow; this server authenticates with a static bearer, which that dialog cannot send. Use the developer config instead (claude_desktop_config.json, under Settings → Developer → Edit Config), which runs stdio servers only, and bridge to the HTTP endpoint with mcp-remote:

{
  "mcpServers": {
    "ledger": {
      "command": "npx",
      "args": [
        "mcp-remote",
        "https://ledger.missionsix.net/mcp",
        "--transport", "http-only",
        "--header", "Authorization:${LEDGER_AUTH}"
      ],
      "env": { "LEDGER_AUTH": "Bearer lm_..." }
    }
  }
}

The header value goes through env and is written without a space after the colon on purpose: mcp-remote's README notes that Claude Desktop on Windows (and Cursor) mangle spaces inside args, and spaces in env values are fine. Drop the two --header entries and the env block to connect anonymously. Restart Desktop after editing.

Cursor

.cursor/mcp.json in the project (or ~/.cursor/mcp.json globally), per the Cursor MCP docs: a url entry is a Streamable HTTP server, and ${env:NAME} reads the environment.

{
  "mcpServers": {
    "ledger": {
      "url": "https://ledger.missionsix.net/mcp",
      "headers": { "Authorization": "Bearer ${env:LEDGER_MCP_TOKEN}" }
    }
  }
}

Plain JSON-RPC with curl

What every host above does under the hood. Streamable HTTP wants Accept to admit both JSON and SSE; the server assigns a session id on initialize that later requests echo back.

URL=https://ledger.missionsix.net/mcp
AUTH="Authorization: Bearer lm_..."     # or leave the -H out to be anonymous

# 1. initialize — capture the session id from the response headers
SID=$(curl -sS -D - -o /dev/null "$URL" -H "$AUTH" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \
  | awk 'tolower($1)=="mcp-session-id:" {print $2}' | tr -d '\r')

# 2. the initialized notification (no id, no reply)
curl -sS "$URL" -H "$AUTH" -H "Mcp-Session-Id: $SID" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'

# 3. list the tools
curl -sS "$URL" -H "$AUTH" -H "Mcp-Session-Id: $SID" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

# 4. call one
curl -sS "$URL" -H "$AUTH" -H "Mcp-Session-Id: $SID" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"quote_wallet","arguments":{"address":"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU","scope":"preview"}}}'

Responses arrive as application/json or as a text/event-stream with one data: line holding the same JSON-RPC message; read either. curl -X DELETE "$URL" -H "Mcp-Session-Id: $SID" ends the session; an abandoned one is reclaimed on its own.

Errors

Refusals share the REST API's codes; the ones an agent meets most:

structuredContent.code Meaning
payment_required pay the challenge in x402, or pass a covering receipt; error names why a presented payment was refused
unauthenticated the tool needs an account (list_wallets, add_wallet): connect with a token
forbidden the token lacks the scope (scan for scan_wallet, read for reads)
not_found the wallet has never been scanned, or no such job / quote
quote_not_found / quote_expired quote it again; a quote lives fifteen minutes
entitlement_consumed this receipt's scan already ran; the receipt still reads
snapshot_limit your account holds as many live share links as it may; revoke_snapshot one first
job_already_running a scan of that wallet is already running; job names it — poll get_job
rate_limited HTTP 429 before any tool runs; wait Retry-After or present a token