Skip to main content
Glama
README.md
# metered-mcp

An MCP server that charges per call over **HTTP 402**, and attaches **auditable proof** to every answer.

Two things an AI agent needs before it can be a paying customer, and neither exists yet:

- **A way to pay.** Agents cannot enter a card number or negotiate a contract. They need to pay a fraction of a cent per request, without an account.
- **A way to trust the answer.** Paying for a number is worthless if the agent cannot check it against something.

This repo is a working implementation of both, in about 1,100 lines of application code.

```
$ python client/agent_client.py

[1] tools/list (free)
    list_sites       free
    query_monthly    0.002 USDC
    find_anomalies   0.01 USDC
    verify_claim     0.001 USDC

[3] query_monthly (paid)
  query_monthly: 402 payment required, 2000 units of USDC
  query_monthly: 200 paid, receipt 7e6bd95669782c27
    total_kwh=1484544.0  observations=12
    provenance: 12 rows cited, self-check={'field': 'total_kwh', 'recomputed': 1484544.0, 'match': True}

[6] replay the same payment
    402 nonce_replayed: this payment nonce has already been spent

[7] pay for query_monthly, then call find_anomalies
    402 request_mismatch: this payment authorizes a different request than the one being made
```

---

## Quickstart

```bash
python -m venv .venv && . .venv/bin/activate    # Windows: .venv\Scripts\activate
pip install -r requirements.txt

python scripts/make_dataset.py                  # writes data/sites_monthly.csv
uvicorn app.main:app --port 8000                # in one terminal
python client/agent_client.py                   # in another
```

Tests (19, none of them touch the network):

```bash
python -m pytest
```

Open `http://127.0.0.1:8000/` for the human-readable landing page, `/pricing` for the machine-readable one.

---

## How the payment flow works

```
client                                   server
  │  POST /mcp {tools/call: query_monthly}  │
  ├───────────────────────────────────────>│
  │                                        │  no PAYMENT-SIGNATURE
  │  402 + PAYMENT-REQUIRED: <base64>      │
  │<───────────────────────────────────────┤  {amount, asset, payTo,
  │                                        │   network, requestDigest}
  │                                        │
  │  signs an authorization bound to       │
  │  sha256(tool + arguments)              │
  │                                        │
  │  POST /mcp + PAYMENT-SIGNATURE         │
  ├───────────────────────────────────────>│
  │                                        │  verify → consume nonce
  │                                        │  → execute → record
  │  200 + PAYMENT-RESPONSE: <receipt>     │
  │<───────────────────────────────────────┤
```

The 402 body and the `PAYMENT-REQUIRED` header carry the same JSON: what to pay, in which asset, to which address, on which network, valid for how long, and **which request the payment is for**.

Prices are published before anyone calls anything — `tools/list` returns `_meta.x402.price` per tool. An agent that cannot see prices before calling cannot budget, which makes it not an agent but a runaway loop.

---

## Security properties (each one has a test)

| Attack | Defence | Test |
|---|---|---|
| Spend one payment twice | Nonce consumed by a `PRIMARY KEY` insert; the second insert loses at the database level, not in a Python set | `test_replayed_nonce_is_rejected` |
| Pay for a cheap call, invoke an expensive one | Authorization carries `resource = sha256(tool+args)`; a mismatch is rejected | `test_payment_cannot_be_spent_on_a_different_request` |
| Pay less than the price | `value >= maxAmountRequired`, checked before anything runs | `test_invalid_payments_are_rejected[underpaid]` |
| Present an expired or not-yet-valid authorization | `validAfter` / `validBefore` window is enforced server-side | `...[expired]`, `...[not_yet_valid]` |
| Forge a signature | `hmac.compare_digest`, constant-time | `...[bad_signature]` |
| Pay on a different chain | `network` must match the challenge exactly | `...[network_mismatch]` |
| Pay someone else | `to` must equal `payTo` | `...[payto_mismatch]` |
| Get charged for a request that cannot run | Full semantic validation happens **before** the paywall | `test_bad_arguments_are_rejected_before_payment_is_requested` |

That last row is the one people skip. If you validate after taking payment, a typo in a `site_id` costs the caller a nonce and the operator a refund. The fix is architectural: every tool has a `normalize()` that does the complete check, and the server runs it before touching the payment path.

---

## Answer provenance

Every result carries a block like this:

```json
"provenance": {
  "dataset": "sites_monthly.csv@sha256:a298374c2153fa13",
  "rows": [13, 14, 15],
  "computed": {"total_kwh": 1484544.0, "observations": 12},
  "method": "sum kwh over rows where site_id == cn-east-01",
  "selfCheck": {"field": "total_kwh", "recomputed": 1484544.0, "match": true}
}
```

Three properties matter:

1. **Row-level citations.** Not "the dataset" — the exact line numbers. A caller can re-read them.
2. **Dataset fingerprint.** If the file changes, every previous answer is visibly from a different version.
3. **A self-check that recomputes the headline number from the cited rows.** This catches the failure that matters most: a number that is internally consistent but does not correspond to the rows the answer points at.

`verify_claim` exposes the same machinery as a tool. It exists for one reason: an agent should be able to check a number before repeating it to a human.

```json
{"metric": "total_kwh", "expected": 2226816.0, "computed": 1484544.0,
 "delta": -742272.0, "match": false, "tolerance": 2.2e-6}
```

---

## The dataset

`data/sites_monthly.csv` is **synthetic** — 6 sites × 36 months of monthly kWh, generated by `scripts/make_dataset.py` with a fixed seed. No real facility, customer or grid data is involved.

Three defects are planted on purpose, and `find_anomalies` recovers exactly those three with no false positives:

| Site | Month | Kind |
|---|---|---|
| `cn-east-02` | 2024-03 | negative reading (meter rollover) |
| `cn-south-01` | 2025-07 | 6× spike |
| `cn-west-02` | 2024-11 | missing reading |

This is deliberate. A demo dataset with no ground truth cannot show that detection works — it can only show that it runs. Swap the file and the fingerprint, the tests and the provenance all continue to work.

---

## What is real, and what is a demo

Being precise about this is the difference between a demo and a lie.

**Real:** the HTTP surface, the challenge/response structure, the check ordering, the request binding, the replay guard, the receipt format, the provenance and self-check, the validation-before-charge design.

**Not real:** settlement. `sign_authorization()` uses HMAC with a shared secret so the repo runs with zero keys, zero chains and zero network. A production verifier validates an EIP-3009 `TransferWithAuthorization` signature against the token contract and settles through a facilitator; `x402.verify_payment()` is the single function to replace. The receipt says `"settlement": "simulated"` rather than pretending.

**Also not implemented:** refunds. Payment is taken after validation and before execution, so the only way to lose money is a bug inside the handler itself.

---

## Layout

```
app/
  config.py     prices, network, payee — all env-overridable
  x402.py       challenge, checks, replay guard, receipts
  ledger.py     SQLite: spent nonces, issued receipts
  data.py       CSV load, row numbering, robust statistics
  tools.py      schemas, normalisers, handlers, provenance
  main.py       FastAPI routes + JSON-RPC dispatch
client/
  agent_client.py   a reference client that pays for what it uses
scripts/
  make_dataset.py   deterministic synthetic data with planted defects
tests/
  test_flow.py      19 tests: happy path, 8 rejection paths, integrity
```

---

## Design notes

**Why the price table lives in code, not in the database.** Prices are part of the advertised contract; a client caches them. Putting them behind a mutable store invites the case where a client budgets 0.002 and gets charged 0.01. Change them by deploying.

**Why `list_sites` is free.** Discovery must never cost anything. A metered discovery endpoint produces agents that guess instead of looking.

**Why modified z-score instead of standard deviation.** One 6× spike inflates stdev enough to hide itself — the outlier rewrites the baseline that is supposed to catch it. Median absolute deviation is not moved by it. The same principle applies to graders: a scoring function must be robust against exactly the failure it is meant to detect.

**Why the nonce insert is a database constraint.** A `set()` in process memory is correct only with one worker. The `PRIMARY KEY` insert is correct with any number of them, and it costs nothing.

---

## Roadmap

- [ ] Swap `sign_authorization` for an EIP-3009 verifier behind the same seam
- [ ] Streamable HTTP transport so a stock MCP client can connect without a shim
- [ ] Refund path: hold the nonce, release it if the handler raises
- [ ] Publish to an x402 discovery directory so agents can find it
- [ ] Rate limiting per payer, not per IP

---

## 中文说明

这是一个**按次收费的 MCP 服务**:AI 智能体调用工具时遇到 `402 Payment Required`,签一笔授权、重试,拿到带溯源信息的结果。

三个设计要点:

1. **先校验再扣款** —— 参数不合法在收费之前就拒绝,不让调用方为一个跑不通的请求付钱。
2. **付款绑定到具体请求** —— 授权里带着 `sha256(工具名+参数)`,付便宜的调用去执行贵的调用会被拒。
3. **每个答案都可追溯** —— 返回结果附数据集指纹、精确行号,并独立重算一次主数字做自检。

数据集是合成的(6 个站点 × 36 个月用电量),故意埋了 3 处异常,`find_anomalies` 能无误差地全部找出。**结算部分是模拟的**,没有真实资金、没有链上交互,接生产环境需要把 `verify_payment` 换成 EIP-3009 验签。

## License

MIT.