npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 readonly or offledger role (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 build

Then, with the environment set (see .env.example), still from the repo root:

node apps/agent-server/dist/index.js

The 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.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 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.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>.

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, removeKey and setApprovalThreshold are absent from REST_METHODS on purpose and must stay absent. Keys can be read (listLedgerKeys) and never written.
  • One deliberate, narrower exception: a verifier or dual key (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. confirmTransfer and listPendingApprovals would 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 from REST_METHODS for 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_policy reports 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_transfer cannot 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 in send's/transfer_on_ledger's own return at submission time (awaiting_cosign plus the proposal) - looking the umid up afterwards, which is all get_transfer does, 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) - so get_transfer says so plainly rather than guessing, and a not_settled result should be read as "no receipt yet," not "still settling."
  • No writes behind an owner, policy-less, readonly or offledger key. 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.