nano-mcp
# nano-mcp
<!-- MCP Registry ownership proof: the official registry verifies a PyPI package by
finding this exact string in the package README (which becomes the PyPI
description). Keep it identical to the server name in server.json.mcpregistry. -->
<!-- mcp-name: io.github.dhyabi2/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).
```bash
# 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:
```bash
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):
```bash
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:
```python
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).
## 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
```bash
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)
```python
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:
```python
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.TDQS
Scored across 7 tools
Most tools target distinct actions: account queries (get_balance/get_history) are separate from the payment flow (quote/quote_usd/verify_payment/pay_and_call). get_address overlaps slightly with the one-time addresses returned by quote tools, and quote vs quote_usd are similar, but the descriptions clarify when each should be used.
The get_balance/get_history/get_address group follows a clear verb_noun pattern, and verify_payment is similarly structured. quote, quote_usd, and pay_and_call deviate a bit from that pattern but are still lowercase, readable, and verb-first.
Seven tools is well-scoped for a Nano payment/call gateway: account lookups, quoting, verification, and execution are covered without redundancy or bloat. Each tool has a clear place in the payment-and-call flow.
The core lifecycle is present: obtain an address, quote a price, pay, verify payment, and invoke the call, with balance/history utilities for account tracking. There is no explicit request-status or cancel/expiry operation, but agents can work around this via re-quoting or verify_payment's status return.