diff --git a/skills/metamask-agent-wallet/SKILL.md b/skills/metamask-agent-wallet/SKILL.md index 79b6e32..fb97a55 100644 --- a/skills/metamask-agent-wallet/SKILL.md +++ b/skills/metamask-agent-wallet/SKILL.md @@ -104,7 +104,7 @@ Match the user's intent to a command and reference file, then read the reference | Execute a token swap or bridge | `mm swap execute` | [swap.md](references/swap.md) | | 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 HTTP `402` / x402 paywalled request or prepare MCP x402 payment metadata | `python3 scripts/x402_pay.py` | [x402.md](references/x402.md) | ## Workflows @@ -134,7 +134,7 @@ CLI behavior lives in `references/`. Repeatable patterns live in `workflows/`. L | Toggle Aave V3 collateral | [aave-collateral.md](workflows/aave-collateral.md) | | Check Aave V3 positions and health factor | [aave-positions.md](workflows/aave-positions.md) | | Discover Aave V3 tokens, rates, and liquidity | [aave-markets.md](workflows/aave-markets.md) | -| Pay an HTTP `402` (x402) paywalled request | [x402-pay.md](workflows/x402-pay.md) | +| Pay an HTTP `402` (x402) paywalled request or MCP x402 tool call | [x402-pay.md](workflows/x402-pay.md) | ## Global Flags @@ -236,7 +236,7 @@ Do not pass unvalidated user input into any command. | Message signing | Always show exact message and chain before signing | | Typed-data signing | Always show domain, primary type, chain, verifying contract, and message summary before signing | | Swaps / bridges | Always confirm from/to tokens, amount, source/destination chain, slippage, quoted output, recipient address (if `--to-address` is set), and the destination gas top-up (if `--refuel` is set) before executing | -| x402 payments | Always confirm asset, decimals-correct amount, network, `payTo`, and resource URL before signing the authorization. One payment attempt per resource, never auto-retry a payment. Autonomous auto-pay is not supported. | +| x402 payments | Always confirm asset, decimals-correct amount, network, `payTo`, and HTTP or MCP resource URL before signing the authorization. One payment attempt per resource, never auto-retry a payment. Autonomous auto-pay is not supported. | | Perps trading | Always confirm symbol, side, size, leverage, venue, order type, and limit price if present before executing | | Perps deposit/withdraw | Always confirm amount, asset, venue, network, and destination where applicable before executing | | Predict trading | Always confirm token ID, side, size, price, order type, market, and outcome before executing | diff --git a/skills/metamask-agent-wallet/references/x402.md b/skills/metamask-agent-wallet/references/x402.md index 2c93049..38605ed 100644 --- a/skills/metamask-agent-wallet/references/x402.md +++ b/skills/metamask-agent-wallet/references/x402.md @@ -1,13 +1,16 @@ # x402 Payments (buyer / payer) Use this when an HTTP request returns **`402 Payment Required`** following the -[x402 protocol](https://docs.x402.org), or when the user asks to pay for / fetch a paywalled API, -endpoint, file, or resource over HTTP. +[x402 protocol](https://docs.x402.org), when an MCP tool returns an x402 +`PaymentRequired` result, or when the user asks to pay for / fetch a paywalled API, +endpoint, file, tool, or resource. `scripts/x402_pay.py` performs the payment: it fetches the URL, reads the server's payment requirements, signs an EIP-3009 authorization with `mm wallet sign-typed-data`, retries with the -payment header, and reports the settlement. Signing is delegated to `mm`, so the private key stays -in the wallet. The script is pure Python standard library (run with `python3`). +payment header, and reports the settlement. For MCP, it parses the tool result's +`PaymentRequired` data and can prepare the `_meta["x402/payment"]` object that an MCP client +uses on the retry. Signing is delegated to `mm`, so the private key stays in the wallet. The +script is pure Python standard library (run with `python3`). ## How x402 works (exact scheme, EVM) @@ -19,17 +22,23 @@ in the wallet. The script is pure Python standard library (run with `python3`). 3. The client retries with the payment header. The server's facilitator settles on-chain and returns the resource plus a settlement header (tx hash). -The script handles both protocol versions: v1 (requirements in the `402` body, retry header +The script handles both HTTP protocol versions: v1 (requirements in the `402` body, retry header `X-PAYMENT`) and v2 (requirements in the `PAYMENT-REQUIRED` header, retry header `PAYMENT-SIGNATURE`). The payload envelope also differs: v1 puts `scheme`/`network` at the top level, while v2 nests the chosen requirements under `accepted` and forwards the top-level `resource` (see the x402 v2 spec, PaymentPayload schema). The script builds the correct envelope per version. +For MCP transport, x402 uses v2 `PaymentRequired` data in the tool result. Servers must return it +in `result.structuredContent` and in `result.content[0].text`; the script prefers +`structuredContent` and falls back to parsing the text field. The payment retry is not an HTTP +header. It is the MCP request parameter `_meta["x402/payment"]`, and settlement is returned in +`_meta["x402/payment-response"]`. + ### Supported - The `exact` scheme on EVM networks (`eip155:*`) using EIP-3009 - `transferWithAuthorization`, protocol v1 and v2. + `transferWithAuthorization`, HTTP protocol v1/v2 and MCP transport v2. ### Not supported (rejected with a clear error) @@ -54,6 +63,8 @@ Inspect first (read-only: no signing, no spending), show the result to the user, ```bash python3 "$SKILL_DIR/scripts/x402_pay.py" inspect [--method M] [--data BODY] python3 "$SKILL_DIR/scripts/x402_pay.py" pay --confirm [--method M] [--data BODY] [--asset ] [--network ] +python3 "$SKILL_DIR/scripts/x402_pay.py" inspect-mcp +python3 "$SKILL_DIR/scripts/x402_pay.py" prepare-mcp --confirm [--asset ] [--network ] ``` `inspect` prints the payment requirement(s) as JSON, including the human-readable amount, asset @@ -65,6 +76,23 @@ The resource may use any HTTP method. For a non-GET endpoint pass `--method` (an request body); the same request is replayed with the payment attached, so a paid `POST` works the same as a `GET`. `--data` defaults to a JSON content type, overridable with `--content-type`. +For MCP, pass either a raw `PaymentRequired` JSON object, a full MCP JSON-RPC tool result, a file +path containing one of those, or `-` to read from stdin. `inspect-mcp` is read-only. `prepare-mcp` +signs an authorization and prints: + +```json +{ + "meta": { + "x402/payment": { + "...": "PaymentPayload" + } + } +} +``` + +The caller must retry the MCP `tools/call` with `params._meta["x402/payment"]` set to that payment +object. `prepare-mcp` does not call the MCP tool and does not perform settlement itself. + ### Example ```bash @@ -73,6 +101,14 @@ python3 "$SKILL_DIR/scripts/x402_pay.py" inspect https://api.example.com/premium python3 "$SKILL_DIR/scripts/x402_pay.py" pay https://api.example.com/premium --confirm ``` +### MCP example + +```bash +python3 "$SKILL_DIR/scripts/x402_pay.py" inspect-mcp payment-required.json +# review asset / amount / network / payTo / mcp:// resource with the user, then: +python3 "$SKILL_DIR/scripts/x402_pay.py" prepare-mcp payment-required.json --confirm +``` + ## What the script checks It pays an option whose `scheme` is `exact` on a network `mm` supports (resolved from @@ -103,12 +139,13 @@ amount; the signed value is the server's atomic amount either way. ## Confirmation -Run `inspect` and show the user the asset, amount, network, `payTo`, and resource URL. Run -`pay ... --confirm` only after the user approves. A signature authorizes a real token debit. +Run `inspect` or `inspect-mcp` and show the user the asset, amount, network, `payTo`, and resource +URL. Run `pay ... --confirm` or `prepare-mcp ... --confirm` only after the user approves. A +signature authorizes a real token debit if submitted to the facilitator/server. ## Idempotency The script is stateless. The EIP-3009 nonce prevents the same signed authorization from settling -twice, and each run makes one payment, but rerunning `pay` for the same resource makes a new -payment. Guard repeated calls at the caller. A local idempotency ledger (a caller-supplied key -mapped to prior settlements) is a possible future improvement. +twice, and each run makes one payment, but rerunning `pay` or `prepare-mcp` for the same resource +makes a new authorization. Guard repeated calls at the caller. A local idempotency ledger (a +caller-supplied key mapped to prior settlements) is a possible future improvement. diff --git a/skills/metamask-agent-wallet/scripts/x402_pay.py b/skills/metamask-agent-wallet/scripts/x402_pay.py index 1e4f294..4076d46 100644 --- a/skills/metamask-agent-wallet/scripts/x402_pay.py +++ b/skills/metamask-agent-wallet/scripts/x402_pay.py @@ -11,6 +11,9 @@ python3 x402_pay.py inspect [--method M] [--data BODY] python3 x402_pay.py pay --confirm [--method M] [--data BODY] [--asset ] [--network ] + python3 x402_pay.py inspect-mcp + python3 x402_pay.py prepare-mcp --confirm + [--asset ] [--network ] `inspect` fetches the URL and prints the payment requirement(s) as JSON (asset, amount, network, payTo, resource) without signing or spending. Review @@ -31,6 +34,7 @@ Examples: python3 x402_pay.py inspect https://api.example.com/premium python3 x402_pay.py pay https://api.example.com/premium --confirm + python3 x402_pay.py inspect-mcp mcp-payment-required.json """ import argparse @@ -238,12 +242,91 @@ def parse_402(status, headers, body): return version, options, resource_info +def read_json_source(source): + """Read JSON from a file path, stdin (`-`), or an inline JSON string.""" + if source == "-": + text = sys.stdin.read() + else: + try: + with open(source, "r", encoding="utf-8") as f: + text = f.read() + except OSError: + text = source + try: + return json.loads(text) + except ValueError as e: + raise CeremonyError("could not parse JSON input: %s" % e) + + +def _looks_like_payment_required(data): + return isinstance(data, dict) and data.get("x402Version") is not None and "accepts" in data + + +def parse_mcp_payment_required(data): + """Extract PaymentRequired from an MCP tool result or raw PaymentRequired. + + Per the x402 MCP transport, clients should prefer result.structuredContent + and fall back to parsing result.content[0].text. + """ + candidate = data + if isinstance(data, dict) and "result" in data: + candidate = data.get("result") + + if _looks_like_payment_required(candidate): + payment_required = candidate + elif isinstance(candidate, dict) and _looks_like_payment_required( + candidate.get("structuredContent") + ): + payment_required = candidate["structuredContent"] + else: + payment_required = None + content = candidate.get("content") if isinstance(candidate, dict) else None + if isinstance(content, list) and content: + text = content[0].get("text") if isinstance(content[0], dict) else None + if text: + try: + parsed = json.loads(text) + except ValueError as e: + raise CeremonyError("could not parse MCP content[0].text as JSON: %s" % e) + if _looks_like_payment_required(parsed): + payment_required = parsed + + if not _looks_like_payment_required(payment_required): + raise CeremonyError( + "input did not contain MCP PaymentRequired data " + "(expected x402Version and accepts in structuredContent or content[0].text)" + ) + + version = payment_required.get("x402Version") + if version != 2: + raise CeremonyError("MCP transport requires x402Version 2, got %r" % version) + + options = [] + for a in payment_required.get("accepts") or []: + amount = a.get("amount", a.get("maxAmountRequired")) + options.append( + { + "scheme": a.get("scheme"), + "network": a.get("network"), + "amount": str(amount) if amount is not None else None, + "payTo": a.get("payTo"), + "asset": a.get("asset"), + "maxTimeoutSeconds": a.get("maxTimeoutSeconds", 3600), + "extra": a.get("extra", {}), + "resource": a.get("resource"), + } + ) + if not options: + raise CeremonyError("MCP PaymentRequired had no payment options") + return version, options, payment_required.get("resource"), payment_required.get("error") + + def describe(option): """Annotate an option with chain and display metadata; never raises.""" out = dict(option) try: chain_id = chain_id_for(option["network"]) - except CeremonyError: + except (CeremonyError, OSError): chain_id = None out["chainId"] = chain_id # extra.assetTransferMethod is absent on v1 and on eip3009 v2 offers; only an @@ -255,7 +338,10 @@ def describe(option): and option.get("scheme") == "exact" and out["assetTransferMethod"] == "eip3009" ) - meta = asset_meta(chain_id, option.get("asset")) + try: + meta = asset_meta(chain_id, option.get("asset")) + except OSError: + meta = None if meta: out["symbol"] = meta["symbol"] out["decimals"] = meta["decimals"] @@ -501,6 +587,66 @@ def cmd_inspect(url, method, data, content_type): ) +def cmd_inspect_mcp(source): + """Print MCP PaymentRequired options without signing or spending.""" + data = read_json_source(source) + version, options, resource_info, error = parse_mcp_payment_required(data) + print( + json.dumps( + { + "status": "payment_required", + "transport": "mcp", + "x402Version": version, + "error": error, + "resource": resource_info, + "options": [describe(o) for o in options], + "retryMetaKey": "x402/payment", + "settlementMetaKey": "x402/payment-response", + }, + indent=2, + ) + ) + + +def cmd_prepare_mcp(source, confirm, want_asset, want_network): + """Sign and print an MCP _meta payment payload without calling the tool.""" + if not confirm: + raise CeremonyError( + "refusing to sign without --confirm; run 'inspect-mcp' first and " + "get user approval, then re-run 'prepare-mcp --confirm'" + ) + data = read_json_source(source) + version, options, resource_info, _error = parse_mcp_payment_required(data) + option = select(options, want_asset, want_network) + chain_id = option["chainId"] + validate(option, chain_id) + + from_addr = wallet_address() + typed_data, authorization = build_typed_data(option, chain_id, from_addr) + resource_url = (resource_info or {}).get("url") or option.get("resource") or "mcp://unknown" + + intent = "x402 MCP: %s %s to %s for %s" % ( + option.get("humanAmount", option["amount"]), + option.get("symbol", ""), + option["payTo"], + resource_url, + ) + signature = sign_typed_data(chain_id, typed_data, intent) + payment = build_payment(version, option, resource_info, signature, authorization, resource_url) + print( + json.dumps( + { + "status": "prepared", + "transport": "mcp", + "meta": {"x402/payment": payment}, + "payment": payment, + "usage": "retry the MCP tools/call with params._meta[\"x402/payment\"] set to this payment object", + }, + indent=2, + ) + ) + + def cmd_pay(url, method, data, content_type, confirm, want_asset, want_network): """Run the payment for one offered option and print the settlement.""" if not confirm: @@ -600,11 +746,34 @@ def main(argv=None): p_pay.add_argument("--asset", help="Disambiguate by asset contract when multiple are offered.") p_pay.add_argument("--network", help="Disambiguate by network when multiple are offered.") + p_inspect_mcp = sub.add_parser( + "inspect-mcp", parents=[fmt], help="Parse an MCP PaymentRequired result (read-only)." + ) + p_inspect_mcp.add_argument( + "source", help="MCP JSON-RPC result, raw PaymentRequired JSON, file path, or '-' for stdin." + ) + + p_prepare_mcp = sub.add_parser( + "prepare-mcp", parents=[fmt], help="Sign and print an MCP _meta payment payload." + ) + p_prepare_mcp.add_argument( + "source", help="MCP JSON-RPC result, raw PaymentRequired JSON, file path, or '-' for stdin." + ) + p_prepare_mcp.add_argument( + "--confirm", action="store_true", help="Required. Explicit user approval to sign." + ) + p_prepare_mcp.add_argument( + "--asset", help="Disambiguate by asset contract when multiple are offered." + ) + p_prepare_mcp.add_argument( + "--network", help="Disambiguate by network when multiple are offered." + ) + args = parser.parse_args(argv) try: if args.command == "inspect": cmd_inspect(args.url, args.method.upper(), args.data, args.content_type) - else: + elif args.command == "pay": cmd_pay( args.url, args.method.upper(), @@ -614,6 +783,10 @@ def main(argv=None): args.asset, args.network, ) + elif args.command == "inspect-mcp": + cmd_inspect_mcp(args.source) + else: + cmd_prepare_mcp(args.source, args.confirm, args.asset, args.network) except CeremonyError as e: print(json.dumps({"status": "error", "error": str(e)}), file=sys.stderr) return 1 diff --git a/skills/metamask-agent-wallet/workflows/x402-pay.md b/skills/metamask-agent-wallet/workflows/x402-pay.md index 36d70b6..32bd354 100644 --- a/skills/metamask-agent-wallet/workflows/x402-pay.md +++ b/skills/metamask-agent-wallet/workflows/x402-pay.md @@ -1,7 +1,8 @@ # x402 pay workflow (buyer) -Use this when an HTTP request returns `402 Payment Required` (x402), or the user asks to -fetch/pay for a paywalled API, endpoint, file, or resource over HTTP. +Use this when an HTTP request returns `402 Payment Required` (x402), an MCP tool returns x402 +`PaymentRequired`, or the user asks to fetch/pay for a paywalled API, endpoint, file, tool, or +resource. `scripts/x402_pay.py` does the payment. Command details are in `references/x402.md`. Call it by its full path inside this skill's directory (`$SKILL_DIR` is the folder containing `SKILL.md`), not @@ -12,13 +13,14 @@ script, not an `mm` command, so do not pass `--toon`/`--format`; it always print 1. Inspect the resource. 2. Show the payment to the user and get approval. -3. Pay. -4. Report the settlement and return the resource. +3. Pay for HTTP, or prepare the MCP `_meta["x402/payment"]` payload for an MCP retry. +4. Report the settlement/resource for HTTP, or hand the MCP payment object to the MCP client retry. ## Inspect ```bash python3 "$SKILL_DIR/scripts/x402_pay.py" inspect +python3 "$SKILL_DIR/scripts/x402_pay.py" inspect-mcp ``` Prints the payment requirement(s) as JSON: asset, human-readable amount, network, `payTo`, and @@ -33,6 +35,7 @@ get explicit approval. A signature authorizes a real token debit. ```bash python3 "$SKILL_DIR/scripts/x402_pay.py" pay --confirm +python3 "$SKILL_DIR/scripts/x402_pay.py" prepare-mcp --confirm ``` Add `--asset ` or `--network ` if the `402` offered more than one eligible @@ -40,6 +43,10 @@ option. For a non-GET resource add `--method` (and `--data` for a body); the sam replayed with the payment attached. On success the script prints the settlement transaction and the resource body. +For MCP, `prepare-mcp` signs and prints the object to set at +`params._meta["x402/payment"]` when retrying the MCP `tools/call`. It does not call the tool or +settle by itself. The MCP server returns settlement at `_meta["x402/payment-response"]`. + ## Edge cases - `error` with "no eligible option": the server offered no `exact`-scheme payment on a network mm