metered-mcp
by notecash
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.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues