Skip to content

feat(x402): add the MCP transport to the buyer skill#25

Open
estmcmxci wants to merge 2 commits into
MetaMask:mainfrom
estmcmxci:feat/x402-mcp-transport
Open

feat(x402): add the MCP transport to the buyer skill#25
estmcmxci wants to merge 2 commits into
MetaMask:mainfrom
estmcmxci:feat/x402-mcp-transport

Conversation

@estmcmxci

Copy link
Copy Markdown

Closes #23 (proposed and scoped there). Extends #16 by @basgys, which landed the x402 HTTP payer — this adds the second of x402 v2's three transports, MCP, per the spec (specs/transports-v2/mcp.md), so an agent can pay x402-gated MCP tools with the same mm-backed signer.

What's in it

Two new subcommands in scripts/x402_pay.py — pure stdlib, zero new dependencies:

  • mcp-inspect — parses the paid tool's PaymentRequired challenge and prints the payment options, read-only. Accepts the whole JSON-RPC response, the tool result, or the bare PaymentRequired object (via --challenge — pass - to read stdin — or --challenge-file); checks structuredContent, content[0].text, and _meta["x402/error"] (see below).
  • mcp-sign --confirm — validates and signs one offered option with mm wallet sign-typed-data and prints the PaymentPayload for the retried tool call's _meta["x402/payment"] — both as the spec's raw JSON object (payment) and as a base64 string (paymentBase64), since real servers disagree on which one they want (see below).

Design notes:

  • The MCP session (and any OAuth on it) stays the caller's — the script signs, the caller delivers. That keeps it transport-honest and dependency-free.
  • The transport-agnostic core from feat: add x402 buyer payment support #16 (select / validate / build_typed_data / sign_typed_data / build_payment) is reused untouched; --confirm, --asset/--network disambiguation, and all validation guards carry over. The HTTP path is behavior-unchanged (the accepts[] normalization was extracted to a shared _normalize_accepts(), keeping the v1 maxAmountRequired fallback in one place).
  • v2 only: the MCP transport is not defined for x402 v1; v1 challenges are refused with a clear error.
  • Fail-closed: mcp-sign refuses a challenge without a resource URL before signing (a v2 payload must forward the resource, so signing first would burn a real authorization on a payload the facilitator rejects). Malformed offers (extra: null, string resource) produce the JSON error contract, not tracebacks. stdin is read only via an explicit --challenge -, so a forgotten flag errors immediately instead of blocking under an agent harness.

Also: conformance tests (scripts/test_x402_pay.py, stdlib unittest, no network and no mm calls) — the core assertion is that the built payload equals the spec's _meta["x402/payment"] object exactly, plus fixtures captured verbatim from a live Cloudflare withX402 run. Docs: MCP sections in references/x402.md and workflows/x402-pay.md, plus SKILL.md routing/validation/confirmation rows.

Where this actually gets used

The x402 MCP transport, as documented, is early — real sellers we found gate payment with a plain HTTP 402/header regardless of protocol underneath, and CDP's own public discovery index can't even describe an in-band MCP resource (its schema's input.type is hardcoded to "http"). The one substantial real implementation of the spec's actual in-band mechanism is Cloudflare's Agents SDK (agents/x402, withX402/paidTool) — official, documented, and already used in production-shaped builds (Crossmint's crossmint-agentic-finance reference demo wires both sides of it).

Reading Cloudflare's real server code turned up two concrete divergences from the spec text, both now handled:

  • The challenge lives in _meta["x402/error"], not structuredContent (there's no structuredContent key at all in Cloudflare's response).
  • The retry's _meta["x402/payment"] is read via JSON.parse(atob(token)) — a base64-encoded string, not the inline object the spec shows.

Verified

  • python3 scripts/test_x402_pay.py → 33/33, including fixtures captured from a live Cloudflare run.
  • Full mainnet round-trip, real money: ran Cloudflare's own official x402-mcp example server locally, unmodified, configured for Base mainnet + the CDP facilitator. mcp-inspect/mcp-sign correctly parsed the real challenge and produced a payload the server accepted; mm's TEE wallet signed a real EIP-3009 authorization; the retry settled for real — tx 0x7f7fe537…bcd6064, $0.01 Base USDC, independently confirmed via eth_getTransactionReceipt (status 0x1, ERC-20 Transfer + EIP-3009 AuthorizationUsed logs both present).
  • The signer core is separately proven on the HTTP transport too: mainnet vs an independent seller (tx) and Base Sepolia (tx).

Field notes from building x402 payers with mm (why some choices look the way they do)

  • v1 wire uses maxAmountRequired where v2 uses amount; miss the fallback and you sign a null value. _normalize_accepts() handles both in one place for both transports.
  • Never blind-take accepts[0]: real sellers offer multiple rails (e.g. Exa lists a Solana rail alongside Base USDC). The existing --asset/--network selection carries over to MCP unchanged.
  • Real MCP-branded x402 sellers we probed (found via CDP's Bazaar index) all gate at the HTTP layer regardless of protocol underneath — worth knowing if you're deciding where to spend interop effort next.

A possible follow-up (kept out of scope here, as discussed in #23): the next_action/handoff convention some sellers use (Coinbase payments-mcp style), which wraps a standard exact-EVM offer — could ship with a redacted capture as a test fixture.

cc @basgys — this extends your #16; happy to adjust naming/scope.

estmcmxci and others added 2 commits July 6, 2026 12:35
x402 v2 defines three transports (http, mcp, a2a); MetaMask#16 landed the HTTP
payer. This adds the MCP transport per specs/transports-v2/mcp.md
(proposed in MetaMask#23): two new subcommands in x402_pay.py, pure stdlib.

- mcp-inspect parses a paid tool's PaymentRequired challenge (JSON-RPC
  response, tool result, or bare object; structuredContent preferred,
  content[0].text fallback) and prints the options read-only.
- mcp-sign --confirm validates and signs one offered option with
  `mm wallet sign-typed-data` and prints the PaymentPayload to place,
  as a raw JSON object, in the retried call's _meta["x402/payment"].
  Settlement returns in _meta["x402/payment-response"].

The MCP session stays the caller's: the script signs, the caller
delivers. The transport-agnostic core from MetaMask#16 (select/validate/
build_typed_data/sign_typed_data/build_payment) is reused untouched;
accepts[] normalization is extracted to _normalize_accepts (shared by
both transports, keeping the v1 maxAmountRequired fallback in one
place). v2 only: the MCP transport is not defined for x402 v1.

Conformance tests (scripts/test_x402_pay.py, stdlib unittest, no
network and no mm calls) use the spec's examples verbatim; the core
assertion is that the built payload equals the spec's
_meta["x402/payment"] object exactly. Docs: MCP sections in
references/x402.md and workflows/x402-pay.md, plus SKILL.md routing,
validation, and confirmation rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6hVwjEbcFMiGCx1Ku74tY
Verified live against cloudflare/agents' official x402-mcp example
(and confirmed with a full mainnet round-trip: real Base USDC, the
unmodified example server, CDP facilitator, tx 0x7f7fe537...bcd6064)
that Cloudflare's Agents SDK diverges from the MCP transport spec text
two ways:

- The PaymentRequired challenge lives in _meta["x402/error"], not
  structuredContent (there is no structuredContent key at all).
  parse_mcp_challenge now checks it explicitly instead of relying on
  the content[].text fallback to catch it by coincidence.
- The retry's _meta["x402/payment"] is read via JSON.parse(atob(token))
  — a base64-encoded string, not the inline object the spec shows.
  mcp-sign now also emits paymentBase64 alongside payment, with a note
  on which to use for which server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6hVwjEbcFMiGCx1Ku74tY
Comment on lines +44 to +56
## MCP variant (paid MCP tool)

You (the caller) own the MCP session; the script only signs. Flow:

1. Call the tool unpaid over your MCP session. A paid tool returns a result with
`isError: true` carrying the `PaymentRequired` challenge.
2. Inspect the challenge (read-only, does not spend). Pass the tool-call response, the tool
result, or the bare `PaymentRequired` object:

```bash
python3 "$SKILL_DIR/scripts/x402_pay.py" mcp-inspect --challenge '<json>'
```

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you separate out workflow in x402-mcp. It reduces the chances of hallucination.

| Check swap or bridge status | `mm swap status` | [swap.md](references/swap.md) |
| Bridge tokens to another chain | `mm swap execute` | [swap.md](references/swap.md) |
| Pay an HTTP `402` / x402 paywalled request | `python3 scripts/x402_pay.py` | [x402.md](references/x402.md) |
| Pay an x402 challenge from a paid MCP tool | `python3 scripts/x402_pay.py` | [x402.md](references/x402.md) |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pay an x402-gated MCP tool call

Comment thread .gitignore
@@ -0,0 +1 @@
__pycache__/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove the gitignore and test case for script. As of now we are not adding the test for scripts.

or settlement failed); surface it to the user — do not re-sign automatically, since a new
signature is a new payment authorization.

### Cloudflare Agents SDK servers (`agents/x402`)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be under ## Notes

Also, use the third person voice instead of first person in the SKILLs.

Comment on lines +173 to +176
Run `inspect` (or `mcp-inspect`) and show the user the asset, amount, network, `payTo`, and
resource. Run `pay ... --confirm` (or `mcp-sign ... --confirm`) only after the user approves. A
signature authorizes a real token debit — over MCP that holds even though the script never
touches the network, because the signed payload settles when the caller replays the tool call.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of inspect (or mcp-inspect) we can add when to use either one. Or something that is descriptive so that agent doesn't always default inspect and sues mcp-inspect as fallback.


## MCP variant (paid MCP tool)

You (the caller) own the MCP session; the script only signs. Flow:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The caller owns the MCP session; the script only signs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add the x402 MCP transport to the buyer skill

2 participants