@siriusprotocol/mcp
v0.3.2
Published
MCP server that gives an LLM agent a funded, spending-limited, private Sirius account — balance, private transfers, x402 paid fetch, quotes and swaps, over stdio
Maintainers
Readme
@siriusprotocol/mcp
A funded, spending-limited, private payment account for your agent — from one block of MCP config.
Point any MCP client at this server and the model gets seven tools: check its balance, pay another account privately, fetch a URL that charges for itself over x402, price and make swaps, and read back exactly what it has spent. No payment code, no key handling, no wallet SDK.
The account is a Sirius agent sub-account: an ordinary Sirius account with a spending policy its parent attached to it — a per-payment ceiling, a rolling 24-hour notional ceiling, and an allowed-asset set. Those limits are enforced by the rail before a payment is admitted, so the model's good behaviour is not what stands between you and your balance.
⚠️ Pin ^0.2.0. Do not use @siriusprotocol/[email protected].
0.1.0 was published 2026-07-27 and is deprecated on npm as of 2026-08-03.
It has a defect that costs money.
In 0.1.0, sirius_pay returns a hardcoded paid: true the moment the
sequencer admits the transaction to its mempool. Admission is not application:
the rail decides later whether to apply a transaction and can refuse it
permanently, and in that build there is no wait, no receipt poll and no drop
path. The string waitForApply does not appear anywhere in its dist. So an
agent running 0.1.0 can be told a payment succeeded when the payer's balance
never moved and the recipient was never paid. This is the same misreading of the
same ack that released $45 of USDC out of /api/withdraw/onchain on
2026-08-03.
0.2.0 waits for a terminal receipt and reports outcome + terminal instead
of a boolean, so a refusal is an error, an applied payment is a fact, and a
timeout is unknown rather than a silent false that invites a double payment.
A bare npx -y @siriusprotocol/mcp resolves to latest, which is 0.2.0, but
pin it anyway. You can verify any version before trusting it:
npm view @siriusprotocol/mcp version # expect 0.2.0 or later
npm pack @siriusprotocol/mcp && tar xzf siriusprotocol-mcp-*.tgz \
&& grep -c waitForApply package/dist/rail.js # expect a non-zero countClaude Desktop
Copy this into claude_desktop_config.json
(macOS: ~/Library/Application Support/Claude/claude_desktop_config.json,
Windows: %APPDATA%\Claude\claude_desktop_config.json), then restart Claude.
Nothing to clone and nothing to build.
{
"mcpServers": {
"sirius": {
"command": "npx",
"args": ["-y", "@siriusprotocol/mcp@^0.2.0"],
"env": {
"SIRIUS_API_BASE": "https://demo.siriusprotocol.xyz/txapi",
"SIRIUS_AGENT_INDEX": "118",
"SIRIUS_AGENT_ACCOUNT_ID": "<the agent's 64-hex account_id>",
"SIRIUS_AGENT_KEY": "<the agent's 64-hex Ed25519 seed>",
"SIRIUS_MCP_MAX_PAYMENT_USD": "1.00"
}
}
}
}That is the whole integration. Ask Claude "what's my Sirius balance?" to confirm it is wired up.
Cursor (~/.cursor/mcp.json), Windsurf, Zed, Cline and anything
else that speaks MCP over stdio take the same block: command, args, env.
From a checkout instead
git clone <this repo> && cd sirius-main/packages/x402 && npm install && npm run build
cd ../mcp && npm install && npm run buildThen swap the command/args above for the built entry point, keeping the same
env:
{ "command": "node", "args": ["/absolute/path/to/sirius-main/packages/mcp/dist/bin.js"] }Configuration
| variable | required | what it does |
| --- | --- | --- |
| SIRIUS_API_BASE | yes | Base URL of the Sirius services-api. |
| SIRIUS_AGENT_INDEX | yes | The agent sub-account's L2 tree index. |
| SIRIUS_AGENT_KEY | yes | The agent's own 32-byte Ed25519 seed, hex. Never a parent or operator key. |
| SIRIUS_AGENT_ACCOUNT_ID | no | The agent's account_id. Checked against the key at startup — set it, and a mismatched pair fails immediately instead of at the first rejected signature. |
| SIRIUS_MCP_MAX_PAYMENT_USD | no | A client-side per-payment ceiling in dollars, checked before anything is signed. Can only tighten the rail's limits. |
| SIRIUS_API_TOKEN | no | Bearer token, if the operator gates reads or writes. |
| SIRIUS_NETWORK | yes, unless SIRIUS_CHAIN_ID is set | x402 network label, and the Solana cluster every signature is bound to. The label still defaults to solana-devnet; the signing domain does not — see below. |
| SIRIUS_CHAIN_ID | only off Solana | "<namespace>:<reference>", e.g. eip155:56 for BNB Chain. Outranks SIRIUS_NETWORK for signing. |
| SIRIUS_ALLOW_MAINNET | only for mainnet | Must be 1 before SIRIUS_NETWORK may name a mainnet cluster. |
| SIRIUS_MCP_APPLY_TIMEOUT_MS | no | How long a spend waits for a terminal receipt before reporting unknown. Default 30000. A longer wait buys fewer unknown outcomes, never a different one. |
| SIRIUS_MCP_APPLY_POLL_MS | no | Receipt poll interval inside that wait. Default 750. |
The server refuses to start — no tools registered, exit code 1, every problem
listed at once — if the API base, the agent index or the agent key is missing or
malformed, if SIRIUS_MCP_MAX_PAYMENT_USD does not parse, if the network is a
mainnet cluster without SIRIUS_ALLOW_MAINNET=1, or if neither
SIRIUS_NETWORK nor SIRIUS_CHAIN_ID names a chain. There is no degraded
mode.
That last one is new in 2026-08-07 and it is a deliberate tightening.
SIRIUS_NETWORK has a devnet default for the advertised label, and until now
that default also reached the signing domain — a server that named no
network signed Solana-devnet bytes off the back of it. A default is the one
thing a signing path must not have: a signature that names no chain is valid on
every chain. Adding SIRIUS_NETWORK=solana-devnet to an existing config
restores exactly today's behaviour, explicitly.
The tools
| tool | arguments | what it does |
| --- | --- | --- |
| sirius_balance | — | Balance, synthetic holdings, and remaining daily limit. |
| sirius_pay | to_account_id, amount_usd, memo? | Private transfer to a Sirius account id. |
| sirius_pay_url | url, method?, headers?, body?, max_payment_usd? | Fetch a URL; if it answers HTTP 402, pay it over x402 and return the paid response. |
| sirius_quote | from_asset, to_asset, amount? | Executable price, spread, fee and estimated output for a swap. |
| sirius_trade | from_asset, to_asset, amount | Private swap between sssd and a synthetic. |
| sirius_limits | — | The configured limits, allowed assets, and current-period spend. |
| sirius_activity | limit? | What this server has spent or been refused, plus the rail's own period total. |
Amounts are decimal strings of whole units — "0.25", "12" — never base
units and never floats. Assets are sssd (the rail's USD unit), sbtc, seth,
ssol, shype; btc, 1, usd and friends resolve too.
There is no fund tool and no withdraw tool. That is deliberate.
An agent using this server can spend and swap what it has been given, and nothing else. It cannot top itself up and it cannot move money off the rail. Both directions are out of band, and both are the parent's job.
Funding is out of band because this server cannot do it. An account is funded by a USDC deposit on Solana, which needs a Solana wallet and SOL for fees. This server holds exactly one secret: the agent's L2 Ed25519 key. It has no Solana keypair, and giving it one would replace "a key with a spending limit" with "a key that owns a wallet" — the entire safety story of the package. To fund an agent, see Setting up the agent account; step 2 is the deposit, and an account gets its tree index from it.
Withdrawal is omitted on purpose, and it is the sharper of the two. A withdraw tool sends money to an arbitrary external Solana address. Every other tool here moves value within the rail under a policy the parent set; a withdrawal leaves the system entirely, and the blast radius of a confused or prompt-injected model is then the whole balance rather than one capped payment. Two further reasons not to ship it today:
- The operator-facilitated withdraw path creates an exit entitlement it then pays out against — a double-claim recorded as live on 2026-08-03. Exposing a known-defective money-exit path to an autonomous agent is not a feature.
- The rail charges a withdrawal against the same budget as a payment, so a
daily_notional_usdsized for trading turnover would also authorise exfiltrating that much per day. The two numbers want to be different, and the policy has only one.
To move funds out, the parent withdraws, with the parent's key, through the
dashboard or POST /api/withdraw/onchain. If your agent needs to request a
withdrawal, have it report the amount to a human — that is a message, not a
transaction.
sirius_pay_url — the interesting one
user: summarise https://api.example.com/v1/inference?q=... for me
model: [sirius_pay_url] → 402 Payment Required, $0.02, sirius-private
→ pays privately, retries, returns the body
→ "balance $249.98, $498.48 left of today's $500"The model writes no payment code. The 402 is parsed, the offer selected, a Sirius
transfer signed with the agent's key, and the request replayed with an
X-PAYMENT header — all inside one tool call. If the URL does not ask for
payment, it is an ordinary fetch and nothing is spent.
Safety
This is an LLM moving real money. The design assumes it will occasionally try to move the wrong amount to the wrong place.
1. Agent accounts only. The server refuses to start without an agent index and its key, and at first use it verifies against the rail that (a) the key really owns that index and (b) the index carries a live agent registration. An index with no registration is the shape of a parent or operator account, so spending is blocked and every tool says why. A revoked registration blocks spending too.
2. Every spend shows its consequence. sirius_pay, sirius_pay_url and
sirius_trade return the resulting balance and the remaining daily limit in the
same response. A model that cannot see the effect of a payment cannot reason
about the next one.
3. A client-side ceiling, checked before the network.
SIRIUS_MCP_MAX_PAYMENT_USD is enforced in-process, before anything is signed
and before any Sirius endpoint is called. For sirius_pay_url the effective cap
is the tightest of the per-call max_payment_usd, the configured ceiling, and
the rail's own per-payment limit — so a 402 the rail would refuse is never
signed at all. This is defence in depth, not the limit of record.
4. The tools say the limits are final. Every spending tool's description states that limits are set by the account's parent, enforced by the rail, and cannot be raised or bypassed from this server. A model told that reports a refusal instead of retrying it in a loop.
5. Devnet by default. Mainnet needs SIRIUS_ALLOW_MAINNET=1. A copy-pasted
config cannot start moving real value because one URL changed.
6. The key never leaves. It is loaded once, used to sign, and never logged, returned, or included in any tool output. Diagnostics go to stderr (stdout is the MCP transport) and name the account by a truncated id.
7. A spend is reported on APPLICATION, not on admission — and "unknown" is its
own answer. POST /api/tx/* returns on mempool admission, which is compatible
with the payment never happening. Every spend here polls the receipt to a
terminal state first. The result is a three-valued outcome, never a boolean:
| outcome | terminal | meaning |
| --- | --- | --- |
| applied | true | the balance moved |
| (a drop) | — | raised as a tool ERROR with the rail's own reason; nothing moved |
| unknown | false | signed and sent, no verdict yet. It may still apply. |
unknown is deliberately not reachable as a falsy success flag. A caller that
mapped paid: false to "the payment failed" would be right for a refusal and
catastrophically wrong for a timeout — the obvious response to a failure is to
send it again, and the first one may still land. sirius_activity keeps the two
apart too: session_total_applied_usd is money that moved,
session_total_unresolved_usd is money that may have, and they must not be
added.
8. The dollar/share unit alias is checked against the rail, not assumed.
Every USD figure in this surface — the amounts the tools accept, the ceiling,
balance_usd, and the cap handed to x402 — rests on one base unit of asset 0
being one micro-dollar. That is true only while r_global_raw == 1e9 (par). The
preflight reads the live rate and blocks spending if it has moved, because
off par those figures are share counts labelled as dollars. Read-only tools keep
working, so you can see the rate and diagnose it. (The rail's own limit checker
makes the same assumption, which is why this asserts rather than quietly
converting — a client that compensated alone would disagree with the thing that
actually admits payments.)
How it reads the spend nonce (and why it works on a private rail)
Signing a spend needs the account's current spend nonce, and a correctly
configured rail does not publish one: GET /api/account/:index only carries
nonce under SIRIUS_PUBLIC_ACCOUNT_READ=1, which exposes every account's
balance sheet to anyone who can reach the API. That flag should stay off.
So the server reads the nonce from whichever source has it:
GET /api/account/:index— one unauthenticated round trip. Used when the deployment does publish a nonce.- Otherwise the owner-authenticated read:
GET /api/account/:index/state/challengereturns a single-use nonce bound to(index, state_version); the server signs it with the agent key it already holds;POST /api/account/:index/statereturns the account's confidential state. Nothing becomes public — a caller who cannot sign for the index learns nothing. RequiresSIRIUS_REAL_PRIVACY=1on the deployment (andSIRIUS_API_TOKENhere, if account reads are gated).
The owner-read challenge is domain-separated (SIRIUS_OWNER_READ_V1) from the
tx-signing tag, so that signature can never be lifted into a spend
authorisation. If neither path yields a nonce, spending fails with both reasons
and nothing is signed.
On top of either read the server keeps its own counter, advanced only on an accepted submission, so two payments in the same epoch do not reuse a nonce.
What the limits actually are
Read crates/services-api/src/routes/agent.rs before repeating any claim about
them. In short: they are operator-enforced policy, applied by the API before
a payment enters the mempool. They are not part of the ZK state transition, no
circuit constrains them, and nothing on Solana checks them. They add no new trust
assumption — the operator already sequences every transaction — but do not
describe them as "trustless" or "enforced by the circuit", because they are
neither.
The same goes for sirius_activity. Its session_entries are this server's own
record of what it did in this process: not durable across restarts, and blind to
spends made with the same key from anywhere else. rail_period_total is the
operator's figure and does cover those. The complete per-payment record the rail
keeps is disclosed only to the account's parent, authenticated by the
parent's key — which this server deliberately does not hold.
Setting up the agent account
The account must exist and be registered before this server is useful.
# 1. Generate a keypair. account_id = the Ed25519 public key.
# (~4 in 5 random keys fail the BN254 canonicality check; loop until one passes.)
# 2. Fund it — a USDC deposit on Solana, or POST /api/tx/deposit on devnet.
# An account gets its tree index on its first deposit.
curl -s "$SIRIUS_API_BASE/api/account/by-id/<agent_account_id_hex>"
# -> { "exists": true, "account_index": 118 }
# 3. Register it under a parent, with the policy. The PARENT signs this.
curl -s -X POST "$SIRIUS_API_BASE/api/agent/create" \
-H "Authorization: Bearer $SIRIUS_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"parent_index": 7,
"agent_pubkey": "<agent_account_id_hex>",
"limits": {
"max_payment_usd": 1,
"daily_notional_usd": 25,
"allowed_assets": [0]
}
}'
# 4. Check it.
curl -s "$SIRIUS_API_BASE/api/agent/118" -H "Authorization: Bearer $SIRIUS_ADMIN_TOKEN"Then put 118 and the agent's key into the MCP config above. The parent can stop
the agent at any time with POST /api/agent/revoke; revocation is terminal and
takes effect on the very next payment.
A note on sizing daily_notional_usd: a swap charges its full input
notional, not its net cost, and a withdrawal charges the same budget as a
payment. Size the limit for turnover.
Embedding it
import { createSiriusMcpServer, loadConfig } from "@siriusprotocol/mcp";
const { server, rail, ledger } = createSiriusMcpServer({ config: loadConfig() });
await server.connect(myTransport);SiriusRail is also usable on its own — it wraps the account, agent-registry,
quote and spend endpoints with the preflight and nonce tracking described above.
Development
@siriusprotocol/x402 is declared as ^0.2.0 and is on npm, so a plain
npm install works. In this repo you usually want the sibling directory's build
output instead, so that a change to the SDK is visible here without a release:
(cd ../x402 && npm install && npm run build)
npm install
npm install ../x402 --no-save # local link; the declared dep stays ^0.2.0
npm run build # tsc -> dist/, ESM, with the bin marked executable
npm test # vitest, mocked fetch, no network
npm run typecheck # tsc --noEmit
npm run tools # spawn the built binary and list its tools over stdionpm run tools is the check the unit tests cannot make: it execs dist/bin.js
exactly as an MCP client does, completes the handshake over stdio, and asserts
the seven tools come back. A stray console.log on stdout passes every unit test
and fails there.
Related
packages/x402— the x402 SDK this package builds on (siriusFetch,SiriusPayer, the canonical Transfer encoding). Reused, never re-derived.crates/services-api/src/routes/agent.rs— agent sub-accounts, the limits, and the trust model.crates/services-api/src/routes/x402.rs— the facilitator.
MIT.
