privily-plugin
v1.0.0
Published
Privily local MCP server — terminal-side Privily wallet integration for Claude Code.
Readme
privily-plugin
The Privily plugin for Claude Code — a private crypto wallet and payments tool. One package bundles a Skill and a local MCP server; the MCP is also usable standalone by any MCP client. Privily lives at app.privily.fi.
Status: 46 tools live, covering withdrawal, deposit, send, disperse, request, pay, exchange, approval, reads & discovery, and optional encryption-at-rest. Production-validated end-to-end across every flow.
The MCP runs as a Node 20+ process on stdio. It owns the Privily Access Key (~/.privily/access-key.json, chmod 0600). It mints backend session tokens lazily via the SDK's loginRPCV0 challenge-response (the stark sk never leaves the machine). For Privily-side operations (withdrawals + internal sends), it signs internally using the SDK's UnitsAccount. For external-chain operations (deposits), the user's external wallet signs and the MCP validates + broadcasts. A local capability-policy engine and a confirmation-gated Skill provide two independent safety layers.
Install
Privily ships as a Claude Code plugin — installing it adds the Privily Skill and the MCP server together. From inside Claude Code:
/pluginFind privily in the Anthropic community marketplace and install it. The
plugin's Skill then walks you through connecting your account — exporting the
Access Key from the Privily web app, then a confirmed import to
~/.privily/access-key.json (chmod 0600).
Standalone MCP (any MCP client). The same package is a normal MCP server:
claude mcp add privily -- npx privily-pluginEnvironment variables
All are optional — sensible defaults ship.
| Var | Default | Purpose |
|---|---|---|
| PRIVILY_ENV | production | One of production / mock. |
| PRIVILY_BACKEND_URL | per env (api.privily.fi / http://127.0.0.1:7777) | Auth + RPC reads. |
| PRIVILY_APPCHAIN_RPC_URL | per env (privily.karnot.xyz for production; loopback for mock) | UnitsProvider broadcast for withdrawals. |
| PRIVILY_APP_URL | per env (app.privily.fi for prod) | Web-app origin — used only to build the share_url for a payment Request (display string, never fetched). |
| PRIVILY_EVM_RPC_URL_<CHAIN> | public RPCs (eth.merkle.io, mainnet.base.org, arb1.arbitrum.io/rpc, mainnet.optimism.io, polygon-rpc.com, bsc-dataseed.binance.org) | Per-chain JSON-RPC for the EVM external-source R4 flows. <CHAIN> ∈ {ETHEREUM, BASE, ARBITRUM, OPTIMISM, POLYGON, BNB}. |
| PRIVILY_TRON_API_URL | https://api.trongrid.io | Tron API endpoint for the Tron external-source R4 flows. |
| PRIVILY_SOLANA_RPC_URL | https://api.mainnet-beta.solana.com | Solana JSON-RPC for the Solana external-source R4 flows. |
| PRIVILY_STARKNET_RPC_URL | https://rpc.starknet.lava.build | External Starknet-L2 JSON-RPC for the Starknet external-source R4 flows. Distinct from PRIVILY_APPCHAIN_RPC_URL (the Privily appchain). |
| PRIVILY_DEBUG | 0 | Verbose stderr logging. |
| PRIVILY_SDK_RPC_URL | (SDK default) | Override the SDK's hardcoded read-RPC URL (used when env=mock to point at the local mock-backend). |
| PRIVILY_INSECURE_SKIP_ENVELOPE | off | Dev hatch for the pre-pivot envelope verifier — refused for env=mock. Largely obsolete. |
| PRIVILY_PASSWORD | (unset) | The encryption-at-rest password. When set, an encrypted Access Key auto-unlocks at startup; when set before Privily_Import_Access_Key, the imported key is sealed. Read ONLY from the environment, never a tool argument. Set it transiently per session — persisting it in a dotfile defeats the encryption. |
| PRIVILY_NEW_PASSWORD | (unset) | The target password for Privily_Change_Password (encrypt a plaintext key, or re-key an encrypted one). Environment-only, like PRIVILY_PASSWORD. |
Tools (46 total)
Plomberie (5):
Privily_Health— liveness probe, no auth.Privily_Authentication— local session-state diagnostic.Privily_Validate_Address— per-chain address sanity check.Privily_Get_Operation_Status— poll operation status bybridge_id(=NewBridge.id/lpId).Privily_Verify_Prepare_Envelope— diagnostic-only since Phase 5.1.
Admin (7):
Privily_Get_Account_Metadata— which account is loaded; JWT presence; encryption state (encryption+unlocked).Privily_Disconnect— clear JWT cache and/or policies file;lock:truedrops the in-memory decrypted key (never deletes the Access Key file).Privily_Get_Capabilities— read the active safety policy + 24h spent.Privily_Set_Capabilities— install or replace the safety policy (confirmation-gated).Privily_Import_Access_Key— two-step Access Key import (preview → user-approved perform); seals the key whenPRIVILY_PASSWORDis set.Privily_Unlock— R0, decrypt an encrypted Access Key for the session (Phase 15). No arguments — the password is read fromPRIVILY_PASSWORDonly.Privily_Change_Password— R0, encrypt a plaintext key or change an encrypted key's password (Phase 15). Confirmation-gated; passwords fromPRIVILY_NEW_PASSWORD/PRIVILY_PASSWORD.
Portfolio (2):
Privily_Get_Account— suffix-masked account + chain_addr + supported_chains + beta_flags + ref_code.Privily_Get_Balances— Privily-appchain aggregated balances + USD value.
Prepare (7):
Privily_Prepare_Withdrawal— preflight Privily → external chain. Calls SDKgetBridgeData, runsverifySignedResponse, builds aLocalPrepareEnvelopecached 5 min.to_addressis optional — omitted → defaults to the wallet-owner address. Optionaldelay({value, unit}) schedules it for later (10 min – 72 h).Privily_Prepare_Deposit— preflight external chain → Privily. Same shape;raw_bridge_txholds the per-chain tx for the agent's wallet to sign.Privily_Prepare_Send— preflight a Send (all 4 directions).from_chain/to_chainselect the direction;getBridgeDatawithconf.isSend.to_addressis always mandatory (the explicit recipient is what makes it a Send);from_addressrequired for an externalfrom_chain.from_token+ optionalto_token— a Send can swap in-flight (to_tokendefaults tofrom_token).isAnon:truehides the sender. Optionaldelayschedules it — external destination only.Privily_Prepare_Disperse— preflight a Disperse: fan one source token out to up to 20 recipients. Always lands on an external chain — Privily→external or external→external (never to Privily).to_chain+ optionalto_tokenare batch-level (every recipient lands on the same chain, receives the same token);recipientsis an array of{to_address, amount, delay_minutes?}legs.from_chain"privily" or external;getDisperseBridgeDatawithconf.isSend.isAnonis one passthrough flag for the whole batch.delay_minutes(per-leg, 10–60, all-or-nothing) schedules each recipient's payout.Privily_Prepare_Pay— preflight a Pay: settle an existing Payment Request. A Pay is a Send whose destination is arequest_id— the backend resolves the amount / receive chain / receive token / recipient FROM the request; the payer only chooses where to fund FROM (from_chain).from_chain"privily" → Privily-funded (R3); an external chain → external-funded (R4,from_addressrequired). No swap-pay (funded with exactly the requested asset). Runs the FULL capability policy.isAnon:truehides the payer (Pay does NOT readanon_mode_default).Privily_Prepare_Exchange— preflight an Exchange: a SELF-DIRECTED swap (token A → token B), optionally across chains. The destination is ALWAYS the user — to a Privily destination the funds return to the connected account; to an external chain, at the user's ownto_address(to pay someone else, use Send). All 4 directions selected byfrom_chain/to_chain;getBridgeDatawithconf.isExchange.from_token+to_tokenboth required;amountis the from_token amount (backend quotes the receive).to_addressrequired for an externalto_chain, omitted for "privily";from_addressrequired for an externalfrom_chain. No Anonymous Mode (no counterparty). Optionaldelayschedules it — external destination only.Privily_Prepare_Approval— preflight a standalone ERC-20 / TRC-20 approval: grant the Privily bridge contract an allowance to move a token WITHOUT immediately bridging. A standalone approval is leg 0 of agetBridgeDataplan, broadcast on its own — the MCP isolates the approve leg and discards the bridge leg; the spender is always the Privily bridge contract.chainaccepts the EVM chains plustron(Phase 14);tokenmust be an ERC-20 / TRC-20 (a native asset has no approve step →SCHEMA_INVALID).unlimited:truewidens the allowance to max-uint256 (default false → an exact-amount approval). Runs a LIGHT capability check (operation/chain/token); the newallow_unlimited_approvalpolicy field can forbid the unlimited variant.
Delayed Execution (Phase 13bis). Prepare_Withdrawal / Prepare_Send / Prepare_Exchange take an optional delay: {value, unit} (unit ∈ minutes|hours); Prepare_Disperse takes a per-leg delay_minutes (all-or-nothing across the batch). A delay is valid ONLY when the destination is external (a flow that lands on Privily cannot be scheduled). Bounds: 10 min – 72 h (60 min cap per Disperse leg) — out-of-bounds → DELAY_OUT_OF_BOUNDS. Two capability-policy fields gate it: allow_delayed (default true) and max_delay_seconds (default null). A delayed Privily-source Submit_* signs a wide-bounds SNIP-9 outside-execution and relays it via processDelayedWithdrawals — output status: "scheduled", tx_hash: null, exec_timestamp; the backend broadcasts at the scheduled time. Poll status only AFTER the delay: a Privily-source delayed flow has nothing on-chain until exec_timestamp; an external-source delayed flow is two-stage — poll the deposit (operation_id) immediately, then poll the destination leg (second_operation_id) only after the schedule.
Submit (11):
Privily_Submit_Withdrawal— R3, MCP signs internally with the stark sk. Output:{tx_hash, bridge_id, status, explorer_url}.Privily_Submit_Deposit— R4, wallet-agnostic. MCP NEVER touches sk material; the agent's wallet signs and supplies a chain-specificsignedPayload. Source chains: EVM (EOA / ERC-1271 / EIP-7702 — raw signed tx hex), Tron (signed-tx JSON string), Solana (base64VersionedTransaction), Starknet (signed v3-invoke JSON) — Phase 14. Multi-leg support vialeg_indexfor EVM ERC-20 / Tron TRC-20 (2-leg approve+bridge); Solana + Starknet are single-leg.Privily_Submit_Send— R3, Privily-source Send. MCP signs internally. Branches on direction: Privily→Privily signs a SNIP-9 outside-execution relayed viaprocessTransfer(a transfer); Privily→external signs + broadcasts a direct appchain invoke (a bridge). Output:{direction, operation_id, tx_hash, status, explorer_url, notes}.Privily_Submit_Send_External— R4, external-source Send (external→Privily, external→external). Wallet-agnostic — reuses theSubmit_Depositmachinery; MCP never touches sk material. Output:{tx_hash, operation_id, second_operation_id?, status, signer_type, …}. external→external is a two-stage settlement: polloperation_idto SUCCESS, thensecond_operation_id(30 polls each); external→Privily is single-stage.Privily_Submit_Disperse— R3, Privily-source disperse. MCP signs internally — ONE appchain invoke covering every leg. Output:{direction, operation_ids, tx_hash, total_legs, status, …}—operation_idsis the per-recipient status-handle array (poll each, 30 polls per leg).Privily_Submit_Disperse_External— R4, external-source disperse. Wallet-agnostic — reuses theSubmit_Depositmachinery. Output:{tx_hash, operation_id, second_operation_ids, total_recipients, …}. Always two-stage: polloperation_id(the deposit) to SUCCESS, then eachsecond_operation_idsentry (30 polls each).Privily_Submit_Pay— R3, Privily-funded Pay. MCP signs internally. StructurallySubmit_Send: Privily→Privily signs a SNIP-9 outside-execution relayed viaprocessTransferwith therequestId(so the backend marks the request EXECUTED); Privily→external signs + broadcasts a direct appchain invoke. Output:{direction, operation_id, tx_hash, status, explorer_url, notes}.Privily_Submit_Pay_External— R4, external-funded Pay (external→Privily, external→external). Wallet-agnostic — reuses theSubmit_Depositmachinery; MCP never touches sk material. Output:{tx_hash, operation_id, second_operation_id?, status, signer_type, …}. external→external is two-stage; external→Privily single-stage.Privily_Submit_Exchange— R3, Privily-source Exchange (in-Privily swap, or Privily→external). MCP signs internally. An Exchange has no counterparty, so BOTH directions sign + broadcast a direct appchain invoke (theSubmit_Withdrawalmechanism — neverprocessTransfer). Output:{direction, operation_id, tx_hash, status, explorer_url, notes}—tx_hashpresent for both directions.Privily_Submit_Exchange_External— R4, external-source Exchange (external→Privily, external→external). Wallet-agnostic — reuses theSubmit_Depositmachinery; MCP never touches sk material. Output:{tx_hash, operation_id, second_operation_id?, status, signer_type, …}. external→external is two-stage; external→Privily single-stage.Privily_Submit_Approval— R4, standalone ERC-20 approval. Wallet-agnostic — reuses theSubmit_Depositmachinery (parse → field-equality → verify → broadcast); MCP never touches sk material. Always a single transaction (noleg_index). Output:{tx_hash, chain, token, token_contract, spender, unlimited, allowance, signer_type, status, explorer_url, notes}— nooperation_id: an approval is a plain on-chain tx, not a Privily operation; confirm it via the explorer, notGet_Operation_Status.
Request (2):
Privily_Create_Request— R2, the simplest Privily flow: it signs NOTHING and broadcasts NOTHING, just creates a Payment Request record (an invoice / pay-link) on the backend via the SDK'srequestPayment. Funds are requested into the connected Privily account by default, or onto an external chain (receive_chain+receive_address).payer_addressomitted → an open public pay-link; set → restricted to one Privily account.isAnon:truehides the requester (the share page renders "Anonymous");anon_mode_defaultapplies when omitted. Output:{request_id, share_url, scope, …}. A payer settles the request LATER via the Pay flow (Privily_Prepare_Pay).Privily_Cancel_Request— R2, the retract counterpart toCreate_Request: cancel a pending Payment Request the user created. Even simpler — signs nothing, broadcasts nothing, moves no value, no capability-policy gate (cancelling is purely subtractive). Input{request_id}(whatCreate_Requestreturned). Once cancelled the pay-link is dead. Only a PENDING request is cancellable, and only by its creator; an already-paid/failed/cancelled request →BACKEND_REJECTED. Output:{request_id, status: "cancelled", message, notes}.
Reads & Discovery (12 — Phase 16):
Privily_Get_History— R1, paginated operation history (onetypefilter over the SDK's four history feeds: all / deposit / withdrawal / transfer).Privily_Get_Prices— R1, oracle USD token prices. PUBLIC — answers before an account is connected.Privily_List_Assets— R1, the supported-token registry + liquidity-provider conf (surfaces the supported chains). PUBLIC.Privily_List_Requests— R1, the account's Payment Requests (sent / received) — the discovery counterpart of Create/Cancel_Request.Privily_Get_Referral— R1, referral code + referees + trading rebates.Privily_List_Addresses— R1, the saved address book (searchable by label / address).Privily_Save_Address— R2, add or update an address-book entry (labelmax 20 chars — the backend's limit). Moves no value — no policy gate; Skill-confirmed.Privily_Delete_Address— R2, delete an address-book entry by id. Skill-confirmed.Privily_Get_Quote— R1, a read-only swap / bridge price estimate — callsgetBridgeDatabut builds no envelope and commits nothing (NON-BINDING).route_available: false+ null pay/receive when the backend has no route.Privily_Verify_Signature— R0, verify an EVM (EIP-191) or Solana (Ed25519) message signature. Pure local — no network, no account.Privily_Search— R1, fuzzy substring discovery across history + requests + addresses.Privily_Fetch— R1, retrieve one entity by id (request / operation / address).
Security model
- Stark sk never sent to the backend, never returned in tool output (regex-scrubbed at the output boundary via
src/audit/redactor.ts), never logged, never accepted as tool input (root validator rejects fields namedsk,private_key,secret_key,entropy,seed,mnemonic,master_key,passphrase,password). - Encryption-at-rest (Phase 15, optional) — when
PRIVILY_PASSWORDis set the Access Key file is sealed withscrypt(KDF) + AES-256-GCM; with no password it stays plaintext at chmod 0600 (the v1 model, unchanged). Self-describing file format (anencryptionheader);LocalStoragedecrypts transparently. The password is read ONLY from the environment — never a tool argument, never logged, never returned — and the leakage gate covers a fixture passphrase. A capability policy may setrequire_encrypted_storageto refuse R3/R4 ops on a plaintext key. - CI import-graph check (
scripts/ci-import-graph-check.ts) gates which source files may import sk-handling SDK functions — onlysrc/signing/,src/admin/,src/storage/,src/auth/jwt.ts, plussrc/client/sdk-shim.tsas the gateway exemption. - CI forbidden-fields scan (
scripts/ci-forbidden-fields-check.ts) walks every tool'sjsonInputSchemaand rejects any sk-shape field name. - Leakage gate (
tests/leakage.test.ts+ per-tool suites) assertsassertNoSkLeakagainst every sk-touching tool's output + error paths. - Backend response signature verified via SDK 1.1.0
verifySignedResponse(ed25519 over SHA-256 of canonicalized JSON; pubkey hardcoded in SDK constants). - Local Access Key file chmod 0600 (POSIX) or owner-only ACL (Windows, via
icacls). - Lockfile at
~/.privily/mcp.lockprevents concurrent MCP processes. - No telemetry, no analytics, no auto-update. Outbound HTTPS limited to the configured Privily backend + per-chain RPCs.
- Localhost-only invariant: refuses
PRIVILY_HTTP_BIND=0.0.0.0. - Capability policy engine with confirmation-gated
Set_Capabilities, per-tx + rolling-24h USD caps, allowed/denied lists for operations / chains / tokens / recipients.
Full audit: docs/SECURITY_AUDIT_PHASE7.md. v1 ships with 14 ✅ / 3 ⚠️ (follow-ups, not failures) / 0 ❌ against the 19-item §17 checklist.
Dev quickstart
npm install
npm run ci # typecheck + import-graph + forbidden-fields + the vitest suite
npm run build
npm run dev # start the MCP on stdio for local invocationSmoke tests (each spawns the MCP child + a mock-backend on a random ephemeral port):
npm run smoke:health # confirms the tool list + Privily_Health responds
npm run smoke:auth-prod # exercises real backend login (needs network)
npm run smoke:policy # exercises Set_Capabilities + enforcement
npm run smoke:prepare # exercises Prepare_Withdrawal flow
npm run smoke:submit # full Prepare → Submit_Withdrawal end-to-end
npm run smoke:send # full Prepare → Submit_Send (Privily → Privily)
npm run smoke:send-external # full Prepare → Submit_Send_External (external → Privily)
npm run smoke:disperse # full Prepare → Submit_Disperse (Privily-source batch)
npm run smoke:disperse-external # full Prepare → Submit_Disperse_External (external-source)
npm run smoke:request # Privily_Create_Request (R2 — creates a pay-link, no signing)
npm run smoke:cancel-request # Create_Request → Cancel_Request round-trip (R2)
npm run smoke:pay # full Prepare_Pay → Submit_Pay (Privily-funded, R3)
npm run smoke:pay-external # full Prepare_Pay → Submit_Pay_External (external-funded, R4)
npm run smoke:exchange # full Prepare_Exchange → Submit_Exchange (in-Privily swap, R3)
npm run smoke:exchange-external # full Prepare_Exchange → Submit_Exchange_External (external-source, R4)
npm run smoke:delayed # scheduled Prepare_Withdrawal → Submit_Withdrawal (Phase 13bis, processDelayedWithdrawals)
npm run smoke:approval # full Prepare_Approval → Submit_Approval (exact + unlimited, Phase 12)
npm run smoke:tron-external # Prepare_Deposit → Submit_Deposit, Tron 2-leg TRC-20 (Phase 14)
npm run smoke:solana-external # Prepare_Deposit → Submit_Deposit, Solana VersionedTransaction (Phase 14)
npm run smoke:starknet-external # Prepare_Deposit → Submit_Deposit, Starknet v3 invoke (Phase 14)
npm run smoke:reads # all 12 Phase-16 read / discovery tools end-to-end
npm run smoke:encryption # Phase 15 — sealed-key auto-unlock + locked-key UNLOCK_FAILED
npm run smoke:deposit # full Prepare → Submit_Deposit (mock viem + EVM stub)
npm run smoke:admin # exercises Disconnect + Get_Account_Metadata
npm run smoke:perf # cold-start <2s + read-tool RTT <200ms targetsReal-prod ops helper (run on your own machine; reads PK from env, never logged):
PRIVILY_EVM_PK=<your-test-wallet-pk> \
PRIVILY_EVM_FROM=<your-evm-address> \
PRIVILY_DEP_CHAIN=base \
PRIVILY_DEP_TOKEN=ETH \
PRIVILY_DEP_AMOUNT=0.001 \
PRIVILY_ENV=production \
npx tsx scripts/manual-deposit-helper.tsPerformance
npm run smoke:perf targets, measured on Windows 11 / Node 24:
| Metric | Target | Measured (mean of 5) | Notes |
|---|---|---|---|
| Cold-start (process boot → initialize response) | < 6000 ms | ~3.4 s | A one-time cost — paid once when the MCP process is launched, not per operation. ESM module-evaluation of the dependency tree: the Privily SDK + starknet + viem + tronweb (~1.1 s) + @solana/web3.js + @noble/* + zod + the MCP SDK, plus 46 tool modules. The target was raised from the original Phase-7 2000 ms (16 tools, 1 chain family) for today's 46-tool / 5-chain surface. Documented future optimization: lazy-load tronweb. |
| Read-tool RTT (Privily_Authentication, warm process) | < 200 ms | ~6 ms | The per-operation latency — LocalStorage stat + zod validate + JSON serialize only. Tightly gated. |
Architecture
~/.privily/ cli/dapp share this path
├── access-key.json (chmod 0600) {chainAddr, account, sk, version:"v0"} — plaintext,
│ OR a {encryption, ciphertext} sealed file (Phase 15)
├── jwt-cache.json (chmod 0600) {jwt, expiresAt, accountAddress}
├── policies.json (chmod 0600) Policy schema (see CAPABILITY_POLICIES.md)
├── audit.csv (chmod 0644) ts,tool,risk,duration,status,notes
└── mcp.lock (chmod 0600) PID-based exclusive lock
mcp/src/
├── server.ts stdio MCP server entry
├── env.ts resolveConfig + validateNodeVersion
├── errors.ts typed McpError catalog (~50 codes)
├── client/
│ ├── sdk-shim.ts (PRIVILEGED) re-exports privily-sdk surface
│ ├── sdk-wrapper.ts getAuthenticatedRpcWrapper(storage)
│ └── sdk-errors.ts interpretSdkError → {code, message, details}
├── storage/ (PRIVILEGED) lockfile + LocalStorage + perms + encryption (Phase 15)
├── auth/ (PRIVILEGED) jwt.ts + session.ts
├── signing/ (PRIVILEGED) privily-appchain.ts (sk-handling)
├── admin/ (PRIVILEGED) capabilities, import-access-key, status, disconnect
├── audit/ redactor + logger
├── policies/ loader + enforcement + daily-counter
├── envelopes/ legacy secp256k1 verifier (diagnostic only)
├── prepare/ runner + withdrawal + deposit + send + disperse + pay + local-envelope
├── submit/ withdrawal + deposit + send(+external) + disperse(+external) + pay(+external) + external-chain-tx
├── request/ create.ts + cancel.ts — Request tools (R2, no signing)
├── chains/
│ ├── adapter.ts generic ChainAdapter interface
│ ├── privily/ appchain-side verifier + broadcaster + intent
│ ├── evm/ parser + verifier + broadcaster + adapter
│ ├── tvm/ Tron — parser + verifier + broadcaster + adapter + approval + allowance
│ ├── svm/ Solana — parser + verifier + broadcaster + adapter
│ └── snvm/ Starknet (external L2) — parser + verifier + broadcaster + adapter
├── validators/ address, freshness, idempotency, ownership
├── portfolio/ account + balances read tools
├── reads/ Phase 16 — history + prices + assets + requests + referral + addresses + quote + verify-signature + search + fetch
├── utils/ prepare-cache (5 min TTL, 256-entry LRU)
└── tools/ tool registry + per-tool handlersLicense
MIT.
