From 9204a92c49290d07d562d760d7d7097e85965d71 Mon Sep 17 00:00:00 2001 From: MQ37 Date: Sun, 17 May 2026 14:09:15 +0200 Subject: [PATCH 01/10] feat(x402): support upto scheme (Permit2 permitWitnessTransferFrom) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `upto` scheme alongside the existing `exact` flow: - `signUptoPayment` — EIP-712 typed-data signing over Permit2's PermitWitnessTransferFrom struct (witness binds {to, facilitator, validAfter}), with decimal-string uint256 nonce. - One-time Permit2 ERC-20 allowance auto-approval — checks `USDC.allowance(wallet, PERMIT2)` and submits `USDC.approve(PERMIT2, MAX_UINT256)` if short. Bypassable with `--no-approve` for advanced/testing flows. - `selectAcceptEntry(accepts, preference)` — picks a valid accept from the 402 `accepts[]` array. `auto` prefers `upto`, falls back to `exact`; explicit `upto` or `exact` forces one. - `parsePaymentRequired` and `extractAcceptFromPaymentRequired` now use the selector instead of hard-coding `exact`. - `mcpc x402 sign` gains `--scheme ` and `--no-approve` flags. - 30 Vitest unit tests cover the new code paths. Spec: https://github.com/coinbase/x402/blob/main/specs/schemes/upto/scheme_upto_evm.md End-to-end validated against api.apify.com on Base Mainnet: - HTTP 402 returns both schemes in accepts[] - mcpc x402 sign --scheme upto produces a wire-correct payload - POST with PAYMENT-SIGNATURE returns 201 + payment-response (success: true, transaction: '' — settlement deferred to the apify-core daemon per spec) Known follow-up (not blocking review): - `--scheme` preference not yet plumbed into the session-level `--x402` flow (only the manual `x402 sign` command honors it today). Investigation doc in X402_UPTO_INVESTIGATION.md captures the on-chain proof, the original CDP /verify schema gap (resolved upstream by now — prod verifies upto fine), and the debugging history. --- X402_UPTO_INVESTIGATION.md | 277 ++++++++++++++++++++ docs/examples/sign-x402.ts | 118 +++++++++ src/cli/commands/x402.ts | 54 +++- src/lib/x402/fetch-middleware.ts | 40 ++- src/lib/x402/signer.ts | 410 +++++++++++++++++++++++++++++- test/unit/lib/x402/signer.test.ts | 407 +++++++++++++++++++++++++++++ 6 files changed, 1275 insertions(+), 31 deletions(-) create mode 100644 X402_UPTO_INVESTIGATION.md create mode 100644 docs/examples/sign-x402.ts create mode 100644 test/unit/lib/x402/signer.test.ts diff --git a/X402_UPTO_INVESTIGATION.md b/X402_UPTO_INVESTIGATION.md new file mode 100644 index 00000000..43bc3961 --- /dev/null +++ b/X402_UPTO_INVESTIGATION.md @@ -0,0 +1,277 @@ +# x402 `upto` scheme — investigation & findings + +Context: while testing **apify-core PR [#27039](https://github.com/apify/apify-core/pull/27039)** ("feat(agentic-payments): add x402 upto scheme support") end-to-end against the local API, we hit a series of failures with the upto scheme that turned out to be partly client-side (mcpc gaps), partly local cache state, and primarily an **upstream CDP facilitator gap**. + +This document captures the full debugging journey, the on-chain proof that the apify-core code is spec-compliant, and the changes made to mcpc to make upto signing self-healing. + +--- + +## TL;DR + +- ✅ `exact` scheme works **end-to-end** on the local API (verify, settle, run, charge, refund — all confirmed on-chain on Base Sepolia). +- ✅ apify-core PR #27039's upto signing/wiring is spec-correct — proven by the official x402 reference facilitator at `x402.org`, which **accepts our payloads, verifies them, and settles them on-chain**. +- ❌ **CDP's `/verify` endpoint has a stale schema** — rejects upto-style `permit2Authorization` payloads with `'paymentPayload' is invalid: must match one of [x402V2PaymentPayload, x402V1PaymentPayload]. schema requires 'authorization', 'transaction'`. This is an upstream Coinbase issue, not anything in apify-core or mcpc. +- 🐛 Found a real bug in apify-core PR #27039: `X402Client.getAllPaymentRequirements` calls `log.error` unconditionally even when the configured kind is supported by the facilitator. Spammy logs. +- ✨ Added Permit2 allowance auto-approval to mcpc's `signUptoPayment` — when the wallet hasn't approved Permit2 yet, mcpc submits the one-time `approve(MAX_UINT256)` transaction before signing. + +--- + +## On-chain evidence (Base Sepolia) + +| Tx | Purpose | Hash | +|---|---|---| +| Initial settlement (exact, $1) | Apify wallet pulls $1 from payer at auth time | [`0x7a1ad9bcd5e412...`](https://sepolia.basescan.org/tx/0x7a1ad9bcd5e412f7793da006db9d5b990b857163e4bb2af98adbb80f7866e535) | +| Initial settlement (exact, $1) | Failed run (auth ok, run rejected) | [`0x3e441527...`](https://sepolia.basescan.org/tx/0x3e441527) | +| Initial settlement (exact, $1) | Successful run (`apify/python-example`) | [`0x712dc7154...`](https://sepolia.basescan.org/tx/0x712dc7154baca4b81f06fdfbcafca0704e5b784c832cd1b83858a3a73f857cf8) | +| Refund (exact, ~$1) × 3 | Daemon refund of unused balance | `0x965c576d...`, `0xc7c745f9...`, `0x6e7f31b5...` | +| Refund (exact, ~$0.50) | Daemon refund after $0.50 retroactive charge | [`0x968e954e5c8f...`](https://sepolia.basescan.org/tx/0x968e954e5c8fc7eca93cc7e0e8a3776d0c9b9b88b2a098f323a7e9c1ca240007) | +| **Permit2 approve (one-time)** | mcpc's auto-approve — `USDC.approve(PERMIT2, MAX_UINT256)` | (issued from the test wallet) | +| **upto settle via x402.org** | x402 reference facilitator pulls $1 via `x402UptoPermit2Proxy.settle()` | [**`0xdc8449a2f72db1...`**](https://sepolia.basescan.org/tx/0xdc8449a2f72db1abce2e2d4a06e7f4e0ecfd097e98382e5b67764edb741aee01) | + +The last one is the smoking gun: it proves the apify-core PR #27039 produces an upto Permit2 witness that the official x402 facilitator accepts and settles on-chain. + +--- + +## Test environment + +- **API**: local `npm run api` from `apify/apify-core` (PR branch `feat/x402-upto-scheme`, head `deb03d8ec`) +- **Daemon**: local `npm run daemons:infinite` (settling daemon picks up finalizable payments every 2s) +- **MongoDB**: local `apify-dev` database +- **mcpc**: local build of `apify/mcp-cli` with the upto signer changes documented below +- **Network**: Base Sepolia (`eip155:84532`) +- **Test wallet**: `0xb5273f0F04Ab2e42b039F826EbA1D8E8Ab25A0A6` +- **Apify wallet**: `0xDF278412eCbE00D6381408F739eB8dA60542a0c4` +- **Test asset**: USDC (`0x036CbD53842c5426634e7929541eC2318f3dCF7e`) +- **Test actor**: `apify/python-example` (had to mongo-patch its `actorPermissionLevel` from `FULL_PERMISSIONS` → `LIMITED_PERMISSIONS` and add a PAY_PER_EVENT `pricingInfos` entry to satisfy the agentic-payments preconditions) + +--- + +## Path 1 — `exact` scheme (works end-to-end) + +1. `POST /v2/acts/apify~python-example/run-sync` with no auth → API returns `402 Payment Required` with both `upto` and `exact` accepts. +2. `mcpc x402 sign --scheme exact ` → produces a valid EIP-3009 `TransferWithAuthorization` signature. +3. POST again with `PAYMENT-SIGNATURE` header → API authenticates the payment, settles $1 on-chain (real CDP facilitator), creates the agentic payment record, runs the actor. +4. Run produces a result; we then retroactively bumped `chargedEventCounts.test-event += 50` and `agenticPayments.remainingBalanceUsd -= 0.5` to simulate $0.50 of usage. +5. We forced `expiresAt` to a past date on all unfinalized payments → settling daemon picks them up → `finalizeExactPayment` issues on-chain refunds for the unused balance (minus gas) → all 4 payments finalized within 10s, all refunds confirmed on-chain. + +Conclusion: the entire `exact` flow works on Base Sepolia against the live CDP facilitator. + +--- + +## Path 2 — `upto` scheme (blocked by CDP) + +### Round 1: API → CDP `/verify` returns 401 unauthorized + +Sign with `mcpc x402 sign --scheme upto …` → POST to API → API responds: + +```json +{"error":{"type":"x402-agentic-payment-unauthorized","message":"The provided payment payload is invalid or could not be verified by the facilitator."}} +``` + +The X402Client just throws on `isValid: false` and discards the underlying `invalidReason` — so the error is opaque from the API logs alone. We needed to talk to the facilitator directly to see the real reason. + +### Direct verify against CDP + +We wrote a helper script (`/tmp/verify-upto.sh` + `/tmp/verify-upto.mjs`) that mirrors the CDPFacilitator JWT auth and posts our signed payload + paymentRequirements to `https://api.cdp.coinbase.com/platform/v2/x402/verify`. + +The actual CDP error: + +```json +{ + "errorType": "invalid_request", + "errorMessage": "'paymentPayload' is invalid: must match one of [x402V2PaymentPayload, x402V1PaymentPayload]. schema requires 'authorization', 'transaction'", + "correlationId": "9f36ab0ecc71e68c-IAD" +} +``` + +CDP's schema validator only accepts `payload.authorization` (EIP-3009 / exact) or `payload.transaction` (likely Solana). It does NOT recognize `payload.permit2Authorization` (upto) — even though `/supported` advertises upto kinds for `eip155:84532`, `eip155:8453`, `eip155:137`, `eip155:42161`, etc. + +### Confirmation against the official x402 reference facilitator + +We POSTed the same payload (witness rebound to x402.org's facilitator address) to `https://www.x402.org/facilitator/verify`: + +```json +{"isValid": true, "payer": "0xb5273f0F04Ab2e42b039F826EbA1D8E8Ab25A0A6"} +``` + +…and then the actual `/settle` call: + +```json +{ + "success": true, + "transaction": "0xdc8449a2f72db1abce2e2d4a06e7f4e0ecfd097e98382e5b67764edb741aee01", + "network": "eip155:84532", + "payer": "0xb5273f0F04Ab2e42b039F826EbA1D8E8Ab25A0A6", + "amount": "1000000" +} +``` + +→ Real USDC moved on-chain via `x402UptoPermit2Proxy.settle()`. Our payload structure is spec-correct; CDP just hasn't updated their `/verify` schema validator to accept the upto variant yet. + +The official x402 v2 schema in [`typescript/packages/core/src/schemas/index.ts`](https://github.com/coinbase/x402/blob/main/typescript/packages/core/src/schemas/index.ts) confirms it: + +```ts +export const PaymentPayloadV2Schema = z.object({ + x402Version: z.literal(2), + resource: ResourceInfoSchema.optional(), + accepted: PaymentRequirementsV2Schema, + payload: Any, // ← Any! Spec accepts permit2Authorization here. + extensions: OptionalAny, +}); +``` + +CDP is enforcing a tighter, older schema that pre-dates the Permit2 upto branch. + +### Side effect — CDP returns rotating facilitator addresses + +CDP load-balances `/supported` responses across multiple signers. For `eip155:84532` upto we observed three different `extra.facilitatorAddress` values on three calls (`0x97AcCe…`, `0xa32cCda…`, `0x67B9CE…`). All five eip155 signers from CDP's `signers["eip155:*"]` array eventually rotate through. + +This isn't broken behavior — each signed payload binds to whichever address the API received from `/supported` at the moment of signing, and CDP can route the verify/settle to the matching signer. But it did initially make us suspect the API's memoize cache was stale. Worth being aware of for anyone debugging similar issues. + +--- + +## Bug found in apify-core PR #27039: unconditional `log.error` + +In `src/packages/agentic-payments/src/x402/x402_client.ts` (added in commit `38b3e028`): + +```ts +async getAllPaymentRequirements(): Promise { + const supportedKinds = await this.memoizedGetSupportedKinds(); + const paymentRequirements: X402PaymentRequirements[] = []; + + try { + await Promise.all(this.accepts.map(async (acceptedKind) => { + const supportedKind = supportedKinds.find(...); + if (supportedKind) { + paymentRequirements.push(this.buildRequirements({ ... })); + } + + log.error('Configured accept is not supported by the facilitator; skipping.', { ... }); + // ↑ Always runs — should be inside an else or guarded + })); + } + ... +} +``` + +The docstring says *"Configured payment kinds the facilitator can't handle are logged and skipped."* — which is the **intent**. The implementation just doesn't match: it logs unconditionally. + +**Real-world proof**: every 402 the local API generated logged this error twice (once per scheme), even though the 402 response correctly listed both schemes in `accepts[]`. + +**Severity**: +- 🟡 Functional impact: none (paymentRequirements still populated correctly) +- 🔴 Operational impact: every 402 generates 1 false error log per configured accept; pollutes Sentry/error dashboards +- 🔴 Misleading future devs investigating "facilitator not supported" issues + +**Fix**: +```ts +if (supportedKind) { + paymentRequirements.push(this.buildRequirements({ ... })); + return; +} +log.warning('Configured accept is not supported by the facilitator; skipping.', { ... }); +``` + +Also recommend changing `log.error` → `log.warning` since skipping a single kind while others remain valid isn't an error — the request is still served. The "no kinds usable at all" case already has its own `log.error` at the bottom of the function. + +--- + +## mcpc changes (added during this investigation) + +Branch state: `feat/x402-upto-scheme` (local-only, not pushed). + +### `src/lib/x402/signer.ts` + +1. **Refactored** the original single `signPayment` into: + - `signExactPayment` — existing EIP-3009 logic + - `signUptoPayment` — new Permit2 `permitWitnessTransferFrom` logic + - `signPayment` — delegates by `accept.scheme` + +2. **Added** `selectAcceptEntry(accepts, preference)` — picks a valid accept entry from a 402 response based on preference (`auto` prefers upto then exact, `upto`/`exact` force one). + +3. **Added** Permit2 allowance auto-approval in `signUptoPayment`: + - Reads `USDC.allowance(wallet, PERMIT2)` via JSON-RPC + - If allowance < required amount, sends a one-time `USDC.approve(PERMIT2, MAX_UINT256)` transaction and waits for receipt + - Skipped via `SignPaymentInput.skipPermit2Approval = true` + - Network is automatically derived from `accept.network` — works on Base Mainnet (`eip155:8453`) and Base Sepolia (`eip155:84532`) without user intervention + +### `src/cli/commands/x402.ts` + +- Added `--scheme ` flag on `mcpc x402 sign` (default: `auto`) +- Added `--no-approve` flag to skip Permit2 allowance auto-approval (advanced/testing) +- Help text updated + +### `test/unit/lib/x402/signer.test.ts` + +- 30 unit tests, all passing: + - 10 for `selectAcceptEntry` and `parsePaymentRequired` scheme preference + - 15 for `signPayment` exact + upto payload shape, amount/expiry overrides, error cases + - 5 for the new Permit2 allowance auto-approval flow (skip when sufficient, approve when short, throw on revert, skip via `skipPermit2Approval`, exact scheme doesn't probe allowance) + +### Known gap (separate, easy fix) + +`mcpc x402 info` is hard-coded to Base Mainnet RPC, so it always shows mainnet balances regardless of which network you're testing on. We hit this when the wallet's mainnet ETH balance was reported but Sepolia was empty, causing the first auto-approve to fail with `gas required exceeds allowance (0)`. Funded the wallet via [Coinbase Sepolia faucet](https://portal.cdp.coinbase.com/products/faucet); next sign produced a successful approve tx and went on to settle. + +--- + +## Settings preconditions (for any future tester) + +To get an actor through the full agentic-payment pipeline locally, the actor needs: +1. `actorPermissionLevel: 'LIMITED_PERMISSIONS'` (not `FULL_PERMISSIONS`) +2. A `pricingInfos` array with at least one `pricingModel: 'PAY_PER_EVENT'` entry where `isPPEPlatformUsagePaidByUser` is not `true` + +Quick mongo patch: +```js +db.acts2.updateOne({_id: 'fkhQSv5AmEAgVtMjy'}, { + $set: { + actorPermissionLevel: 'LIMITED_PERMISSIONS', + pricingInfos: [{ + createdAt: new Date(), + startedAt: new Date('2025-01-01'), + apifyMarginPercentage: 0.2, + pricingModel: 'PAY_PER_EVENT', + pricingPerEvent: { + actorChargeEvents: { + 'test-event': { + eventTitle: 'Test event', + eventDescription: 'Test event for agentic payments', + eventPriceUsd: 0.01, + isPrimaryEvent: true, + }, + }, + }, + minimalMaxTotalChargeUsd: 1, + }], + }, +}); +``` + +The agentic-payments check is enforced in `src/packages/agentic-payments/src/utils.ts` — `ensureActorIsAllowedForAgenticPayments`. + +--- + +## Recommendations for PR #27039 + +In rough order of priority: + +1. **Fix the `log.error` bug** (small, separate commit). It's spamming logs per 402 response. +2. **Decide on the upto-with-CDP situation** — three options: + - **Ship as-is**, document the CDP limitation, monitor for upstream fix. The upto code is correct, it just won't authenticate end-to-end against CDP today. + - **Disable upto in prod settings** (`settings.prod.json` / `settings.staging.json`) until CDP fixes the schema. Keep upto in dev/test settings so testing continues. + - **Switch facilitator from CDP → x402.org** (or run a self-hosted x402 facilitator). This unblocks upto end-to-end today, but adds operational surface area. +3. **Report the CDP issue** to Coinbase. Reproducer is in this doc; correlation IDs from our verify attempts are stored above. +4. **Earlier review notes** still relevant before merge: + - Cap `usageInAssetUnits` in `finalizeUptoPayment` against the original Permit2 authorized amount, to prevent on-chain revert from rounding drift. + - Add retry budget / terminal-state handling for upto settlement failures, otherwise the daemon retries forever and grows the `transactions[]` array unboundedly. + - Verify the `X402_WALLET_PRIVATE_KEY` in each environment derives to the previously-hardcoded `apifyWalletAddress` — that explicit address check is gone in the PR. + +--- + +## Direct-verify helper script (kept under `/tmp` — not in the repo) + +For future debugging of CDP rejections: + +- `/tmp/verify-upto.sh` — bash wrapper that prompts for the four inputs (PAYMENT-REQUIRED, PAYMENT-SIGNATURE, CDP key id, CDP key secret) using `read -s` +- `/tmp/verify-upto.mjs` — Node ESM script that mirrors `CDPFacilitator`'s JWT auth (`jose` Ed25519) and posts to `/verify` and `/supported`, printing the full response (including `invalidReason`) + +The Node script imports `jose` by absolute path from apify-core's `node_modules` so it runs without installing anything in `/tmp`. diff --git a/docs/examples/sign-x402.ts b/docs/examples/sign-x402.ts new file mode 100644 index 00000000..5e4b7c67 --- /dev/null +++ b/docs/examples/sign-x402.ts @@ -0,0 +1,118 @@ +#!/usr/bin/env -S npx tsx +/** + * Minimal x402 payment signer — drop-in alternative to `mcpc x402 sign`. + * + * Signs the EIP-3009 `exact` scheme on Base / Base Sepolia and prints the + * base64 PAYMENT-SIGNATURE header value. Does NOT handle the `upto` scheme + * (Permit2 approval flow) — use `mcpc x402 sign` or the official x402 SDK + * for that. + * + * Usage: + * npm install viem tsx + * PRIVATE_KEY=0x... npx tsx sign-x402.ts + */ + +import { privateKeyToAccount } from 'viem/accounts'; +import { createWalletClient, http, type Hex } from 'viem'; +import { base, baseSepolia, type Chain } from 'viem/chains'; + +interface Accept { + scheme: string; + network: string; + amount: string; + asset: Hex; + payTo: Hex; + maxTimeoutSeconds?: number; + extra?: { name?: string; version?: string }; +} + +interface PaymentRequired { + resource?: { url?: string; mimeType?: string; description?: string }; + accepts: Accept[]; +} + +const NETWORKS: Record = { + [`eip155:${base.id}`]: { chain: base, rpc: 'https://mainnet.base.org' }, + [`eip155:${baseSepolia.id}`]: { chain: baseSepolia, rpc: 'https://sepolia.base.org' }, +}; + +const TRANSFER_WITH_AUTHORIZATION = { + TransferWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + ], +} as const; + +const [, , paymentRequiredB64] = process.argv; +const privateKey = process.env.PRIVATE_KEY as Hex | undefined; +if (!privateKey || !paymentRequiredB64) { + console.error('Usage: PRIVATE_KEY=0x... npx tsx sign-x402.ts '); + process.exit(1); +} + +// 1. Decode PAYMENT-REQUIRED and pick the first `exact`-scheme accept entry. +const header: PaymentRequired = JSON.parse( + Buffer.from(paymentRequiredB64, 'base64').toString('utf8') +); +const accept = header.accepts?.find((a) => a.scheme === 'exact'); +if (!accept) throw new Error('No `exact` scheme entry in PAYMENT-REQUIRED.accepts'); + +const network = NETWORKS[accept.network]; +if (!network) throw new Error(`Unsupported network: ${accept.network}`); + +// 2. Build the authorization. `value` is already in atomic units (USDC has 6 decimals). +// `validAfter: 0` means "valid immediately"; `validBefore` is now + timeout. +const account = privateKeyToAccount(privateKey); +const value = BigInt(accept.amount); +const validBefore = BigInt(Math.floor(Date.now() / 1000) + (accept.maxTimeoutSeconds ?? 3600)); +const nonce = ('0x' + + [...crypto.getRandomValues(new Uint8Array(32))] + .map((b) => b.toString(16).padStart(2, '0')) + .join('')) as Hex; + +// 3. Sign EIP-712 typed data. The domain's verifyingContract is the USDC token +// contract itself — EIP-3009 is implemented by the token, not by a separate proxy. +const client = createWalletClient({ account, chain: network.chain, transport: http(network.rpc) }); +const signature = await client.signTypedData({ + domain: { + name: accept.extra?.name ?? 'USDC', + version: accept.extra?.version ?? '2', + chainId: network.chain.id, + verifyingContract: accept.asset, + }, + types: TRANSFER_WITH_AUTHORIZATION, + primaryType: 'TransferWithAuthorization', + message: { from: account.address, to: accept.payTo, value, validAfter: 0n, validBefore, nonce }, +}); + +// 4. Wrap into the x402 payment payload and base64-encode for the PAYMENT-SIGNATURE header. +const payload = { + x402Version: 2, + resource: header.resource ?? { url: 'https://example.com', mimeType: 'application/json' }, + payload: { + signature, + authorization: { + from: account.address, + to: accept.payTo, + value: value.toString(), + validAfter: '0', + validBefore: validBefore.toString(), + nonce, + }, + }, + accepted: { + scheme: 'exact', + network: accept.network, + asset: accept.asset, + amount: value.toString(), + payTo: accept.payTo, + maxTimeoutSeconds: accept.maxTimeoutSeconds ?? 3600, + extra: { name: accept.extra?.name ?? 'USDC', version: accept.extra?.version ?? '2' }, + }, +}; + +console.log(Buffer.from(JSON.stringify(payload)).toString('base64')); diff --git a/src/cli/commands/x402.ts b/src/cli/commands/x402.ts index 8f89a714..b64afeb1 100644 --- a/src/cli/commands/x402.ts +++ b/src/cli/commands/x402.ts @@ -233,8 +233,10 @@ async function removeWalletCmd(options: { outputMode: OutputMode }): Promise { throw new ClientError('No wallet configured. Create one with: mcpc x402 init'); } + // Resolve scheme preference + const schemePreference = + options.scheme === 'upto' || options.scheme === 'exact' || options.scheme === 'auto' + ? options.scheme + : 'auto'; + // Parse PAYMENT-REQUIRED header - const { header, accept } = parsePaymentRequired(options.paymentRequired); + const { header, accept } = parsePaymentRequired(options.paymentRequired, schemePreference); // Resolve overrides let amountOverride: bigint | undefined; @@ -265,6 +273,7 @@ async function signPaymentCommand(options: SignOptions): Promise { resource: header.resource, ...(amountOverride !== undefined && { amountOverride }), ...(expiryOverride !== undefined && { expiryOverride }), + ...(options.noApprove === true && { skipPermit2Approval: true }), }); if (options.outputMode === 'json') { @@ -346,8 +355,10 @@ export async function handleX402Command(args: string[]): Promise { 'after', ` ${chalk.bold('sign options:')} - --amount Override amount in USD - --expiry Override expiry in seconds` + --amount Override amount in USD (for upto: max authorization cap) + --expiry Override expiry in seconds + --scheme Payment scheme: auto (default), upto, or exact + --no-approve Skip the upto Permit2 allowance check & auto-approval` ); const resolveOutputMode = (cmd: Command): OutputMode => { @@ -387,16 +398,33 @@ ${chalk.bold('sign options:')} .command('sign ') .description('Sign a payment using the wallet') .helpOption('-h, --help', 'Display help') - .option('--amount ', 'Override amount in USD') + .option( + '--amount ', + 'Override amount in USD (for upto this sets the max authorization cap)' + ) .option('--expiry ', 'Override expiry in seconds') - .action(async (paymentRequired, opts, cmd) => { - await signPaymentCommand({ + .option('--scheme ', 'Payment scheme preference (default: auto)', 'auto') + .option( + '--no-approve', + 'For the upto scheme: skip the on-chain Permit2 allowance check & auto-approval' + ) + .action( + async ( paymentRequired, - amount: opts.amount, - expiry: opts.expiry, - outputMode: resolveOutputMode(cmd), - }); - }); + opts: { amount?: string; expiry?: string; scheme?: string; approve?: boolean }, + cmd + ) => { + // Commander turns --no-approve into opts.approve = false + await signPaymentCommand({ + paymentRequired, + amount: opts.amount, + expiry: opts.expiry, + scheme: opts.scheme, + noApprove: opts.approve === false, + outputMode: resolveOutputMode(cmd), + }); + } + ); // Show help if no subcommand if (args.length === 0) { diff --git a/src/lib/x402/fetch-middleware.ts b/src/lib/x402/fetch-middleware.ts index 0c326a88..1086bfb5 100644 --- a/src/lib/x402/fetch-middleware.ts +++ b/src/lib/x402/fetch-middleware.ts @@ -21,9 +21,11 @@ import type { Tool } from '@modelcontextprotocol/sdk/types.js'; import { signPayment, parsePaymentRequired, + selectAcceptEntry, type SignerWallet, type PaymentRequiredAccept, type PaymentRequiredHeader, + type SchemePreference, } from './signer.js'; import { createLogger } from '../logger.js'; @@ -79,6 +81,9 @@ export interface X402FetchMiddlewareOptions { /** Shared mutable cache for reusing payment signatures across tool calls */ paymentCache: X402PaymentCache; + + /** Payment scheme preference when multiple accepts are available (default: auto) */ + schemePreference?: SchemePreference; } /** @@ -93,7 +98,7 @@ export function createX402FetchMiddleware( baseFetch: FetchLike, options: X402FetchMiddlewareOptions ): FetchLike { - const { wallet, getToolByName, paymentCache } = options; + const { wallet, getToolByName, paymentCache, schemePreference } = options; return async (url: string | URL, init?: RequestInit): Promise => { // Try to get a payment signature (cached or freshly signed) for tools/call requests @@ -110,7 +115,15 @@ export function createX402FetchMiddleware( // HTTP 402 — invalidate cache and fall through to fallback logger.debug('Payment rejected (HTTP 402), invalidating cache'); paymentCache.signature = null; - return handle402Fallback(url, init, response, baseFetch, wallet, paymentCache); + return handle402Fallback( + url, + init, + response, + baseFetch, + wallet, + paymentCache, + schemePreference + ); } // No payment needed — make request normally @@ -118,7 +131,15 @@ export function createX402FetchMiddleware( // Check for HTTP 402 fallback if (response.status === 402) { - return handle402Fallback(url, init, response, baseFetch, wallet, paymentCache); + return handle402Fallback( + url, + init, + response, + baseFetch, + wallet, + paymentCache, + schemePreference + ); } return response; @@ -213,7 +234,8 @@ async function handle402Fallback( response402: Response, baseFetch: FetchLike, wallet: SignerWallet, - paymentCache: X402PaymentCache + paymentCache: X402PaymentCache, + schemePreference?: SchemePreference ): Promise { // Extract PAYMENT-REQUIRED header (case-insensitive) const paymentRequiredBase64 = @@ -229,7 +251,7 @@ async function handle402Fallback( let header: PaymentRequiredHeader; let accept: PaymentRequiredAccept; try { - ({ header, accept } = parsePaymentRequired(paymentRequiredBase64)); + ({ header, accept } = parsePaymentRequired(paymentRequiredBase64, schemePreference)); } catch (error) { logger.warn('Failed to parse PAYMENT-REQUIRED header:', error); return response402; @@ -295,7 +317,7 @@ function extractToolCallName(body: RequestInit['body'] | undefined): string | un /** * Extract `PaymentRequiredAccept` from a PaymentRequired object. - * Returns the first "exact" scheme accept entry, or undefined if not found. + * Uses scheme selection logic: prefers valid `upto`, falls back to valid `exact`. * * The data is expected to have the shape: `{ x402Version, accepts: [...] }` */ @@ -310,8 +332,8 @@ export function extractAcceptFromPaymentRequired(data: unknown): const obj = data as Record; if (!Array.isArray(obj.accepts) || obj.accepts.length === 0) return undefined; - const accept = (obj.accepts as PaymentRequiredAccept[]).find((a) => a.scheme === 'exact'); - if (!accept || !accept.payTo || !accept.amount || !accept.network || !accept.asset) { + const accept = selectAcceptEntry(obj.accepts as PaymentRequiredAccept[], 'auto'); + if (!accept) { return undefined; } @@ -365,7 +387,7 @@ export function extractPaymentRequiredFromResult( const content = toolResult.content; if (!Array.isArray(content) || content.length === 0) return undefined; - const first = content[0] as ToolResultContent | undefined; + const first = content[0]; if (!first || first.type !== 'text' || typeof first.text !== 'string') return undefined; try { diff --git a/src/lib/x402/signer.ts b/src/lib/x402/signer.ts index 42170336..fd7261e5 100644 --- a/src/lib/x402/signer.ts +++ b/src/lib/x402/signer.ts @@ -1,13 +1,24 @@ /** * x402 payment signing logic - * Reusable module for signing EIP-3009 TransferWithAuthorization payments. + * Reusable module for signing EIP-3009 TransferWithAuthorization (exact scheme) + * and Permit2 permitWitnessTransferFrom (upto scheme) payments. * Used by both the CLI `x402 sign` command and the fetch middleware. */ import { privateKeyToAccount } from 'viem/accounts'; -import { createWalletClient, http, type Hex } from 'viem'; +import { + createPublicClient, + createWalletClient, + encodeFunctionData, + getAddress, + http, + type Hex, +} from 'viem'; import { base, baseSepolia } from 'viem/chains'; import { ClientError } from '../errors.js'; +import { createLogger } from '../logger.js'; + +const logger = createLogger('x402-signer'); // --------------------------------------------------------------------------- // Constants @@ -16,6 +27,45 @@ import { ClientError } from '../errors.js'; export const X402_VERSION = 2; const USDC_DECIMALS = 6; +/** Canonical Permit2 contract address (CREATE2, same on all EVM chains). */ +const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3'; + +/** x402 upto scheme Permit2 proxy contract address (vanity: 0x4020…0002). */ +const X402_UPTO_PERMIT2_PROXY = '0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002'; + +/** Clock-skew grace period for validAfter (seconds). */ +const VALID_AFTER_CLOCK_SKEW_SECONDS = 600; + +/** Maximum uint256 — used for unlimited Permit2 allowance approval. */ +const MAX_UINT256 = BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'); + +/** Minimal ERC-20 ABI fragments for allowance / approve. */ +const ERC20_ALLOWANCE_ABI = [ + { + type: 'function', + name: 'allowance', + stateMutability: 'view', + inputs: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + ], + outputs: [{ name: '', type: 'uint256' }], + }, +] as const; + +const ERC20_APPROVE_ABI = [ + { + type: 'function', + name: 'approve', + stateMutability: 'nonpayable', + inputs: [ + { name: 'spender', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ type: 'bool' }], + }, +] as const; + const TRANSFER_WITH_AUTHORIZATION_TYPES = { TransferWithAuthorization: [ { name: 'from', type: 'address' }, @@ -27,6 +77,25 @@ const TRANSFER_WITH_AUTHORIZATION_TYPES = { ], } as const; +const UPTO_PERMIT2_WITNESS_TYPES = { + PermitWitnessTransferFrom: [ + { name: 'permitted', type: 'TokenPermissions' }, + { name: 'spender', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + { name: 'witness', type: 'Witness' }, + ], + TokenPermissions: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + Witness: [ + { name: 'to', type: 'address' }, + { name: 'facilitator', type: 'address' }, + { name: 'validAfter', type: 'uint256' }, + ], +} as const; + // --------------------------------------------------------------------------- // Network configs // --------------------------------------------------------------------------- @@ -64,7 +133,7 @@ export interface PaymentRequiredAccept { asset: string; payTo: string; maxTimeoutSeconds: number; - extra?: { name?: string; version?: string }; + extra?: { name?: string; version?: string; facilitatorAddress?: string }; } export interface PaymentRequiredHeader { @@ -73,6 +142,8 @@ export interface PaymentRequiredHeader { accepts: PaymentRequiredAccept[]; } +export type SchemePreference = 'auto' | 'exact' | 'upto'; + /** Minimal wallet info needed for signing */ export interface SignerWallet { privateKey: string; // Hex with 0x prefix @@ -87,6 +158,14 @@ export interface SignPaymentInput { amountOverride?: bigint; /** Override expiry in seconds (default: from accept.maxTimeoutSeconds or 3600) */ expiryOverride?: number; + /** + * For the upto scheme: skip the on-chain Permit2 allowance check & auto-approval. + * Default false — the signer will check `USDC.allowance(wallet, PERMIT2)` and submit a + * one-time `USDC.approve(PERMIT2, MAX_UINT256)` transaction if the allowance is short of + * the amount being authorized. Pass `true` if you've already approved or want to manage + * approvals yourself. + */ + skipPermit2Approval?: boolean; } export interface SignPaymentResult { @@ -116,15 +195,73 @@ function randomBytes32(): Hex { return ('0x' + [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')) as Hex; } +/** + * 256-bit random nonce for Permit2, encoded as a uint256 *decimal* string. + * + * Permit2 (used by the upto scheme) expects a uint256 nonce — distinct from + * EIP-3009's `bytes32` nonce. Sending the hex-encoded form to a strict facilitator + * (e.g. CDP) makes the whole `permit2Authorization` payload fail JSON-schema validation. + * + * Matches the official x402 SDK's `createPermit2Nonce()` byte-for-byte. + */ +function randomPermit2Nonce(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + const hex = '0x' + [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); + return BigInt(hex).toString(); +} + +// --------------------------------------------------------------------------- +// Scheme selection +// --------------------------------------------------------------------------- + +function isValidExactAccept(a: PaymentRequiredAccept): boolean { + return a.scheme === 'exact' && Boolean(a.payTo && a.amount && a.network && a.asset); +} + +function isValidUptoAccept(a: PaymentRequiredAccept): boolean { + return ( + a.scheme === 'upto' && + Boolean(a.payTo && a.amount && a.network && a.asset) && + Boolean(a.extra?.facilitatorAddress) + ); +} + +/** + * Select the best accept entry from the array based on user preference. + * + * - `auto` → prefer valid `upto`, fallback to valid `exact` + * - `upto` → require valid `upto`; undefined if none + * - `exact` → require valid `exact`; undefined if none + */ +export function selectAcceptEntry( + accepts: PaymentRequiredAccept[], + preference: SchemePreference = 'auto' +): PaymentRequiredAccept | undefined { + if (preference === 'upto') { + return accepts.find(isValidUptoAccept); + } + if (preference === 'exact') { + return accepts.find(isValidExactAccept); + } + // auto: prefer upto, fallback exact + const upto = accepts.find(isValidUptoAccept); + if (upto) return upto; + return accepts.find(isValidExactAccept); +} + // --------------------------------------------------------------------------- // PAYMENT-REQUIRED header parsing // --------------------------------------------------------------------------- /** - * Parse a base64-encoded PAYMENT-REQUIRED header value - * Returns the parsed header and the first "exact" scheme accept entry + * Parse a base64-encoded PAYMENT-REQUIRED header value. + * Returns the parsed header and the selected accept entry based on scheme preference. */ -export function parsePaymentRequired(base64Value: string): { +export function parsePaymentRequired( + base64Value: string, + schemePreference: SchemePreference = 'auto' +): { header: PaymentRequiredHeader; accept: PaymentRequiredAccept; } { @@ -146,10 +283,12 @@ export function parsePaymentRequired(base64Value: string): { throw new ClientError('PAYMENT-REQUIRED header has no "accepts" entries.'); } - const accept = header.accepts.find((a) => a.scheme === 'exact'); + const accept = selectAcceptEntry(header.accepts, schemePreference); if (!accept) { + const requested = schemePreference === 'auto' ? 'exact or upto' : schemePreference; + const available = header.accepts.map((a) => a.scheme).join(', '); throw new ClientError( - `No "exact" scheme found in PAYMENT-REQUIRED accepts. Available: ${header.accepts.map((a) => a.scheme).join(', ')}` + `No valid "${requested}" scheme found in PAYMENT-REQUIRED accepts. Available: ${available}` ); } @@ -167,9 +306,24 @@ export function parsePaymentRequired(base64Value: string): { // --------------------------------------------------------------------------- /** - * Sign an x402 payment and return a base64-encoded PAYMENT-SIGNATURE header value + * 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; + if (accept.scheme === 'upto') { + return signUptoPayment(input); + } + if (accept.scheme === 'exact') { + return signExactPayment(input); + } + throw new ClientError(`Unsupported x402 scheme: ${accept.scheme}`); +} + +/** + * Sign an x402 `exact` scheme payment using EIP-3009 TransferWithAuthorization. + */ +async function signExactPayment(input: SignPaymentInput): Promise { const { wallet, accept, resource } = input; // Resolve network @@ -263,3 +417,241 @@ export async function signPayment(input: SignPaymentInput): Promise { + const { wallet, tokenAddress, requiredAmount, networkConfig } = params; + const walletAddress = getAddress(wallet.address) as Hex; + const permit2 = getAddress(PERMIT2_ADDRESS) as Hex; + + const publicClient = createPublicClient({ + chain: networkConfig.chain, + transport: http(networkConfig.rpcUrl), + }); + + const currentAllowance = (await publicClient.readContract({ + address: tokenAddress, + abi: ERC20_ALLOWANCE_ABI, + functionName: 'allowance', + args: [walletAddress, permit2], + })) as bigint; + + if (currentAllowance >= requiredAmount) { + logger.debug( + `Permit2 allowance sufficient (${currentAllowance.toString()} >= ${requiredAmount.toString()})` + ); + return { previousAllowance: currentAllowance, newAllowance: currentAllowance }; + } + + logger.info( + `Permit2 allowance is ${currentAllowance.toString()} < ${requiredAmount.toString()} required. Submitting one-time approve(MAX_UINT256) transaction…` + ); + + const account = privateKeyToAccount(wallet.privateKey as Hex); + const walletClient = createWalletClient({ + account, + chain: networkConfig.chain, + transport: http(networkConfig.rpcUrl), + }); + + const approveData = encodeFunctionData({ + abi: ERC20_APPROVE_ABI, + functionName: 'approve', + args: [permit2, MAX_UINT256], + }); + + const approveTxHash = await walletClient.sendTransaction({ + to: tokenAddress, + data: approveData, + }); + + logger.info(`Permit2 approve tx submitted: ${approveTxHash}. Waiting for confirmation…`); + + const receipt = await publicClient.waitForTransactionReceipt({ hash: approveTxHash }); + if (receipt.status !== 'success') { + throw new ClientError( + `Permit2 approve transaction reverted on-chain: ${approveTxHash}. Inspect at ${networkConfig.label} explorer.` + ); + } + + // Re-read to confirm. + const newAllowance = (await publicClient.readContract({ + address: tokenAddress, + abi: ERC20_ALLOWANCE_ABI, + functionName: 'allowance', + args: [walletAddress, permit2], + })) as bigint; + + logger.info( + `Permit2 approve confirmed in block ${receipt.blockNumber}. New allowance: ${newAllowance.toString()}` + ); + + return { approveTxHash, previousAllowance: currentAllowance, newAllowance }; +} + +/** + * Sign an x402 `upto` scheme payment using Permit2 permitWitnessTransferFrom. + * The payer authorizes a maximum amount; the facilitator settles the actual usage later. + * + * Before signing, this checks `USDC.allowance(wallet, PERMIT2)` and, if insufficient, submits + * a one-time `USDC.approve(PERMIT2, MAX_UINT256)` transaction so the upto scheme can actually + * settle on-chain. Pass `skipPermit2Approval: true` to bypass the check. + */ +async function signUptoPayment(input: SignPaymentInput): Promise { + const { wallet, accept, resource } = input; + + // Resolve network + const networkConfig = NETWORKS[accept.network]; + if (!networkConfig) { + throw new ClientError( + `Unknown network "${accept.network}" in payment requirements. Supported: ${Object.keys(NETWORKS).join(', ')}` + ); + } + + // Resolve amount (max authorization cap) + const amountAtomicUnits = input.amountOverride ?? BigInt(accept.amount); + const amountUsd = Number(amountAtomicUnits) / 10 ** USDC_DECIMALS; + + // Resolve expiry + const expirySeconds = (input.expiryOverride ?? accept.maxTimeoutSeconds) || 3600; + + // Validate facilitator address (required by upto scheme for witness binding) + const facilitatorAddress = accept.extra?.facilitatorAddress; + if (!facilitatorAddress) { + throw new ClientError( + 'upto scheme requires facilitatorAddress in paymentRequirements.extra. ' + + 'Ensure the server is configured with an upto facilitator.' + ); + } + + // EIP-3009 metadata (used for token contract identification in extra) + const tokenName = accept.extra?.name ?? 'USDC'; + const tokenVersion = accept.extra?.version ?? '2'; + + // Ensure Permit2 has sufficient ERC-20 allowance from the payer's wallet. + // Without this, the on-chain settle will revert. One-time setup per (wallet, token). + if (!input.skipPermit2Approval) { + await ensurePermit2Allowance({ + wallet, + tokenAddress: getAddress(accept.asset) as Hex, + requiredAmount: amountAtomicUnits, + networkConfig, + }); + } + + // Sign with Permit2 domain (NOT the token contract domain) + const account = privateKeyToAccount(wallet.privateKey as Hex); + const walletClient = createWalletClient({ + account, + chain: networkConfig.chain, + transport: http(networkConfig.rpcUrl), + }); + + // Permit2 expects a uint256 nonce as a *decimal* string — NOT a bytes32 hex string + // like EIP-3009 uses. Strict facilitators (CDP) reject the hex form with a misleading + // "schema requires authorization, transaction" error. + const nonce = randomPermit2Nonce(); + const now = Math.floor(Date.now() / 1000); + const validAfter = (now - VALID_AFTER_CLOCK_SKEW_SECONDS).toString(); + const deadline = (now + expirySeconds).toString(); + + if (BigInt(deadline) <= BigInt(validAfter)) { + throw new ClientError( + `Invalid time window: deadline (${deadline}) must be after validAfter (${validAfter}). ` + + `Check that maxTimeoutSeconds (${accept.maxTimeoutSeconds}) is positive.` + ); + } + + const chainId = networkConfig.chain.id; + + const signature = await walletClient.signTypedData({ + domain: { + name: 'Permit2', + chainId, + verifyingContract: PERMIT2_ADDRESS as Hex, + }, + types: UPTO_PERMIT2_WITNESS_TYPES, + primaryType: 'PermitWitnessTransferFrom', + message: { + permitted: { + token: getAddress(accept.asset), + amount: amountAtomicUnits, + }, + spender: getAddress(X402_UPTO_PERMIT2_PROXY), + nonce: BigInt(nonce), + deadline: BigInt(deadline), + witness: { + to: getAddress(accept.payTo), + facilitator: getAddress(facilitatorAddress), + validAfter: BigInt(validAfter), + }, + }, + }); + + // Build x402 payload + const paymentPayload = { + x402Version: X402_VERSION, + resource: resource ?? { + url: 'https://mcp.apify.com/mcp', + description: 'MCP Server', + mimeType: 'application/json', + }, + payload: { + signature, + permit2Authorization: { + permitted: { + token: getAddress(accept.asset), + amount: amountAtomicUnits.toString(), + }, + from: account.address, + spender: X402_UPTO_PERMIT2_PROXY, + nonce, + deadline, + witness: { + to: getAddress(accept.payTo), + facilitator: getAddress(facilitatorAddress), + validAfter, + }, + }, + }, + accepted: { + scheme: 'upto', + network: networkConfig.networkId, + asset: accept.asset, + amount: amountAtomicUnits.toString(), + payTo: accept.payTo, + maxTimeoutSeconds: expirySeconds, + extra: { + name: tokenName, + version: tokenVersion, + ...(facilitatorAddress ? { facilitatorAddress } : {}), + }, + }, + }; + + const paymentSignatureBase64 = Buffer.from(JSON.stringify(paymentPayload)).toString('base64'); + + return { + paymentSignatureBase64, + from: account.address, + to: accept.payTo, + amountUsd, + amountAtomicUnits, + networkLabel: networkConfig.label, + expiresAt: new Date(Number(deadline) * 1000), + }; +} diff --git a/test/unit/lib/x402/signer.test.ts b/test/unit/lib/x402/signer.test.ts new file mode 100644 index 00000000..569e5bf0 --- /dev/null +++ b/test/unit/lib/x402/signer.test.ts @@ -0,0 +1,407 @@ +/** + * Unit tests for x402 signer (exact + upto schemes) + */ + +import { ClientError } from '../../../../src/lib/errors.js'; +import { + parsePaymentRequired, + selectAcceptEntry, + signPayment, + X402_VERSION, + type PaymentRequiredAccept, + type SignerWallet, +} from '../../../../src/lib/x402/signer.js'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const MOCK_SIGNATURE = '0xdeadbeef1234567890'; +const MOCK_ADDRESS = '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B'; +const MOCK_APPROVE_TX_HASH = '0xabcdef1234567890'; + +// `vi.mock` is hoisted above local `const` declarations, so the mock fns used +// inside the factory must come from `vi.hoisted`. Same pattern as grep.test.ts. +const { mockReadContract, mockSendTransaction, mockWaitForTransactionReceipt, mockSignTypedData } = + vi.hoisted(() => ({ + mockReadContract: vi.fn(), + mockSendTransaction: vi.fn(), + mockWaitForTransactionReceipt: vi.fn(), + mockSignTypedData: vi.fn(), + })); + +vi.mock('viem', () => ({ + createPublicClient: vi.fn().mockReturnValue({ + readContract: (...args: unknown[]) => mockReadContract(...args), + waitForTransactionReceipt: (...args: unknown[]) => mockWaitForTransactionReceipt(...args), + }), + createWalletClient: vi.fn().mockReturnValue({ + signTypedData: (...args: unknown[]) => mockSignTypedData(...args), + sendTransaction: (...args: unknown[]) => mockSendTransaction(...args), + }), + encodeFunctionData: vi.fn().mockReturnValue('0xencodedapprovedata'), + getAddress: vi.fn((addr: string) => addr.toLowerCase()), + http: vi.fn().mockReturnValue('http-transport'), +})); + +vi.mock('viem/accounts', () => ({ + privateKeyToAccount: vi.fn().mockReturnValue({ + address: '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + }), +})); + +// Default mock behaviour — allowance is sufficient, no approve needed, signing succeeds. +beforeEach(() => { + mockReadContract.mockReset(); + mockSendTransaction.mockReset(); + mockWaitForTransactionReceipt.mockReset(); + mockSignTypedData.mockReset(); + mockReadContract.mockResolvedValue(BigInt('1000000000000000000000000')); // huge allowance + mockSendTransaction.mockResolvedValue(MOCK_APPROVE_TX_HASH); + mockWaitForTransactionReceipt.mockResolvedValue({ status: 'success', blockNumber: 1n }); + mockSignTypedData.mockResolvedValue(MOCK_SIGNATURE); +}); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const VALID_EXACT_ACCEPT: PaymentRequiredAccept = { + scheme: 'exact', + network: 'eip155:84532', + amount: '1000000', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + payTo: '0xdf278412ecbe00d6381408f739eb8da60542a0c4', + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, +}; + +const VALID_UPTO_ACCEPT: PaymentRequiredAccept = { + scheme: 'upto', + network: 'eip155:84532', + amount: '5000000', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + payTo: '0xdf278412ecbe00d6381408f739eb8da60542a0c4', + maxTimeoutSeconds: 3600, + extra: { + name: 'USDC', + version: '2', + facilitatorAddress: '0x4020a4f3b7b90cca423b9fabcc0ce57c6c240002', + }, +}; + +const MOCK_WALLET: SignerWallet = { + privateKey: '0x1234567890abcdef', + address: MOCK_ADDRESS, +}; + +function buildPaymentRequired(accepts: PaymentRequiredAccept[]): string { + const header = { + x402Version: 2, + resource: { + url: 'https://mcp.apify.com/mcp', + description: 'MCP Server', + mimeType: 'application/json', + }, + accepts, + }; + return Buffer.from(JSON.stringify(header)).toString('base64'); +} + +// --------------------------------------------------------------------------- +// selectAcceptEntry +// --------------------------------------------------------------------------- + +describe('selectAcceptEntry', () => { + it('auto: prefers upto over exact', () => { + const result = selectAcceptEntry([VALID_EXACT_ACCEPT, VALID_UPTO_ACCEPT], 'auto'); + expect(result?.scheme).toBe('upto'); + }); + + it('auto: falls back to exact when upto is invalid', () => { + const invalidUpto = { ...VALID_UPTO_ACCEPT, extra: { name: 'USDC' } }; // missing facilitatorAddress + const result = selectAcceptEntry([VALID_EXACT_ACCEPT, invalidUpto], 'auto'); + expect(result?.scheme).toBe('exact'); + }); + + it('auto: returns undefined when nothing valid', () => { + const result = selectAcceptEntry([], 'auto'); + expect(result).toBeUndefined(); + }); + + it('upto: returns upto when valid', () => { + const result = selectAcceptEntry([VALID_EXACT_ACCEPT, VALID_UPTO_ACCEPT], 'upto'); + expect(result?.scheme).toBe('upto'); + }); + + it('upto: returns undefined when upto invalid', () => { + const invalidUpto = { ...VALID_UPTO_ACCEPT, extra: {} }; + const result = selectAcceptEntry([VALID_EXACT_ACCEPT, invalidUpto], 'upto'); + expect(result).toBeUndefined(); + }); + + it('exact: returns exact when valid', () => { + const result = selectAcceptEntry([VALID_UPTO_ACCEPT, VALID_EXACT_ACCEPT], 'exact'); + expect(result?.scheme).toBe('exact'); + }); + + it('exact: returns undefined when exact invalid', () => { + const invalidExact = { ...VALID_EXACT_ACCEPT, payTo: '' }; + const result = selectAcceptEntry([invalidExact, VALID_UPTO_ACCEPT], 'exact'); + expect(result).toBeUndefined(); + }); + + it('auto: prefers first valid upto', () => { + const secondUpto = { ...VALID_UPTO_ACCEPT, amount: '9999999' }; + const result = selectAcceptEntry([VALID_EXACT_ACCEPT, VALID_UPTO_ACCEPT, secondUpto], 'auto'); + expect(result?.amount).toBe('5000000'); + }); + + it('auto: prefers first valid exact when no upto', () => { + const secondExact = { ...VALID_EXACT_ACCEPT, amount: '9999999' }; + const result = selectAcceptEntry([VALID_EXACT_ACCEPT, secondExact], 'auto'); + expect(result?.amount).toBe('1000000'); + }); +}); + +// --------------------------------------------------------------------------- +// parsePaymentRequired +// --------------------------------------------------------------------------- + +describe('parsePaymentRequired', () => { + it('parses exact-only header with auto', () => { + const b64 = buildPaymentRequired([VALID_EXACT_ACCEPT]); + const { header, accept } = parsePaymentRequired(b64, 'auto'); + expect(header.x402Version).toBe(2); + expect(accept.scheme).toBe('exact'); + }); + + it('parses upto-only header with auto', () => { + const b64 = buildPaymentRequired([VALID_UPTO_ACCEPT]); + const { header, accept } = parsePaymentRequired(b64, 'auto'); + expect(accept.scheme).toBe('upto'); + }); + + it('mixed header: auto selects upto', () => { + const b64 = buildPaymentRequired([VALID_EXACT_ACCEPT, VALID_UPTO_ACCEPT]); + const { accept } = parsePaymentRequired(b64, 'auto'); + expect(accept.scheme).toBe('upto'); + }); + + it('mixed header: force exact', () => { + const b64 = buildPaymentRequired([VALID_UPTO_ACCEPT, VALID_EXACT_ACCEPT]); + const { accept } = parsePaymentRequired(b64, 'exact'); + expect(accept.scheme).toBe('exact'); + }); + + it('mixed header: force upto', () => { + const b64 = buildPaymentRequired([VALID_EXACT_ACCEPT, VALID_UPTO_ACCEPT]); + const { accept } = parsePaymentRequired(b64, 'upto'); + expect(accept.scheme).toBe('upto'); + }); + + it('throws on invalid JSON after base64 decode', () => { + const b64 = Buffer.from('not-json').toString('base64'); + expect(() => parsePaymentRequired(b64)).toThrow(ClientError); + expect(() => parsePaymentRequired(b64)).toThrow('not valid JSON'); + }); + + it('throws on empty accepts', () => { + const b64 = buildPaymentRequired([]); + expect(() => parsePaymentRequired(b64)).toThrow(ClientError); + expect(() => parsePaymentRequired(b64)).toThrow('no "accepts" entries'); + }); + + it('throws when no matching scheme (auto)', () => { + const b64 = buildPaymentRequired([{ ...VALID_EXACT_ACCEPT, scheme: 'unknown' }]); + expect(() => parsePaymentRequired(b64, 'auto')).toThrow(ClientError); + expect(() => parsePaymentRequired(b64, 'auto')).toThrow('exact or upto'); + }); + + it('throws when forced scheme unavailable', () => { + const b64 = buildPaymentRequired([VALID_EXACT_ACCEPT]); + expect(() => parsePaymentRequired(b64, 'upto')).toThrow(ClientError); + expect(() => parsePaymentRequired(b64, 'upto')).toThrow('upto'); + }); +}); + +// --------------------------------------------------------------------------- +// signPayment +// --------------------------------------------------------------------------- + +describe('signPayment', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('exact scheme: produces correct payload shape', async () => { + const result = await signPayment({ wallet: MOCK_WALLET, accept: VALID_EXACT_ACCEPT }); + + expect(result.from).toBe(MOCK_ADDRESS); + expect(result.to).toBe(VALID_EXACT_ACCEPT.payTo); + expect(result.networkLabel).toBe('Base Sepolia (testnet)'); + + const payload = JSON.parse( + Buffer.from(result.paymentSignatureBase64, 'base64').toString('utf-8') + ); + expect(payload.x402Version).toBe(X402_VERSION); + expect(payload.accepted.scheme).toBe('exact'); + expect(payload.payload.authorization).toBeDefined(); + expect(payload.payload.authorization.from).toBe(MOCK_ADDRESS); + expect(payload.payload.authorization.to).toBe(VALID_EXACT_ACCEPT.payTo); + expect(payload.payload.authorization.value).toBe(VALID_EXACT_ACCEPT.amount); + expect(payload.payload.permit2Authorization).toBeUndefined(); + }); + + it('upto scheme: produces correct payload shape with permit2Authorization', async () => { + const result = await signPayment({ wallet: MOCK_WALLET, accept: VALID_UPTO_ACCEPT }); + + expect(result.from).toBe(MOCK_ADDRESS); + expect(result.to).toBe(VALID_UPTO_ACCEPT.payTo); + expect(result.networkLabel).toBe('Base Sepolia (testnet)'); + + const payload = JSON.parse( + Buffer.from(result.paymentSignatureBase64, 'base64').toString('utf-8') + ); + expect(payload.x402Version).toBe(X402_VERSION); + expect(payload.accepted.scheme).toBe('upto'); + expect(payload.payload.permit2Authorization).toBeDefined(); + expect(payload.payload.authorization).toBeUndefined(); + + const permit2 = payload.payload.permit2Authorization; + expect(permit2.from).toBe(MOCK_ADDRESS); + expect(permit2.spender).toBe('0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002'); + expect(permit2.permitted.token).toBe(VALID_UPTO_ACCEPT.asset.toLowerCase()); + expect(permit2.permitted.amount).toBe(VALID_UPTO_ACCEPT.amount); + expect(permit2.witness.to).toBe(VALID_UPTO_ACCEPT.payTo.toLowerCase()); + expect(permit2.witness.facilitator).toBe( + VALID_UPTO_ACCEPT.extra!.facilitatorAddress!.toLowerCase() + ); + + // accepted should include facilitatorAddress + expect(payload.accepted.extra.facilitatorAddress).toBe( + VALID_UPTO_ACCEPT.extra!.facilitatorAddress + ); + }); + + it('upto scheme: throws when facilitatorAddress missing', async () => { + const invalidUpto = { ...VALID_UPTO_ACCEPT, extra: { name: 'USDC', version: '2' } }; + await expect(signPayment({ wallet: MOCK_WALLET, accept: invalidUpto })).rejects.toThrow( + ClientError + ); + await expect(signPayment({ wallet: MOCK_WALLET, accept: invalidUpto })).rejects.toThrow( + 'facilitatorAddress' + ); + }); + + it('exact scheme: uses amountOverride', async () => { + const result = await signPayment({ + wallet: MOCK_WALLET, + accept: VALID_EXACT_ACCEPT, + amountOverride: 2000000n, + }); + const payload = JSON.parse( + Buffer.from(result.paymentSignatureBase64, 'base64').toString('utf-8') + ); + expect(payload.accepted.amount).toBe('2000000'); + expect(payload.payload.authorization.value).toBe('2000000'); + expect(result.amountUsd).toBe(2); + }); + + it('upto scheme: uses amountOverride as max cap', async () => { + const result = await signPayment({ + wallet: MOCK_WALLET, + accept: VALID_UPTO_ACCEPT, + amountOverride: 3000000n, + }); + const payload = JSON.parse( + Buffer.from(result.paymentSignatureBase64, 'base64').toString('utf-8') + ); + expect(payload.accepted.amount).toBe('3000000'); + expect(payload.payload.permit2Authorization.permitted.amount).toBe('3000000'); + expect(result.amountUsd).toBe(3); + }); + + it('unsupported scheme: throws', async () => { + const invalid = { ...VALID_EXACT_ACCEPT, scheme: 'unknown' }; + await expect(signPayment({ wallet: MOCK_WALLET, accept: invalid })).rejects.toThrow( + 'Unsupported x402 scheme: unknown' + ); + }); + + it('unknown network: throws', async () => { + const invalid = { ...VALID_EXACT_ACCEPT, network: 'eip155:99999' }; + await expect(signPayment({ wallet: MOCK_WALLET, accept: invalid })).rejects.toThrow( + 'Unknown network' + ); + }); + + // ------------------------------------------------------------------------- + // upto scheme — Permit2 allowance auto-approval + // ------------------------------------------------------------------------- + + describe('upto: Permit2 allowance auto-approval', () => { + it('skips approval when existing allowance >= required amount', async () => { + // VALID_UPTO_ACCEPT.amount is "5000000" — return exactly that. + mockReadContract.mockResolvedValueOnce(BigInt('5000000')); + + await signPayment({ wallet: MOCK_WALLET, accept: VALID_UPTO_ACCEPT }); + + expect(mockReadContract).toHaveBeenCalledTimes(1); + expect(mockSendTransaction).not.toHaveBeenCalled(); + expect(mockWaitForTransactionReceipt).not.toHaveBeenCalled(); + }); + + it('sends approve(MAX_UINT256) when allowance is short, then signs', async () => { + // First read: insufficient. Second read after approve: max. + mockReadContract + .mockResolvedValueOnce(0n) + .mockResolvedValueOnce(BigInt('1000000000000000000000000')); + + const result = await signPayment({ wallet: MOCK_WALLET, accept: VALID_UPTO_ACCEPT }); + + expect(mockSendTransaction).toHaveBeenCalledTimes(1); + const sendArgs = mockSendTransaction.mock.calls[0]?.[0] as { + to: string; + data: string; + }; + expect(sendArgs.to).toBe(VALID_UPTO_ACCEPT.asset.toLowerCase()); + expect(sendArgs.data).toBe('0xencodedapprovedata'); + expect(mockWaitForTransactionReceipt).toHaveBeenCalledWith({ hash: MOCK_APPROVE_TX_HASH }); + expect(mockReadContract).toHaveBeenCalledTimes(2); // before + after + expect(result.paymentSignatureBase64).toBeDefined(); + }); + + it('throws when approve transaction reverts on-chain', async () => { + mockReadContract.mockResolvedValueOnce(0n); + mockWaitForTransactionReceipt.mockResolvedValueOnce({ + status: 'reverted', + blockNumber: 1n, + }); + + await expect(signPayment({ wallet: MOCK_WALLET, accept: VALID_UPTO_ACCEPT })).rejects.toThrow( + 'reverted on-chain' + ); + }); + + it('skipPermit2Approval bypasses the allowance check entirely', async () => { + mockReadContract.mockResolvedValueOnce(0n); // would normally trigger approve + + await signPayment({ + wallet: MOCK_WALLET, + accept: VALID_UPTO_ACCEPT, + skipPermit2Approval: true, + }); + + expect(mockReadContract).not.toHaveBeenCalled(); + expect(mockSendTransaction).not.toHaveBeenCalled(); + }); + + it('exact scheme does not perform allowance check', async () => { + await signPayment({ wallet: MOCK_WALLET, accept: VALID_EXACT_ACCEPT }); + + expect(mockReadContract).not.toHaveBeenCalled(); + expect(mockSendTransaction).not.toHaveBeenCalled(); + }); + }); +}); From 01121532882b9ccf5e60d5f0ad46cb10cb7a5dcb Mon Sep 17 00:00:00 2001 From: MQ37 Date: Wed, 20 May 2026 14:34:29 +0200 Subject: [PATCH 02/10] feat(x402): scheme-aware debug logs and 6-decimal USD precision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When verbose mode is on, the x402 signer now announces the scheme + key payment fields up front: [x402-signer] Signing x402 payment: scheme=upto network=eip155:8453 amount=1000000 asset=0x... payTo=0x... facilitator=0x... The two existing 'payment signed' summaries in the fetch middleware now include `scheme=` and the bridge retry log uses the same precision — all USD amounts in debug logs are now 6 decimals (USDC atomic precision) instead of 4. --- src/bridge/index.ts | 2 +- src/lib/x402/fetch-middleware.ts | 4 ++-- src/lib/x402/signer.ts | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/bridge/index.ts b/src/bridge/index.ts index fd556da6..1c67bfa7 100644 --- a/src/bridge/index.ts +++ b/src/bridge/index.ts @@ -1129,7 +1129,7 @@ class BridgeProcess { }); this.x402PaymentCache.signature = signed.paymentSignatureBase64; logger.debug( - `Fresh payment signed for retry: $${signed.amountUsd.toFixed(4)} to ${signed.to} on ${signed.networkLabel}` + `Fresh payment signed for retry: $${signed.amountUsd.toFixed(6)} to ${signed.to} on ${signed.networkLabel}` ); } catch (signError) { logger.warn('Failed to sign fresh payment for 402 retry:', signError); diff --git a/src/lib/x402/fetch-middleware.ts b/src/lib/x402/fetch-middleware.ts index 1086bfb5..d9094012 100644 --- a/src/lib/x402/fetch-middleware.ts +++ b/src/lib/x402/fetch-middleware.ts @@ -214,7 +214,7 @@ async function getOrSignPayment( try { const result = await signPayment({ wallet, accept }); logger.debug( - `Fresh payment signed: $${result.amountUsd.toFixed(4)} to ${result.to} on ${result.networkLabel}` + `Fresh payment signed: scheme=${accept.scheme} amount=$${result.amountUsd.toFixed(6)} to=${result.to} network=${result.networkLabel}` ); paymentCache.signature = result.paymentSignatureBase64; return result.paymentSignatureBase64; @@ -266,7 +266,7 @@ async function handle402Fallback( }); logger.debug( - `402 fallback payment signed: $${result.amountUsd.toFixed(4)} to ${result.to} on ${result.networkLabel}` + `402 fallback payment signed: scheme=${accept.scheme} amount=$${result.amountUsd.toFixed(6)} to=${result.to} network=${result.networkLabel}` ); // Cache the freshly signed payment for subsequent calls diff --git a/src/lib/x402/signer.ts b/src/lib/x402/signer.ts index fd7261e5..3d787345 100644 --- a/src/lib/x402/signer.ts +++ b/src/lib/x402/signer.ts @@ -311,6 +311,9 @@ export function parsePaymentRequired( */ export async function signPayment(input: SignPaymentInput): Promise { const { accept } = 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 ?? ''}` + ); if (accept.scheme === 'upto') { return signUptoPayment(input); } From 459bfbaa5f66d386896c319a0695b232404b5e62 Mon Sep 17 00:00:00 2001 From: MQ37 Date: Wed, 20 May 2026 14:34:44 +0200 Subject: [PATCH 03/10] feat(sessions): mark x402-authenticated sessions in listings Sessions with auto-payment enabled show a yellow [x402] marker in `mcpc` listings, matching the visual style of the OAuth and proxy markers. OAuth and x402 are mutually exclusive auth mechanisms, so the marker replaces the (OAuth: ...) one when both happen to be set on the session record. --- CHANGELOG.md | 3 +++ src/cli/output.ts | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 849124ca..451fff0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `mcpc connect` (with no arguments) now auto-discovers standard MCP config files (`.mcp.json`, `mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`, `~/.claude.json`, Claude Desktop, Windsurf, Kiro, etc.) in the current directory and home directory, and connects every server defined across them. Entries with duplicate session names are deduplicated (project-scoped files win over global ones). VS Code's `"servers"` key is also supported. - `mcpc connect` auto-connects to `mcp.apify.com` as `@apify` when the `APIFY_API_TOKEN` environment variable is set, using it as a Bearer token. Existing live sessions are reused without restart. +- `mcpc x402 sign` supports the x402 `upto` scheme alongside `exact`. Use `--scheme ` to pick a preference (default `auto` prefers `upto`, falls back to `exact`). The signer auto-grants a one-time `USDC.approve(PERMIT2, MAX_UINT256)` allowance on first upto sign; pass `--no-approve` to skip. +- Sessions using x402 auto-payment now show a yellow `[x402]` marker in session listings, alongside the existing OAuth and proxy markers. ### Changed - Stdio (command-based) config entries are now skipped by default when connecting from a config file (`mcpc connect `). Pass `--stdio` to include them. Single-entry connects (`mcpc connect file:entry @session`) are not affected. +- x402 debug logs now announce the selected scheme (`scheme=upto` / `scheme=exact`) up front and print USD amounts with 6-decimal precision (USDC atomic unit). Enable with `--verbose` or `MCPC_VERBOSE=1`. - **Breaking:** `mcpc connect --json` now always returns an array of `InitializeResult` objects (extended with `toolNames` and `_mcpc` metadata), regardless of whether you connect a single server, a config file, or auto-discover all configs. Skipped/failed entries carry `_mcpc.status` (`created` | `active` | `failed` | `skipped`) and `_mcpc.skipReason` / `_mcpc.error`. The previous wrapper-object shapes (`{configFile, results, skipped}` and `{discovered, results, skipped}`) have been removed. - `tools-call --task` now prints the task ID and recovery commands when interrupted with Ctrl+C, so you can fetch or cancel the server-side task later diff --git a/src/cli/output.ts b/src/cli/output.ts index 8cac5b7a..d34b3baf 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1306,9 +1306,12 @@ export function formatSessionLine(session: SessionData): string { } const targetStr = truncateWithEllipsis(target, 80); - // Format auth info (transport type omitted — obvious from context) + // Format auth info. OAuth and x402 are mutually exclusive auth mechanisms; + // x402 takes precedence when both happen to be present on the session record. let infoStr = ''; - if (!session.server.command && session.profileName) { + if (session.x402) { + infoStr = theme.yellow('[x402]'); + } else if (!session.server.command && session.profileName) { infoStr = chalk.dim('(OAuth: ') + theme.magenta(session.profileName) + chalk.dim(')'); } From 0d65e963caa2f37fecd81faebddd5e8688ecbd90 Mon Sep 17 00:00:00 2001 From: MQ37 Date: Wed, 20 May 2026 15:55:36 +0200 Subject: [PATCH 04/10] feat(x402): pin scheme preference on a session via --x402-scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpc connect --x402-scheme ` plumbs the scheme preference end-to-end: - CLI validates against `X402_SCHEME_PREFERENCES` (canonical source in `lib/types.ts`) and rejects `--x402-scheme` without `--x402`. - Persisted on `SessionData.x402Scheme` so `mcpc restart` reuses the choice. - Forwarded to the bridge as `--x402-scheme ` and passed to `createX402FetchMiddleware({ schemePreference })`, which already honored the option. Default (when not specified) is `auto` — prefer upto, fall back to exact — same as the existing `mcpc x402 sign` default. --- CHANGELOG.md | 1 + src/bridge/index.ts | 23 ++++++++++++++++++++--- src/cli/commands/sessions.ts | 16 +++++++++++++++- src/cli/index.ts | 22 +++++++++++++++++++++- src/lib/bridge-manager.ts | 16 +++++++++++++++- src/lib/types.ts | 5 +++++ 6 files changed, 77 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 451fff0e..e33c99b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `mcpc connect` (with no arguments) now auto-discovers standard MCP config files (`.mcp.json`, `mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`, `~/.claude.json`, Claude Desktop, Windsurf, Kiro, etc.) in the current directory and home directory, and connects every server defined across them. Entries with duplicate session names are deduplicated (project-scoped files win over global ones). VS Code's `"servers"` key is also supported. - `mcpc connect` auto-connects to `mcp.apify.com` as `@apify` when the `APIFY_API_TOKEN` environment variable is set, using it as a Bearer token. Existing live sessions are reused without restart. - `mcpc x402 sign` supports the x402 `upto` scheme alongside `exact`. Use `--scheme ` to pick a preference (default `auto` prefers `upto`, falls back to `exact`). The signer auto-grants a one-time `USDC.approve(PERMIT2, MAX_UINT256)` allowance on first upto sign; pass `--no-approve` to skip. +- `mcpc connect --x402-scheme ` pins the scheme preference on the session. The choice is persisted to `sessions.json` and reused on `mcpc restart`. Requires `--x402`. - Sessions using x402 auto-payment now show a yellow `[x402]` marker in session listings, alongside the existing OAuth and proxy markers. ### Changed diff --git a/src/bridge/index.ts b/src/bridge/index.ts index 1c67bfa7..04f9ed80 100644 --- a/src/bridge/index.ts +++ b/src/bridge/index.ts @@ -10,8 +10,8 @@ import { createServer, type Server as NetServer, type Socket } from 'net'; import { unlink } from 'fs/promises'; import { createMcpClient, CreateMcpClientOptions } from '../core/index.js'; import type { McpClient } from '../core/index.js'; -import type { ServerConfig, IpcMessage, LoggingLevel } from '../lib/index.js'; -import { KEEPALIVE_INTERVAL_MS } from '../lib/types.js'; +import type { ServerConfig, IpcMessage, LoggingLevel, X402SchemePreference } from '../lib/index.js'; +import { KEEPALIVE_INTERVAL_MS, X402_SCHEME_PREFERENCES } from '../lib/types.js'; import { createLogger, setVerbose, initFileLogger, closeFileLogger } from '../lib/index.js'; import { fileExists, @@ -75,6 +75,7 @@ interface BridgeOptions { proxyConfig?: ProxyConfig; // Proxy server configuration mcpSessionId?: string; // MCP session ID for resumption (Streamable HTTP only) x402?: boolean; // Enable x402 auto-payment + x402Scheme?: X402SchemePreference; // x402 scheme preference (only with x402: true) insecure?: boolean; // Skip TLS certificate verification } @@ -615,6 +616,7 @@ class BridgeProcess { wallet, getToolByName, paymentCache: this.x402PaymentCache, + ...(this.options.x402Scheme && { schemePreference: this.options.x402Scheme }), }); } @@ -1613,7 +1615,7 @@ async function main(): Promise { if (args.length < 2) { console.error( - 'Usage: mcpc-bridge [--verbose] [--profile ] [--proxy-host ] [--proxy-port ] [--mcp-session-id ] [--x402] [--insecure]' + 'Usage: mcpc-bridge [--verbose] [--profile ] [--proxy-host ] [--proxy-port ] [--mcp-session-id ] [--x402] [--x402-scheme ] [--insecure]' ); process.exit(1); } @@ -1651,6 +1653,18 @@ async function main(): Promise { // Parse --x402 flag (for x402 payment signing) const x402 = args.includes('--x402'); + // Parse --x402-scheme argument. Validated at the CLI layer; the bridge silently + // ignores invalid values to keep the spawn surface tolerant. + let x402Scheme: X402SchemePreference | undefined; + const x402SchemeIndex = args.indexOf('--x402-scheme'); + const x402SchemeArg = x402SchemeIndex !== -1 ? args[x402SchemeIndex + 1] : undefined; + if ( + x402SchemeArg !== undefined && + (X402_SCHEME_PREFERENCES as readonly string[]).includes(x402SchemeArg) + ) { + x402Scheme = x402SchemeArg as X402SchemePreference; + } + // Parse --insecure flag (skip TLS certificate verification) const insecure = args.includes('--insecure'); @@ -1676,6 +1690,9 @@ async function main(): Promise { if (x402) { bridgeOptions.x402 = true; } + if (x402Scheme) { + bridgeOptions.x402Scheme = x402Scheme; + } if (insecure) { bridgeOptions.insecure = true; } diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index 58f25af3..7da2b89f 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -15,7 +15,12 @@ import { redactHeaders, } from '../../lib/index.js'; import { DISCONNECTED_THRESHOLD_MS } from '../../lib/types.js'; -import type { ServerConfig, ProxyConfig, ServerDetails } from '../../lib/types.js'; +import type { + ServerConfig, + ProxyConfig, + ServerDetails, + X402SchemePreference, +} from '../../lib/types.js'; import { formatOutput, formatSuccess, @@ -300,6 +305,7 @@ export async function connectSession( proxy?: string; proxyBearerToken?: string; x402?: boolean; + x402Scheme?: X402SchemePreference; insecure?: boolean; skipDetails?: boolean; quiet?: boolean; @@ -462,6 +468,7 @@ export async function connectSession( ...(profileName && { profileName }), ...(proxyConfig && { proxy: proxyConfig }), ...(options.x402 && { x402: true }), + ...(options.x402 && options.x402Scheme && { x402Scheme: options.x402Scheme }), ...(options.insecure && { insecure: true }), // Clear any previous error status (unauthorized, expired) when reconnecting ...(isReconnect && { status: 'active' }), @@ -500,6 +507,9 @@ export async function connectSession( if (options.x402) { bridgeOptions.x402 = true; } + if (options.x402 && options.x402Scheme) { + bridgeOptions.x402Scheme = options.x402Scheme; + } if (options.insecure) { bridgeOptions.insecure = true; } @@ -957,6 +967,10 @@ export async function restartSession( bridgeOptions.x402 = session.x402; } + if (session.x402 && session.x402Scheme) { + bridgeOptions.x402Scheme = session.x402Scheme; + } + if (session.insecure) { bridgeOptions.insecure = session.insecure; } diff --git a/src/cli/index.ts b/src/cli/index.ts index a32c99d9..4244785e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -27,7 +27,8 @@ import * as tasks from './commands/tasks.js'; import * as grepCmd from './commands/grep.js'; import { handleX402Command } from './commands/x402.js'; import { clean } from './commands/clean.js'; -import type { OutputMode } from '../lib/index.js'; +import type { OutputMode, X402SchemePreference } from '../lib/index.js'; +import { X402_SCHEME_PREFERENCES } from '../lib/index.js'; import { extractOptions, getVerboseFromEnv, @@ -64,6 +65,7 @@ interface HandlerOptions { profile?: string; noProfile?: boolean; x402?: boolean; + x402Scheme?: X402SchemePreference; insecure?: boolean; schema?: string; schemaMode?: 'strict' | 'compatible' | 'ignore'; @@ -108,6 +110,18 @@ function getOptionsFromCommand(command: Command): HandlerOptions { } if (verbose) options.verbose = verbose; if (opts.x402) options.x402 = true; + if (opts.x402Scheme) { + const scheme = opts.x402Scheme as string; + if (!(X402_SCHEME_PREFERENCES as readonly string[]).includes(scheme)) { + throw new Error( + `Invalid --x402-scheme value: "${scheme}". Valid schemes are: ${X402_SCHEME_PREFERENCES.join(', ')}` + ); + } + if (!options.x402) { + throw new Error('--x402-scheme requires --x402 to be set'); + } + options.x402Scheme = scheme as X402SchemePreference; + } if (opts.insecure) options.insecure = true; if (opts.schema) options.schema = opts.schema; if (opts.schemaMode) { @@ -441,6 +455,10 @@ Full docs: ${docsUrl}` .option('--proxy-bearer-token ', 'Require authentication for access to proxy server') .option('--stdio', 'Launch all local stdio servers from selected config files') .option('--x402', 'Enable x402 auto-payment using the configured wallet') + .option( + '--x402-scheme ', + 'x402 scheme preference (default: auto = prefer upto, fall back to exact)' + ) .addHelpText( 'after', ` @@ -550,6 +568,7 @@ ${jsonHelp( proxy: opts.proxy, proxyBearerToken: opts.proxyBearerToken, x402: opts.x402, + ...(opts.x402 && globalOpts.x402Scheme && { x402Scheme: globalOpts.x402Scheme }), ...(globalOpts.insecure && { insecure: true }), }); } else { @@ -559,6 +578,7 @@ ${jsonHelp( proxy: opts.proxy, proxyBearerToken: opts.proxyBearerToken, x402: opts.x402, + ...(opts.x402 && globalOpts.x402Scheme && { x402Scheme: globalOpts.x402Scheme }), ...(globalOpts.insecure && { insecure: true }), }); } diff --git a/src/lib/bridge-manager.ts b/src/lib/bridge-manager.ts index f8c4cc3c..73e2e926 100644 --- a/src/lib/bridge-manager.ts +++ b/src/lib/bridge-manager.ts @@ -15,7 +15,13 @@ import { spawn, type ChildProcess } from 'child_process'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import type { ServerConfig, AuthCredentials, ProxyConfig, X402WalletCredentials } from './types.js'; +import type { + ServerConfig, + AuthCredentials, + ProxyConfig, + X402WalletCredentials, + X402SchemePreference, +} from './types.js'; import { getSocketPath, waitForFile, @@ -101,6 +107,7 @@ export interface StartBridgeOptions { proxyConfig?: ProxyConfig; // Proxy server configuration mcpSessionId?: string; // MCP session ID for resumption (Streamable HTTP only) x402?: boolean; // Enable x402 auto-payment using the wallet + x402Scheme?: X402SchemePreference; // x402 scheme preference; only meaningful with x402: true insecure?: boolean; // Skip TLS certificate verification } @@ -133,6 +140,7 @@ export async function startBridge(options: StartBridgeOptions): Promise") profileName?: string; // Name of auth profile (for OAuth servers) x402?: boolean; // x402 auto-payment enabled for this session + x402Scheme?: X402SchemePreference; // x402 scheme preference (default: auto = prefer upto, fall back to exact) insecure?: boolean; // Skip TLS certificate verification pid?: number; // Bridge process PID protocolVersion?: string; // Negotiated MCP version From e3743806b1ffec11e8ae62eaa20d90574691c86b Mon Sep 17 00:00:00 2001 From: MQ37 Date: Thu, 21 May 2026 10:40:32 +0200 Subject: [PATCH 05/10] fix(x402): honor schemePreference on proactive sign and tool-result retry paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--x402-scheme` previously only kicked in on the HTTP-402 fallback. Two other signing paths defaulted to `auto` and signed whatever the server preferred: 1. The proactive-sign path (`getOrSignPayment`) read only the flat `_meta.x402.{scheme,…}` fields, missing the new `accepts[]` advertising and ignoring `schemePreference` entirely. 2. The tool-result retry path (`extractAcceptFromPaymentRequired` called from `BridgeProcess.handlePaymentRequiredRetry`) hard-coded `selectAcceptEntry(..., 'auto')`. Both now honor the configured preference end-to-end: - `createX402FetchMiddleware` passes `schemePreference` into `getOrSignPayment`. - New `selectAcceptFromToolMeta` helper consumes `_meta.x402.accepts[]` when present (post apify-mcp-server #876), falling back to flat fields only when the preference matches the flat scheme (or preference is `auto`). - `extractAcceptFromPaymentRequired` takes a `schemePreference` parameter and the bridge passes `this.options.x402Scheme` through to it. When the proactive path can't honor the preference (e.g. pre-#876 server with flat-only `_meta.x402.scheme=upto` and `--x402-scheme exact`), it skips signing and lets the 402 fallback handle it \u2014 the 402 response is the authoritative source of `accepts[]` regardless of what the server advertises proactively. Refs apify/mcpc#238 review comment from @TateLyman. New `fetch-middleware.test.ts` covers: - proactive sign with accepts=[exact, upto] and schemePreference=exact \u2192 signs exact - proactive sign with accepts=[exact, upto] and schemePreference=upto \u2192 signs upto - proactive sign with accepts=[upto] and schemePreference=exact \u2192 skips - proactive sign with legacy flat-only upto and schemePreference=exact \u2192 skips - proactive sign with default auto preference \u2192 prefers upto - extractAcceptFromPaymentRequired with each preference value --- src/bridge/index.ts | 2 +- src/lib/x402/fetch-middleware.ts | 94 ++++++-- test/unit/lib/x402/fetch-middleware.test.ts | 226 ++++++++++++++++++++ 3 files changed, 298 insertions(+), 24 deletions(-) create mode 100644 test/unit/lib/x402/fetch-middleware.test.ts diff --git a/src/bridge/index.ts b/src/bridge/index.ts index 04f9ed80..0eada55f 100644 --- a/src/bridge/index.ts +++ b/src/bridge/index.ts @@ -1113,7 +1113,7 @@ class BridgeProcess { const paymentRequired = extractPaymentRequiredFromResult(toolResult); if (!paymentRequired) return { handled: false }; - const parsed = extractAcceptFromPaymentRequired(paymentRequired); + const parsed = extractAcceptFromPaymentRequired(paymentRequired, this.options.x402Scheme); if (!parsed) { logger.warn('Payment-required tool result but could not extract supported payment terms'); return { handled: false }; diff --git a/src/lib/x402/fetch-middleware.ts b/src/lib/x402/fetch-middleware.ts index d9094012..a3a31870 100644 --- a/src/lib/x402/fetch-middleware.ts +++ b/src/lib/x402/fetch-middleware.ts @@ -34,16 +34,26 @@ const logger = createLogger('x402-middleware'); /** MCP _meta key for x402 payment (per x402 MCP spec) */ const MCP_PAYMENT_META_KEY = 'x402/payment'; -/** Payment information from tool's _meta.x402 */ +/** + * Payment information from tool's `_meta.x402`. + * + * Apify exposes two shapes side-by-side: + * - **`accepts[]`** carries every advertised scheme (post apify-mcp-server #876). + * Walk this when present — it's the only way to honor `--x402-scheme` against + * servers that advertise multiple schemes. + * - **Flat preferred fields** mirror the server's preferred entry for back-compat + * with clients that don't iterate `accepts[]`. Used as a fallback. + */ interface ToolPaymentMeta { paymentRequired: boolean; + accepts?: PaymentRequiredAccept[]; scheme?: string; network?: string; amount?: string; asset?: string; payTo?: string; maxTimeoutSeconds?: number; - extra?: { name?: string; version?: string }; + extra?: { name?: string; version?: string; facilitatorAddress?: string }; } /** Parsed JSON-RPC request body (enough to identify tools/call) */ @@ -102,7 +112,13 @@ export function createX402FetchMiddleware( return async (url: string | URL, init?: RequestInit): Promise => { // Try to get a payment signature (cached or freshly signed) for tools/call requests - const paymentSignature = await getOrSignPayment(init, wallet, getToolByName, paymentCache); + const paymentSignature = await getOrSignPayment( + init, + wallet, + getToolByName, + paymentCache, + schemePreference + ); if (paymentSignature) { const enhancedInit = injectPayment(init, paymentSignature); const response = await baseFetch(url, enhancedInit); @@ -155,7 +171,8 @@ async function getOrSignPayment( init: RequestInit | undefined, wallet: SignerWallet, getToolByName: ((name: string) => Tool | undefined) | undefined, - paymentCache: X402PaymentCache + paymentCache: X402PaymentCache, + schemePreference?: SchemePreference ): Promise { if (!getToolByName || !init?.body) { return undefined; @@ -192,25 +209,14 @@ async function getOrSignPayment( return paymentCache.signature; } - // Check if we have enough info to sign - if (!x402.scheme || !x402.network || !x402.amount || !x402.asset || !x402.payTo) { + const accept = selectAcceptFromToolMeta(x402, schemePreference); + if (!accept) { logger.debug( - `Tool "${toolName}" has x402 metadata but missing fields, skipping payment signing` + `Tool "${toolName}" _meta.x402 does not advertise a usable accept for schemePreference=${schemePreference ?? 'auto'}, deferring to 402 fallback` ); return undefined; } - // Build accept from tool metadata and sign fresh - const accept: PaymentRequiredAccept = { - scheme: x402.scheme, - network: x402.network, - amount: x402.amount, - asset: x402.asset, - payTo: x402.payTo, - maxTimeoutSeconds: x402.maxTimeoutSeconds || 3600, - ...(x402.extra && { extra: x402.extra }), - }; - try { const result = await signPayment({ wallet, accept }); logger.debug( @@ -316,12 +322,51 @@ function extractToolCallName(body: RequestInit['body'] | undefined): string | un } /** - * Extract `PaymentRequiredAccept` from a PaymentRequired object. - * Uses scheme selection logic: prefers valid `upto`, falls back to valid `exact`. + * Pick an accept entry from a tool's `_meta.x402` block honoring `schemePreference`. + * + * Prefers the spec-shaped `accepts[]` array when present; falls back to the flat fields + * only when the preference matches the flat scheme (or the preference is `auto`). + * Returning undefined defers signing to the 402 fallback path, which re-runs the + * selector against the authoritative PAYMENT-REQUIRED header. + */ +function selectAcceptFromToolMeta( + x402: ToolPaymentMeta, + schemePreference?: SchemePreference +): PaymentRequiredAccept | undefined { + const preference = schemePreference ?? 'auto'; + + if (Array.isArray(x402.accepts) && x402.accepts.length > 0) { + return selectAcceptEntry(x402.accepts, preference); + } + + if (!x402.scheme || !x402.network || !x402.amount || !x402.asset || !x402.payTo) { + return undefined; + } + if (preference !== 'auto' && preference !== x402.scheme) { + return undefined; + } + + return { + scheme: x402.scheme, + network: x402.network, + amount: x402.amount, + asset: x402.asset, + payTo: x402.payTo, + maxTimeoutSeconds: x402.maxTimeoutSeconds || 3600, + ...(x402.extra && { extra: x402.extra }), + }; +} + +/** + * Extract `PaymentRequiredAccept` from a PaymentRequired object honoring scheme preference. * - * The data is expected to have the shape: `{ x402Version, accepts: [...] }` + * Expected shape: `{ x402Version, accepts: [...] }`. Default preference is `auto` + * (prefer upto, fall back to exact) when the caller doesn't pin one. */ -export function extractAcceptFromPaymentRequired(data: unknown): +export function extractAcceptFromPaymentRequired( + data: unknown, + schemePreference?: SchemePreference +): | { accept: PaymentRequiredAccept; resource?: { url?: string; description?: string; mimeType?: string }; @@ -332,7 +377,10 @@ export function extractAcceptFromPaymentRequired(data: unknown): const obj = data as Record; if (!Array.isArray(obj.accepts) || obj.accepts.length === 0) return undefined; - const accept = selectAcceptEntry(obj.accepts as PaymentRequiredAccept[], 'auto'); + const accept = selectAcceptEntry( + obj.accepts as PaymentRequiredAccept[], + schemePreference ?? 'auto' + ); if (!accept) { return undefined; } diff --git a/test/unit/lib/x402/fetch-middleware.test.ts b/test/unit/lib/x402/fetch-middleware.test.ts new file mode 100644 index 00000000..f2f2342e --- /dev/null +++ b/test/unit/lib/x402/fetch-middleware.test.ts @@ -0,0 +1,226 @@ +/** + * Tests for the proactive-sign path and the tool-result retry path's scheme handling. + * + * Regression target: `--x402-scheme exact` must not be silently overridden by the + * proactive `_meta.x402` path or by the tool-result retry helper. Both used to + * hard-code `auto` and pick whatever the server preferred (which now defaults to + * `upto` after apify-mcp-server #876). + */ + +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; + +import { + createX402FetchMiddleware, + extractAcceptFromPaymentRequired, + type X402PaymentCache, +} from '../../../../src/lib/x402/fetch-middleware.js'; +import type { PaymentRequiredAccept, SignerWallet } from '../../../../src/lib/x402/signer.js'; + +// --------------------------------------------------------------------------- +// Mocks — vi.mock is hoisted above local const declarations +// --------------------------------------------------------------------------- + +const { mockSignPayment } = vi.hoisted(() => ({ mockSignPayment: vi.fn() })); + +vi.mock('../../../../src/lib/x402/signer.js', async () => { + const actual = await vi.importActual( + '../../../../src/lib/x402/signer.js' + ); + return { + ...actual, + signPayment: (...args: unknown[]) => mockSignPayment(...args), + }; +}); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const WALLET: SignerWallet = { + privateKey: '0x1111111111111111111111111111111111111111111111111111111111111111', + address: '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', +}; + +const EXACT_ACCEPT: PaymentRequiredAccept = { + scheme: 'exact', + network: 'eip155:8453', + amount: '1000000', + asset: '0xExactAsset', + payTo: '0xPayee', + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, +}; + +const UPTO_ACCEPT: PaymentRequiredAccept = { + scheme: 'upto', + network: 'eip155:8453', + amount: '1000000', + asset: '0xUptoAsset', + payTo: '0xPayee', + maxTimeoutSeconds: 18_000, + extra: { name: 'USDC', version: '2', facilitatorAddress: '0xFacilitator' }, +}; + +function makePaidTool(metaX402: Record): Tool { + return { + name: 'paid-tool', + description: 'Paid tool', + inputSchema: { type: 'object' }, + _meta: { x402: { paymentRequired: true, ...metaX402 } }, + } as unknown as Tool; +} + +function toolsCallBody(toolName: string): string { + return JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: toolName, arguments: {} }, + }); +} + +beforeEach(() => { + mockSignPayment.mockReset(); + mockSignPayment.mockResolvedValue({ + paymentSignatureBase64: 'mock-signature-base64', + from: WALLET.address, + to: '0xPayee', + amountUsd: 1, + amountAtomicUnits: 1_000_000n, + networkLabel: 'Base Mainnet', + expiresAt: new Date(), + }); +}); + +// --------------------------------------------------------------------------- +// proactive-sign path — getOrSignPayment via createX402FetchMiddleware +// --------------------------------------------------------------------------- + +describe('createX402FetchMiddleware proactive sign', () => { + 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 }; + const fetchFn = createX402FetchMiddleware( + vi.fn().mockResolvedValue(new Response('', { status: 200 })), + { + wallet: WALLET, + getToolByName: () => tool, + paymentCache: cache, + schemePreference: 'exact', + } + ); + + await fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }); + + expect(mockSignPayment).toHaveBeenCalledTimes(1); + const accept = mockSignPayment.mock.calls[0]?.[0]?.accept as PaymentRequiredAccept; + expect(accept.scheme).toBe('exact'); + expect(accept.asset).toBe('0xExactAsset'); + }); + + it('with schemePreference=upto and accepts=[exact, upto], signs upto', async () => { + const tool = makePaidTool({ accepts: [EXACT_ACCEPT, UPTO_ACCEPT], ...EXACT_ACCEPT }); + const cache: X402PaymentCache = { signature: null }; + const fetchFn = createX402FetchMiddleware( + vi.fn().mockResolvedValue(new Response('', { status: 200 })), + { + wallet: WALLET, + getToolByName: () => tool, + paymentCache: cache, + schemePreference: 'upto', + } + ); + + await fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }); + + const accept = mockSignPayment.mock.calls[0]?.[0]?.accept as PaymentRequiredAccept; + expect(accept.scheme).toBe('upto'); + expect(accept.asset).toBe('0xUptoAsset'); + }); + + it('with schemePreference=exact and accepts=[upto] only, skips proactive sign', async () => { + const tool = makePaidTool({ accepts: [UPTO_ACCEPT], ...UPTO_ACCEPT }); + const cache: X402PaymentCache = { signature: null }; + const baseFetch = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + getToolByName: () => tool, + paymentCache: cache, + schemePreference: 'exact', + }); + + await fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }); + + expect(mockSignPayment).not.toHaveBeenCalled(); + expect(baseFetch).toHaveBeenCalledTimes(1); + }); + + it('with schemePreference=exact and legacy flat-only _meta.x402 advertising upto, defers to 402 fallback', async () => { + // Pre-#876 server: flat fields only, no accepts[]. Server's preferred scheme is upto. + const tool = makePaidTool({ ...UPTO_ACCEPT }); + const cache: X402PaymentCache = { signature: null }; + const baseFetch = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + getToolByName: () => tool, + paymentCache: cache, + schemePreference: 'exact', + }); + + await fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }); + + expect(mockSignPayment).not.toHaveBeenCalled(); + }); + + it('with schemePreference=auto and accepts=[exact, upto], prefers upto', async () => { + const tool = makePaidTool({ accepts: [EXACT_ACCEPT, UPTO_ACCEPT], ...UPTO_ACCEPT }); + const cache: X402PaymentCache = { signature: null }; + const fetchFn = createX402FetchMiddleware( + vi.fn().mockResolvedValue(new Response('', { status: 200 })), + { + wallet: WALLET, + getToolByName: () => tool, + paymentCache: cache, + // schemePreference unset → defaults to auto + } + ); + + await fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }); + + const accept = mockSignPayment.mock.calls[0]?.[0]?.accept as PaymentRequiredAccept; + expect(accept.scheme).toBe('upto'); + }); +}); + +// --------------------------------------------------------------------------- +// tool-result retry path — extractAcceptFromPaymentRequired +// --------------------------------------------------------------------------- + +describe('extractAcceptFromPaymentRequired', () => { + const paymentRequired = { + x402Version: 2, + accepts: [EXACT_ACCEPT, UPTO_ACCEPT], + resource: { url: 'mcp://tool/foo', description: 'foo' }, + }; + + it('defaults to auto (prefers upto) when schemePreference is omitted', () => { + const result = extractAcceptFromPaymentRequired(paymentRequired); + expect(result?.accept.scheme).toBe('upto'); + }); + + it('honors schemePreference=exact', () => { + const result = extractAcceptFromPaymentRequired(paymentRequired, 'exact'); + expect(result?.accept.scheme).toBe('exact'); + }); + + it('honors schemePreference=upto', () => { + const result = extractAcceptFromPaymentRequired(paymentRequired, 'upto'); + expect(result?.accept.scheme).toBe('upto'); + }); + + it('returns undefined when schemePreference=exact and only upto is available', () => { + const uptoOnly = { x402Version: 2, accepts: [UPTO_ACCEPT] }; + const result = extractAcceptFromPaymentRequired(uptoOnly, 'exact'); + expect(result).toBeUndefined(); + }); +}); From b776b2113d39c938aae36e5fd600a9d244bf4647 Mon Sep 17 00:00:00 2001 From: MQ37 Date: Thu, 21 May 2026 10:56:19 +0200 Subject: [PATCH 06/10] docs(x402): document upto scheme, session preferences and flags in README - Explains `exact` (EIP-3009) vs `upto` (Permit2) scheme semantics. - Documents `--x402-scheme ` session configuration and persistence. - Adds `--scheme` and `--no-approve` options to `mcpc x402 sign` table. - Cleans up type duplication of `SchemePreference` by aliasing `X402SchemePreference` imported from types.ts. --- README.md | 44 +++++++++++++++++++++++++++--------------- src/lib/x402/signer.ts | 3 ++- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 843eb400..6e5b713f 100644 --- a/README.md +++ b/README.md @@ -350,7 +350,7 @@ and auto-reconnects on network failures or its own crashes (10s cooldown on fail **Session states:** | State | Meaning | -|------------------| ----------------------------------------------------------------------------------------------- | +| ---------------- | ----------------------------------------------------------------------------------------------- | | 🟢`live` | Bridge process running and server responding | | 🟡`connecting` | Initial bridge startup in progress (`mcpc connect`) | | 🟡`reconnecting` | Bridge crashed or lost auth; auto-reconnecting in the background | @@ -681,13 +681,20 @@ This is entirely **opt-in**: existing functionality is unaffected unless you exp ### How it works -1. **Server returns HTTP 402** with a `PAYMENT-REQUIRED` header describing the price and payment details. -2. `mcpc` parses the header, signs an [EIP-3009](https://eips.ethereum.org/EIPS/eip-3009) `TransferWithAuthorization` using your local wallet. -3. `mcpc` retries the request with a `PAYMENT-SIGNATURE` header containing the signed payment. +The x402 protocol defines different payment **schemes**: + +- **`exact`** (Standard EIP-3009): The client signs an exact `TransferWithAuthorization` on USDC. Settles on-chain immediately at call-time. +- **`upto`** (Permit2): The client signs a maximum authorization cap using Uniswap's `Permit2` witness signatures. The facilitator verifies the signature off-chain immediately, and settles the actual accumulated usage later (asynchronously). + +Regardless of the scheme, the general flow is: + +1. **Server returns HTTP 402** with a `PAYMENT-REQUIRED` header advertising its supported schemes and details. +2. `mcpc` parses the header, picks the best scheme, and signs the payment payload using your local wallet. + - For `upto`, `mcpc` automatically checks and grants the one-time on-chain Permit2 allowance if needed (requires a small native ETH float for gas). +3. `mcpc` retries the request with a `PAYMENT-SIGNATURE` header containing the signed payload. 4. The server verifies the signature and fulfills the request. -For tools that advertise pricing in their `_meta.x402` metadata, `mcpc` can **proactively sign** payments -on the first request, avoiding the 402 round-trip entirely. +For tools that advertise pricing in their `_meta.x402` metadata, `mcpc` can **proactively sign** payments on the first request, avoiding the 402 round-trip entirely. This path is fully scheme-aware and respects your configured session preference. ### Wallet setup @@ -731,10 +738,12 @@ mcpc x402 sign --amount 1.00 --expiry 3600 --json **Options:** -| Option | Description | -| -------------------- | ------------------------------------------------------------- | -| `--amount ` | Override the payment amount in USD (e.g. `0.50` for $0.50) | -| `--expiry ` | Override the payment expiry in seconds from now (e.g. `3600`) | +| Option | Description | +| -------------------- | ----------------------------------------------------------------------- | +| `--amount ` | Override the payment amount in USD (e.g. `0.50` for $0.50) | +| `--expiry ` | Override the payment expiry in seconds from now (e.g. `3600`) | +| `--scheme ` | Scheme preference: `auto` (default, upto > exact), `upto`, or `exact` | +| `--no-approve` | For `upto`, skip checking and auto-approving on-chain Permit2 allowance | The command outputs the signed `PAYMENT-SIGNATURE` header value and an MCP config snippet that can be used directly with other MCP clients. @@ -744,18 +753,21 @@ that can be used directly with other MCP clients. Pass the `--x402` flag when connecting to a session or running direct commands: ```bash -# Create a session with x402 payment support +# Create a session with x402 payment support (auto selects best scheme) mcpc connect mcp.apify.com @apify --x402 -# The session now automatically handles 402 responses +# Force a specific scheme preference (auto, upto, exact) +mcpc connect mcp.apify.com @apify --x402 --x402-scheme exact + +# The session now automatically handles 402 responses using your preference mcpc @apify tools-call expensive-tool query:="hello" -# Restart a session with x402 enabled -mcpc @apify restart --x402 +# Restart a session with x402 enabled or update scheme preference +mcpc @apify restart --x402-scheme upto ``` -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. +When `--x402` or `--x402-scheme` 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 inside `sessions.json` and reused automatically across reconnects or restarts. ### Supported networks diff --git a/src/lib/x402/signer.ts b/src/lib/x402/signer.ts index 3d787345..0c3e665c 100644 --- a/src/lib/x402/signer.ts +++ b/src/lib/x402/signer.ts @@ -17,6 +17,7 @@ import { import { base, baseSepolia } from 'viem/chains'; import { ClientError } from '../errors.js'; import { createLogger } from '../logger.js'; +import type { X402SchemePreference } from '../types.js'; const logger = createLogger('x402-signer'); @@ -142,7 +143,7 @@ export interface PaymentRequiredHeader { accepts: PaymentRequiredAccept[]; } -export type SchemePreference = 'auto' | 'exact' | 'upto'; +export type SchemePreference = X402SchemePreference; /** Minimal wallet info needed for signing */ export interface SignerWallet { From 0cc5a6cfec1cf0f955881374a8584afc37a4e57f Mon Sep 17 00:00:00 2001 From: MQ37 Date: Thu, 21 May 2026 11:12:59 +0200 Subject: [PATCH 07/10] fix(x402): preserve schemePreference on automatic bridge failover When the bridge process crashes in the background, `restartBridge` reads the saved session record from `sessions.json` and spawns a new bridge. Previously, it only forwarded `session.x402: true` to `bridgeOptions` but forgot to plumb `session.x402Scheme` (the pinned preference). This caused the restarted bridge to revert to the default `auto` scheme preference, violating the pinned session policy. Now correctly forwards `session.x402Scheme` on automatic crash restarts. Refs Rule 25: Plumb user-visible configurations end-to-end to avoid leaky parameter gaps. --- src/lib/bridge-manager.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/bridge-manager.ts b/src/lib/bridge-manager.ts index 73e2e926..bfb30474 100644 --- a/src/lib/bridge-manager.ts +++ b/src/lib/bridge-manager.ts @@ -443,6 +443,10 @@ export async function restartBridge(sessionName: string): Promise Date: Thu, 21 May 2026 11:30:43 +0200 Subject: [PATCH 08/10] fix(x402): type-declare schemePreference on bulk-connect options; drop dead conditional in upto signer Two small leaky-parameter findings from a second-pass review (Rule 25 in code-quality skill): 1. `BulkConnectOptions` did not declare `x402Scheme`. The value was passed implicitly via `{ ...globalOpts }` spread to `connectAllFromConfig` / `connectAllFromStandardConfigs`, so it survived at runtime, but the typed parameter view dropped it \u2014 fragile to any future refactor that destructures the options object instead of re-spreading it. Declare the field and add explicit spreads at the two CLI call sites so the contract is type-enforced. 2. `signUptoPayment` built the accepted.extra block with `...(facilitatorAddress ? { facilitatorAddress } : {})`, but the function throws earlier when `facilitatorAddress` is empty \u2014 the false branch was unreachable. Inline the field directly with a one-line invariant comment. --- src/cli/commands/sessions.ts | 1 + src/cli/index.ts | 2 ++ src/lib/x402/signer.ts | 8 +++----- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index 7da2b89f..785d190f 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -1034,6 +1034,7 @@ type BulkConnectOptions = { proxyBearerToken?: string; stdio?: boolean; x402?: boolean; + x402Scheme?: X402SchemePreference; insecure?: boolean; }; diff --git a/src/cli/index.ts b/src/cli/index.ts index 4244785e..cef91a96 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -515,6 +515,7 @@ ${jsonHelp( ...(opts.proxyBearerToken && { proxyBearerToken: opts.proxyBearerToken as string }), ...(opts.stdio && { stdio: true }), ...(opts.x402 && { x402: opts.x402 as boolean }), + ...(opts.x402 && globalOpts.x402Scheme && { x402Scheme: globalOpts.x402Scheme }), ...(globalOpts.insecure && { insecure: true }), }); return; @@ -544,6 +545,7 @@ ${jsonHelp( ...(opts.proxyBearerToken && { proxyBearerToken: opts.proxyBearerToken as string }), ...(opts.stdio && { stdio: true }), ...(opts.x402 && { x402: opts.x402 as boolean }), + ...(opts.x402 && globalOpts.x402Scheme && { x402Scheme: globalOpts.x402Scheme }), ...(globalOpts.insecure && { insecure: true }), }); return; diff --git a/src/lib/x402/signer.ts b/src/lib/x402/signer.ts index 0c3e665c..472ac3b2 100644 --- a/src/lib/x402/signer.ts +++ b/src/lib/x402/signer.ts @@ -639,11 +639,9 @@ async function signUptoPayment(input: SignPaymentInput): Promise Date: Thu, 21 May 2026 11:58:03 +0200 Subject: [PATCH 09/10] refactor(x402)!: collapse --x402-scheme into --x402 [auto|upto|exact] Single user-visible flag instead of two. Boolean+enum data model becomes one nullable string field. Eliminates the 'scheme set without x402: true' class of bugs and shrinks Rule 25 surface area in half. CLI: - `--x402 [scheme]` (optional value): bare `--x402` defaults to 'auto'; `--x402 upto` / `--x402 exact` pins the preference. - `--x402-scheme` removed. - Commander's greedy parser eats the next token as the value; CLI validates it must be in {auto,upto,exact} and throws otherwise so a URL/session can't slip through silently. Help text documents `--x402=` as the unambiguous form when followed by positional args. Data model: - `SessionData.x402: X402SchemePreference | undefined` (presence = enabled). - Legacy fields `x402: boolean` + `x402Scheme` are normalised on session read by `normaliseLegacyX402` and rewritten on the next save. Read once, then the on-disk format converges to the new shape. Plumbing tightened (one parameter instead of two): - `BridgeOptions.x402`, `StartBridgeOptions.x402`, `HandlerOptions.x402`, `BulkConnectOptions.x402` all become `X402SchemePreference?`. - Bridge IPC arg is now `--x402 ` (was `--x402` + `--x402-scheme`). - `createX402FetchMiddleware` and `extractAcceptFromPaymentRequired` receive the value directly from `this.options.x402` \u2014 no second field to keep in sync. Tests: - 7 new unit tests for `normaliseLegacyX402` covering legacy true/false, legacy true+scheme, idempotency, defensive drop on invalid strings, stale `x402Scheme` sidecar without parent flag. - Stub-resistant per Rule 22: 5 of 7 fail when the migrator is no-op'd. - Full suite: 627 tests pass (+7 from previous 620). Docs: - README auth-flags table updated; 'Using x402 with MCP servers' subsection rewritten to show the new examples and document the equals form. - CHANGELOG Unreleased entry rewritten. Breaking: `--x402-scheme` was added in this same Unreleased cycle and never shipped \u2014 no released-API users to migrate. The on-disk legacy shape is auto-migrated transparently on read. --- CHANGELOG.md | 4 +- README.md | 35 ++++---- src/bridge/index.ts | 39 ++++----- src/cli/commands/sessions.ts | 18 +---- src/cli/index.ts | 43 +++++----- src/lib/bridge-manager.ts | 23 ++---- src/lib/sessions.ts | 37 ++++++++- src/lib/types.ts | 12 ++- src/lib/x402/fetch-middleware.ts | 4 +- .../lib/sessions.normaliseLegacyX402.test.ts | 79 +++++++++++++++++++ 10 files changed, 197 insertions(+), 97 deletions(-) create mode 100644 test/unit/lib/sessions.normaliseLegacyX402.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e33c99b9..5002f56b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `mcpc connect` (with no arguments) now auto-discovers standard MCP config files (`.mcp.json`, `mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`, `~/.claude.json`, Claude Desktop, Windsurf, Kiro, etc.) in the current directory and home directory, and connects every server defined across them. Entries with duplicate session names are deduplicated (project-scoped files win over global ones). VS Code's `"servers"` key is also supported. - `mcpc connect` auto-connects to `mcp.apify.com` as `@apify` when the `APIFY_API_TOKEN` environment variable is set, using it as a Bearer token. Existing live sessions are reused without restart. - `mcpc x402 sign` supports the x402 `upto` scheme alongside `exact`. Use `--scheme ` to pick a preference (default `auto` prefers `upto`, falls back to `exact`). The signer auto-grants a one-time `USDC.approve(PERMIT2, MAX_UINT256)` allowance on first upto sign; pass `--no-approve` to skip. -- `mcpc connect --x402-scheme ` pins the scheme preference on the session. The choice is persisted to `sessions.json` and reused on `mcpc restart`. Requires `--x402`. +- `mcpc connect --x402 [auto|upto|exact]` enables x402 auto-payment with an optional scheme preference. Bare `--x402` defaults to `auto` (prefer upto, fall back to exact). The choice is persisted to `sessions.json` and reused on `mcpc restart`. Use `--x402=` when the flag is followed by positional arguments to avoid Commander's greedy parsing of the next token. - Sessions using x402 auto-payment now show a yellow `[x402]` marker in session listings, alongside the existing OAuth and proxy markers. ### Changed @@ -24,7 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `mcpc connect` and `mcpc restart` no longer fail with `ENOENT` when the macOS Keychain prompts for a password. Credentials are now read from the keychain *before* the bridge process is spawned, so the bridge's IPC startup timer no longer races a foreground password dialog (#55) +- `mcpc connect` and `mcpc restart` no longer fail with `ENOENT` when the macOS Keychain prompts for a password. Credentials are now read from the keychain _before_ the bridge process is spawned, so the bridge's IPC startup timer no longer races a foreground password dialog (#55) - Background auto-reconnect now correctly marks sessions as `unauthorized` when the server returns 401/403, instead of leaving them stuck in `connecting` after the bridge crashed on an unhandled rejection - Sessions using a static bearer token (via `--header "Authorization: ..."`) no longer flip between `unauthorized` and `connecting` on every `mcpc` invocation — they stay `unauthorized` until you `mcpc login` or reconnect. OAuth-profile sessions still auto-retry because tokens may have been refreshed by another session - Stdio servers no longer fail silently: the bridge now captures the child's stderr, writes it to `~/.mcpc/logs/bridge-.log`, and appends a tail of the most recent lines to `mcpc connect` errors. This makes it obvious when a stdio server crashes on startup due to e.g. missing TLS trust (`NODE_EXTRA_CA_CERTS`), missing proxy vars, or missing credentials (#195) diff --git a/README.md b/README.md index 6e5b713f..1a9a53b5 100644 --- a/README.md +++ b/README.md @@ -485,13 +485,13 @@ always win over stored profiles, and credentials are never silently downgraded. is missing, expired, or invalid, `mcpc` fails with an error that includes the right `mcpc login` command to recover. -| Flag | Behavior | -| ------------------------------- | ------------------------------------------------------------------------------------------- | -| `--header "Authorization: ..."` | Use explicit header; skip OAuth auto-detection. Cannot combine with `--profile`. | -| `--profile ` | Require the named profile to exist. | -| `--no-profile` | Connect anonymously even if a `default` profile exists. | -| `--x402` | Skip OAuth auto-detection; use x402 payments instead. Combine with `--profile` to use both. | -| _(none)_ | Use `default` profile if it exists; otherwise connect anonymously. | +| Flag | Behavior | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `--header "Authorization: ..."` | Use explicit header; skip OAuth auto-detection. Cannot combine with `--profile`. | +| `--profile ` | Require the named profile to exist. | +| `--no-profile` | Connect anonymously even if a `default` profile exists. | +| `--x402 [scheme]` | Skip OAuth auto-detection; use x402 payments instead. Optional scheme: `auto` (default), `upto`, `exact`. Combine with `--profile` to use both. | +| _(none)_ | Use `default` profile if it exists; otherwise connect anonymously. | Config file headers (from `--config`) apply to servers loaded from that file. @@ -750,24 +750,29 @@ that can be used directly with other MCP clients. ### Using x402 with MCP servers -Pass the `--x402` flag when connecting to a session or running direct commands: +Pass the `--x402` flag when connecting to a session. It accepts an optional scheme preference +(`auto`, `upto`, or `exact`); bare `--x402` defaults to `auto`. ```bash -# Create a session with x402 payment support (auto selects best scheme) +# Create a session with x402 payment support (auto picks the best advertised scheme) mcpc connect mcp.apify.com @apify --x402 -# Force a specific scheme preference (auto, upto, exact) -mcpc connect mcp.apify.com @apify --x402 --x402-scheme exact +# Pin a specific scheme — value goes after the flag +mcpc connect mcp.apify.com @apify --x402 exact + +# When --x402 precedes positional args, use the equals form to avoid Commander's +# greedy [optional] argument parser eating the URL/session as the value. +mcpc connect --x402=upto mcp.apify.com @apify # The session now automatically handles 402 responses using your preference mcpc @apify tools-call expensive-tool query:="hello" -# Restart a session with x402 enabled or update scheme preference -mcpc @apify restart --x402-scheme upto +# Restart re-uses the saved scheme from sessions.json — no need to repeat the flag +mcpc @apify restart ``` -When `--x402` or `--x402-scheme` 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 inside `sessions.json` and reused automatically across reconnects or restarts. +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. ### Supported networks diff --git a/src/bridge/index.ts b/src/bridge/index.ts index 0eada55f..1642ab85 100644 --- a/src/bridge/index.ts +++ b/src/bridge/index.ts @@ -74,8 +74,8 @@ interface BridgeOptions { profileName?: string; // Auth profile name for token refresh proxyConfig?: ProxyConfig; // Proxy server configuration mcpSessionId?: string; // MCP session ID for resumption (Streamable HTTP only) - x402?: boolean; // Enable x402 auto-payment - x402Scheme?: X402SchemePreference; // x402 scheme preference (only with x402: true) + /** x402 scheme preference; presence enables x402 auto-payment, absence disables. */ + x402?: X402SchemePreference; insecure?: boolean; // Skip TLS certificate verification } @@ -616,7 +616,7 @@ class BridgeProcess { wallet, getToolByName, paymentCache: this.x402PaymentCache, - ...(this.options.x402Scheme && { schemePreference: this.options.x402Scheme }), + ...(this.options.x402 && { schemePreference: this.options.x402 }), }); } @@ -1113,7 +1113,7 @@ class BridgeProcess { const paymentRequired = extractPaymentRequiredFromResult(toolResult); if (!paymentRequired) return { handled: false }; - const parsed = extractAcceptFromPaymentRequired(paymentRequired, this.options.x402Scheme); + const parsed = extractAcceptFromPaymentRequired(paymentRequired, this.options.x402); if (!parsed) { logger.warn('Payment-required tool result but could not extract supported payment terms'); return { handled: false }; @@ -1615,7 +1615,7 @@ async function main(): Promise { if (args.length < 2) { console.error( - 'Usage: mcpc-bridge [--verbose] [--profile ] [--proxy-host ] [--proxy-port ] [--mcp-session-id ] [--x402] [--x402-scheme ] [--insecure]' + 'Usage: mcpc-bridge [--verbose] [--profile ] [--proxy-host ] [--proxy-port ] [--mcp-session-id ] [--x402 ] [--insecure]' ); process.exit(1); } @@ -1650,19 +1650,17 @@ async function main(): Promise { mcpSessionId = args[mcpSessionIdIndex + 1]; } - // Parse --x402 flag (for x402 payment signing) - const x402 = args.includes('--x402'); - - // Parse --x402-scheme argument. Validated at the CLI layer; the bridge silently - // ignores invalid values to keep the spawn surface tolerant. - let x402Scheme: X402SchemePreference | undefined; - const x402SchemeIndex = args.indexOf('--x402-scheme'); - const x402SchemeArg = x402SchemeIndex !== -1 ? args[x402SchemeIndex + 1] : undefined; - if ( - x402SchemeArg !== undefined && - (X402_SCHEME_PREFERENCES as readonly string[]).includes(x402SchemeArg) - ) { - x402Scheme = x402SchemeArg as X402SchemePreference; + // Parse `--x402 `. The CLI always spawns the bridge with an explicit + // scheme value; if a bare `--x402` slips through (no value, or invalid value) + // we default to `auto` to keep the spawn surface tolerant. + let x402: X402SchemePreference | undefined; + const x402Index = args.indexOf('--x402'); + if (x402Index !== -1) { + const value = args[x402Index + 1]; + x402 = + value !== undefined && (X402_SCHEME_PREFERENCES as readonly string[]).includes(value) + ? (value as X402SchemePreference) + : 'auto'; } // Parse --insecure flag (skip TLS certificate verification) @@ -1688,10 +1686,7 @@ async function main(): Promise { bridgeOptions.mcpSessionId = mcpSessionId; } if (x402) { - bridgeOptions.x402 = true; - } - if (x402Scheme) { - bridgeOptions.x402Scheme = x402Scheme; + bridgeOptions.x402 = x402; } if (insecure) { bridgeOptions.insecure = true; diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index 785d190f..2cf8586c 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -304,8 +304,7 @@ export async function connectSession( noProfile?: boolean; proxy?: string; proxyBearerToken?: string; - x402?: boolean; - x402Scheme?: X402SchemePreference; + x402?: X402SchemePreference; insecure?: boolean; skipDetails?: boolean; quiet?: boolean; @@ -467,8 +466,7 @@ export async function connectSession( server: sessionTransportConfig, ...(profileName && { profileName }), ...(proxyConfig && { proxy: proxyConfig }), - ...(options.x402 && { x402: true }), - ...(options.x402 && options.x402Scheme && { x402Scheme: options.x402Scheme }), + ...(options.x402 && { x402: options.x402 }), ...(options.insecure && { insecure: true }), // Clear any previous error status (unauthorized, expired) when reconnecting ...(isReconnect && { status: 'active' }), @@ -505,10 +503,7 @@ export async function connectSession( bridgeOptions.proxyConfig = proxyConfig; } if (options.x402) { - bridgeOptions.x402 = true; - } - if (options.x402 && options.x402Scheme) { - bridgeOptions.x402Scheme = options.x402Scheme; + bridgeOptions.x402 = options.x402; } if (options.insecure) { bridgeOptions.insecure = true; @@ -967,10 +962,6 @@ export async function restartSession( bridgeOptions.x402 = session.x402; } - if (session.x402 && session.x402Scheme) { - bridgeOptions.x402Scheme = session.x402Scheme; - } - if (session.insecure) { bridgeOptions.insecure = session.insecure; } @@ -1033,8 +1024,7 @@ type BulkConnectOptions = { proxy?: string; proxyBearerToken?: string; stdio?: boolean; - x402?: boolean; - x402Scheme?: X402SchemePreference; + x402?: X402SchemePreference; insecure?: boolean; }; diff --git a/src/cli/index.ts b/src/cli/index.ts index cef91a96..cd47585e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -64,8 +64,11 @@ interface HandlerOptions { verbose?: boolean; profile?: string; noProfile?: boolean; - x402?: boolean; - x402Scheme?: X402SchemePreference; + /** + * x402 scheme preference. Presence enables x402 for the run; value is the preference. + * `--x402` (no value) resolves to `'auto'` (prefer upto, fall back to exact). + */ + x402?: X402SchemePreference; insecure?: boolean; schema?: string; schemaMode?: 'strict' | 'compatible' | 'ignore'; @@ -109,18 +112,19 @@ function getOptionsFromCommand(command: Command): HandlerOptions { options.profile = opts.profile; } if (verbose) options.verbose = verbose; - if (opts.x402) options.x402 = true; - if (opts.x402Scheme) { - const scheme = opts.x402Scheme as string; - if (!(X402_SCHEME_PREFERENCES as readonly string[]).includes(scheme)) { + + // Commander returns `true` for `--x402` (no value) and a string for `--x402 `. + // Normalise to the canonical scheme preference; reject other strings loudly so + // commander's greedy [optional] arg parser can't silently eat a positional like a URL. + if (opts.x402 === true) { + options.x402 = 'auto'; + } else if (typeof opts.x402 === 'string') { + if (!(X402_SCHEME_PREFERENCES as readonly string[]).includes(opts.x402)) { throw new Error( - `Invalid --x402-scheme value: "${scheme}". Valid schemes are: ${X402_SCHEME_PREFERENCES.join(', ')}` + `Invalid --x402 value: "${opts.x402}". Expected one of ${X402_SCHEME_PREFERENCES.join(', ')}, or pass --x402 with no value for the default.` ); } - if (!options.x402) { - throw new Error('--x402-scheme requires --x402 to be set'); - } - options.x402Scheme = scheme as X402SchemePreference; + options.x402 = opts.x402 as X402SchemePreference; } if (opts.insecure) options.insecure = true; if (opts.schema) options.schema = opts.schema; @@ -454,10 +458,9 @@ Full docs: ${docsUrl}` .option('--proxy <[host:]port>', 'Start proxy MCP server for session') .option('--proxy-bearer-token ', 'Require authentication for access to proxy server') .option('--stdio', 'Launch all local stdio servers from selected config files') - .option('--x402', 'Enable x402 auto-payment using the configured wallet') .option( - '--x402-scheme ', - 'x402 scheme preference (default: auto = prefer upto, fall back to exact)' + '--x402 [scheme]', + 'Enable x402 auto-payment using the configured wallet; optional scheme: auto (default, prefer upto), upto, or exact. Use --x402= when followed by positional args.' ) .addHelpText( 'after', @@ -514,8 +517,7 @@ ${jsonHelp( ...(opts.proxy && { proxy: opts.proxy as string }), ...(opts.proxyBearerToken && { proxyBearerToken: opts.proxyBearerToken as string }), ...(opts.stdio && { stdio: true }), - ...(opts.x402 && { x402: opts.x402 as boolean }), - ...(opts.x402 && globalOpts.x402Scheme && { x402Scheme: globalOpts.x402Scheme }), + ...(globalOpts.x402 && { x402: globalOpts.x402 }), ...(globalOpts.insecure && { insecure: true }), }); return; @@ -544,8 +546,7 @@ ${jsonHelp( ...(opts.proxy && { proxy: opts.proxy as string }), ...(opts.proxyBearerToken && { proxyBearerToken: opts.proxyBearerToken as string }), ...(opts.stdio && { stdio: true }), - ...(opts.x402 && { x402: opts.x402 as boolean }), - ...(opts.x402 && globalOpts.x402Scheme && { x402Scheme: globalOpts.x402Scheme }), + ...(globalOpts.x402 && { x402: globalOpts.x402 }), ...(globalOpts.insecure && { insecure: true }), }); return; @@ -569,8 +570,7 @@ ${jsonHelp( config: parsed.file, proxy: opts.proxy, proxyBearerToken: opts.proxyBearerToken, - x402: opts.x402, - ...(opts.x402 && globalOpts.x402Scheme && { x402Scheme: globalOpts.x402Scheme }), + ...(globalOpts.x402 && { x402: globalOpts.x402 }), ...(globalOpts.insecure && { insecure: true }), }); } else { @@ -579,8 +579,7 @@ ${jsonHelp( ...(headers && { headers }), proxy: opts.proxy, proxyBearerToken: opts.proxyBearerToken, - x402: opts.x402, - ...(opts.x402 && globalOpts.x402Scheme && { x402Scheme: globalOpts.x402Scheme }), + ...(globalOpts.x402 && { x402: globalOpts.x402 }), ...(globalOpts.insecure && { insecure: true }), }); } diff --git a/src/lib/bridge-manager.ts b/src/lib/bridge-manager.ts index bfb30474..cd4c5821 100644 --- a/src/lib/bridge-manager.ts +++ b/src/lib/bridge-manager.ts @@ -106,8 +106,8 @@ export interface StartBridgeOptions { headers?: Record; // Headers to send via IPC (caller stores in keychain) proxyConfig?: ProxyConfig; // Proxy server configuration mcpSessionId?: string; // MCP session ID for resumption (Streamable HTTP only) - x402?: boolean; // Enable x402 auto-payment using the wallet - x402Scheme?: X402SchemePreference; // x402 scheme preference; only meaningful with x402: true + /** x402 scheme preference; presence enables x402 auto-payment, absence disables. */ + x402?: X402SchemePreference; insecure?: boolean; // Skip TLS certificate verification } @@ -140,7 +140,6 @@ export async function startBridge(options: StartBridgeOptions): Promise { return { sessions: {} }; } + for (const session of Object.values(storage.sessions)) { + normaliseLegacyX402(session); + } + return storage; } catch (error) { logger.warn(`Failed to load sessions: ${(error as Error).message}`); @@ -86,6 +91,36 @@ async function saveSessionsInternal(storage: SessionsStorage): Promise { const SESSIONS_DEFAULT_CONTENT = JSON.stringify({ sessions: {} }, null, 2); +/** + * Normalise the legacy two-field x402 shape into the consolidated single field. + * + * Legacy (pre-consolidation): `{ x402: boolean, x402Scheme?: 'auto'|'upto'|'exact' }`. + * Current: `{ x402?: 'auto'|'upto'|'exact' }` — presence enables, value is the preference. + * + * Mutates the session in place; the next `saveSession`/`updateSession` writes the + * normalised shape back to disk, so the migration cost is one read. + */ +export function normaliseLegacyX402( + session: SessionData & { x402Scheme?: X402SchemePreference } +): void { + const rawX402: unknown = session.x402; + const legacyScheme = session.x402Scheme; + + delete session.x402Scheme; + delete (session as { x402?: unknown }).x402; + + if (typeof rawX402 === 'boolean') { + if (rawX402) session.x402 = legacyScheme ?? 'auto'; + return; + } + if ( + typeof rawX402 === 'string' && + (X402_SCHEME_PREFERENCES as readonly string[]).includes(rawX402) + ) { + session.x402 = rawX402 as X402SchemePreference; + } +} + /** * Load sessions from storage (with locking) */ diff --git a/src/lib/types.ts b/src/lib/types.ts index 5a761c1f..10148b6f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -146,8 +146,16 @@ export interface SessionData { name: string; server: ServerConfig; // Transport configuration (header values redacted to "") profileName?: string; // Name of auth profile (for OAuth servers) - x402?: boolean; // x402 auto-payment enabled for this session - x402Scheme?: X402SchemePreference; // x402 scheme preference (default: auto = prefer upto, fall back to exact) + /** + * x402 auto-payment scheme preference. Presence enables x402 for the session; + * the value is the preference (`auto` = prefer upto, fall back to exact). + * Absent / undefined means x402 is disabled. + * + * Legacy shape (pre-consolidation): `x402: boolean` + a sibling `x402Scheme`. The + * session loader normalises both shapes on read — see `normaliseLegacyX402` in + * `src/lib/sessions.ts`. + */ + x402?: X402SchemePreference; insecure?: boolean; // Skip TLS certificate verification pid?: number; // Bridge process PID protocolVersion?: string; // Negotiated MCP version diff --git a/src/lib/x402/fetch-middleware.ts b/src/lib/x402/fetch-middleware.ts index a3a31870..0330f697 100644 --- a/src/lib/x402/fetch-middleware.ts +++ b/src/lib/x402/fetch-middleware.ts @@ -39,8 +39,8 @@ const MCP_PAYMENT_META_KEY = 'x402/payment'; * * Apify exposes two shapes side-by-side: * - **`accepts[]`** carries every advertised scheme (post apify-mcp-server #876). - * Walk this when present — it's the only way to honor `--x402-scheme` against - * servers that advertise multiple schemes. + * Walk this when present — it's the only way to honor the session's `--x402 ` + * preference against servers that advertise multiple schemes. * - **Flat preferred fields** mirror the server's preferred entry for back-compat * with clients that don't iterate `accepts[]`. Used as a fallback. */ diff --git a/test/unit/lib/sessions.normaliseLegacyX402.test.ts b/test/unit/lib/sessions.normaliseLegacyX402.test.ts new file mode 100644 index 00000000..f3659369 --- /dev/null +++ b/test/unit/lib/sessions.normaliseLegacyX402.test.ts @@ -0,0 +1,79 @@ +/** + * Unit tests for the on-read migration that consolidates the legacy + * `{ x402: boolean, x402Scheme?: 'auto'|'upto'|'exact' }` shape into the + * current `{ x402?: 'auto'|'upto'|'exact' }` shape. + * + * The migration runs on every session load, so it must be idempotent on + * already-migrated records and defensive against bogus values that snuck in + * via hand-edited sessions.json files. + */ +import { describe, expect, it } from 'vitest'; + +import { normaliseLegacyX402 } from '../../../src/lib/sessions.js'; +import type { SessionData, X402SchemePreference } from '../../../src/lib/types.js'; + +function baseSession(): SessionData & { x402Scheme?: X402SchemePreference } { + return { + name: '@test', + server: { url: 'https://example.test' }, + createdAt: '2026-01-01T00:00:00.000Z', + }; +} + +describe('normaliseLegacyX402()', () => { + it('migrates legacy `x402: true` (no scheme) to `x402: "auto"`', () => { + const session = { ...baseSession(), x402: true as unknown as X402SchemePreference }; + normaliseLegacyX402(session); + expect(session.x402).toBe('auto'); + expect(session.x402Scheme).toBeUndefined(); + }); + + it('migrates legacy `x402: true` + `x402Scheme: "exact"` to `x402: "exact"`', () => { + const session = { + ...baseSession(), + x402: true as unknown as X402SchemePreference, + x402Scheme: 'exact' as const, + }; + normaliseLegacyX402(session); + expect(session.x402).toBe('exact'); + expect(session.x402Scheme).toBeUndefined(); + }); + + it('clears legacy `x402: false` regardless of `x402Scheme`', () => { + const session = { + ...baseSession(), + x402: false as unknown as X402SchemePreference, + x402Scheme: 'upto' as const, + }; + normaliseLegacyX402(session); + expect(session.x402).toBeUndefined(); + expect(session.x402Scheme).toBeUndefined(); + }); + + it('is idempotent on already-migrated `x402: "upto"`', () => { + const session = { ...baseSession(), x402: 'upto' as const }; + normaliseLegacyX402(session); + expect(session.x402).toBe('upto'); + expect(session.x402Scheme).toBeUndefined(); + }); + + it('drops invalid string values defensively (hand-edited sessions.json)', () => { + const session = { ...baseSession(), x402: 'bogus' as unknown as X402SchemePreference }; + normaliseLegacyX402(session); + expect(session.x402).toBeUndefined(); + }); + + it('leaves sessions without x402 untouched', () => { + const session = baseSession(); + normaliseLegacyX402(session); + expect(session.x402).toBeUndefined(); + expect(session.x402Scheme).toBeUndefined(); + }); + + it('strips `x402Scheme` even when `x402` is unset (clean stale sidecar)', () => { + const session = { ...baseSession(), x402Scheme: 'exact' as const }; + normaliseLegacyX402(session); + expect(session.x402).toBeUndefined(); + expect(session.x402Scheme).toBeUndefined(); + }); +}); From 52883ebc24e8da2782c3c161a9283813c49c17e4 Mon Sep 17 00:00:00 2001 From: MQ37 Date: Thu, 21 May 2026 12:09:37 +0200 Subject: [PATCH 10/10] fix(cli): use ClientError for CLI validation so users see a clean message The four validation throws in `getOptionsFromCommand` (`--timeout`, `--x402`, `--schema-mode`, `--max-chars`) used plain `new Error(...)`, which bubbled up as an uncaught exception and dumped a full Node stack trace on stderr. Swap them to `ClientError` so the top-level handler formats them as a one-line "Error: ..." message and exits with code 1, matching every other user-visible validation error in the codebase. --- src/cli/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index cd47585e..c3dc24aa 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -100,7 +100,7 @@ function getOptionsFromCommand(command: Command): HandlerOptions { if (opts.timeout) { const timeout = parseInt(opts.timeout as string, 10); if (isNaN(timeout) || timeout <= 0) { - throw new Error( + throw new ClientError( `Invalid --timeout value: "${opts.timeout as string}". Must be a positive number (seconds).` ); } @@ -120,7 +120,7 @@ function getOptionsFromCommand(command: Command): HandlerOptions { options.x402 = 'auto'; } else if (typeof opts.x402 === 'string') { if (!(X402_SCHEME_PREFERENCES as readonly string[]).includes(opts.x402)) { - throw new Error( + throw new ClientError( `Invalid --x402 value: "${opts.x402}". Expected one of ${X402_SCHEME_PREFERENCES.join(', ')}, or pass --x402 with no value for the default.` ); } @@ -131,7 +131,7 @@ function getOptionsFromCommand(command: Command): HandlerOptions { if (opts.schemaMode) { const mode = opts.schemaMode as string; if (mode !== 'strict' && mode !== 'compatible' && mode !== 'ignore') { - throw new Error( + throw new ClientError( `Invalid --schema-mode value: "${mode}". Valid modes are: strict, compatible, ignore` ); } @@ -141,7 +141,7 @@ function getOptionsFromCommand(command: Command): HandlerOptions { if (opts.maxChars) { const maxChars = parseInt(opts.maxChars as string, 10); if (isNaN(maxChars) || maxChars <= 0) { - throw new Error( + throw new ClientError( `Invalid --max-chars value: "${opts.maxChars as string}". Must be a positive number (characters).` ); }