Skip to main content
Glama
catena-oss

x402-mcp-demo

by catena-oss
README.md
# x402-mcp-demo

An MCP server whose tool invocations are metered and charged over x402, plus
a paying-proxy reference client that lets any standard MCP client use the
paid tool without knowing x402 exists. Settlement is real testnet USDC on
Base Sepolia, landing in a Catena sandbox account.

```mermaid
flowchart LR
  CL["Standard MCP client<br/>Claude Code, Inspector"] -->|stdio JSON-RPC| PX["Paying proxy<br/>holds the wallet, spend cap"]
  PX -->|Streamable HTTP + x402| SV["Paid MCP server<br/>gate in front of the handler"]
  SV -->|verify then settle| F[Facilitator]
  F -->|USDC| CA[(Catena sandbox account)]

  classDef pay stroke-width:2px
  class PX,SV pay
```

## How it works

The x402 challenge lives at the HTTP layer of the MCP Streamable HTTP
transport, underneath the JSON-RPC framing, so the MCP protocol itself is
untouched and standard clients stay compatible.

- `initialize`, `tools/list`, and the free `pricing` tool cost nothing.
- `tools/call` on `premium_market_signal` draws a 402 with an x402 v2
  challenge (exact scheme). The proxy pays it, the facilitator settles into
  the configured `payTo`, and only then does a successful tool result return.
  Middleware order is the invariant: unpaid calls never reach the tool
  handler; MCP HTTP 4xx cancels settlement.
- The proxy refuses a paid call BEFORE paying when its running total would
  pass `PROXY_SPEND_CAP_USD`. The cap is configuration, never derived from
  tool arguments, so a prompt-injected tool call cannot raise it.

The call-by-call sequence, including where settlement is cancelled, is in
[docs/architecture.md](docs/architecture.md).

## Setup

Requires Node >= 22.13 (see `.nvmrc`) and pnpm.

```sh
corepack enable
pnpm install
cp .env.example .env
# SELLER_PAY_TO_ADDRESS: your Catena sandbox account's base-sepolia USDC
#   deposit address, from app.catena.com
# BUYER_EVM_PRIVATE_KEY: a testnet wallet the proxy pays from. Fund it with
#   Base Sepolia USDC at https://faucet.circle.com (select Base Sepolia).
#   USDC only; no ETH is needed, transfers are gasless EIP-3009.
```

Both entry points exit `2` when configuration is missing or invalid, and `1`
when a dependency they need is unreachable (the facilitator for the server,
the upstream MCP server for the proxy).

## Demo: the whole loop in one command

```sh
pnpm demo
```

Boots the paid server against the public x402 facilitator, drives a standard
MCP client through the paying proxy, and prints: free discovery, then the
paid tool call settling $0.02 of testnet USDC into the Catena deposit
address.

## See the 402 yourself

Run `pnpm server` in one terminal, then ask for the paid tool without paying.
The server answers `/healthz` with its price and paid-tool name, which is
also what the proxy probes at startup:

```sh
curl -s http://localhost:4040/healthz
```

```
{"status":"ok","paidTool":"premium_market_signal","price":"$0.02"}
```

The challenge itself travels in the `PAYMENT-REQUIRED` response header, not
in the body (the body is `{}`), so decode the header to read it:

```sh
curl -si -X POST http://localhost:4040/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"premium_market_signal","arguments":{"topic":"usdc"}}}' \
  | grep -i '^payment-required:' | tr -d '\r' | cut -d' ' -f2 | base64 -d
```

```
{"x402Version":2,"error":"Payment required","resource":{"url":"http://localhost:4040/mcp","description":"One invocation of the premium_market_signal MCP tool","mimeType":""},"accepts":[{"scheme":"exact","network":"eip155:84532","amount":"20000","asset":"0x036CbD53842c5426634e7929541eC2318f3dCF7e","payTo":"0x000000000000000000000000000000000000dEaD","maxTimeoutSeconds":300,"extra":{"name":"USDC","version":"2"}}]}
```

Drop `| grep ...` to see the status line: `HTTP/1.1 402 Payment Required`.
The tool never ran, so nothing settled.

## Use it from Claude Code (standard client)

Run the paid server in one terminal (`pnpm server`), then register the proxy
as an ordinary stdio MCP server in `.mcp.json`:

```json
{
  "mcpServers": {
    "paid-market-signal": {
      "command": "pnpm",
      "args": ["--dir", "/path/to/x402-mcp-demo", "proxy"]
    }
  }
}
```

The proxy reads `BUYER_EVM_PRIVATE_KEY` and `UPSTREAM_MCP_URL` from this
repo's own `.env`, so no secret goes into `.mcp.json`. (`.mcp.json` is
gitignored here anyway; keep it that way if you copy this setup.)

Claude Code lists both tools and calls them normally; the proxy pays the 402
behind the scenes. MCP Inspector works the same way:
`npx @modelcontextprotocol/inspector pnpm proxy`.

## Tests

`pnpm test` runs the server and proxy suites against an in-process server
with a recording fake facilitator: no network, no money. Each money-path
invariant has a test that fails if it breaks.

| Invariant                                          | Test                                                                       |
| -------------------------------------------------- | -------------------------------------------------------------------------- |
| Discovery and free tools cost nothing              | serves initialize, tools/list and free tools without any payment           |
| An unpaid paid-tool call gets a 402 before it runs | rejects an unpaid paid-tool call with a 402 challenge before the tool runs |
| A paid call settles exactly once                   | runs the paid tool once the client pays, and discovery stays free after    |
| Discovery stays free through the proxy too         | keeps free surfaces free through the proxy                                 |
| A standard client pays without knowing x402 exists | pays for the paid tool transparently and returns its result                |
| A JSON-RPC batch is refused, never gated per item  | rejects JSON-RPC batch requests outright (fail closed)                     |
| MCP HTTP 4xx cancels settlement                    | does not settle when a paid call returns MCP HTTP 4xx                      |
| A notification (no id) is never charged            | does not charge a notification-shaped paid tools/call (no id)              |
| An unparsable body is refused, not priced          | refuses a paid tools/call sent as text/plain, unparsed and uncharged       |
| One upstream execution per paid call               | posts a paid call twice (402 then paid retry) and settles once             |
| Only USDC on the pinned network is ever signed     | refuses an off-policy challenge (wrong network, wrong asset) unsigned      |
| The spend cap binds before any payment             | refuses a call past the spend cap before any payment                       |
| Concurrent calls cannot both slip under the cap    | caps concurrent paid calls: only one of two settles under a one-call cap   |

## Scope

Public surfaces only: the MCP TypeScript SDK, the public x402 packages and
facilitator, and a Catena sandbox account as the receiving side. Versions and
limits: [docs/architecture.md](docs/architecture.md).

## License

MIT