DEV Community

holistis
holistis

Posted on

A payment gateway for MCP servers, and the security bugs I found in my own code first

Fewer than 5% of MCP servers make money. I put a real payment gate in front of one and wrote down what actually broke.

The problem, with numbers

There are 10,000+ Model Context Protocol (MCP) servers out there right now, with 97 million+ combined downloads. MCP is the emerging standard for how AI agents call tools — search, databases, APIs, whatever a server wants to expose. It's had a huge adoption wave.

Fewer than 5% of the people running those servers earn anything from them.

Not because nobody would pay. It's because "let people pay per call" means building, from scratch, on top of your actual product:

  • an HTTP 402 challenge/response flow
  • signature verification for the payment authorization (real cryptographic recovery, not just "is there a signature-shaped string present")
  • settlement against a payment facilitator
  • replay protection (nobody should be able to reuse the same signed payment twice)
  • revenue accounting per caller, per tool, per owner

None of that has anything to do with what the MCP server actually does. So most owners just... don't monetize, and eat the hosting cost, or don't run a business around it at all.

What I built

mcp-paywall is a proxy you drop in front of an existing MCP server, unmodified. It speaks x402 — the HTTP-402 + EIP-3009-signed-authorization pattern — and settles payments in USDC on Base.

AI agent / client
      │  POST /mcp/:serverId  (MCP Streamable HTTP, JSON-RPC)
      ▼
┌─────────────────────────────────────────────────────────────┐
│ gateway-server.mjs                                            │
│                                                                 │
│  1. Peek at the JSON-RPC method BEFORE any MCP processing.    │
│     Not "tools/call", or the tool is free  →  pass through.   │
│                                                                 │
│  2. "tools/call" on a priced tool, no X-PAYMENT header        │
│         →  real HTTP 402 + accepts[] challenge                │
│                                                                 │
│  3. X-PAYMENT present  →  verify (local EIP-712 signature      │
│     recovery + expiry + payee + amount) + settle via           │
│     facilitator  →  invalid at ANY step  →  402                │
│                                                                 │
│  4. Valid  →  record the paid call + revenue split             │
│     →  forward the call, unmodified, to the REAL upstream      │
│     MCP server (stdio)                                         │
└─────────────────────────────────────────────────────────────┘
      │
      ▼
 real MCP server — completely unaware it's being paid for
Enter fullscreen mode Exit fullscreen mode

The important design choice: payment gating happens at the raw HTTP layer, before the request ever reaches MCP/JSON-RPC handling. That's what makes the 402 a genuine HTTP status code that x402-aware clients already know how to retry against, instead of a JSON-RPC error smuggled inside an HTTP 200.

The owner keeps 85% of every paid call; the gateway keeps 15% for metering, verification, and payout bookkeeping. Pricing is per-tool, in USDC's 6-decimal base units, set in a single config entry — no changes to the upstream server's code at all.

You can see it live and unpaid right now:

curl -s -X POST https://wazir-x402.duckdns.org/mcp-paywall/mcp/3ilm \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_vulnerabilities","arguments":{"query":"oracle"}}}'
Enter fullscreen mode Exit fullscreen mode

That returns a real 402 with the price and payTo address — no tool output, no data leak, exactly the behavior an x402-aware client needs to see to retry with a signed payment.

The part I actually care about: verification is real, not a presence check

The easy way to build something like this is to check "does an X-PAYMENT header exist" and call it done. That's not verification, it's a formality.

recoverEip3009Signer() does actual EIP-712 typed-data signature recovery against USDC's real domain (name "USD Coin", version "2", chain id 8453, the real Base USDC contract address) using ethers.verifyTypedData. There's no "always valid" shortcut anywhere in that path. I proved this to myself by writing a test that tampers a single byte of a real, correctly-formed signature — it gets rejected because the recovered address genuinely no longer matches, not because of a format check catching malformed input.

The full decision matrix that's tested end-to-end: missing payment, valid payment, replayed payment (same nonce twice), tampered signature, payment to the wrong address, underpayment, expired authorization, and a simulated facilitator failure on an otherwise-perfect payment. Every rejection path is checked twice — that it returns HTTP 402 (not 200), and that the response body never contains the real tool output. 50 automated tests, run against a real spawned instance of my own 3ilm-mcp server on npm (its actual 1,032-finding vulnerability dataset, not a stub).

I also did one thing past the test suite: signed one real EIP-3009 authorization with a fresh, unfunded test wallet and sent it at the live public endpoint with the facilitator check turned on (not simulated). Every local check passed — structure, expiry, payee, amount, signature recovery — the request reached the real Coinbase facilitator over the network, and came back with an honest rejection (zero balance, as expected). No data leaked, nothing miscredited. That's the closest I got to a full production round-trip without actually moving money.

What I learned building this

Two things worth writing down, because neither is obvious until you hit it.

A trusted-actor bug I found in my own code before anyone else could. The first version of the owner dashboard had no auth at all — it was publicly readable. In a real multi-owner deployment, anyone who could guess or find a serverId could read that owner's revenue numbers. I closed it with a per-server random token compared using a hashed constant-time check (SHA-256 both sides, then crypto.timingSafeEqual, so neither a length mismatch nor timing tells you anything), verified with tests that assert the 401 body contains zero revenue figures, not just a generic error. While I was in there I also found that the dashboard's read path re-scanned the entire ledger file on every hit, unbounded — a disk-I/O cost at volume, and separately an unthrottled oracle for guessing tokens. Fixed with a fixed-window rate limiter that runs before the token check specifically, so brute-forcing the token is throttled too, not just legitimate traffic. I found both of these myself, in an adversarial pass against my own work, before showing it to anyone — that pass is the thing that actually caught them, not the first "it builds and the happy path works" version.

A real crash that had nothing to do with my own logic. Reusing one StreamableHTTPServerTransport instance across sequential requests crashed the Node process outright on the second request — a native libuv assertion, Windows + Node 24 + the official MCP SDK. Root-caused to transport reuse, not anything in my gateway code, fixed by constructing a fresh Server+Transport pair per HTTP request. I haven't reproduced the original crash on Linux (the VPS where this actually runs), but the per-request-transport fix should hold regardless of platform, since it removes the shared state entirely rather than working around a platform quirk.

What's genuinely not done yet

Being specific here on purpose, because "it builds" has burned me before as a claim to make:

  • No real USDC has ever moved. The facilitator call in the full round-trip above happened for real; the crypto is real; but no transferWithAuthorization has ever been broadcast on-chain, and the payout script has never run against mainnet with a funded key.
  • No real MCP client has driven this. All testing went through raw HTTP calls speaking the same wire protocol a real agent would use — nothing has actually pointed Claude Desktop, Claude Code, or ChatGPT at this gateway and watched it pay and get an answer.
  • No load or concurrency testing. Every paid call today is one child-process round-trip plus one facilitator round-trip; there's no queuing story yet for concurrent calls hitting the same upstream server.
  • Only one real server is integration-tested end-to-end (my own 3ilm-mcp). It's the honest proof this works technically, not proof it works across the variety of MCP servers actually out there.

If any of that changes your read on "is this ready for my server" — it should. It's a working prototype with real cryptography and a live public endpoint, not a finished product.

About me / this project

I'm a solo builder — no company, no team. I build these with AI-agent tooling as leverage (the code, the test suite, and the adversarial security pass on my own work were all done that way), but the design decisions, the verification of what's actually true versus claimed, and the "did this really work" checking are mine. If a number in this post turns out to be wrong, that's on me to fix, not the tooling.

If you run an MCP server and the "nobody pays for this" problem sounds familiar, or if you can see a hole in the payment-verification logic I haven't found yet, I'd genuinely like to hear about it in the comments.

Top comments (0)