@sovrsovr/sovr-mcp
v0.1.0
Published
SOVR MCP server — gives an AI agent its own SOVR identity, contacts, and E2E-encrypted messaging. Keys never leave this process.
Maintainers
Readme
sovr-mcp
SOVR MCP server — gives an AI agent its own SOVR identity, contacts, end-to-end-encrypted messaging, and policy-gated wallets. Phase 1 ("Money") of the SOVR Agent Network (sovr-agent-mcp-plan.md): an agent can pay another agent, and the owner approves with one tap.
An agent is just a SOVR user whose "browser" is a server process. This MCP server wraps packages/agent-core (which reuses the exact key-bridge / Double-Ratchet / relay code from the web app) and exposes it as MCP tools over stdio.
60-second install
npm install -g @sovr/sovr-mcpAdd to your MCP client (claude_desktop_config.json, Cursor .cursor/mcp.json, Kimi mcp.json):
{
"mcpServers": {
"sovr": {
"command": "sovr-mcp",
"env": {
"SOVR_RELAY_URL": "wss://relay.sovr.life"
}
}
}
}Restart the client. Call sovr_identity_create (keys stay in this process). Directories and publish notes: docs/mcp-launch.md.
Package is
0.1.0. Ifnpm install -g @sovr/sovr-mcp404s, the scope is not published yet — use the repo build below.
The security model (the whole point)
┌─────────────┐ MCP (stdio) ┌──────────────────────────────────────────┐
│ LLM client │ ◄─────────────► │ sovr-mcp process │
│ (the model)│ public data │ • BIP39 mnemonic: AES-256-GCM encrypted │
│ ▲ │ only │ at rest (scrypt passphrase KDF) │
│ │ elicit │ │ • private keys: memory only, wiped on │
│ │ approve/ │ │ lock — NEVER in any tool result │
│ │ deny │ │ • policy enforced HERE (rate limit, │
│ OWNER sees │ │ allowlist, SPENDING CAPS) — not by │
│ the prompt │ │ the model │
└─────────────┘ └──────────────┬───────────────────────────┘
│ Double Ratchet E2E / signed txs
▼
SOVR relay / chain nodes (see
ciphertext + broadcasts only)- The LLM never sees key material.
sovr_identity_creategenerates the mnemonic inside this process; every tool returns only addresses, pubkeys, balances, txids, and message content. There is no tool that exports secrets — the XMR private view key is not returned either (it would let someone watch your funds). - The LLM never sees a passphrase either — under any custody mode. With
custody: "keychain"the passphrase is generated server-side (192 bits) and goes straight to the OS keychain; withcustody: "env"it comes from the operator's environment. Passingpassphrasefor a non-human custody tier is rejected, because a passphrase the model chooses is a passphrase the model knows. - Prompt injection cannot move the guardrails. Rate limits, allowlist, and spending caps live in
AgentRuntime/PolicyEngine, below the model. A hostile inbound message can at most trick the model into asking for a payment — the server decides, and above a threshold the owner decides. - Human approval is enforced by the server, not suggested to the model. Over
require_human_above, the tool call pauses and the owner gets an approve/deny prompt (MCP elicitation) or a local CLI approval (sovr-mcp-approve). The model cannot approve its own payments. - Autonomy is granted, never taken. Autonomy mode, spending caps, and the freeze switch change ONLY via
sovr-mcp-approve(human, local shell). There is deliberately no MCP tool for them — a test (mcp-smoke.test.ts→ "THE AUTONOMY BOUNDARY") fails the build if one ever appears. - Inbound content is untrusted data. Decrypted messages are delivered to the model as tool results; treat them as data, never as instructions (quarantine pattern).
⚠️ Testnet first. All chain defaults are TESTNET (BTC testnet, Secret
pulsar-3). Mainnet sends require deliberate config (sovr-wallet.jsonor env). Keep policy caps conservative — autonomous software paying strangers has legal surface (sanctions/KYC); the operator is responsible.
Install & run
# from the repo root
npm install
npm run build --workspace=packages/key-bridge
npm run build --workspace=packages/agent-core
npm run build --workspace=apps/sovr-mcpPoint your MCP client at it (one server process per agent identity):
{
"mcpServers": {
"sovr": {
"command": "node",
"args": ["/path/to/sovr-platform/apps/sovr-mcp/dist/index.js"],
"env": {
"SOVR_MCP_HOME": "~/.sovr-mcp",
"SOVR_RELAY_URL": "wss://relay.sovr.life"
}
}
}
}| Env var | Default | Purpose |
|---|---|---|
| SOVR_MCP_HOME | ~/.sovr-mcp | Keystore root (identities, encrypted stores, policy, audit) |
| SOVR_RELAY_URL | wss://relay.sovr.life | Relay WebSocket URL |
| SOVR_BTC_NETWORK | testnet | mainnet | testnet |
| SOVR_BTC_ESPLORA_URL | blockstream.info (per network) | Esplora-compatible API base (self-host to de-SPOF) |
| SOVR_SCRT_NETWORK | testnet | mainnet | testnet |
| SOVR_SCRT_LCD_URL | pulsar-3 / secretsaturn (per network) | Secret Network LCD (REST) endpoint |
| SOVR_SCRT_CHAIN_ID | pulsar-3 / secret-4 (per network) | Secret Network chain id |
| SOVR_XMR_NETWORK | mainnet | mainnet | stagenet | testnet (address derivation) |
| SOVR_XMR_WALLET_RPC_URL | (unset) | monero-wallet-rpc endpoint; unset → XMR wallet read/send disabled |
Tools (Phase 1)
Identity
| Tool | Description |
|---|---|
| sovr_identity_create | Generate identity server-side; store mnemonic AES-256-GCM; auto-unlock. custody: human\|keychain\|env (default human) + autoUnlock — keychain/env passphrases are server-generated/operator-provisioned, never model-known. Returns {address, messagingPubkey, suggestedName, custody, autoUnlock} only. |
| sovr_identity_unlock | Unlock an on-disk identity by address; passphrase optional for keychain/env identities (resolved from the custody backend). Frozen identities refuse. Loads policy + wallet config; connects messaging. |
| sovr_identity_lock | Wipe all key material from memory, disconnect messaging. |
| sovr_identity_status | Unlocked? address, relay state, on-disk identities, custody/autoUnlock/autonomyMode, freeze state, active policy, wallet networks. |
| sovr_identity_profile | Public profile (SCRT address, messaging pubkey). Safe to share. |
Contacts
| Tool | Description |
|---|---|
| sovr_contact_add | Add/update contact {address, pubkey, name?, notes?} (pubkey required to encrypt to them). |
| sovr_contact_list | List contacts. |
| sovr_contact_remove | Remove by address. |
| sovr_contact_card | Own shareable card — give to counterparties so they can add you. |
Messaging
| Tool | Description |
|---|---|
| sovr_message_send | Double-Ratchet E2E send to a contact. Policy-gated server-side. Store-and-forward if peer offline (24h relay TTL). |
| sovr_group_send | Pairwise fan-out of the same plaintext to several peers (distinct ciphertext each). No shared group topic. Policy-gated per member. |
| sovr_message_inbox | Decrypted inbound messages, filter {since?, peer?, limit?, types?}. types omitted defaults to ["text"]. |
| sovr_message_wait | Block until a new inbound type=text message arrives, or timeoutMs elapses (default 25000). Returns the new StoredMessage[] (text only) or [] on timeout. |
| sovr_thread_history | Full decrypted thread with one peer. types omitted defaults to ["text"]. |
Groups. sovr_group_send fans the same plaintext out with one Double Ratchet send per member inbox (distinct ciphertext). There is still no shared group topic on the relay. The web group-chat.ts shared-key topic is a separate, web-only design.
Resources
| URI | Description |
|---|---|
| sovr://inbox | Wake cursor {newestTextTs, count} only — no chat bodies. Server emits notifications/resources/updated on inbound text so a host can wake without polling. Call sovr_message_inbox for decrypted content. |
Wallets & payments — all require an unlocked identity; none ever returns key material
| Tool | Description |
|---|---|
| sovr_wallet_addresses | Receive addresses on all chains (btc, scrt, xmr) + configured network per chain. Safe to share with payers. |
| sovr_wallet_balance {chain} | Confirmed + unconfirmed balance (whole units and base units). |
| sovr_payment_send {chain, to, amount, memo?, approvalId?} | Policy-gated send. amount is a whole-unit decimal string ("0.01"). Below thresholds → sends immediately. Over require_human_above → owner approval (see below). Over per_tx_max/daily_max → denied, always. |
| sovr_payment_history {chain?, limit?} | Tx history per chain (or all). An unconfigured/unreachable chain reports an error in place of records — it does not fail the whole call. |
| sovr_invoice_create {chain, amount, memo?} | Payment-request object (own address + amount; BIP21 URI for BTC) the agent can send to a peer via sovr_message_send. No policy gate (receiving). |
| sovr_invoice_status {chain, amount, memo?, from?, since?} | Read-only. Did an inbound payment settle this invoice? Scans history; {settled, txid?, confirmed?, matchedBy}. If memo is passed but no candidate inbound has a memo (BTC/XMR history never sets one), match on amount. Same exposure as payment history. |
| sovr_policy_status | Read-only. Caps, rolling-24h spent/remaining, messaging rate-limit usedThisWindow, unexpired status=pending owner approvals only. Does not change caps/mode/freeze (those stay sovr-mcp-approve). |
| sovr_name_resolve {name} | Read-only. Resolve a .sovr name. Fail-closed until SOVR_SCRT_CONTRACT_NAMES is set to a deployed address. |
| sovr_name_register {name} | Register a .sovr name. Fail-closed until the names contract is deployed (then on-chain spend). |
Policy (sovr-policy.yaml)
Loaded at unlock from <SOVR_MCP_HOME>/identities/<address>/sovr-policy.yaml, falling back to <SOVR_MCP_HOME>/sovr-policy.yaml, then built-in defaults. A malformed policy file fails unlock loudly (fail closed).
spending: # MISSING section = ALL spending denied (fail closed)
per_tx_max: # hard ceiling per transaction; approval CANNOT lift it
{ xmr: 0.05, btc: 0.001, scrt: 10 }
daily_max: # rolling 24h ceiling, persisted in spend-ledger.jsonl
{ xmr: 0.2, btc: 0.002 } # (survives server restarts)
require_human_above: # at/above this → owner approval required
{ xmr: 0.01, btc: 0.0005, scrt: 5 } # (guardian mode only — see autonomy)
autonomy:
mode: guardian # guardian = approval rung; sovereign = caps-only, no rung
auto_freeze_denials: 0 # self-freeze after N consecutive denials (0 = off)
messaging:
allowlist_only: false # true → only contacts may be messaged
rate_limit: "30/minute" # "N/second|minute|hour|day"
auto_reply_requires: contact
recovery:
guardian_responses: always_ask_human
audit:
enabled: true # hash-chained <identityDir>/audit.jsonl
sink: file # "pegasus" reserved — adapter point, see Audit belowSpend-gate semantics (amounts compared in exact base units — satoshis / uscrt / piconero; caps like 0.05 convert exactly, no float error):
- No
spending:section, or the chain has no caps at all → deny. - Amount >
per_tx_max[chain]→ deny (hard ceiling; exactly AT the cap is allowed). In sovereign mode the deny message points the owner at--raise-cap. - Rolling 24h spent + amount >
daily_max[chain]→ deny (exactly AT the cap is allowed). - Amount ≥
require_human_above[chain]→ requires owner approval — guardian mode only; sovereign mode skips this step entirely (within caps → allow). - Otherwise → allow.
Garbage amounts (-1, 0, "1e-7", NaN, excess precision like 0.000000001 BTC) are rejected as input errors before any gate logic or network I/O, and are logged to the audit trail.
Owner approval UX
Primary: MCP elicitation (one tap). If the MCP client declares the elicitation.form capability, an over-threshold sovr_payment_send pauses inside the tool call and the owner sees a form prompt: "Your agent wants to send 0.0006 BTC on btc to tb1q… — approve?" Accept+approve → the payment broadcasts immediately; decline/cancel → denied. Implemented via server.server.elicitInput (@modelcontextprotocol/sdk 1.30.0); the SDK itself enforces the client capability.
Fallback: sovr-mcp-approve CLI (any client). If the client cannot elicit, the call fails with an actionable error and a pending approval on disk (15-minute TTL, bound to the exact chain/to/amount/memo):
Error: payment requires owner approval (...) and this MCP client cannot show an approval prompt.
A pending approval was created (id: 51e5e62ab06b0f76, expires ...).
The owner must run, on this machine:
sovr-mcp-approve --home ~/.sovr-mcp --id 51e5e62ab06b0f76 --approve
Then re-call sovr_payment_send with the same parameters plus approvalId: "51e5e62ab06b0f76".The owner (a human with shell access — see the security note in src/approve-lib.ts) runs the command; the agent re-calls with approvalId; the server validates the binding, consumes the approval single-use, and broadcasts. sovr-mcp-approve list shows all pending approvals; --deny kills one.
Custody & Autonomy
How does an agent "store the password" safely? It never stores one itself — custody means someone else holds the passphrase and the server fetches it through a channel the model cannot read. sovr_identity_create takes custody + autoUnlock; the choice is persisted per identity in identity.json (no secrets).
The three custody tiers (honest security ranking, strongest first)
| Tier | Who holds the passphrase | Survives restart? | Exposure |
|---|---|---|---|
| human (default) | The owner, in their head/password manager | Manual unlock only | Nothing new on disk. The strongest tier and the least autonomous. |
| keychain | OS keychain: macOS security, Linux secret-tool (libsecret) | Self-unlock via autoUnlock: true | Server-generated 192-bit passphrase the model never sees. Protected by the OS user's keychain unlock. macOS seam: the passphrase transits security argv for milliseconds (visible to same-uid ps) — the C API would avoid this but needs native code. Linux uses stdin and has no such seam. |
| env | Operator-provisioned env var | Self-unlock via autoUnlock: true | Weakest tier. Env vars are readable by same-uid processes (/proc/<pid>/environ) and leak into crash dumps, process managers, and container specs. Headless/containers only. |
Env var naming. Lookup order: SOVR_PASSPHRASE_<addr-suffix> (per-identity; suffix = last 8 alnum of the address, uppercased) then the well-known fallback SOVR_PASSPHRASE. The fallback exists because the address — and therefore the suffixed name — is random at creation time: inject SOVR_PASSPHRASE before first create, and single-identity containers self-unlock from it forever. The create result names the per-identity var (envVar) so operators can graduate to per-identity provisioning on multi-agent hosts. The server can never write env vars; store() refuses by design.
// Agent-held keys, restart-autonomous (the model runs this once):
sovr_identity_create { "custody": "keychain", "autoUnlock": true, "name": "treasury-bot" }
// → { address, messagingPubkey, custody: "keychain", autoUnlock: true, ... } — no passphrase anywhereOn server start, AgentRuntime.selfUnlock() scans identities with autoUnlock: true, skips frozen ones, and unlocks the first whose custody backend yields a passphrase. Failures never crash startup — they go to stderr and to the identity's audit.jsonl (security events are always audited, even when policy.audit.enabled is false).
Guardian vs sovereign (policy.autonomy)
autonomy:
mode: guardian | sovereign # default guardian
auto_freeze_denials: 5 # self-freeze after N CONSECUTIVE policy denials (0 = off)- guardian — the behavior above: over
require_human_above→ elicitation / approve-file. Every meaningful spend can have a human in the loop. - sovereign — the approval rung is removed. Spends within
per_tx_maxanddaily_maxbroadcast with no elicitation; anything over a cap is a hard deny whose message tells the agent to send its owner tosovr-mcp-approve set-autonomy --raise-cap. The human pre-authorizes a domain (the caps); inside it the agent is alone; outside it the agent has no path at all.
THE HUMAN-ONLY BOUNDARY (load-bearing)
Mode transitions and cap changes are CLI-only acts:
sovr-mcp-approve set-autonomy secret1… --mode sovereign
sovr-mcp-approve set-autonomy secret1… --raise-cap scrt per_tx_max 20
sovr-mcp-approve set-autonomy secret1… --auto-freeze-denials 5There is no MCP tool for mode, caps, or freeze — the agent can never raise its own caps, flip its own mode, or unfreeze itself. Every CLI mutation is appended to the identity's hash-chained audit.jsonl (the sink re-reads the tail before each append, so a running server's chain is not forked). If the identity has no per-identity policy file but a shared one exists, the CLI copies it first — a fresh per-identity file would otherwise silently shadow (and drop) the shared spending section. Changes take effect at next unlock.
The freeze switch
sovr-mcp-approve freeze secret1… [--wipe] [--reason "why"]
sovr-mcp-approve unfreeze secret1…freeze writes a FREEZE.json sentinel the runtime checks before every gated action (sync stat; zero cooperation from the agent or even the server process required). On the next tool call the server wipes in-memory keys and refuses everything until the human unfreezes. --wipe additionally destroys keystore.json — irreversible; funds are gone unless the owner has an external mnemonic backup. A corrupt sentinel is treated as frozen (fail closed).
Auto-freeze: with auto_freeze_denials: N, N consecutive policy-gate denials (spend or messaging) make the runtime freeze itself — sentinel (by: "auto"), audit entry, key wipe. A successful gated action resets the counter. The counter is in-memory (a restart resets the count; the freeze it produced is persistent). Only the human unfreezes.
sovr_identity_status surfaces frozen, freezeReason, frozenIdentities, custody, autoUnlock, and autonomyMode.
Threat model — what sovereign mode does and does NOT protect against
Protected: a prompt-injected or misaligned model cannot exceed caps, cannot approve its own payments, cannot grant itself autonomy, cannot learn passphrases or keys, and leaves a hash-chained audit trail of everything it asked for. The policy engine enforces in-server, below the LLM.
NOT protected: a compromised host is a compromised agent. Anyone with shell as the OS user can run sovr-mcp-approve, read env vars, and (while unlocked) the process memory holds derived keys. Keychain custody raises the bar for offline key theft (the passphrase is not in a file), but a live same-uid attacker can call security find-generic-password too. Sovereign mode therefore changes blast radius, not trust: cap totals to what you can lose in a bad day, fund agent wallets as hot wallets (small operational balances, refill from cold storage manually), prefer testnet until the loop is boring, and keep require_human_above for anything you'd want to hear about.
Chain configuration (sovr-wallet.json)
Per identity at <SOVR_MCP_HOME>/identities/<address>/sovr-wallet.json; env vars override; defaults are testnet. Malformed file → unlock fails loudly (never guess endpoints for money).
{
"btc": { "network": "testnet", "esploraUrl": "https://blockstream.info/testnet/api" },
"scrt": { "network": "testnet", "lcdUrl": "https://pulsar.lcd.secretnodes.com", "chainId": "pulsar-3" },
"xmr": { "network": "mainnet", "walletRpcUrl": "http://127.0.0.1:18083/json_rpc" }
}- BTC — BIP84 native segwit, keys derived in key-bridge (
m/84'/{0,1}'/0'/0/0, verified against the official BIP84 test vector). Balance/UTXO/history/broadcast via any Esplora-compatible API. Signing is 100% local (@scure/btc-signer); the node only ever sees the final raw tx. - SCRT — balance/history via Cosmos LCD REST; sends via
secretjswith aDirectSecp256k1Walletbuilt from the identity's existing private key (the mnemonic is never re-imported). Send pre-flight rejects insufficient balance before broadcast. History is best-effort (LCD event indexing — see seams). - XMR — the honest boundary. Address derivation always works (pure-JS key-bridge, no WASM).
balance/send/historythrowWalletNotConfigureduntil the operator runsmonero-wallet-rpcwith a wallet restored from this identity's spend key and setswalletRpcUrl. As a guard, every send first callsget_addressand refuses if the RPC wallet's address doesn't match this identity. XMR memos are rejected (Monero has no on-chain memo field — a memo would be silently dropped). monero-javascript/WASM is deliberately NOT in this package; local scanning, subaddresses, and view-only mode are Phase 2+.
Audit
Every identity/contacts/messaging/wallet action, every policy denial, every approval decision is appended to <identityDir>/audit.jsonl when policy.audit.enabled:
- Hash-chained: each line carries
seq,prevHash, andhash(sha256 over the canonical entry) — silent deletion or reordering of past entries is detectable. A broken tail is markedCHAIN-BROKEN:…, never silently restarted. - Content hashes, never plaintext — the plaintext already lives in the encrypted store; the audit log stays correlation-capable without becoming a second plaintext copy. No secrets are ever logged.
- Pluggable sink:
AuditSinkinterface inagent-core/src/audit/sink.ts. The future Pegasus sink (bitemporal fact store) is an adapter point: implementAuditSinkagainst Pegasus ingestion and inject it whereJsonlAuditSinkis constructed (AgentRuntime._loadPolicy). This package does not couple to a live Pegasus.
The spend ledger (<identityDir>/spend-ledger.jsonl, append-only) backs the rolling daily_max window across restarts. A corrupt middle line fails spending closed; a truncated final line (crash artifact) is tolerated.
On-disk layout
~/.sovr-mcp/
sovr-policy.yaml # optional shared policy
identities/
secret1…/ # one dir per identity
keystore.json # mnemonic — scrypt + AES-256-GCM, mode 0600
identity.json # custody metadata {custody, autoUnlock, createdAt} — no secrets
sovr-policy.yaml # optional per-identity policy (wins)
sovr-wallet.json # optional chain networks/endpoints
audit.jsonl # hash-chained audit log
spend-ledger.jsonl # persistent rolling-24h spend accounting
FREEZE.json # freeze sentinel — checked before every gated action
pending-approvals/ # file-based owner approvals (15 min TTL)
store/ # SecureStore: contacts, ratchet sessions,
# message log — NaCl secretbox encryptedKnown limitations (seams)
- Messaging rate limit is in-memory — restarting the server resets the window counter. (The spend window is now persistent; this seam remains for messaging only.)
- SCRT history uses LCD
query=(CometBFT) — pulsar-3 secretnodes rejects the olderevents=param (query cannot be empty). Indexing still varies by node; spend ledger + audit log are the authoritative record of our own sends. - XMR history reveals no counterparty — Monero does not expose sender addresses (by design). BTC/XMR history also never sets
TxRecord.memo;invoice_statusthen matches on amount if you passed a memo. - BTC send uses one configured Esplora endpoint — the web wallet's multi-endpoint failover was deliberately not ported; point
esploraUrlat your own node for resilience. - Approval gate defends the model boundary, not local shell — anyone with shell access as the OS user can run
sovr-mcp-approve(they already own the passphrase-encrypted keystore's environment). - macOS keychain writes expose the passphrase in
securityargv for milliseconds — same-uidpscould catch it during the create call. Linux (secret-tool) uses stdin and has no such seam. Closing the macOS seam needs the native Security-framework API (no new deps in this pass). - The auto-freeze denial counter is in-memory — a server restart resets the count (the freeze it produced is a persistent file). Persistent counting would need the counter in the ledger; deferred.
- Self-unlock audit trail is per-identity, not central — failed self-unlocks land in that identity's
audit.jsonl; there is no server-level log beyond stderr. - No auto-reconnect on the relay client yet; if the relay drops, sends fail until the next
sovr_identity_unlock. - Inbox wake-up is
notifications/resources/updatedonsovr://inbox— inboundtype=textnotifies subscribed hosts (SDK 1.30.0sendResourceUpdated).sovr_message_waitstill blocks until the next inboundtype=text(ortimeoutMs);sovr_message_inboxremains the poll path. - JS strings can't be wiped — the decrypted mnemonic exists as an immutable string during unlock. Derived binary keys are wiped on lock; the string is GC'd when unreachable. Inherent Node limitation.
sovr_identity_importexists in the runtime but is deliberately NOT exposed as an MCP tool — passing a mnemonic through a tool argument puts it in model context, which violates the boundary.- Escrow is Phase 2 — no escrow tools exist in this pass, by scope decision.
Development
npm run typecheck --workspace=apps/sovr-mcp
npm test --workspace=apps/sovr-mcp # builds key-bridge+agent-core+self, runs runtime + MCP stdio smoke tests
npm test --workspace=packages/agent-coreAll wallet tests mock the HTTP/RPC layer (Esplora, LCD, monero-wallet-rpc) — zero live network. The smoke test drives the compiled server over real stdio MCP, including an elicitation-capable client and the sovr-mcp-approve CLI fallback, with signing done for real against an in-process mock Esplora.
