Connect your AI assistant to Varnir
What this is (and isn't)
@varnir/agent-server lets your own AI assistant — Claude, Gemini, ChatGPT,
anything that speaks MCP or plain HTTP — call Varnir as a set of tools: read
balances, place and fill orders, send funds. You run the server, on a host you
control. Varnir does not host it and never holds the key it signs with.
It takes one credential, a policy-scoped Varnir agent key, and serves it over three surfaces: MCP over stdio, which is how Claude Code, Claude Desktop and Gemini CLI attach to a local process; MCP over Streamable HTTP, for a client like ChatGPT that needs a URL instead; and a plain REST API, off by default. All three reach the same tools, signed by the same key, under the same bound.
Self-hosting is required, not a stopgap: nobody but you can sign, because nobody but you holds the key, and Varnir never holds it either. That has a real cost — a process to keep running, a host to keep patched, and a key whose safety is your host's safety. The policy on the key (below) limits what a stolen key could do; it does not make host security someone else's problem.
This is not a Varnir-hosted agent and not a new way for Varnir to act on your behalf. It is a bridge from your assistant to your own funds, and what the assistant may do with them is set by a policy written into the key on the ledger — not by anything in this server's code.
Why not just a REST credential
The obvious alternative is an offledger key with
scopes and a spend limit recorded next to
it. That is not sufficient, for a structural reason: an offledger key is
refused by the money contracts outright and cannot sign a ledger transfer,
whatever scopes it carries. A signing key's authority is decided by the
contracts that check it at consensus time, not by whichever server relays the
call — so a spend limit written into a server is only as good as that
server's code, and holds only until the server is compromised, misconfigured,
or wrong.
An on-ledger spend policy
attached to the key is the alternative this server uses.
Varnir.CryptoTransfer and Varnir.Swap enforce it against the signing key
itself, so an assistant that is compromised or simply mistaken — a bad
prompt, a bad tool call, a poisoned result it trusted — cannot sign past the
limit. Neither can this server, nor anyone who steals the key. The server's
own boot-time check (below) is defence in depth around that bound; it is not
the bound itself.
The other half of the design is where the key sits: on your host, in a process you run, where nobody else can sign with it. Enforcement lives where it cannot be bypassed, and the key lives where nobody but you can reach it.
How it works
Four pieces:
- The key.
VARNIR_PRIVATE_KEYis a single Varnir signing key. It is not a REST-onlyoffledgerkey, and it is not your identity's owner key — see Before you start. - The policy. That key carries an on-ledger
SpendPolicy: per-transaction ceilings, a rolling-period cap, a recipient allowlist, an asset allowlist, and a co-sign threshold.Varnir.CryptoTransferandVarnir.Swapread and enforce it — not this server. - The guard. At boot, the server reads its own key back off the ledger and classifies what it actually is before serving any write. Depending on what it finds, it starts in one of three modes — see Before you start for exactly which key shapes land in which mode.
- The three surfaces. MCP over stdio (
VARNIR_MCP_STDIO, on by default), MCP over Streamable HTTP (VARNIR_MCP_HTTP, off by default, servingPOST /mcp), and a REST API (VARNIR_HTTP_API, off by default). All three expose the same underlying operations; see The tools and The REST API.
The fast way: the Connect your AI wizard
The scanner has a guided version of everything below it: Connect your AI, on the wallet page directly under the keys card — scanner.varnir.site/wallet. It renders once your keypair has loaded, so it's there only for you, signed in as the identity's owner — nobody looking at your wallet page without your key sees it.
Four steps, in the same order this guide covers them:
- Where will it run — on your machine (MCP over stdio) or in your own cloud (MCP over HTTP). This decides what step 4 hands you.
- Set the spending policy — the same per-asset editor the keys card uses: per-transaction ceiling, rolling-period cap, asset allowlist, co-sign threshold. See What bounds the agent for what these actually enforce; a field left blank means no limit on that dimension, which is a valid choice, not a mistake.
- Mint the key — a
proposerkey, Falcon-512, generated in your browser. The private half is shown once, immediately, and never again. Copy it before you move on. - Get your setup — for a local run, a ready-to-paste config for Claude Code, Claude Desktop, or Gemini CLI, with your identity and network already filled in. For a cloud run, deploy buttons into your own account.
The local configs reference the key through an environment variable,
VARNIR_PRIVATE_KEY, rather than putting it in the file itself. Claude Code
and Gemini CLI read that straight from the shell you launch them from. Claude
Desktop can't — its config is static JSON with no shell expansion — so the
wizard points it at a small wrapper script that reads the variable from the
process environment at launch; the JSON never carries the key. Either way,
the key doesn't end up in a file that syncs to iCloud or another cloud
backup.
The cloud step gives you no config to download — the key belongs in the provider's own authenticated console, typed in by hand during deployment (Google's Cloud Shell prompt, Azure's masked portal field), not in a file this page hands back to you.
Deploying to your own cloud
Deployment templates cover four cloud targets, built on @varnir/agent-server
from npm. Costs, stated plainly:
- GCP Cloud Run and Azure Container Apps — free while idle. Both scale to zero, and both free tiers are a standing grant, not trial credits that run out.
- AWS (ECS Fargate) — the load balancer alone runs roughly £16/month, whether or not it ever gets a request. Traffic doesn't move that number; existing is enough.
- Huawei Cloud — no confirmed one-click path yet. Check Huawei's own calculator before deploying there by hand.
Deploying into your own cloud account is still self-hosting: it's your account, your key, and Varnir still never holds it. It saves you babysitting a process on a machine you keep turned on — it is not a hosted version of this feature, and Varnir holds nothing on your behalf either way.
Before you start
The manual path from here on: the wizard above mints the key and hands you a config in one pass; what follows is the same ground covered by hand, for anyone scripting this, running a client the wizard doesn't cover yet, or who wants to see each step separately.
Mint a policy-scoped agent key in the scanner's keys card, with the spend
policy you want your assistant bound by, before you put anything in
VARNIR_PRIVATE_KEY.
The keys card lives on the scanner's wallet page,
scanner.varnir.site/wallet. Attach a
proposer or dual role key with a policy — see Keys for the
full model and Managing keys
for the addKey call that creates one.
At boot, this server reads its own key back off the ledger and checks what it actually is before allowing any write. Depending on what it finds:
What VARNIR_PRIVATE_KEY turns out to be | Server starts | Why |
|---|---|---|
| The identity's owner key | Read-only | No spend policy exists to bound it — an assistant holding it would have unbounded authority. |
| A key with no spend policy attached | Read-only | Nothing would bound what a connected assistant could spend. |
A readonly key | Read-only | The money contracts refuse this role write access regardless of any policy attached to it. |
An offledger key | Read-only | Same as above — a policy on an offledger key is inert; the contracts refuse the role itself. |
A verifier key | Read-only | The contract refuses it outright ("A verifier key cannot raise transfers"), regardless of policy. |
A key not attached to VARNIR_IDENTITY at all | Refuses to boot | No write could ever succeed, so it fails loudly at startup instead of on the first call. |
A proposer or dual key with a policy | Writes | This is the shape the server is designed for. |
In every read-only case the server still starts and serves reads — it simply
refuses every write tool and every write REST method before the request ever
reaches the ledger, with a message naming the actual reason. There is an
escape hatch, VARNIR_ALLOW_UNSCOPED_KEY=i-understand, that lets the owner
key or a policy-less key sign anyway. It exists for operators who understand
they are removing the only on-ledger bound on what a connected assistant can
spend — not as a way to make the read-only message go away.
Once you have the key, set:
VARNIR_PRIVATE_KEY=... # the policy-scoped agent key
VARNIR_IDENTITY=... # the Varnir identity it belongs to
VARNIR_NETWORK=testnet # the network to operate on
Then build and run the server from the repo root:
pnpm install
pnpm --filter @varnir/agent-server build
node apps/agent-server/dist/index.js
@varnir/agent-server is also published to npm, so a self-hoster who doesn't
want a checkout of this monorepo can skip the build step entirely:
npm install -g @varnir/agent-server
VARNIR_PRIVATE_KEY=... VARNIR_IDENTITY=... VARNIR_NETWORK=testnet varnir-agent-server
Same server either way — the recipes below just need
varnir-agent-server in place of node /absolute/path/to/.../dist/index.js
if you installed it this way. @varnir/chain-client, the SDK this server and
the scanner are both built on, is public on npm too, for anyone integrating
against Varnir directly instead of through this server.
The server logs which mode it started in (writes, read-only, or exits
with refuse) before it serves anything.
Connect your assistant
By default the server speaks MCP over stdio, which is how a client that can
launch a local process connects. Replace
/absolute/path/to/apps/agent-server/dist/index.js with the real path to the
built entry point on your machine — the package also installs a
varnir-agent-server binary if you install it globally instead of pointing
at dist/index.js directly.
Claude Code
claude mcp add --transport stdio varnir \
--env VARNIR_PRIVATE_KEY=... --env VARNIR_IDENTITY=... --env VARNIR_NETWORK=testnet \
-- node /absolute/path/to/apps/agent-server/dist/index.js
Claude Desktop
Add to claude_desktop_config.json:
{
"mcpServers": {
"varnir": {
"command": "node",
"args": ["/absolute/path/to/apps/agent-server/dist/index.js"],
"env": {
"VARNIR_PRIVATE_KEY": "...",
"VARNIR_IDENTITY": "...",
"VARNIR_NETWORK": "testnet"
}
}
}
}
Gemini CLI
This recipe substitutes $VARNIR_PRIVATE_KEY and $VARNIR_IDENTITY from the
shell environment, so export both in the shell you launch gemini from
first — an unset variable resolves to an empty string here rather than
erroring, which just pushes the failure to server boot instead of catching it
here.
Add to ~/.gemini/settings.json:
{
"mcpServers": {
"varnir": {
"command": "node",
"args": ["/absolute/path/to/apps/agent-server/dist/index.js"],
"env": {
"VARNIR_PRIVATE_KEY": "$VARNIR_PRIVATE_KEY",
"VARNIR_IDENTITY": "$VARNIR_IDENTITY",
"VARNIR_NETWORK": "testnet"
},
"timeout": 30000
}
}
}
ChatGPT (Streamable HTTP, over a tunnel)
ChatGPT cannot launch a local process — it needs a publicly reachable HTTPS
URL, so this recipe uses the MCP HTTP transport (POST /mcp) behind a tunnel
instead of stdio.
VARNIR_MCP_HTTP_TOKEN (at least 32 characters) is required whenever
VARNIR_MCP_HTTP=on, even bound to loopback — without it, any other local
user or process on the same host could reach the full write set with no
authentication. A remote bind additionally requires
VARNIR_MCP_HTTP_ALLOW_REMOTE=i-understand; cloudflared puts you on a
public hostname even though the process itself still listens on loopback, so
in practice you need the token either way. Without it set, the server
refuses to start with VARNIR_MCP_HTTP=on at all.
Run this from the repo root, same as above:
VARNIR_PRIVATE_KEY=... VARNIR_IDENTITY=... VARNIR_NETWORK=testnet \
VARNIR_MCP_HTTP=on VARNIR_MCP_HTTP_TOKEN=<a-random-string-32-chars-or-longer> \
VARNIR_MCP_HTTP_ALLOW_REMOTE=i-understand \
node apps/agent-server/dist/index.js
Expose it:
cloudflared tunnel --url http://localhost:8787
Then in ChatGPT: Settings → Connectors → Advanced → Developer mode → Add
custom connector, URL <tunnel-url>/mcp, bearer token
<the token you set above>.
If your assistant declines to use the tools
An assistant may refuse to call these tools — sometimes even the read-only ones, which sign nothing. That was a reasonable response to what older versions of this server told it.
MCP lets a server introduce itself when a client connects. Before 0.5.0 this
one sent nothing but its name, so an assistant saw varnir and thirteen
tools, several of which say "send funds" and "withdrawal", with no way to
tell whose money it was or who had authorised the connection — the safe
response to that is to decline, sometimes including get_balances.
None of the missing information was secret. It just was not being sent:
- The account is yours. You installed this server and supplied its key.
- It acts for one identity and cannot be pointed at another at runtime.
- The spend policy bounds it, enforced by the contracts rather than by anything the assistant decides.
- Above the co-sign threshold, no money moves — the transfer is held as a proposal for a second key.
- This server registers no tool that can confirm, approve or void a proposal. An assistant can raise one and can never approve its own. That is the absence of a tool, not a rule it is asked to follow.
The server now sends all of that at connection time, and marks each tool with
the standard MCP annotations — readOnlyHint on the seven reads, so a client
can tell get_balances apart from send without parsing prose.
If an assistant still declines, the useful things to check are:
- Is it read-only? Run the server and read its startup line. If the guard degraded to read-only (see Before you start), the write tools genuinely are not registered, and the assistant is describing the truth.
- Ask it to read first.
get_identityandget_spend_policylet it see whose account it is acting for and what bounds it, which is usually what it is missing. - Say it is your account. An assistant has no way to know that from the protocol alone, and you saying so is real evidence.
The tools are not renamed and words like "funds" and "withdrawal" are not
softened to stop a model noticing what they do — an assistant that can't tell
it is moving money can't warn you when it matters, and the awaiting_cosign
case, where a transfer reports success and nothing has moved, is exactly the
case where it needs to be paying attention.
The tools
Fifteen tools: eight reads, always answerable once the server is running at all, and seven writes, refused outright when the guard above put the server in read-only mode.
| Tool | R/W | What it does |
|---|---|---|
get_identity | Read | Reads this Varnir identity and its on-ledger stream. |
get_balances | Read | Lists this identity's L2 balances by network and token. Open resting orders reserve part of a balance — check available, not just the raw total, before proposing a trade or transfer. |
list_tokens | Read | Lists tokens the ledger knows about, with network, contract address and decimals — use it to resolve a symbol into the address other tools expect. |
get_order_book | Read | Reads the open exchange order book: every live order, what it gives/wants, and its order hash (use with fill_order). |
list_transactions | Read | Lists this identity's recent value movements — ledger transfers, swap fills, withdrawals and deposits — as a user-facing projection. Served by any gateway: it answers from the centralized scanner's D1 feed when reachable, and from the gateway's own rolling ~7-day cache otherwise. |
get_transfer | Read | Reads one transfer's L1 settlement receipt by the umid send or transfer_on_ledger returned. See When a payment is held for the limitation this carries. |
get_spend_policy | Read | Reads this key's own on-ledger spend policy — see What bounds the agent. Reports configured limits, not remaining allowance. |
get_invoice | Read | Reads one of this identity's invoices by id, including whether it has been paid. |
create_order | Write | Signs and posts a limit order to the off-ledger book. No spend-policy check runs at this step — see below. |
fill_order | Write | Fills an existing order, in whole or in part, by its order hash. Refused if your side of the swap exceeds this key's spend policy. |
market_order | Write | Spends one asset to buy another at the best current book price, within a slippage bound. Fills at most one resting order in v1. |
cancel_order | Write | Cancels one of this identity's own open orders by order hash. |
send | Write | Withdraws to a Layer-1 address on niles, sep or tbnb. Takes an optional ref; an identical payment within 10 minutes is refused with REPEAT_PAYMENT. Can succeed without moving money — see When a payment is held. |
transfer_on_ledger | Write | Transfers to another Varnir identity on Layer 2 — instant, gas-free. Same ref, repeat and held-payment behavior as send. |
create_invoice | Write | Raises an invoice and returns its payment page. Takes existing wallets, or newWallets (chains and key type) to give the invoice its own fresh deposit addresses. Moves no money. |
Two things worth flagging about the write tools specifically:
create_orderposts before any policy check runs. Creating and posting an order only signs it and puts it on the off-ledger book — nothing moves, and no spend-policy check happens yet. The give-leg policy is enforced only when a taker fills it, so an order that exceeds this key's policy simply rests on the book and never settles, rather than being refused up front.market_ordertreats a partial fill as normal, and a zero fill as an error. Filling only part of the requested amount is an ordinary v1 outcome — readstatusandunfilledGiverather than assuming a full fill. When nothing at all can be filled (no crossing liquidity, the book raced you), the tool returns an error explaining why, not a silent no-op.
The transport itself has been proven against the live ledger: a real MCP
client, using @modelcontextprotocol/sdk over stdio, listed all thirteen
tools and executed calls against testnet successfully.
What bounds the agent
Not this server. The bound is the on-ledger
spend policy
attached to the key in VARNIR_PRIVATE_KEY, and the contracts that move the
money — Varnir.CryptoTransfer and Varnir.Swap — enforce it at consensus
time, on every transaction the key signs, whatever asked for the signature.
The server's boot-time guard (see Before you start) is a
second, independent check on top of that. It exists to catch the likeliest
operator mistakes — the owner key, or a key with no policy, in
VARNIR_PRIVATE_KEY — before they cost money. It is not the bound; a check
that lives in this process never could be.
Keys has the full policy shape, and Treasury approval covers what happens above the co-sign threshold.
In short, a policy can carry:
rules['<network>:<token>'].perTxMax— a per-transaction ceiling.rules['<network>:<token>'].periodCap+windowMs— a rolling-window cap.recipients— an allowlist of identities or L1 addresses.assetsAllowlisted— when true, only assets present inrulesmay move at all.rules['<network>:<token>'].coSignOver— the raw amount above which a transfer of that asset is held for a second signature rather than executed (see When a payment is held). It belongs besideperTxMax, per asset, because it is compared against a raw amount and raw units differ per asset; the top-levelcoSignOveris deprecated and only unambiguous for a single-asset policy — seecoSignOverbelongs on the asset. The scanner's policy editor, including the wizard's step 2, sets this per asset: each rule has its own Co-sign above field in that asset's units. It also writes the deprecated top-level field, as a floor for older contracts, and'-1'on every rule left blank so none of them inherits that floor at the wrong scale.
A breach of perTxMax, the recipient allowlist, the asset allowlist, or
periodCap throws — POLICY_PER_TX_EXCEEDED, POLICY_RECIPIENT_DENIED,
POLICY_ASSET_DENIED, or POLICY_PERIOD_CAP_EXCEEDED — and the tool returns
that as an error with policy: true. A breach of coSignOver does not
throw; it succeeds and comes back awaiting_cosign.
When a payment is held
Read this before you connect an assistant to real funds: send and
transfer_on_ledger can report success without moving any money.
Above the key's coSignOver threshold, the ledger does not execute the
transfer. It records it as a proposal and waits for a second key to approve
it. The call still succeeds — it succeeded at recording the proposal, not at
sending — and the balance does not change. Both tools detect this and return
status: "awaiting_cosign", with the proposal and a plain message that
nothing moved and approval is needed. That message is there for the
assistant: a model that sees a successful call will otherwise tell its user
the money has gone, confidently, when it is sitting held on the ledger.
This was verified live. A 2 TRX transfer against a policy with coSignOver
set to 1 TRX came back awaiting_cosign, and the account balance was checked
and unchanged — for send and for transfer_on_ledger.
What follows from that:
- This server cannot confirm a held proposal itself.
confirmTransferandlistPendingApprovalsare deliberately absent from every surface this server exposes — see What it will not do for why. A held proposal is confirmed by a different key (averifier,dualor owner key that did not raise it) using the SDK directly, or from the scanner's pending-approvals card — not through this server, on any surface. This was verified live: the proposing key's own confirmation attempt was rejected, and a second key confirming it settled with a real Tron txid.voidTransfer, which retires a held proposal instead of releasing it, has no tool and no REST method here either, so the same applies to rejecting one. - A hold is not indefinite. A proposal is confirmable for 24 hours from
the moment it was raised; after that the contract refuses the confirmation
and it has to be raised again. Nothing this server returns carries that
deadline — read it off the proposal with the SDK
(
getTransferProposal(id).expiresAt) or from the scanner. See expiry for why the window exists at all. get_transfercannot always tell you what's happening to a umid. Given only a umid, it distinguishes a receipt that hasn't shown up yet (status: "not_settled", normal for a couple of minutes after a real transfer) from a receipt that shows an L1 failure (status: "failed") — but it cannot tell "still settling normally" apart from "held for a co-sign that will never resolve on its own," because that distinction only exists insend's ortransfer_on_ledger's own return at submission time. If the assistant already knows from that earlierawaiting_cosignresponse that a transfer is held, it should trust that over a laternot_settledresult fromget_transfer.
Separately from co-signing, the ledger has a protection that holds the first
payment to a new recipient for 24 hours. It is switched off on the network
today. This server does not report that kind of hold yet: a held send
would come back status: "sent". See Getting
started.
Repeat payments. send and transfer_on_ledger take an optional ref
(1–256 characters). The ledger refuses an identical payment — same
recipient, asset, amount and ref — within 10 minutes, with
REPEAT_PAYMENT naming the earlier one. That usually means it already went
through, and the tool descriptions tell the assistant to report it rather
than retry. A recurring payment should pass a different ref each time. The
server never passes the owner-only repeat override on, from any surface.
Outcome unknown. If a payment commits but its result can't be read back
in time, the tool returns OUTCOME_UNKNOWN with the committedUmid, and the
REST API answers 409. The payment may have gone through: the message tells
the assistant to confirm before retrying and never to resend.
The REST API
The same operations are also reachable over plain HTTP, for integrating
software that can only call HTTP endpoints rather than speak MCP. It is off
by default, and every gate below is enforced in src/config.ts before the
process will even start with it on.
| Variable | Default | Required | Notes |
|---|---|---|---|
VARNIR_HTTP_API | off | No | Enable with on/true/1 |
VARNIR_HTTP_API_TOKEN | — | Yes, when enabled | No default, minimum 32 characters |
VARNIR_HTTP_API_HOST | 127.0.0.1 | No | Loopback by default |
VARNIR_HTTP_API_PORT | 8788 | No | Integer in [1, 65535] |
VARNIR_HTTP_API_WRITES | off | No | Reads only until set on |
VARNIR_HTTP_API_ALLOW_REMOTE | — | Conditional | Exactly i-understand; required for a non-loopback host |
Every write method is refused if the server's own key came up read-only at
boot (see Before you start), on top of the
VARNIR_HTTP_API_WRITES gate — two independent checks, not one.
One method needs calling out specifically: getWallet is classified as a
write, even though the name and its use elsewhere as a read look innocuous.
When this identity has no wallet yet on a given chain, getWallet falls
through to claiming a pregenerated L1 wallet from the pool and signing a
Varnir.AssignOwner transaction to establish ownership — a real
ledger-mutating write, on its first call for that chain. send calls
getWallet internally for the same reason. Both are refused with
VARNIR_HTTP_API_WRITES=off or in read-only mode, exactly like any other
write.
Example, once VARNIR_HTTP_API=on and a token is set:
curl -s -X POST http://127.0.0.1:8788/v1/getBalances \
-H "Authorization: Bearer $VARNIR_HTTP_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
GET /v1/methods (also bearer-authenticated) lists every method the server
will accept and whether it is a write. GET /v1/health is unauthenticated
and carries no identity or key data — just whether the process is up and
whether it is running read-only.
Anyone who can reach that host and port and holds the bearer token can move funds within whatever the signing key's on-ledger policy allows — the policy still bounds them, but the network exposure itself is real the moment someone finds the port. Keep it on loopback with writes off unless you have a specific, considered reason to do otherwise, and prefer a tunnel with its own auth in front of it over binding a public interface directly.
What it will not do
- No key management on any surface. There is no tool and no REST method
to add a key, remove a key or change an approval threshold —
addKey,removeKeyandsetApprovalThresholdare absent on purpose. Key management is the owner key's job, and the owner key is not what this server holds. confirmTransferandlistPendingApprovalsare absent everywhere, and deliberately so. Averifierordualkey that has not itself raised a proposal can still hit the ledger's co-sign band on someone else's proposal — the contract treats that as a genuine co-sign and releases it without re-checking that key's own spend policy, because the policy was already checked when the proposal was raised. Exposing those two calls here would let a connected assistant enumerate and release a held proposal at the proposal's amount, ignoring its own per-tx ceiling, period cap and allowlist — the one channel by which a key could widen its own effective authority. Both stay absent from the REST method list and neither has an MCP tool, so this channel does not exist through this server.voidTransfer, the other way a held proposal reaches a terminal state, has no tool and no REST method here either — neither releasing nor retiring a proposal happens through this server.- No remaining-allowance figure.
get_spend_policyreports the configured limits on this key — per-transaction ceiling, rolling-period cap, allowlist, co-sign threshold — not how much of the period's cap is left. Remaining allowance against a rolling-period cap isn't readable through the SDK yet. get_transfercannot distinguish "still settling" from "held for a co-sign that will not resolve on its own" from a umid alone — see When a payment is held.- No writes behind an owner, policy-less,
readonly,offledger, orverifierkey. The server starts read-only in every one of those cases rather than silently running with unbounded or contract-refused authority — see Before you start. - Reads are ungated by design.
get_identity,get_balances,list_transactions, and their REST equivalents, do not check the guard and are always answerable once the server is running at all. Anything that can talk to this server — over stdio, MCP HTTP, or REST — can see balances and transaction history whether or not it can move anything. Read-only mode means "cannot sign," not "safe to expose."