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 (3)

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

The tampered-byte test is the part that makes this worth reading. Recovering a real EIP-712 signature and showing that flipping one byte breaks it is what separates verification from a presence check.

Your decision matrix is thorough on everything that happens before the money moves, and every row in it is a reason to reject a payment. The case I did not see is on the other side: settlement succeeds at step 3, then the upstream call at step 4 errors or the stdio process dies, and the caller has paid and gets nothing. In card and webhook work that is the most common production complaint by a distance, and I would guess it stings more here, since an on-chain settlement cannot be dropped the way an uncaptured authorization can. You would know that side better than I do.

So do you settle before or after a successful upstream response, and if it has to be before, is there a credit or retry ledger so the caller is not silently down the money? Neither order is free, since settling after means handing back tool output for nothing when settlement fails. You are picking between eating a failed delivery and eating a failed collection, and at fractions of a cent per call I would deliver first and absorb the misses.

Separately on replay: is nonce uniqueness enforced at the storage layer, or is it a read-then-write check that two identical signed payments landing at the same instant could both pass?

Collapse
 
holistis profile image
holistis

Thank you, this is the most useful comment this piece has gotten. You called both of these correctly, so I went and checked the actual code instead of guessing.

On ordering: you were right. Settlement ran before the upstream call, no try/catch around it, no credit or retry path if delivery then failed. I've restructured it so settlement only happens after the upstream tool call succeeds. If the upstream throws, settlement is never even attempted and the signed authorization is never marked used, so the caller can just resubmit it. If the upstream succeeds but settlement itself then fails, the caller doesn't get the result either I went with your framing exactly, we eat the wasted compute rather than ever collecting for nothing delivered. Wrote a test that forces a real upstream crash to prove no charge happens in that case, not just a happy-path assertion.

On the nonce check: also a fair catch, and only partially closed. It's an in-memory Set plus a JSONL append for audit, not a single atomic storage-layer operation. I added a second check immediately before the actual settlement call, which closes most of the window. But I'll be straight with you: there's still a narrow gap between that check and the facilitator call resolving.

I looked at adding a proper lock and backed off a lock that doesn't get released correctly on every crash path is worse than the race it's meant to prevent, and EIP-3009's authorizationState is already enforced atomically on-chain by the USDC contract itself, so an actual double-spend on the same nonce can't complete even if our local check races.

I'm leaning on that contract-level guarantee rather than claiming we've closed it ourselves.
Both fixed, tested, and deployed live now.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.