feat(x402): support upto scheme + observability + session marker#238
Conversation
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 <auto|upto|exact>` 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.
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.
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.
`mcpc connect --x402-scheme <auto|upto|exact>` 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 <value>` 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.
|
Ran a focused pass on the One launch-risk edge I would fix before merge:
Relevant paths:
Why it matters: Suggested patch shape:
Scope: code review only. I did not run a paid Apify call, send payment headers, sign wallet payloads, or attempt settlement. |
…etry paths
`--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 #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
|
Good catch — fixed in e374380. The
When the proactive path can't honor the preference (e.g. pre-#876 server with flat-only New
|
…ADME - Explains `exact` (EIP-3009) vs `upto` (Permit2) scheme semantics. - Documents `--x402-scheme <auto|upto|exact>` 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.
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.
jancurn
left a comment
There was a problem hiding this comment.
Make sure that if one uses mcpc connect --x402-schema xxx we implicitly consider as if they added --x402 too, to make it less error prone
…p 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.
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=<scheme>` 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 <scheme>` (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.
…sage 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.
|
@jancurn I unified the CLI args and right now there is only the |
The standalone example was orphaned — not referenced from README, docs, tests, or any code. Users should use `mcpc x402 sign` or the official x402 SDK instead.
`3600` was inlined at 3 call-sites (exact signer, upto signer, proactive `_meta.x402` fallback). Replace with a single named export from `signer.ts`.
|
|
||
| // Commander returns `true` for `--x402` (no value) and a string for `--x402 <scheme>`. | ||
| // 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. |
There was a problem hiding this comment.
Can we add an e2e test to ensure mcpc connect --x402 mpc.apify.com works well?
jancurn
left a comment
There was a problem hiding this comment.
Some minor comments. BTW any way to add e2e tests for this?
- normaliseLegacyX402: drop the x402Scheme sidecar branch. That two-field shape only existed mid-feature-branch (0d65e96..adb6a6b); no released version ever wrote it to disk. Keep only the v0.3.0 `{x402: boolean}` → `auto` migration and bogus-value defense. - Bridge --x402 parser: throw on missing/invalid value instead of silently falling back to 'auto'. CLI already validates strictly; the tolerant branch was dead code that would hide bugs from direct bridge invocation. - README upto section: compress the 3-bullet + 4-step explanation into one paragraph. Same information, half the lines.
|
@jancurn still finalizing - I noticed there was some POC junk. For e2e tests we would need to have some wallet - I will create an issue for that. Or we can use the agentic payments e2e Actor that is running daily and we can use latest version of mcpc there directly. |
|
Cheers for info. Is there no way to mock these tests somehow? |
Commander's `[optional]` arg parser greedily consumes the next token as
the option value, so `mcpc connect --x402 mcp.apify.com @s` would parse
the URL as the scheme. Pre-process argv in `main()`: when `--x402` is
followed by a token that's not in {auto, upto, exact}, rewrite to
`--x402=auto` so the next token stays a positional. Tokens that are
valid schemes pass through unchanged.
Validated end-to-end against mcp.apify.com: `mcpc connect --x402
'https://mcp.apify.com?payment=x402&actors=apify/instagram-scraper' @s`
now succeeds and signs an upto-scheme payment to the facilitator.
Drops the now-redundant `--x402=<scheme>` workaround note from
`connect` help, README, and CHANGELOG. Also removes the dead
`x402?: boolean` field from `extractOptions()` (unread since the
`--x402-scheme` collapse in adb6a6b).
|
@jancurn we can mock them but that would not be e2e and I think that ultimately loses the point. I will create issue for that and we can iterate on this and use real wallet for that, but the issue is that it locks the 1 USDC for each run for an hour and that might lead to failures of CICD sometimes - so the wallet would need to be properly funded with a buffer for this scenario. |
x402 is marked experimental in `mcpc x402 init` output; breaking the
on-disk session shape between releases is acceptable. v0.3.0 users
with persisted `{x402: true}` sessions will see the bridge fail loudly
with "--x402 requires a scheme" on next restart \u2014 the fix is to
re-run `mcpc connect --x402 <url> @session`.
Removes the function, its call-site in `loadSessionsInternal`, the
dedicated test file (5 tests), and the legacy-shape paragraph from the
`SessionData.x402` JSDoc.
Context
apify-core PR #27039 ships x402
uptoscheme support. The prod 402payment-requiredresponse now carries bothexactanduptoinaccepts[], but mcpc's signer only spokeexact(EIP-3009TransferWithAuthorization).Server-side counterpart: apify/apify-mcp-server#876.
Solution
uptoscheme signing — Permit2PermitWitnessTransferFromtyped-data signing with a one-timeUSDC.approve(PERMIT2, MAX_UINT256)auto-grant. Skippable with--no-approve.selectAcceptEntry(accepts, preference)picks from a 402 header byauto/upto/exact. The selector is honored on every code path:mcpc x402 sign --scheme, the proactive_meta.x402path, and the tool-result 402 retry path.mcpc connect --x402 [auto|upto|exact]persists the scheme tosessions.jsonand reuses it onmcpc restart. CLI validates against the canonicalX402_SCHEME_PREFERENCESconst inlib/types.ts; legacy{ x402: boolean, x402Scheme }sessions are normalised on read.scheme=…plus key payment fields up front; USD amounts use 6-decimal (USDC atomic) precision. Sessions using x402 show a yellow[x402]marker in listings.Worth your attention
upto— apify-core settles 60 min after the last run finishes (or when balance drops to dust / authorization is about to expire). On-chain transfer hash arrives in a follow-uppermitWitnessTransferFromtx, not in the immediatepayment-responseheader (which carriestransaction: "").--x402-schemewas collapsed into--x402 [scheme]mid-review (adb6a6b). Commander's greedy parser eats the next token as the value; pass--x402=<scheme>when followed by positional args.X402SchemePreference+X402_SCHEME_PREFERENCESlive inlib/types.ts. CLI validation, bridge parsing, session normalisation, and middleware all import from there — no string-literal drift.