# DeepTally > Solana wallet reporting over a REST API and a hosted MCP endpoint, paid per call with x402 (USDC on Solana). Reporting only, never custody. Quotes are free and need no account. A scan or a read is paid per call inside the request; the receipt an answer carries buys the same read again. An account (an API key or an MCP token) is optional and adds a watchlist and free reads of the wallets it watches. ## Docs - [MCP guide](https://deeptally.io/docs/mcp.md): the whole client-side guide as markdown -- endpoint, authentication, every tool, how a paid tool call settles, and configuration for the common hosts - [Terms of service](https://deeptally.io/docs/terms.md): reporting rather than custody, per-call pricing in USDC, and what happens when a scan fails or runs out of budget - [Privacy policy](https://deeptally.io/docs/privacy.md): what is held and why, including that chain data is public and shared between customers, and that page views are cookieless - [Contact](https://deeptally.io/docs/contact.md): how to reach a person, and the three identifiers that make a support request answerable in one reply - [OpenAPI document](https://deeptally.io/v1/openapi.json): every REST endpoint, its parameters and its responses ## Endpoints Every endpoint below is an entry point: it takes no account and no key. `/mcp` is Streamable HTTP carrying JSON-RPC 2.0, with `Authorization: Bearer lm_...` or nothing at all. A route marked paid answers an unpaid request with HTTP 402 and the price; the OpenAPI document above has the parameters and the response of each one. - [GET /healthz](https://deeptally.io/healthz): free - [GET /health](https://deeptally.io/health): free - [GET /v1/openapi.json](https://deeptally.io/v1/openapi.json): free - [GET /v1/blockhash](https://deeptally.io/v1/blockhash): free - [GET /v1/quote/{address}](https://deeptally.io/v1/quote/{address}): free - [GET /v1/prices/spot](https://deeptally.io/v1/prices/spot): free - [GET /v1/prices/history](https://deeptally.io/v1/prices/history): free - [POST /v1/scans](https://deeptally.io/v1/scans): paid per call (x402, USDC on Solana) - [GET /v1/jobs](https://deeptally.io/v1/jobs): free - [GET /v1/jobs/{id}](https://deeptally.io/v1/jobs/{id}): free - [POST /v1/jobs/{id}/cancel](https://deeptally.io/v1/jobs/{id}/cancel): free - [POST /v1/jobs/{id}/retry](https://deeptally.io/v1/jobs/{id}/retry): free - [POST /v1/events](https://deeptally.io/v1/events): free - [POST /v1/auth/logout](https://deeptally.io/v1/auth/logout): free - [POST /v1/auth/siws/nonce](https://deeptally.io/v1/auth/siws/nonce): free - [POST /v1/auth/siws/verify](https://deeptally.io/v1/auth/siws/verify): free - [GET /auth/login](https://deeptally.io/auth/login): free - [GET /auth/callback](https://deeptally.io/auth/callback): free - [GET /auth/logout](https://deeptally.io/auth/logout): free - [GET /v1/wallets/{address}](https://deeptally.io/v1/wallets/{address}): paid per call (x402, USDC on Solana) - [GET /v1/wallets/{address}/ledger](https://deeptally.io/v1/wallets/{address}/ledger): paid per call (x402, USDC on Solana) - [GET /v1/wallets/{address}/positions](https://deeptally.io/v1/wallets/{address}/positions): paid per call (x402, USDC on Solana) - [GET /v1/wallets/{address}/value-history](https://deeptally.io/v1/wallets/{address}/value-history): paid per call (x402, USDC on Solana) - [GET /v1/wallets/{address}/flows](https://deeptally.io/v1/wallets/{address}/flows): paid per call (x402, USDC on Solana) - [GET /v1/txns/{signature}](https://deeptally.io/v1/txns/{signature}): free - [GET /v1/reports](https://deeptally.io/v1/reports): paid per call (x402, USDC on Solana) - [POST /v1/wallets/{address}/reclassify](https://deeptally.io/v1/wallets/{address}/reclassify): free - [GET /v1/snapshots/{token}](https://deeptally.io/v1/snapshots/{token}): free - [GET /v1/snapshots/{token}/og.png](https://deeptally.io/v1/snapshots/{token}/og.png): free - [ANY /mcp](https://deeptally.io/mcp): free - [GET /llms.txt](https://deeptally.io/llms.txt): free - [GET /llms-full.txt](https://deeptally.io/llms-full.txt): free --- # MCP: connecting an agent The server hosts a [Model Context Protocol](https://modelcontextprotocol.io) 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](https://x402.org) (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". - **Endpoint**: `https://ledger.missionsix.net/mcp` (under `scripts/dev-run.sh`: `http://localhost:9096/mcp`) - **Transport**: Streamable HTTP (`modelcontextprotocol/go-sdk`), JSON-RPC 2.0 over `POST` - **Auth**: `Authorization: Bearer lm_…` (an MCP token) or nothing at all - **Payment**: x402 `exact` scheme, USDC on Solana mainnet, settled by the server's own facilitator ## 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. | | `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: - the receipt of the scan that produced the data (pass it as `receipt`), or of an earlier read of the same wallet; - your account watching the wallet (`add_wallet`, or the portal), when `owner_reads_free` is on; - a fresh payment against the challenge the tool returns (below). 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: - `payment`: the base64 `X-PAYMENT` value an x402 client builds for the challenge (exactly what `x402pay` would have sent as a header); - `quote_id`: the challenge's `extra.quoteId` — for the read tools only; `scan_wallet`'s `quote_id` already is the quote. 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 `POST`s 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.** ```json {"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`. ```json {"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= 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 -challenge '' prints the payment value; or x402pay mcp-call -keyfile -url -tool get_ledger -args '' 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: ```sh $ 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. ```json {"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. ```json {"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. ```json {"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: ```sh $ 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. ### 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: - reads (`get_ledger`, `get_positions`, `get_report`) of the wallet the payment was for, as the `receipt` argument or an `X-Receipt` header, for **30 days** from settlement (`pricing.entitlement_ttl`, `720h` by default) — whether the payment was for a scan or for a read; - one `scan_wallet` of that wallet and scope, when the payment was for a scan and the scan has not been started yet (a second `scan_wallet` with a spent receipt is `entitlement_consumed`; the receipt still reads). 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` `POST`s 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 `POST`s (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](https://code.claude.com/docs/en/mcp) (`--transport http`, `--header`; `--scope user` makes it available in every project, the default `local` in this one): ```sh 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: ```json { "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`](https://github.com/geelen/mcp-remote): ```json { "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](https://cursor.com/docs/context/mcp): a `url` entry is a Streamable HTTP server, and `${env:NAME}` reads the environment. ```json { "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. ```sh 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 |