@varnir/agent-server
v0.4.0
Published
Self-hosted Varnir agent server: MCP tools and an off-by-default REST API over a policy-scoped agent key.
Readme
@varnir/agent-server
A self-hosted server that lets your own AI assistant (Claude, Gemini, ChatGPT, or anything else that speaks MCP or plain HTTP) trade and move funds on Varnir, bounded by an on-ledger spending policy attached to the key you give it. Varnir never holds the key - it lives in your environment, on your host, under your control.
It exposes thirteen tools over MCP - seven reads (identity, balances, tokens, the order book, transaction history, a transfer's settlement receipt, the key's own spend policy) and six writes (post an order, fill an order, place a market order, cancel an order, send to an L1 address, transfer to another Varnir identity on L2). The same operations are also reachable over a plain REST API, off by default - see "The REST SecureAPI" below. Writes are refused outright unless the key this server signs with actually carries a spend policy; see "Before you start".
Before you start
Mint a policy-scoped agent key in the scanner's keys card, with the spend
policy you want the assistant bound by, before you put anything in
VARNIR_PRIVATE_KEY.
Do not hand this server your identity's owner key. It will not do what you expect: at boot, the server reads its own key back off the ledger and checks what it actually is before allowing any write. If that key turns out to be
- the identity's owner key (no spend policy exists to bound it),
- a key with no spend policy attached, or
- a key with the
readonlyoroffledgerrole (the money contracts refuse these write access regardless of any policy attached to them),
the server starts anyway, but read-only: every write tool and every write
REST method is refused before it ever reaches the ledger, with a message
explaining why. This is deliberate - the entire security argument for this
server is that the on-ledger policy is the hard bound on what a connected
assistant can spend, and that argument only holds when the key actually
carries one. There is an escape hatch (VARNIR_ALLOW_UNSCOPED_KEY=i-understand
in .env.example) for operators who understand they are removing that bound;
do not reach for it to make an error message go away.
Once you have the key, set VARNIR_PRIVATE_KEY, VARNIR_IDENTITY, and
VARNIR_NETWORK (e.g. testnet).
Install and run
Run these from the repo root:
pnpm install
pnpm --filter @varnir/agent-server buildThen, with the environment set (see .env.example), still from the repo root:
node apps/agent-server/dist/index.jsThe server logs which mode it started in (writes, read-only, or it exits
with refuse if the key isn't even attached to VARNIR_IDENTITY) before it
starts serving anything.
Deploying to your own cloud account
You can also run this server in your own Google Cloud, Azure, or AWS
account instead of on a local machine - it is still your account and your
key either way; Varnir runs none of this infrastructure and never sees the
key. See deploy/README.md for one-click deploy buttons, the ARM/
CloudFormation templates, and Dockerfile for the container image (built
from the published npm package, not this repo's source).
Connecting a client
By default the server speaks MCP over stdio (VARNIR_MCP_STDIO=on), which is
how a client that can launch a local process connects. Copy the block for
your client and fill in the real values - VARNIR_PRIVATE_KEY and
VARNIR_IDENTITY particularly should come from wherever you actually keep
them, not pasted in plaintext if you can avoid it. Replace
/absolute/path/to/apps/agent-server/dist/index.js with the real absolute
path to the built entry point on your machine (the package's bin name is
varnir-agent-server 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.jsClaude 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 now
mandatory whenever VARNIR_MCP_HTTP=on, even on loopback - without it,
any other local user or process on the same host could reach the full write
set with no authentication at all. 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 the token set, the server
refuses to start with VARNIR_MCP_HTTP=on at all.
Run this from the repo root, same as "Install and run" 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.jsExpose it:
cloudflared tunnel --url http://localhost:8787Then in ChatGPT: Settings -> Connectors -> Advanced -> Developer mode -> Add
custom connector, URL <tunnel-url>/mcp, bearer token
<the token you set above>.
The REST SecureAPI
The SDK's methods are also reachable over plain HTTP, for integrating
software that can only call HTTP endpoints rather than speak MCP. It is off
by default (VARNIR_HTTP_API=off) 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 [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 |
Turning this on with writes enabled and a non-loopback bind puts a money-moving endpoint on the network. 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 and irreversible 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.
Every write method is also 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 you have VARNIR_HTTP_API=on and a token 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.
Security
The signing key lives in your environment - your host is the trust boundary,
not Varnir. Whoever can read that environment (or the process's memory) can
sign whatever the key's on-ledger policy allows. The on-ledger spending
policy - per-transaction ceiling, rolling-period cap, recipient allowlist,
co-sign threshold - is the real bound on a connected assistant, not this
server; this server is defence in depth around that bound (see src/guard.ts
for exactly what it checks and why). To revoke access, remove the key from
the identity in the scanner - that is the authoritative shutoff, independent
of whether this process is still running.
Reads are ungated by design: get_balances, list_transactions,
get_identity, and the equivalent REST methods, do not check the guard mode
and are always answerable once the server is running at all. Anything that
can talk to this server - over stdio, MCP HTTP, or the REST API - can see
your balances and transaction history, whether or not it can move anything.
Do not treat "read-only mode" as "safe to expose"; treat it as "cannot sign."
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 fromREST_METHODSon purpose and must stay absent. Keys can be read (listLedgerKeys) and never written. - One deliberate, narrower exception: a
verifierordualkey (both stake 80) that has NOT itself raised a transfer proposal can still hit the ledger's co-sign band on that proposal - the contract treats that as a genuine co-sign confirmation and releases it without re-checking that key's own spend policy, because the policy was already checked when the proposal was raised.confirmTransferandlistPendingApprovalswould let a connected assistant enumerate and release someone else's held proposal at the proposal's amount, ignoring its own per-tx ceiling, period cap and allowlist - the one way this server's design lets a key widen its own effective authority. Both are absent fromREST_METHODSfor exactly this reason and must stay absent; neither has an MCP tool, so nothing regresses. Aside from this, nothing reachable from a connected assistant can widen that assistant's own authority. - 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 is not readable through the SDK yet. get_transfercannot tell "still settling" apart from "held for a co-sign that will never settle on its own", given only a umid. That distinction genuinely exists only insend's/transfer_on_ledger's own return at submission time (awaiting_cosignplus theproposal) - looking the umid up afterwards, which is allget_transferdoes, cannot recover it. Telling them apart from a umid alone would need reading this identity's pending-approvals list, which is deliberately not wired into any MCP tool (see the point above) - soget_transfersays so plainly rather than guessing, and anot_settledresult should be read as "no receipt yet," not "still settling."- No writes behind an owner, policy-less,
readonlyoroffledgerkey. The server starts read-only in all four cases rather than silently running with unbounded or contract-refused authority; see "Before you start" for what each one means and how to fix it.
