Skip to main content
Glama

nano-mcp

An MCP server + SDK so any AI agent can hold XNO (Nano) and pay per API call, transacting through the public node rpc.nano.to (no local node, no issuer, no bridge).

Built by an AI agent (Rai). Tests are run against the live rpc.nano.to node.

Public release — this is the clean, gate-2 release of the nano-mcp project. It is a single-commit, secret-scanned snapshot (no git history, no withdrawn draft/x402 P1 duplicate), published so any agent or API consumer can use the nano_mcp and nano_sdk packages. The working copy with the full law-ledger history stays private.

5-minute quickstart

The MCP server is stdio-only and needs one secret: a master secret it uses to derive a one-time payment address per request. Nothing else is required (the default RPC is the public rpc.nano.to node).

# 1. run it straight from a checkout (uv resolves the deps for you)
NANO_PAYMENT_MASTER_SECRET=$(python3 -c "import os;print(os.urandom(32).hex())") uv run python -m nano_mcp.server

# 2. or install the packages and register the server with an MCP client
uv venv .venv && source .venv/bin/activate
uv pip install -e ".[dev]"
python -m pytest -m "not network"        # 472 offline tests collected; 3 journal-plugin modules skip (not in this release)

# 3. installing the package puts the server on PATH as `nano-mcp` -- this is the
#    command an MCP client config starts, and the one the registry entry means
#    by its `stdio` transport
NANO_PAYMENT_MASTER_SECRET=$(python3 -c "import os;print(os.urandom(32).hex())") nano-mcp

nano-mcp starts and lists its tools with no configuration at all, which is what an MCP directory asks of it; the tools that must derive a payment address refuse until NANO_PAYMENT_MASTER_SECRET is set, naming the variable. To see that exchange yourself, exactly as a directory performs it:

python tools/directory_handshake.py -- nano-mcp
# 7 tools: get_address, get_balance, get_history, pay_and_call, quote, quote_usd, verify_payment
# the handshake completed: a directory can start this server and list its tools

Verified public install (pinned to the v0.1.0 tag):

uv pip install "nano-mcp @ git+https://github.com/dhyabi2/nano-mcp-public.git@v0.1.0"
python -c "import nano_mcp, nano_sdk; print('nano_mcp OK')"

Use the SDK to derive a wallet and read a balance:

from nano_sdk import RpcClient, Wallet
from nano_sdk.crypto import derive_account

# seed from your env only — never commit it
account = derive_account(SEED, 0)
print(account.address)

wallet = Wallet(seed=SEED, client=RpcClient())   # reads rpc.nano.to
print(wallet.balance_raw())   # integer raw; nano_str() formats it as XNO

To stand up the x402 facilitator and paid tool surface, see nano_mcp/facilitator.py and nano_mcp/paidtool.py; live tests need NANO_RPC_URL + NANO_RPC_KEY and a funded test wallet (the on-chain send is written but — honestly — not yet faked, see Status).

Related MCP server: Nerve RPC MCP

Why Nano

  • Feeless — no per-transaction fee, so truly per-call (even micro) billing is economic; no batching.

  • Instant — >99.9% of transactions settle in under a second.

  • Green — no mining.

  • No issuer — self-custody; nothing to freeze, no trusted third party to verify the payment.

Repo layout

  • nano_sdk/ — pure-Python SDK: derive a wallet from a seed, read balance/history, and (later blocks) sign + publish sends via rpc.nano.to. Crypto is in nano_sdk/crypto.py.

  • nano_mcp/ — MCP server exposing wallet and pay-per-call tools, plus a self-hostable facilitator (nano_mcp/facilitator.py) exposing the x402 exact-on-nano /supported, /verify, /settle surface (verifies on ≥2 independent RPCs, fails closed).

  • tests/ — pytest; nano_sdk/crypto.py vectors are validated against the live node.

Install / test

uv venv .venv && source .venv/bin/activate
uv pip install -e ".[dev]"
# NANO_RPC_URL + NANO_RPC_KEY must be set for live read/send tests
python -m pytest -m "not network"   # offline tests
python -m pytest                    # includes live rpc.nano.to reads

Sending and receiving from a wallet (SDK)

from nano_sdk import Wallet

w = Wallet(seed=SEED)                              # keeps the account's current representative
w = Wallet(seed=SEED, representative="nano_...")   # every block re-delegates to this one
block_hash, block = w.send("nano_...", amount_raw)

A Nano send and a Nano receive both carry the account's representative, so every block also says who the account delegates its voting weight to.

  • No representative passed (representative=None, the default): a send or a receive keeps the representative the node reports for the account. Only an account with none (unopened, or a blank answer) gets DEFAULT_REPRESENTATIVE.

  • A representative passed or assigned (Wallet(representative=X), or wallet.representative = X later): every block signs X, re-delegating the account to it. Any address counts, DEFAULT_REPRESENTATIVE included. Assigning None goes back to keeping the account's current representative.

  • SessionWallet (buyer SDK) has no representative option: a session send keeps the sub-account's current representative, or the sub-account's own address if it has none.

The RPC node (rpc.nano.to by default) is trusted for the representative just as it already is for the frontier and balance a block is built from. Its answer is checked before any proof-of-work is requested: a representative that is not a string or not a checksum-valid nano_ address raises RpcError, and nothing is signed or broadcast.

Upgrade note: representatives on send and receive

With the stock RpcClient, earlier versions re-delegated the account on every block: RpcClient.account_info did not ask the node for the representative, so the node never sent one, and Wallet.send and Wallet.receive signed Wallet.representative -- with the default field, DEFAULT_REPRESENTATIVE -- while SessionWallet.send signed the sub-account's own address. Now:

  • The meaning of an explicit Wallet(representative=X) changed. The previous release signed info.get("representative") or self.representative, so X was a fallback, used only when the node's answer carried no representative. With the stock RpcClient the node never sent one, so X was in practice signed every time; but a custom ClientLike, an RpcClient subclass or a proxy that did return the representative saw the node's value win over X. Now any non-None Wallet.representative is signed into every send and receive, re-delegating the account to X even when the node reports a different representative. If you passed X meaning "only for opening new accounts", leave representative as None: an account with no representative still gets DEFAULT_REPRESENTATIVE, and every other account keeps its own.

  • Sends and receives keep the account's current representative unless one is passed explicitly. Code that relied on Wallet() re-pointing accounts at DEFAULT_REPRESENTATIVE should pass representative=DEFAULT_REPRESENTATIVE.

  • Wallet.representative is now str | None and defaults to None. It is kept exactly as given, so a wallet built without one reads None there (it used to read DEFAULT_REPRESENTATIVE). Code that read the attribute to learn which representative a block would sign should treat None as "the account's current one".

  • RpcClient.account_info now sends "representative": "true" in the request body, and its result has a representative key (for an opened account). HTTP mocks that match the exact account_info request body need updating.

  • Exceptions changed:

    • An invalid explicit representative (for example "" or "bogus") now raises ValueError on every Wallet.send and Wallet.receive, right after the account_info read and before work_generate is requested or anything is signed or broadcast.

    • A node answer whose representative is not a string, or not a checksum-valid nano_ address, now raises RpcError before work_generate is requested. It used to raise ValueError, after work_generate had already run. Callers that catch ValueError around send or receive should also catch RpcError.

Upgrade notes: payment proofs and the claim store

  • Claims are case-insensitive on the block hash. One payment settles once whatever hex case its paymentProof is spelt in. The node is asked for the hash as sent, and transaction in verify/settle responses echoes it as sent.

  • consumption_key(block_hash, request_id) now returns the hash upper-cased (the request_id is unchanged, and stays case-sensitive). ClaimStore.claim and is_claimed normalise the hash the same way.

  • Upgrade step for a custom claim_store. settle calls its claim() exactly once, with the canonical key (hash upper-cased), the same call as before except for the key. It never calls is_claimed and never passes the key as sent. Keys your store holds from a previous release are in whatever case the payer sent, so before upgrading add the upper-case form of each one that is not already there (keep the old keys), or a payment settled before the upgrade can be settled once more in upper case. Add only if absent: some keys are already upper case, and two old spellings of one payment share one upper-case key, so a plain insert hits a duplicate and stops part way:

    import re
    old = re.compile(r"nano:live ([0-9A-Fa-f]{64})\n? (.*)", re.S)
    for key in list(my_store_keys()):            # every key the store holds
        m = old.fullmatch(key)
        if not m:
            continue
        new = f"nano:live {m[1].upper()} {m[2]}"
        if new != key:
            my_store.claim(new)   # add-if-absent; False only means it is there

    In SQL, use INSERT OR IGNORE (or your database's equivalent) for the new keys.

    The same applies to a ClaimStore subclass that overrides claim() or does not run ClaimStore.__init__ (its own backend): it is treated as a custom store.

  • The built-in ClaimStore migrates an existing claims.db itself. Every open first runs a cheap read-only check for an old-spelling key that lacks its canonical copy; only if one exists does it take the write lock and add the canonical keys (old rows are kept; PRAGMA user_version is not used). This also covers claims a previous release wrote during a rollback. If the file is read-only, or another writer holds the lock, the open still succeeds as long as the directory holding it is writable (on a read-only mount or directory the open fails, as it did before); the store remembers the migration is pending and retries it before its next claim, and while it still cannot run each claim also looks the key up under any older spelling.

  • Settled through the built-in ClaimStore (or a subclass that runs ClaimStore.__init__ and does not override claim()), a proof not spelt in upper case is also stored under the key exactly as sent, so a rollback to the previous release still refuses it. Calling ClaimStore.claim() directly stores the canonical key only.

  • Plan the first open after upgrading. The first open of an old claims.db migrates it inside one write transaction, and the time grows with the number of claims: about 1 s per 100,000 and about 13 s for 1,000,000 in our measurements. Disk use for the claims table roughly doubles (one canonical copy per old row; 203 MB became 401 MB for 1,000,000 claims). While it runs, any other process settling against the same file waits the 5 s busy timeout and then fails with database is locked. Stop other writers for that first open, or expect those errors. Later opens only read (about 0.25 s per 100,000 old rows) and take no write lock.

  • Do not run the previous release and this one against the same claims.db at the same time (for example during a rolling deploy). A claim the previous release writes after this release opened the file can be settled once more through this release in another spelling, until this release next opens the file. Upgrade every writer together.

  • After a rollback, the previous release behaves as it always did: it refuses the spelling a payment was settled in (this release also stores the key as sent), but it still accepts a spelling neither release wrote, e.g. the same hash in another case.

  • A paymentProof with a trailing newline (or any other character outside the 64 hex digits) is refused.

  • PaidToolServer.execute (paid_tool_execute) returns an invalid refusal for a malformed proof instead of raising ValueError.

Address encoding (verified against the node)

PrivK[i] = blake2b-256(seed || uint32be(i)), public key via Ed25519-Blake2b, address = nano_ + fixed-width-52 big-endian base32 of the public key + fixed-width-8 base32 of the little-endian blake2b-40(public_key) checksum.

Status

Built and verified under the Law Ledger (.ledger/): blocks 2–15 done.

  • Block 2: SDK derive + live read (L0, L1).

  • Block 3: SDK send (sign + PoW + publish) with balance + 0.01 XNO/day cap guards (L3).

  • Block 4: MCP pay-per-call server — one-time address per request (L4), exactly-once verify_payment (L5).

  • Block 5: end-to-end probe (L6) + evidence gate that journals a payment as nano_tx only if it comes from an account we do NOT control (L7). ledger probe → 88/100.

  • Block 6: dollar-priced quotes — the exact XNO for a USD price from the median of three independent sources, expiring in ≤30s, pure computation (L8, L9).

  • Block 7: Buyer SDK — owner-signed Ed25519 Mandate + capped per-session sub-accounts. SessionWallet lets an owner delegate limited, expiring spending authority to an autonomous agent: it verifies the owner signature, the session binding, the per-session cap, the 0.01 XNO/day cap and the balance guard before any block is broadcast, so a compromised agent cannot drain the wallet (L10, L11).

  • Block 12: self-hostable x402 exact-on-nano facilitator — /supported, /verify, /settle HTTP surface that verifies every payment proof on at least two independent RPC endpoints (fails closed if any cannot confirm) and settles it with an atomic single-use claim, exactly once (L16, L17). This is the real facilitator the x402 spec's "Reference implementations" section describes.

  • Block 13: the multi-RPC verifier parses the real Nano block_info shape (block_account / contents.type / contents.destination / confirmed:"true") and confirms a real on-chain send on two independent public RPCs (L18).

  • Block 14: the HTTP 402 Resource Server — returns 402 Payment Required with a payment-required header (one-time nano_ payTo + exact amount) and serves the protected result only after the client presents a verified-and-settled payment-signature (L19, L20). The missing HTTP half of the x402 protocol.

  • Block 15: the MCP paidTool wrapper (nano_mcp/paidtool.py) — paid_tool_request issues a one-time nano payTo + exact amount, and paid_tool_execute verifies the proof on two independent RPCs, settles it exactly once, and returns the protected tool result — the same handshake the HTTP server runs, exposed to MCP agents (L21, L22). Closes the last roadmap-stage-1 deliverable.

  • Block 16: the scorecard reads real evidence. The open rail scorecard's --journal path previously accepted only a hand-written JSON array, so its "measured share from nano receipts" could not be computed from the actual evidence store. nano_mcp/journaldb.py is a stdlib, read-only, network-free adapter that reads the real nano-pulse journal DB (kind=nano_tx, written by append_nano_tx), and scorecard build/verify --journal-db <path> feeds it straight into the share computation. scorecard/published.json now reproduces exactly from the real DB (strategy law L5/L6): with no external receipts yet it honestly reads 0% share (L23).

L2 (a live funded on-chain send confirmed via rpc.nano.to) is recorded STUCK: no funded test wallet exists, and the money rules forbid seeking funds. Every real component is exercised end-to-end through a chain stub; the live-funded confirmation leg is written but not faked.

Available Tools

7 tools
get_addressA

Return the one-time Nano payment address for request_id (or mint a fresh).

Inputs: request_id. Returns: result

get_balanceA

Return the raw balance of a nano_ account via rpc.nano.to.

Inputs: account (required). Returns: result

get_historyB

Return the last count on-chain entries for a nano_ account.

Inputs: account (required), count.

pay_and_callC

Agent side: given a quoted request_id, pay its one-time address and call tool after verify. NOTE: broadcast is not executed here in tests (no funded wallet); this returns the target so a host calling the server can send via the SDK wallet (Wallet.send) then verify.

Inputs: tool (required), amount_raw (required), request_id (required).

quoteA

Price a call in Nano. Returns {request_id, address, price_raw, price_nano} where address is the ONE-TIME payment address for this request. Send the exact amount there, then call verify_payment(request_id, amount_raw).

Inputs: price_nano (required), request_id.

quote_usdA

Price a call in dollars. Converts the USD price to the exact XNO amount via the MEDIAN of three independent public price sources and returns {request_id, address, price_raw, price_usd, rate_xno_usd, expires_at}. The quote expires in <=30s; pay the exact price_raw to address before that, then call verify_payment(request_id, price_raw). This is pure computation — nothing is held or converted.

Inputs: price_usd (required), request_id.

verify_paymentA

Service side: approve the call for request_id once an on-chain send of at least amount_raw is confirmed to its one-time address. Approves exactly once; a repeat returns status='spent'.

amount_raw can only make the check STRICTER: the amount actually required is the higher of it and what this server quoted for request_id.

There is deliberately no require_onchain argument here. The service method takes one so in-process tests can run without a node, but over the tool surface the caller is the payer, and a payer that can pass require_onchain=false is approved with tx_hash "simulated" without any payment at all.

Inputs: amount_raw (required), request_id (required).

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    B3.3/5.0

    Scored across 7 tools

    Disambiguation3/5

    The quote/quote_usd pair and get_address partially overlap: quote already returns the one-time address, and both quote tools initiate the same request_id lifecycle, so an agent must read descriptions to pick correctly. pay_and_call also overlaps with the explicit quote → pay → verify_payment flow, though the 'Agent side' vs 'Service side' framing in descriptions helps disambiguate. verify_payment, get_balance, and get_history are clearly distinct.

    Naming Consistency4/5

    Six of seven tools follow a verb_noun or verb_object pattern (verify_payment, get_address, get_balance, get_history, pay_and_call, quote_usd). The lone bare 'quote' and the noun-like quote_usd break the pattern slightly, but overall the set is predictable and readable.

    Tool Count5/5

    Seven tools is well-scoped for a Nano payment-gating server: quoting, address issuance, payment verification, an agent-side convenience wrapper, and two account-inspection utilities. No tool feels gratuitous given the two-sided (payer/service) design.

    Completeness4/5

    Core lifecycle is covered: quote (XNO and USD) → get address → pay → verify, plus balance/history reads, and verify_payment doubles as a status check since repeats return status='spent'. Minor gaps remain—no explicit quote-status/inspection tool that doesn't consume the request, no cancel/expiry reconciliation, and pay_and_call is stubbed without broadcast—but agents can work around these.

    Maintenance

    ActivityActive
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      B
      quality
      D
      maintenance
      Enables AI agents using the Model Context Protocol (MCP) to send Nano cryptocurrency and retrieve account/block information via Nano node RPC.
      4
      16 npm
      11
      MIT
    • F
      license
      Not graded
      quality
      D
      maintenance
      Exposes Nerve blockchain JSON-RPC and REST APIs as MCP tools, enabling AI agents to receive, pay, swap, and query balances with local account management and no gas fees.
      1
      -