diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e470d47..62cac479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- New `mcpc connect --x402-max-amount ` option that caps every single x402 payment, e.g. `--x402-max-amount 0.50`. A payment above the cap fails instead of being signed, whatever the server asks for. The cap is stored with the session and reused on restart. - New [REFERENCE.md](docs/REFERENCE.md) with the full `--help` output of every mcpc command, generated from the CLI itself so it always matches the release. - `mcpc @session` and `server-discover` now show the description and website URL a server advertises about itself, right below its name. diff --git a/README.md b/README.md index f75d77aa..722859f5 100644 --- a/README.md +++ b/README.md @@ -829,6 +829,24 @@ mcpc @apify restart When `--x402` is active, a fetch middleware wraps all HTTP requests to the MCP server. If any request returns HTTP 402, the middleware transparently signs and retries. Your scheme preference is persisted in `sessions.json` and reused on every reconnect or restart. +### Limiting how much a session can spend + +Without a limit, a session pays whatever a tool call turns out to cost. `--x402-max-amount ` +caps every single payment — the price the server asks for, or for the `upto` scheme the maximum +authorization you sign: + +```bash +# Never sign a single payment above $0.50 +mcpc connect mcp.apify.com @apify --x402 --x402-max-amount 0.50 +``` + +A payment above the cap is refused locally, before anything is signed, and the tool call fails with +the amount that was asked for. The check covers all three payment paths: proactively priced tools, +HTTP 402 challenges, and payment-required tool results. + +The cap is stored in `sessions.json` and reused on every reconnect and restart, so a crashed +session comes back capped. To change it, close the session and connect again. + ### Supported networks | Network | Status | diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index cd10ee8d..fad34bf1 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -120,6 +120,7 @@ Options: --stdio Launch all local stdio servers from selected config files --protocol-version Pin the MCP protocol version (see below) --x402 [scheme] Enable x402 auto-payment (see below) + --x402-max-amount Refuse any single x402 payment above this amount --json Output in JSON format Server formats: @@ -152,6 +153,10 @@ Protocol version: x402 payments (experimental): --x402 pays for paid tool calls from the wallet set up with mcpc x402. Schemes: auto (default, prefers upto), upto, exact. + --x402-max-amount caps every single payment, e.g. --x402-max-amount 0.50. + A payment above the cap fails instead of being signed, whatever the server asks + for. The cap is stored with the session and reused on restart; close the session + to change it. Output: For a single server, shows session, server info, capabilities, and tools. diff --git a/skills/mcpc/SKILL.md b/skills/mcpc/SKILL.md index 0cf56560..870b94c9 100644 --- a/skills/mcpc/SKILL.md +++ b/skills/mcpc/SKILL.md @@ -244,6 +244,8 @@ mcpc @apify skills-get --raw # print the SKILL.md markdown (pipe to a (`--no-profile`, `--stdio`, `--proxy`, and `-H` are options of `connect`, not global flags.) `mcpc` also has experimental `--x402` auto-payment for paid MCP tools — see `mcpc help x402`. +When spending unattended, add `--x402-max-amount ` (e.g. `--x402-max-amount 0.50`): any single +payment above it is refused instead of signed, and the cap survives session restarts. ## Debugging diff --git a/src/bridge/index.ts b/src/bridge/index.ts index 141d83df..a6826e29 100644 --- a/src/bridge/index.ts +++ b/src/bridge/index.ts @@ -75,6 +75,8 @@ import type { ProxyConfig } from '../lib/types.js'; // only here and load the implementations lazily at the x402-gated call sites. import type { X402PaymentCache } from '../lib/x402/fetch-middleware.js'; import type { SignerWallet } from '../lib/x402/signer.js'; +// Spend-limit helpers are deliberately dependency-free, so they stay a static import. +import { X402PaymentLimitError, parseMaxAmountUsd, usdToAtomicUnits } from '../lib/x402/limits.js'; import type { FetchLike } from '@modelcontextprotocol/client'; // HTTP proxy and TLS settings are configured in main() after parsing --insecure flag @@ -96,6 +98,8 @@ interface BridgeOptions { protocolVersion?: string; // Protocol version negotiated by the resumed session (only set with mcpSessionId) /** x402 scheme preference; presence enables x402 auto-payment, absence disables. */ x402?: X402SchemePreference; + /** Local spend limit in USD applied to every single x402 payment. */ + x402MaxAmountUsd?: number; insecure?: boolean; // Skip TLS certificate verification } @@ -678,11 +682,13 @@ class BridgeProcess { return this.client?.getCachedTools()?.find((t: Tool) => t.name === name); }; const { createX402FetchMiddleware } = await import('../lib/x402/fetch-middleware.js'); + const maxAmountAtomicUnits = this.x402MaxAmountAtomicUnits(); customFetch = createX402FetchMiddleware(proxyFetch, { wallet, getToolByName, paymentCache: this.x402PaymentCache, ...(this.options.x402 && { schemePreference: this.options.x402 }), + ...(maxAmountAtomicUnits !== undefined && { maxAmountAtomicUnits }), }); } @@ -1301,6 +1307,12 @@ class BridgeProcess { } } + /** The session's `--x402-max-amount` in atomic units, or undefined when uncapped. */ + private x402MaxAmountAtomicUnits(): bigint | undefined { + const maxAmountUsd = this.options.x402MaxAmountUsd; + return maxAmountUsd === undefined ? undefined : usdToAtomicUnits(maxAmountUsd); + } + /** * Handle a tool result that contains x402 payment-required data. * Signs a fresh payment, caches it, and retries the tool call once. @@ -1337,18 +1349,23 @@ class BridgeProcess { // Invalidate cache and sign fresh this.x402PaymentCache.signature = null; + const { signPayment } = await import('../lib/x402/signer.js'); + const maxAmountAtomicUnits = this.x402MaxAmountAtomicUnits(); try { - const { signPayment } = await import('../lib/x402/signer.js'); const signed = await signPayment({ wallet: this.x402Wallet, accept: parsed.accept, resource: parsed.resource, + ...(maxAmountAtomicUnits !== undefined && { maxAmountAtomicUnits }), }); this.x402PaymentCache.signature = signed.paymentSignatureBase64; logger.debug( `Fresh payment signed for retry: $${signed.amountUsd.toFixed(6)} to ${signed.to} on ${signed.networkLabel}` ); } catch (signError) { + // Refusing to exceed the spend limit must reach the caller — returning the + // payment-required result instead would read as "the server wants payment". + if (signError instanceof X402PaymentLimitError) throw signError; logger.warn('Failed to sign fresh payment for 402 retry:', signError); return { handled: false }; } @@ -1899,7 +1916,7 @@ async function main(): Promise { if (args.length < 2) { console.error( - 'Usage: mcpc-bridge [--verbose] [--profile ] [--proxy-host ] [--proxy-port ] [--mcp-session-id ] [--protocol-version ] [--x402 ] [--insecure]' + 'Usage: mcpc-bridge [--verbose] [--profile ] [--proxy-host ] [--proxy-port ] [--mcp-session-id ] [--protocol-version ] [--x402 ] [--x402-max-amount ] [--insecure]' ); process.exit(1); } @@ -1955,6 +1972,18 @@ async function main(): Promise { x402 = value as X402SchemePreference; } + // Parse `--x402-max-amount ` (local spend limit for every single payment). + let x402MaxAmountUsd: number | undefined; + const x402MaxAmountIndex = args.indexOf('--x402-max-amount'); + if (x402MaxAmountIndex !== -1) { + try { + x402MaxAmountUsd = parseMaxAmountUsd(args[x402MaxAmountIndex + 1] ?? ''); + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } + } + // Parse --insecure flag (skip TLS certificate verification) const insecure = args.includes('--insecure'); @@ -1983,6 +2012,9 @@ async function main(): Promise { if (x402) { bridgeOptions.x402 = x402; } + if (x402MaxAmountUsd !== undefined) { + bridgeOptions.x402MaxAmountUsd = x402MaxAmountUsd; + } if (insecure) { bridgeOptions.insecure = true; } diff --git a/src/cli/commands/connect.ts b/src/cli/commands/connect.ts index 5d133e8a..56b44f12 100644 --- a/src/cli/commands/connect.ts +++ b/src/cli/commands/connect.ts @@ -50,6 +50,7 @@ import { storeKeychainProxyBearerToken, } from '../../lib/auth/keychain.js'; import { getWallet } from '../../lib/wallets.js'; +import { formatUsdAmount, usdToAtomicUnits } from '../../lib/x402/limits.js'; import chalk from 'chalk'; // ora is loaded lazily at the spinner call site — it is only needed for // human-mode bulk connects and costs ~50 ms at import. @@ -145,6 +146,7 @@ type ConnectSessionOptions = { proxyBearerToken?: string; protocolVersion?: string; x402?: X402SchemePreference; + x402MaxAmountUsd?: number; insecure?: boolean; skipDetails?: boolean; quiet?: boolean; @@ -292,12 +294,32 @@ export async function connectSession( // Validate --protocol-version (if provided) assertSupportedProtocolVersion(options.protocolVersion); + if (options.x402MaxAmountUsd !== undefined && !options.x402) { + throw new ClientError('--x402-max-amount requires --x402'); + } + + /** Render a stored or requested spend limit for error messages. */ + const formatLimit = (maxAmountUsd: number | undefined): string => + maxAmountUsd === undefined ? 'no limit' : formatUsdAmount(usdToAtomicUnits(maxAmountUsd)); + // Check if session already exists const existingSession = await getSession(name); if (existingSession) { const bridgeStatus = getBridgeStatus(existingSession); if (bridgeStatus === 'live') { + // A live bridge keeps the limit it was started with, so silently ignoring a + // different --x402-max-amount would leave the session spending on the old one. + if ( + options.x402MaxAmountUsd !== undefined && + options.x402MaxAmountUsd !== existingSession.x402MaxAmountUsd + ) { + throw new ClientError( + `Session ${name} is already active with a different x402 spend limit ` + + `(${formatLimit(existingSession.x402MaxAmountUsd)}, requested ${formatLimit(options.x402MaxAmountUsd)}). ` + + `To change it, run: mcpc ${name} close` + ); + } // Session exists and bridge is running - just show server info if (options.outputMode === 'human' && !options.quiet) { console.log(formatSuccess(`Session ${name} is already active`)); @@ -389,6 +411,11 @@ export async function connectSession( logger.debug(`Using x402 wallet: ${wallet.address}`); } + // A bare reconnect (`mcpc connect @name` after a crash) must not drop the + // spend limit the session was created with — restoring it keeps the guard on until + // the session is explicitly closed. + const effectiveX402MaxAmountUsd = options.x402MaxAmountUsd ?? existingSession?.x402MaxAmountUsd; + // Create or update session record (without pid - that comes from startBridge) // Store serverConfig with headers redacted (actual values in keychain) const isReconnect = !!existingSession; @@ -403,6 +430,9 @@ export async function connectSession( ...(profileName && { profileName }), ...(proxyConfig && { proxy: proxyConfig }), ...(options.x402 && { x402: options.x402 }), + ...(effectiveX402MaxAmountUsd !== undefined && { + x402MaxAmountUsd: effectiveX402MaxAmountUsd, + }), ...(options.insecure && { insecure: true }), // Clear any previous error status (unauthorized, expired) when reconnecting ...(isReconnect && { status: 'active' }), @@ -432,6 +462,9 @@ export async function connectSession( ...(profileName && { profileName }), ...(proxyConfig && { proxyConfig }), ...(options.x402 && { x402: options.x402 }), + ...(effectiveX402MaxAmountUsd !== undefined && { + x402MaxAmountUsd: effectiveX402MaxAmountUsd, + }), ...(options.insecure && { insecure: true }), }; @@ -646,6 +679,7 @@ type BulkConnectOptions = { stdio?: boolean; protocolVersion?: string; x402?: X402SchemePreference; + x402MaxAmountUsd?: number; insecure?: boolean; }; diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index 96cd022e..a845149f 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -438,6 +438,7 @@ export async function restartSession( ...(profileName && { profileName }), ...(session.proxy && { proxyConfig: session.proxy }), ...(session.x402 && { x402: session.x402 }), + ...(session.x402MaxAmountUsd !== undefined && { x402MaxAmountUsd: session.x402MaxAmountUsd }), ...(session.insecure && { insecure: session.insecure }), }; diff --git a/src/cli/index.ts b/src/cli/index.ts index 3c3d18d1..e95ecc96 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -35,6 +35,7 @@ import { clean } from './commands/clean.js'; import { MCPC_OAUTH_CALLBACK_HOSTS, MCPC_OAUTH_CALLBACK_PORTS } from '../lib/auth/oauth-utils.js'; import type { OutputMode, X402SchemePreference } from '../lib/index.js'; import { X402_SCHEME_PREFERENCES } from '../lib/index.js'; +import { parseMaxAmountUsd } from '../lib/x402/limits.js'; import { extractOptions, preProcessSkillArgv, @@ -89,6 +90,8 @@ interface HandlerOptions { * `--x402` (no value) resolves to `'auto'` (prefer upto, fall back to exact). */ x402?: X402SchemePreference; + /** Local spend limit in USD applied to every single x402 payment. */ + x402MaxAmountUsd?: number; insecure?: boolean; schema?: string; schemaMode?: 'strict' | 'compatible' | 'ignore'; @@ -146,6 +149,10 @@ function getOptionsFromCommand(command: Command): HandlerOptions { } options.x402 = opts.x402 as X402SchemePreference; } + if (opts.x402MaxAmount !== undefined) { + options.x402MaxAmountUsd = parseMaxAmountUsd(opts.x402MaxAmount as string); + if (!options.x402) throw new ClientError('--x402-max-amount requires --x402.'); + } if (opts.insecure) options.insecure = true; if (opts.schema) options.schema = opts.schema; if (opts.schemaMode) { @@ -489,6 +496,7 @@ Full docs: ${docsUrl}` .option('--stdio', 'Launch all local stdio servers from selected config files') .option('--protocol-version ', 'Pin the MCP protocol version (see below)') .option('--x402 [scheme]', 'Enable x402 auto-payment (see below)') + .option('--x402-max-amount ', 'Refuse any single x402 payment above this amount') .addHelpText( 'after', ` @@ -522,6 +530,10 @@ ${chalk.bold('Protocol version:')} ${chalk.bold('x402 payments (experimental):')} --x402 pays for paid tool calls from the wallet set up with mcpc x402. Schemes: auto (default, prefers upto), upto, exact. + --x402-max-amount caps every single payment, e.g. --x402-max-amount 0.50. + A payment above the cap fails instead of being signed, whatever the server asks + for. The cap is stored with the session and reused on restart; close the session + to change it. ${outputHelp([ 'For a single server, shows session, server info, capabilities, and tools.', 'Bulk connects list every session with its state, then a summary.', @@ -553,6 +565,9 @@ ${outputHelp([ ...(opts.stdio && { stdio: true }), ...(opts.protocolVersion && { protocolVersion: opts.protocolVersion as string }), ...(globalOpts.x402 && { x402: globalOpts.x402 }), + ...(globalOpts.x402MaxAmountUsd !== undefined && { + x402MaxAmountUsd: globalOpts.x402MaxAmountUsd, + }), ...(globalOpts.insecure && { insecure: true }), }); // Trailing blank line to match the spacing of other commands (human mode only). @@ -585,6 +600,9 @@ ${outputHelp([ ...(opts.stdio && { stdio: true }), ...(opts.protocolVersion && { protocolVersion: opts.protocolVersion as string }), ...(globalOpts.x402 && { x402: globalOpts.x402 }), + ...(globalOpts.x402MaxAmountUsd !== undefined && { + x402MaxAmountUsd: globalOpts.x402MaxAmountUsd, + }), ...(globalOpts.insecure && { insecure: true }), }); return; @@ -610,6 +628,9 @@ ${outputHelp([ proxyBearerToken: opts.proxyBearerToken, ...(opts.protocolVersion && { protocolVersion: opts.protocolVersion as string }), ...(globalOpts.x402 && { x402: globalOpts.x402 }), + ...(globalOpts.x402MaxAmountUsd !== undefined && { + x402MaxAmountUsd: globalOpts.x402MaxAmountUsd, + }), ...(globalOpts.insecure && { insecure: true }), }); } else { @@ -620,6 +641,9 @@ ${outputHelp([ proxyBearerToken: opts.proxyBearerToken, ...(opts.protocolVersion && { protocolVersion: opts.protocolVersion as string }), ...(globalOpts.x402 && { x402: globalOpts.x402 }), + ...(globalOpts.x402MaxAmountUsd !== undefined && { + x402MaxAmountUsd: globalOpts.x402MaxAmountUsd, + }), ...(globalOpts.insecure && { insecure: true }), }); } diff --git a/src/cli/output.ts b/src/cli/output.ts index 2a635336..d989013a 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -4,6 +4,7 @@ */ import chalk from 'chalk'; +import { formatUsdAmount, usdToAtomicUnits } from '../lib/x402/limits.js'; import type { DiscoverResult, GetPromptResult, @@ -1545,7 +1546,12 @@ export function formatSessionLine(session: SessionData): string { // x402 takes precedence when both happen to be present on the session record. let infoStr = ''; if (session.x402) { - infoStr = theme.yellow('[x402]'); + // Show the spend limit — it is the difference between a capped wallet and an open one. + infoStr = theme.yellow( + session.x402MaxAmountUsd === undefined + ? '[x402]' + : `[x402 max ${formatUsdAmount(usdToAtomicUnits(session.x402MaxAmountUsd))}]` + ); } else if (!session.server.command && session.profileName) { infoStr = chalk.dim('(OAuth: ') + theme.magenta(session.profileName) + chalk.dim(')'); } diff --git a/src/lib/bridge-manager.ts b/src/lib/bridge-manager.ts index 1497ceaf..7113d84f 100644 --- a/src/lib/bridge-manager.ts +++ b/src/lib/bridge-manager.ts @@ -120,6 +120,8 @@ export interface StartBridgeOptions { protocolVersion?: string; // Protocol version negotiated by the resumed session (only pass with mcpSessionId) /** x402 scheme preference; presence enables x402 auto-payment, absence disables. */ x402?: X402SchemePreference; + /** Local spend limit in USD applied to every single x402 payment. */ + x402MaxAmountUsd?: number; insecure?: boolean; // Skip TLS certificate verification } @@ -153,6 +155,7 @@ export async function startBridge(options: StartBridgeOptions): Promise => { // Try to get a payment signature (cached or freshly signed) for tools/call requests @@ -134,7 +141,8 @@ export function createX402FetchMiddleware( wallet, getToolByName, paymentCache, - schemePreference + schemePreference, + maxAmountAtomicUnits ); if (paymentSignature) { const enhancedInit = injectPayment(init, paymentSignature); @@ -155,7 +163,8 @@ export function createX402FetchMiddleware( baseFetch, wallet, paymentCache, - schemePreference + schemePreference, + maxAmountAtomicUnits ); } @@ -171,7 +180,8 @@ export function createX402FetchMiddleware( baseFetch, wallet, paymentCache, - schemePreference + schemePreference, + maxAmountAtomicUnits ); } @@ -189,7 +199,8 @@ async function getOrSignPayment( wallet: SignerWallet, getToolByName: ((name: string) => Tool | undefined) | undefined, paymentCache: X402PaymentCache, - schemePreference?: SchemePreference + schemePreference?: SchemePreference, + maxAmountAtomicUnits?: bigint ): Promise { if (!init?.body) { return undefined; @@ -247,13 +258,20 @@ async function getOrSignPayment( } try { - const result = await signPayment({ wallet, accept }); + const result = await signPayment({ + wallet, + accept, + ...(maxAmountAtomicUnits !== undefined && { maxAmountAtomicUnits }), + }); logger.debug( `Fresh payment signed: scheme=${accept.scheme} amount=$${result.amountUsd.toFixed(6)} to=${result.to} network=${result.networkLabel}` ); paymentCache.signature = result.paymentSignatureBase64; return result.paymentSignatureBase64; } catch (error) { + // Over the spend limit is a decision, not a failure to sign: surface it instead of + // letting the call go out unpaid and get charged through the 402 path. + if (error instanceof X402PaymentLimitError) throw error; logger.warn(`Payment signing failed for tool "${toolName}":`, error); return undefined; } @@ -270,7 +288,8 @@ async function handle402Fallback( baseFetch: FetchLike, wallet: SignerWallet, paymentCache: X402PaymentCache, - schemePreference?: SchemePreference + schemePreference?: SchemePreference, + maxAmountAtomicUnits?: bigint ): Promise { // Extract PAYMENT-REQUIRED header (case-insensitive) const paymentRequiredBase64 = @@ -298,6 +317,7 @@ async function handle402Fallback( wallet, accept, resource: header.resource, + ...(maxAmountAtomicUnits !== undefined && { maxAmountAtomicUnits }), }); logger.debug( @@ -318,6 +338,9 @@ async function handle402Fallback( const retryInit = injectPayment(originalInit, result.paymentSignatureBase64); return await baseFetch(url, retryInit); } catch (error) { + // The caller asked for the payment to be refused above the limit — returning the 402 + // here would hide that behind the server's own "payment required" message. + if (error instanceof X402PaymentLimitError) throw error; logger.warn('402 fallback signing failed:', error); return response402; } diff --git a/src/lib/x402/limits.ts b/src/lib/x402/limits.ts new file mode 100644 index 00000000..effd0d2a --- /dev/null +++ b/src/lib/x402/limits.ts @@ -0,0 +1,53 @@ +/** + * Local spend limit for x402 auto-payments. + * + * Deliberately dependency-free (no viem, no signer) so the CLI can validate + * `--x402-max-amount` at startup without pulling in the bundled crypto code. + * + * The limit is enforced in `signPayment()`, the single choke point every + * automatic payment path goes through: proactive `_meta.x402` signing, the + * HTTP 402 fallback, and the bridge's payment-required tool-result retry. + */ + +import { ClientError } from '../errors.js'; + +/** Decimals of the stablecoins x402 settles in (USDC on Base). */ +export const USDC_DECIMALS = 6; + +/** Smallest payment that can be expressed on-chain, in USD. */ +export const MIN_AMOUNT_USD = 1 / 10 ** USDC_DECIMALS; + +/** + * A payment was refused locally because it exceeded the session's spend limit. + * + * Distinct from other signing failures on purpose: the payment paths swallow + * signing errors and let the request go out unpaid, which for a spend limit + * would silently degrade into "call the paid tool without paying". Callers + * re-throw this one so the limit is always visible. + */ +export class X402PaymentLimitError extends ClientError {} + +/** Convert a USD amount to atomic units of a 6-decimal stablecoin. */ +export function usdToAtomicUnits(usd: number): bigint { + return BigInt(Math.round(usd * 10 ** USDC_DECIMALS)); +} + +/** Format atomic units as USD, keeping cents but not trailing noise ($0.50, $0.000125). */ +export function formatUsdAmount(atomicUnits: bigint): string { + const usd = Number(atomicUnits) / 10 ** USDC_DECIMALS; + return `$${usd.toFixed(USDC_DECIMALS).replace(/(\.\d{2}\d*?)0+$/, '$1')}`; +} + +/** + * Validate a `--x402-max-amount` value and return it in USD. + * Throws a `ClientError` with the expected format when it is not a usable amount. + */ +export function parseMaxAmountUsd(value: string | number): number { + const usd = typeof value === 'number' ? value : Number(value.trim()); + if (!Number.isFinite(usd) || usd < MIN_AMOUNT_USD) { + throw new ClientError( + `Invalid --x402-max-amount value: "${String(value)}". Expected a dollar amount of at least ${MIN_AMOUNT_USD.toFixed(USDC_DECIMALS)} (e.g. 0.50).` + ); + } + return usd; +} diff --git a/src/lib/x402/signer.ts b/src/lib/x402/signer.ts index 3254a4f3..3c720797 100644 --- a/src/lib/x402/signer.ts +++ b/src/lib/x402/signer.ts @@ -19,6 +19,7 @@ import { import { ClientError } from '../errors.js'; import { createLogger } from '../logger.js'; import type { X402SchemePreference } from '../types.js'; +import { USDC_DECIMALS, X402PaymentLimitError, formatUsdAmount } from './limits.js'; const logger = createLogger('x402-signer'); @@ -27,7 +28,6 @@ const logger = createLogger('x402-signer'); // --------------------------------------------------------------------------- export const X402_VERSION = 2; -const USDC_DECIMALS = 6; /** Fallback expiry when neither the caller nor the `accept` advertise one. */ export const DEFAULT_PAYMENT_EXPIRY_SECONDS = 3600; @@ -171,6 +171,12 @@ export interface SignPaymentInput { * approvals yourself. */ skipPermit2Approval?: boolean; + /** + * Local spend limit in atomic units. When set, signing a payment that authorizes + * more than this throws `X402PaymentLimitError` instead — for the `upto` scheme + * that caps the maximum authorization, not the amount finally captured. + */ + maxAmountAtomicUnits?: bigint; } export interface SignPaymentResult { @@ -310,12 +316,41 @@ export function parsePaymentRequired( // Signing // --------------------------------------------------------------------------- +/** + * Refuse a payment that authorizes more than the session's `--x402-max-amount`. + * + * Runs before any scheme-specific signing so every automatic payment path — proactive + * `_meta.x402` signing, the HTTP 402 fallback, and the bridge's payment-required retry — + * is capped by the same check, whatever terms the server sends. + */ +function assertWithinSpendLimit(input: SignPaymentInput): void { + const { accept, amountOverride, maxAmountAtomicUnits } = input; + if (maxAmountAtomicUnits === undefined) return; + + let amountAtomicUnits: bigint; + try { + amountAtomicUnits = amountOverride ?? BigInt(accept.amount); + } catch { + throw new X402PaymentLimitError( + `x402 payment refused: amount "${accept.amount}" is not a valid atomic-unit integer, so it cannot be checked against --x402-max-amount.` + ); + } + + if (amountAtomicUnits <= maxAmountAtomicUnits) return; + throw new X402PaymentLimitError( + `x402 payment refused: ${formatUsdAmount(amountAtomicUnits)} to ${accept.payTo} exceeds the ` + + `${formatUsdAmount(maxAmountAtomicUnits)} limit set by --x402-max-amount. ` + + `To allow it, reconnect the session with a higher --x402-max-amount.` + ); +} + /** * Sign an x402 payment and return a base64-encoded PAYMENT-SIGNATURE header value. * Delegates to scheme-specific signers based on `accept.scheme`. */ export async function signPayment(input: SignPaymentInput): Promise { const { accept } = input; + assertWithinSpendLimit(input); logger.debug( `Signing x402 payment: scheme=${accept.scheme} network=${accept.network} amount=${accept.amount} asset=${accept.asset} payTo=${accept.payTo} facilitator=${accept.extra?.facilitatorAddress ?? ''}` ); diff --git a/test/unit/cli/output.test.ts b/test/unit/cli/output.test.ts index dee35e83..cd773e69 100644 --- a/test/unit/cli/output.test.ts +++ b/test/unit/cli/output.test.ts @@ -2186,6 +2186,18 @@ describe('formatSessionLine', () => { expect(output).not.toContain('stdio'); }); + it('should show the x402 spend limit when the session has one', () => { + const base: SessionData = { + name: '@paid', + server: { url: 'https://mcp.example.com' }, + x402: 'auto', + createdAt: '2025-01-01T00:00:00Z', + }; + + expect(formatSessionLine(base)).toContain('[x402]'); + expect(formatSessionLine({ ...base, x402MaxAmountUsd: 0.5 })).toContain('[x402 max $0.50]'); + }); + it('should include proxy info when configured', () => { const session: SessionData = { name: '@proxy-test', diff --git a/test/unit/lib/x402/fetch-middleware.test.ts b/test/unit/lib/x402/fetch-middleware.test.ts index 23b48139..e023e663 100644 --- a/test/unit/lib/x402/fetch-middleware.test.ts +++ b/test/unit/lib/x402/fetch-middleware.test.ts @@ -15,6 +15,7 @@ import { type X402PaymentCache, } from '../../../../src/lib/x402/fetch-middleware.js'; import type { PaymentRequiredAccept, SignerWallet } from '../../../../src/lib/x402/signer.js'; +import { X402PaymentLimitError } from '../../../../src/lib/x402/limits.js'; // --------------------------------------------------------------------------- // Mocks — vi.mock is hoisted above local const declarations @@ -172,6 +173,22 @@ describe('createX402FetchMiddleware proactive sign', () => { expect(new Headers(init.headers).get('PAYMENT-SIGNATURE')).toBeNull(); }); + it('fails the call instead of sending it unpaid when the payment is over the limit', async () => { + mockSignPayment.mockRejectedValue(new X402PaymentLimitError('x402 payment refused: $1.00')); + const baseFetch = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + getToolByName: () => makePaidTool({ accepts: [EXACT_ACCEPT] }), + paymentCache: { signature: null }, + maxAmountAtomicUnits: 500_000n, + }); + + await expect( + fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }) + ).rejects.toThrow('x402 payment refused'); + expect(baseFetch).not.toHaveBeenCalled(); + }); + it('with schemePreference=exact and accepts=[exact, upto], signs exact', async () => { const tool = makePaidTool({ accepts: [EXACT_ACCEPT, UPTO_ACCEPT], ...UPTO_ACCEPT }); const cache: X402PaymentCache = { signature: null }; @@ -303,6 +320,44 @@ describe('createX402FetchMiddleware HTTP 402 fallback', () => { const init = baseFetch.mock.calls[2]?.[1] as RequestInit; expect(new Headers(init.headers).get('PAYMENT-SIGNATURE')).toBe('mock-signature-base64'); }); + + it('passes the spend limit to the signer', async () => { + const baseFetch = vi + .fn() + .mockResolvedValueOnce( + new Response('', { status: 402, headers: { 'PAYMENT-REQUIRED': paymentRequiredHeader } }) + ) + .mockResolvedValue(new Response('', { status: 200 })); + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + getToolByName: () => undefined, + paymentCache: { signature: null }, + maxAmountAtomicUnits: 500_000n, + }); + + await fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }); + + expect(mockSignPayment.mock.calls[0]?.[0]?.maxAmountAtomicUnits).toBe(500_000n); + }); + + it('surfaces a refused payment instead of returning the 402 to the caller', async () => { + mockSignPayment.mockRejectedValue(new X402PaymentLimitError('x402 payment refused: $1.00')); + const baseFetch = vi + .fn() + .mockResolvedValue( + new Response('', { status: 402, headers: { 'PAYMENT-REQUIRED': paymentRequiredHeader } }) + ); + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + getToolByName: () => undefined, + paymentCache: { signature: null }, + maxAmountAtomicUnits: 500_000n, + }); + + await expect( + fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }) + ).rejects.toThrow('x402 payment refused'); + }); }); // --------------------------------------------------------------------------- diff --git a/test/unit/lib/x402/limits.test.ts b/test/unit/lib/x402/limits.test.ts new file mode 100644 index 00000000..dec8344a --- /dev/null +++ b/test/unit/lib/x402/limits.test.ts @@ -0,0 +1,54 @@ +/** + * Unit tests for the x402 local spend limit helpers (`--x402-max-amount`). + */ + +import { ClientError } from '../../../../src/lib/errors.js'; +import { + MIN_AMOUNT_USD, + formatUsdAmount, + parseMaxAmountUsd, + usdToAtomicUnits, +} from '../../../../src/lib/x402/limits.js'; + +describe('usdToAtomicUnits', () => { + it('converts dollars to 6-decimal atomic units', () => { + expect(usdToAtomicUnits(1)).toBe(1_000_000n); + expect(usdToAtomicUnits(0.5)).toBe(500_000n); + expect(usdToAtomicUnits(MIN_AMOUNT_USD)).toBe(1n); + }); + + it('rounds binary-float amounts to the nearest atomic unit', () => { + // 0.07 * 1e6 is 70000.00000000001 in IEEE 754 — must not truncate to 69999 + expect(usdToAtomicUnits(0.07)).toBe(70_000n); + expect(usdToAtomicUnits(0.29)).toBe(290_000n); + }); +}); + +describe('formatUsdAmount', () => { + it('keeps cents but drops trailing noise', () => { + expect(formatUsdAmount(500_000n)).toBe('$0.50'); + expect(formatUsdAmount(1_000_000n)).toBe('$1.00'); + expect(formatUsdAmount(1_234_500n)).toBe('$1.2345'); + }); + + it('keeps sub-cent amounts readable', () => { + expect(formatUsdAmount(125n)).toBe('$0.000125'); + expect(formatUsdAmount(1n)).toBe('$0.000001'); + }); +}); + +describe('parseMaxAmountUsd', () => { + it('accepts positive dollar amounts', () => { + expect(parseMaxAmountUsd('0.50')).toBe(0.5); + expect(parseMaxAmountUsd('2')).toBe(2); + expect(parseMaxAmountUsd(' 1.25 ')).toBe(1.25); + }); + + it.each(['', '0', '-1', 'abc', '1.0abc', 'Infinity', '0.0000001'])( + 'rejects %o', + (value: string) => { + expect(() => parseMaxAmountUsd(value)).toThrow(ClientError); + expect(() => parseMaxAmountUsd(value)).toThrow('--x402-max-amount'); + } + ); +}); diff --git a/test/unit/lib/x402/signer.test.ts b/test/unit/lib/x402/signer.test.ts index d15f9070..897efa6d 100644 --- a/test/unit/lib/x402/signer.test.ts +++ b/test/unit/lib/x402/signer.test.ts @@ -11,6 +11,7 @@ import { type PaymentRequiredAccept, type SignerWallet, } from '../../../../src/lib/x402/signer.js'; +import { X402PaymentLimitError } from '../../../../src/lib/x402/limits.js'; // --------------------------------------------------------------------------- // Mocks @@ -326,6 +327,61 @@ describe('signPayment', () => { expect(result.amountUsd).toBe(3); }); + // ------------------------------------------------------------------------- + // local spend limit (--x402-max-amount) + // ------------------------------------------------------------------------- + + it('signs when the amount is at the spend limit', async () => { + const result = await signPayment({ + wallet: MOCK_WALLET, + accept: VALID_EXACT_ACCEPT, + maxAmountAtomicUnits: BigInt(VALID_EXACT_ACCEPT.amount), + }); + expect(result.paymentSignatureBase64).toBeTruthy(); + }); + + it('refuses to sign above the spend limit, naming both amounts', async () => { + const attempt = signPayment({ + wallet: MOCK_WALLET, + accept: VALID_EXACT_ACCEPT, // $1.00 + maxAmountAtomicUnits: 500_000n, // $0.50 + }); + await expect(attempt).rejects.toBeInstanceOf(X402PaymentLimitError); + await expect(attempt).rejects.toThrow('$1.00'); + await expect(attempt).rejects.toThrow('$0.50 limit'); + }); + + it('caps the upto scheme by its maximum authorization', async () => { + await expect( + signPayment({ + wallet: MOCK_WALLET, + accept: VALID_UPTO_ACCEPT, // authorizes up to $5.00 + maxAmountAtomicUnits: 1_000_000n, + }) + ).rejects.toBeInstanceOf(X402PaymentLimitError); + }); + + it('checks amountOverride rather than the advertised amount', async () => { + await expect( + signPayment({ + wallet: MOCK_WALLET, + accept: VALID_EXACT_ACCEPT, + amountOverride: 9_000_000n, + maxAmountAtomicUnits: 2_000_000n, + }) + ).rejects.toBeInstanceOf(X402PaymentLimitError); + }); + + it('refuses an amount it cannot check against the limit', async () => { + await expect( + signPayment({ + wallet: MOCK_WALLET, + accept: { ...VALID_EXACT_ACCEPT, amount: 'not-a-number' }, + maxAmountAtomicUnits: 1_000_000n, + }) + ).rejects.toBeInstanceOf(X402PaymentLimitError); + }); + it('unsupported scheme: throws', async () => { const invalid = { ...VALID_EXACT_ACCEPT, scheme: 'unknown' }; await expect(signPayment({ wallet: MOCK_WALLET, accept: invalid })).rejects.toThrow(