Skip to content

Why it's safe

The MCP server hands a model a wallet, so the question that matters is: what stops it overspending? The answer is the same in every case — the spend policy is the boundary, and the SDK enforces it before anything moves on-chain. The model never reaches the wallet directly; it goes through piprail_pay_request, which checks the policy first and only signs if it passes.

PIPRAIL_MAX_AMOUNT, PIPRAIL_MAX_TOTAL, PIPRAIL_TOKENS, and PIPRAIL_HOSTS are enforced by the SDK before any on-chain send. An over-budget or off-policy request comes back as a declined result and nothing moves — the model cannot overspend even if it tries.

{
"PIPRAIL_MAX_AMOUNT": "0.10", // per-payment ceiling
"PIPRAIL_MAX_TOTAL": "10.00", // lifetime cap, per token
"PIPRAIL_TOKENS": "USDC", // only these symbols (+ `native` for the coin)
"PIPRAIL_HOSTS": "*.example.com" // optional: only these hosts
}

When a request breaches the policy, the tool returns a structured refusal — { declined: true, reason, reasonCode: 'POLICY' } — instead of paying; nothing moves. The defaults are deliberately small and safe — 0.10 per payment, 10.00 lifetime per token, USDC on Base — so a misconfigured or zero-config server still can’t drain a wallet.

Checked against the token’s true decimals

Section titled “Checked against the token’s true decimals”

A price in a 402 challenge is just a number; its meaning depends on the token’s decimals. The SDK resolves the token’s true on-chain decimals and evaluates the cap against the real base amount — so a malicious server can’t slip past a limit by understating a price or claiming the wrong decimals.

Server says "0.10". SDK resolves USDC = 6 decimals → 100000 base units.
Cap is 0.10 → 100000 base units. Equal: allowed.
A server that mislabels the token can't make a 100.00 payment look like 0.10.

This is why PIPRAIL_ALLOW_UNKNOWN_TOKENS defaults to false: a token the SDK can’t price has no trustworthy decimals to check the cap against, so it’s refused rather than guessed at. Keep it false.

The server runs on your machine, with your key, against your RPC. PipRail runs no service and holds nothing — payments settle wallet-to-wallet, straight from your wallet to the resource’s payTo. There is no facilitator in the path and no account to sign up for.

The wallet secret is read once, from the environment, and never logged. Only the name of the env var that supplied it is surfaced in the startup banner — never the value:

Terminal window
export PIPRAIL_PRIVATE_KEY=0xYOUR_PRIVATE_KEY # stays in your process; never printed
npx -y @piprail/mcp

A mistyped policy var is a silent footgun — PIPRAIL_MAX_AMUONT would be ignored, and the cap you thought you set wouldn’t apply. The config layer refuses to start on any unrecognized PIPRAIL_* variable, naming the offender and listing the valid ones:

Unknown PipRail config var(s): PIPRAIL_MAX_AMUONT.
Valid vars: PIPRAIL_PRIVATE_KEY, PIPRAIL_WALLET_KEY, PIPRAIL_CHAIN, PIPRAIL_RPC_URL, PIPRAIL_MAX_AMOUNT, …

It fails loudly instead of running with a budget that isn’t the one you meant. The same fail-fast applies to an unknown chain and to a half-armed rolling window (PIPRAIL_WINDOW_TOTAL and PIPRAIL_WINDOW_SECONDS must be set together or neither).

The boundary is code you can read. @piprail/mcp and @piprail/sdk are MIT-licensed and open source — the policy check, the decimals resolution, and the wallet handling are all in the open, with no backend you’d have to take on trust. The config parser is pure and side-effect-free: it takes an env object you pass in and never touches process.env itself, so the exact rules above are unit-tested.

If you want to embed the same checks in your own process rather than run the server, the config parser and client-options builder are exported — see Use it as a library. The policy semantics themselves are documented under Payment policy and Evaluating a policy.