x402 MCP Demo
by Thinkflow-ro
README.md
# x402 MCP tool demo — a real 402, a real rejection, and a real settlement
A minimal demo of the [x402 payment protocol](https://x402.org) protecting an MCP-style
tool endpoint, run against the real `x402==2.16.0` Python package and its default public
facilitator (`https://x402.org/facilitator`) on **Base Sepolia testnet**. Nothing here is
hand-written protocol JSON — every response in `captured_responses/` came out of the
library, unedited.
Companion to: *[How to Charge for an MCP Tool: A Working x402 Endpoint in Under an
Hour](https://thinkflow.ro/blog/mcp-x402-monetization)*.
## What's here
| File | What it does |
|---|---|
| `server.py` | FastAPI endpoint (`POST /mcp/tools/vendor_audit`) gated by `x402ResourceServer` + the EVM `exact` scheme, priced at $0.01 USDC on Base Sepolia |
| `gen_wallets.py` | Generates two throwaway EOAs — a receiving address and a paying address. **Zero funds, testnet only, never reuse these keys.** |
| `client_probe.py` | Acts as the paying agent: gets the real 402, signs a real payment payload with the zero-balance account, and shows the facilitator's real rejection |
| `captured_responses/` | The three response bodies this produced, verbatim — the 402 demand, the insufficient-balance rejection, and the settled 200 |
## Run it
```bash
pip install -r requirements.txt
python gen_wallets.py # writes wallets.json (gitignored) -- two fresh, empty keys
python server.py # starts on 127.0.0.1:8402
python client_probe.py # in a second terminal
```
## What you'll see
**Round 1 — no payment.** A 402 with a `payment-required` header (base64 JSON): scheme,
network, asset contract, amount in the token's smallest unit, and the address to pay.
**Round 2 — a real signed payment, zero balance.** The client builds and signs an actual
EIP-3009 authorization with the throwaway key, attaches it as `X-PAYMENT`, and the
facilitator checks it against the real Base Sepolia chain state. It comes back:
```json
{ "error": "invalid_exact_evm_insufficient_balance" }
```
That string is the facilitator's real error code, not a guess — it is what you get when the
protocol works exactly as designed and the payer simply doesn't have the money.
**Round 3 — funded, settled for real.** The `agent_address` was funded with 20 testnet USDC
from [Circle's public faucet](https://faucet.circle.com), then `client_probe.py` was run
again against the same server. This time round 2 comes back `200`:
```json
{
"url": "https://example.com/mcp",
"recommendation": "expose as a priced x402 tool at $0.01/call",
"paid": true
}
```
with a `payment-response` header that decodes to:
```json
{ "success": true, "payer": "0x3214cB6C...E0E9", "transaction": "0x9530b663...0dd86", "network": "eip155:84532" }
```
That transaction hash is real and independently verifiable —
[view it on Base Sepolia Blockscout](https://base-sepolia.blockscout.com/tx/0x9530b6634adabf59dd8234e993d2bb9cba1c30703f359f9b1d5d9fc42108dd86):
status `ok`, sent to the USDC contract, method `transferWithAuthorization` — the exact
EIP-3009 gasless-transfer pattern the payer signed. Anyone can check this without trusting
this README.
## Why the manual round trip in `client_probe.py`
`x402` 2.16.0 ships `PaymentRoundTripper`, but it's a callback (`handle_response(...)`) meant
to be wired into a custom `httpx`/`requests` transport — it is not a drop-in transport by
itself, despite what the docstring implies. `client_probe.py` calls the same three
primitives it calls internally (`get_payment_required_response` →
`create_payment_payload` → `encode_payment_signature_header`) directly, which is fewer
moving parts for a demo and produces identical wire output.
## Safety notes
- `gen_wallets.py` creates keys with `eth_account.Account.create()` — cryptographically
random, never funded, never touching mainnet in this demo.
- `wallets.json` is gitignored. `wallets.example.json` shows the shape with a well-known
burn address so the repo is runnable from a clean clone without secrets in git history.
- The facilitator URL, network, and asset contract are the library's own testnet defaults —
nothing here talks to Base mainnet or moves real value.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues