x402 Over MCP: A Payment With No Status Code
The x402 payment protocol has an MCP binding, but MCP has no status code. This is what replaces the 402, what it costs, and where it is still unsolved.
An x402 payment over MCP travels through the tool-result error channel. MCP has no per-tool status code, so a server signals “payment required” by returning a successful JSON-RPC response whose result carries isError: true and a PaymentRequired payload. The client retries the identical tools/call with the signed payment attached at params._meta[“x402/payment”], and the server answers with the real tool result plus a receipt at _meta[“x402/payment-response”].
That is the entire loop. Everything interesting about paying over MCP follows from one fact: the error channel is the payment channel. This post walks the wire format, then follows the consequences — including one retry-safety problem the specification has not resolved.
HTTP: a 402 status with the terms in a PAYMENT-REQUIRED header, the payment on PAYMENT-SIGNATURE, the receipt on PAYMENT-RESPONSE.
MCP: isError: true with a PaymentRequired payload in structuredContent, the payment at params._meta[“x402/payment”], the receipt at result._meta[“x402/payment-response”]. Same protocol, no status code to branch on.
1. The mapping, side by side
The x402 V2 transport specification defines an MCP binding alongside the HTTP one. Same payment payload, same verification, same settlement — different plumbing on the wire.
| Step | HTTP | MCP |
|---|---|---|
| Challenge signal | Status code 402 | result.isError === true |
| Challenge payload | PAYMENT-REQUIRED header, mirrored in the body | result.structuredContent, mirrored in result.content[0].text |
| Payment attached | PAYMENT-SIGNATURE request header | params._meta[“x402/payment”] |
| Receipt | PAYMENT-RESPONSE response header | result._meta[“x402/payment-response”] |
| Resource identity | A URL | mcp://tool/<toolName>, scoped to the endpoint |
| Retry shape | New request, same URL | New tools/call, identical name and arguments |
2. The loop, step by step
The specification defines six steps.
- The client calls a paid tool with no payment attached.
- The server returns a tool result with
isError: trueand aPaymentRequiredpayload. - The client extracts the payment requirements and builds a
PaymentPayload. - The client retries the tool call with the payment at
_meta[“x402/payment”]. - The server verifies the payment, executes the tool, settles the payment.
- The server returns the tool result with settlement info at
_meta[“x402/payment-response”].
Note that steps 1 to 4 are two network calls, and the first one is expected to fail. A caller who has never seen the endpoint will always pay for one failed round trip before the real one. Over MCP that failed call is not exceptional in any observable way — it arrives as a normal JSON-RPC success with an error flag inside it.
Here is the challenge, abbreviated from the specification’s example. Note that the whole payload appears twice — once as an object, once as a string:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"isError": true,
"structuredContent": {
"x402Version": 2,
"error": "Payment required to access this resource",
"resource": { "url": "mcp://tool/financial_analysis", "mimeType": "application/json" },
"accepts": [{
"scheme": "exact",
"network": "eip155:84532",
"amount": "10000",
"asset": "0x036CbD...f3dCF7e",
"payTo": "0x209693...312287C",
"maxTimeoutSeconds": 60,
"extra": { "name": "USDC", "version": "2" }
}]
},
"content": [{ "type": "text", "text": "{ ...the same object, JSON.stringify'd... }" }]
}
}
And the retry, which is the same call plus _meta. The payment sits beside name and arguments, not inside them:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "financial_analysis",
"arguments": { "ticker": "AAPL" },
"_meta": {
"x402/payment": {
"x402Version": 2,
"resource": { "url": "mcp://tool/financial_analysis", "mimeType": "application/json" },
"accepted": { "scheme": "exact", "network": "eip155:84532", "amount": "10000" },
"payload": {
"signature": "0x2d6a7588...",
"authorization": {
"from": "0x857b06...e36b66",
"to": "0x209693...312287C",
"value": "10000",
"validAfter": "1740672089",
"validBefore": "1740672154",
"nonce": "0xf37466...3480"
}
}
}
}
}
}
3. One flag, three meanings
This is the sharpest edge in the design. The specification’s own error table assigns isError: true to all three of these:
- Payment required — nothing was presented, here are the terms.
- Payment invalid — something was presented and rejected.
- Settlement failed — the tool ran, the payment did not clear.
There is no distinct code to branch on. The only reliable discriminator is the payload: a genuine challenge carries structuredContent with an x402Version field and an accepts array. Anything else arriving under isError: true is a real failure.
The operational consequence is concrete. Error middleware that treats isError as failure will record every payment challenge as an error, so error rates are inflated by exactly the number of unpaid probes — which, in a metered API, is the number of people considering whether to pay. A dashboard built on that number is measuring buyer intent and calling it breakage.
We hit exactly this while wiring up metered endpoints on our own side. Our request log needed a separate column to distinguish “presented a payment we never charged” from “paid and failed”, because both look like a 4xx on an anonymous request until you record the settlement explicitly. The status code alone could not carry it.
4. The terms ship twice
Servers MUST return the PaymentRequired payload in two places: structuredContent as an object, and content[0].text as a JSON string of the same object. The spec is explicit that both are required and that content[0].text is simply JSON.stringify(structuredContent).
This is a reasonable compatibility trade — clients that cannot read structured content still need the terms. But it has two costs worth naming. The most frequent response in the entire flow carries the payload twice, and the flow’s most frequent response is the unpaid probe. And you now have two representations that must stay identical, with no mechanism in the spec that enforces it.
Client-side, the guidance is to prefer structuredContent and fall back to parsing content[0].text, checking for x402Version and accepts before treating a result as a payment challenge. That check is not optional; it is the only thing standing between your error handler and a misread challenge.
5. Where the payment goes — and where it must not
The payment payload is attached at params._meta[“x402/payment”]. _meta sits beside name and arguments at the request-params level, so it is out of band from the tool’s own inputs.
Putting the payment inside arguments looks equivalent and is not. It would be delivered to your tool as an ordinary parameter, which the tool then has to recognise and ignore — and on the retry it becomes part of a payload whose shape the server already signed and validated. The spec’s placement is deliberate; keeping payment out of arguments is what lets a tool author ignore payment entirely.
6. The case the spec has not resolved
The specification instructs servers on what to return when settlement fails after the tool has already executed:
“If settlement fails after the tool has already executed, the server should not return the tool’s content — only the payment error.”
Read that carefully in a non-idempotent setting and the problem is visible. The client receives isError: true, no content, and a payment error. It cannot distinguish nothing happened, safe to retry from it happened, do not retry. Both look identical on the wire.
For a read-only quote, that ambiguity is harmless. For an action that moves money or writes state, the retry branch is the dangerous one, and a well-behaved automated client — which is exactly who this protocol targets — will take the retry branch, because an error looks like something worth retrying.
This is a genuine design gap rather than an implementation detail, and it is the kind of thing that only shows up once real traffic flows through. A settlement identifier the client could query independently, or a distinct signal for “ran but unpaid”, would close it. Neither exists in the transport today.
7. Discovery: one endpoint, many tools
Coinbase runs a hosted MCP server over the Bazaar catalogue, reachable without an API key, exposing three tools: search_resources for semantic search, proxy_tool_call to invoke a discovered resource, and validate_endpoint for read-only diagnostics. Searching is free; only proxy_tool_call against a paid resource triggers payment, and it uses the standard transport rather than a Bazaar-specific mechanism.
The identity shift matters more than the tooling. Over HTTP, a discovered resource is a URL and the URL is its identity. Over MCP, one endpoint multiplexes many tools, so a resource is the pair (endpoint, tool name) — surfaced in the payload as mcp://tool/<toolName>. That form does not carry which server you are on, so two servers exposing a tool of the same name are not interchangeable, and any catalogue has to carry the endpoint out of band. It is a small thing that will produce a confusing bug report the first time it is forgotten.
8. Where we stand, and why
It is worth being precise about our own position, because it shapes what we can and cannot vouch for here.
We operate both surfaces. Payment is live over HTTP on POST /v1/positions and POST /v1/swaps, priced per request in USDC on Base and on Arc, settled through a facilitator against a wallet signature — no account, no key to hold. Our MCP server is separate and free: six read-only tools over the live catalogue, covering statistics, networks, open positions, position detail, quotes and metrics. Reading is free by design; preparing is what costs.
We have not shipped paid MCP tools, and that is a decision rather than an omission. Sections 3 and 6 are the reasons. For a route that returns a signed on-chain position, handing the client an isError: true it cannot interpret, on a path where the work may already have happened, is not a trade we want to make before the ecosystem settles the question.
What would change our mind: the transport distinguishing the three states under isError, or a settlement identifier a client can query on its own. Either one turns a design gap into a decision, and the rest of this post is still the work.
What we can speak to from experience is the HTTP side of the same protocol, including the parts that are easy to get wrong — reading the wrong header version, letting a buyer pay for a request you cannot serve, and paying out before checking the request. Those are ordinary engineering mistakes that happen to be unusually expensive here, and the mitigations are worth copying whether or not you ever use MCP.
9. Implementation checklist
- Detect a challenge by
isError === trueandstructuredContent.x402VersionandstructuredContent.accepts. Never key on the flag alone. - Prefer
structuredContent; fall back to parsingcontent[0].text. - Attach the payment at
params._meta[“x402/payment”], never insidearguments. - Return
PaymentRequiredin both fields, byte-identical. - Attach the receipt at
result._meta[“x402/payment-response”]. - Count unpaid challenges separately from genuine tool failures.
- Never auto-retry a non-idempotent tool after a post-execution payment error, until the settlement outcome is independently queryable.
x402 over MCP is the same payment protocol with the status code removed. The terms arrive as an error, the payment rides in _meta, and the receipt comes back in _meta — which works, and which makes the failure cases harder to read than HTTP makes them.
Frequently asked
How does an x402 payment work over MCP?
The server returns a tool result with isError set to true containing a PaymentRequired payload holding x402Version and an accepts array of payment options. The client then retries the identical tools/call request with the signed PaymentPayload attached at params._meta["x402/payment"]. The server verifies and settles it, returns the real tool result, and attaches the receipt at result._meta["x402/payment-response"].
What replaces the HTTP 402 status code in MCP?
Nothing replaces it exactly. MCP has no per-tool status code, so the payment-required signal is the isError boolean on the tool result, with the PaymentRequired payload in structuredContent and again in content[0].text.
Where is the payment attached in an MCP tool call?
At params._meta["x402/payment"], which sits beside name and arguments at the request-params level, not inside arguments. Putting it in arguments makes it an ordinary tool parameter that the tool itself must ignore.
How does a client distinguish payment required from a genuine tool error?
Both arrive as isError true. The discriminator is the payload: a payment challenge carries structuredContent with x402Version and accepts. The x402 MCP spec assigns isError true to payment required, payment invalid and settlement failed alike.
How does a client receive the settlement receipt over MCP?
In result._meta["x402/payment-response"], carrying success, transaction, network and payer on a successful settlement.
What happens if settlement fails after the tool already ran?
The spec directs the server to return only the payment error and withhold the tool content. The client therefore cannot tell whether the work happened, which makes an automatic retry unsafe if the underlying action was not idempotent.
Sources
- x402 V2 transport specification: MCP — x402 Foundation. The normative source for every wire-format detail quoted here.
- Discover & pay over MCP — Coinbase Developer Platform documentation. Hosted Bazaar MCP server and client wiring.
- Bazaar discovery extension — x402 documentation.
- MCP specification: the
_metafield — Model Context Protocol, revision 2025-06-18. - Reference implementation of x402 over MCP — Cloudflare
agents,packages/agents/src/mcp/client/x402.ts. Cited by the specification atpackages/agents/src/mcp/x402.ts, which has since moved. - Runnable x402-over-MCP example — Cloudflare Workers + MCP server that charges for tool calls.
- x402 V2 launch — 24 June 2026. Renamed
PAYMENT-SIGNATURE,PAYMENT-REQUIREDandPAYMENT-RESPONSE, and deprecated theX-*forms.
Read next
Our MCP server exposes six read-only tools over the live catalogue — statistics, networks, open positions, position detail, quotes and metrics. No key, no account, nothing it can do moves a token.
yield.trdefi.com/mcp JSON-RPC over POST only — there is no page to open here, which is the same reason this article exists · yield.trdefi.com/docs/api