The seven ways an MCP server says 'pay me' — and why your tooling sees none of them
I pointed a validator at 100 payment-enabled MCP servers, and it found zero that wanted money. Then I knocked on the same 100 by hand. 83 of them asked to be paid on the spot.
Same servers. Opposite verdicts. The servers were not broken, and neither was my validator. They were just talking about money in places my tooling never thought to look. It turned out there were seven of those places.
I ran this in March 2026. I probed 495 ERC-8004 registrations, 58 servers listed on x402scan, and 100 resources from Coinbase’s x402 discovery API. My MCP validator recognized 0 of the 100 discovery resources as wanting payment. A raw HTTP knock on the same 100 got a real HTTP 402 back from 83 of them. That status code is the web’s way of saying “payment required.” The gap was never the servers. It was where I was reading.
Seven hiding places for the same three words
Every server below is saying the same thing: pay first, then I answer. The only thing that changes is where it hides the message.
-
In the place the spec recommends. The official transport spec says a paid tool should hand back a normal result, flagged as an error, with the payment terms attached as structured data. It is the clean, documented path. Not one server in the whole test set used it.
-
In a proper error object that turned out to be empty. Several returned an error carrying code
402and a slot for the payment terms. The slot was always undefined. The right envelope, no letter inside. -
In an error message written for a human to read. One server put the entire payment object into the error’s text field, serialized as a string. The structure was intact, just stuffed into a field meant for a sentence rather than a data record.
-
On the front door, before you ask for anything. One server returned
402at the initial handshake, before any tool was called, with an empty body. The spec puts payment on the tool call, not on the connection, so this reads as a broken connection rather than a price tag. -
Encoded in an HTTP header. One server carried the payment terms in a header as Base64-encoded text. Decode it and you find two accepted payment rails and an amount. The body itself said nothing.
-
Behind a success code. One server answered
HTTP 200, the code for “here you go,” but tucked apaymentRequiredblob inside the response, again Base64-encoded. It even flagged the call as successful, though nothing had been paid. -
Somewhere else entirely. One server handed back an ordinary result containing a URL. Call that URL, and the payment demand finally shows up, on a different endpoint. The MCP layer never mentioned money at all; it just pointed at another door.
There is an eighth habit worth naming only to dismiss it: some servers mention payment in their tool descriptions. That is a label, not a gate, so it does not count.
Why six of the seven disappeared
Six of those seven were sending real, well-formed payment data. It still reached my code as nothing. Here is the one line that erased it.
When an MCP server answers with anything other than success, the standard TypeScript SDK, the library most people build on, raises an error. That error object has room for a status code and a message, and nothing else. There is no structured field for the payment terms. So the SDK takes the whole response, payment object and all, and flattens it into the message text as a string.
By the time the data reaches your handler, it is not a payment object anymore. It is a sentence. A validator looking for a structured field finds an empty slot. A validator waiting for a proper tool result never gets one, because the SDK already threw the error first. Only the one documented path could have survived that trip intact, and no server used it.
If you want to see it yourself, the class and the exact throw both live in @modelcontextprotocol/sdk, in the streamable-HTTP client.
One server, start to finish
Take the third hiding place: the payment object smuggled into an error message.
My first validator did the obvious thing. It called the tool, caught the 402 error, looked in the structured slot for the terms, found nothing, and wrote the server off as not asking for payment.
Then I ran the same call as a plain HTTP request and read the raw response myself. The payment object was right there: amount, asset, recipient, network, all of it. The SDK had simply pressed it flat into the error text on the way to me. Parsing that text back into JSON recovered every field. The server had been correct the entire time. I was the one reading in the wrong place.
Now multiply that by the whole set. 83 of 100 servers were asking to be paid, and my MCP validator scored zero. The servers were speaking. I was listening at the wrong wall.
How to actually hear it
Read the raw transport, not just the SDK’s error.
- Knock with a plain HTTP request first, before any SDK wraps the response. Read the status line, the headers, and the body yourself.
- Look in four places, not one: the structured error data, the error message (try parsing it as JSON), the Base64 blob inside a success response, and any URL handed back in a normal result.
- Treat a
402at the handshake as a price tag, not a dead connection. My own connector filed it under “connection failed” before I understood it was the fourth hiding place.
If you build on Node, you do not have to rediscover these paths yourself. The FlowMCP/x402-core building blocks handle signature verification and payment objects, and FlowMCP/x402-mcp-middleware wires the 402 flow into an MCP server. Start there and add the four-place read above.
One honest caveat: I measured all of this in March 2026, and the ground moves fast. The spec even changed that first recommended path from required to optional between December 2025 and February 2026. Re-probe before you trust any single route. The map holds; the individual servers will not.