Skip to content

MCP transport (seller)

x402 is transport-agnostic: the same payment envelope rides over plain HTTP, over the Agent2Agent (A2A) protocol, or over the Model Context Protocol (MCP)‘s tool calls. Over HTTP a 402 is a status code and a header; over MCP a payment is an ordinary tool call whose result carries the x402 envelope in MCP’s structured fields instead of base64 headers:

  • a 402 challenge is an isError tool result with the X402Challenge (the spec’s PaymentRequired) in structuredContent and a byte-equal content[0].text;
  • the payment rides in the call’s params._meta["x402/payment"];
  • the settlement rides in the result’s _meta["x402/payment-response"].

This is the third official x402 transport, and the conspicuous missing twin of the A2A handler.

PipRail gives you the MCP seller side as a thin adapter over the exact same PaymentGate you already use for HTTP and A2A — no second verifier, no backend, no chain-specific code. It’s a codec above the PaymentDriver boundary, so every chain family works over MCP for free, exactly as it works over HTTP.

import { createPaymentGate, createMcpPaymentTool } from '@piprail/sdk'
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.05', payTo: '0xMerchant…' })
const paidTool = createMcpPaymentTool({
gate, // ← the SAME gate your HTTP path uses (one shared replay set — see "Co-resident with HTTP")
resourceUrl: 'mcp://my-server/forecast', // cosmetic — MCP tools have no inherent URL
fulfill: async ({ receipt }) => [
// `receipt` is the X402Receipt: `receipt.amount` is BASE units (e.g. "50000"), `receipt.asset`,
// `receipt.transaction`, … — format with the token's decimals if you want a human amount.
{ type: 'text', text: `Settled (tx ${receipt.transaction}) — clear skies tomorrow.` },
],
})
// In your MCP tool handler:
const result = await paidTool.handleToolCall(params) // → an MCP tool result (402 or settled output)

handleToolCall(params) takes one inbound MCP tool call and returns the next MCP tool result: a 402 challenge when there’s no payment, the fulfill output (plus the settlement _meta) when a proof verifies, or a fresh re-challenge when a proof is rejected. You wire that into your MCP server’s tool handler — see Mount it in an MCP server.

handleToolCall returns a complete MCP tool result, so wiring it into the official @modelcontextprotocol/sdk low-level Server is one line in your CallToolRequest handler — pass the request params straight through and return what it gives you.

Terminal window
npm i @piprail/sdk @modelcontextprotocol/sdk
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { createPaymentGate, createMcpPaymentTool } from '@piprail/sdk'
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.05', payTo: '0xMerchant…' })
const paidTool = createMcpPaymentTool({
gate,
resourceUrl: 'mcp://forecast-server/forecast',
fulfill: async ({ params }) => [
{ type: 'text', text: `Forecast for ${params.arguments?.city ?? 'your city'}: clear skies.` },
],
})
const server = new Server({ name: 'forecast-server', version: '1.0.0' }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{ name: 'forecast', description: 'A pay-gated forecast (x402 over MCP).', inputSchema: { type: 'object' } }],
}))
server.setRequestHandler(CallToolRequestSchema, async (req) => {
if (req.params.name === 'forecast') {
// The whole seller: one inbound tool call → the next tool result (402 challenge or settled output).
return paidTool.handleToolCall(req.params)
}
throw new Error(`unknown tool: ${req.params.name}`)
})
await server.connect(new StdioServerTransport())

That’s the whole seller: the first call (no _meta payment) returns an isError 402 challenge the client reads with fromMcpPaymentRequired; the client pays and retries the same call with the proof under params._meta["x402/payment"]; the retry verifies, settles, and returns your fulfill output with the settlement under _meta["x402/payment-response"].

handleToolCall mirrors gate.verify()’s VerifyPaymentResult exactly, then dresses the verdict in an MCP tool result:

InboundWhat PipRail returnsisErrorCarries
No _meta["x402/payment"]a fresh challenge (toMcpPaymentRequired)truestructuredContent = the X402Challenge, byte-equal content[0].text
A payment, verified + settled (kind: 'paid')your fulfill content(unset)_meta["x402/payment-response"] = { success, transaction, network, payer }
Settled, but your fulfill threwa settled response + an error note in content(unset)the success _meta["x402/payment-response"] — never a re-challenge
A payment, rejected (kind: 'invalid')a FRESH re-challenge — retryabletruea new PaymentRequired, byte-identical shape to the first
A payment, malformed/empty (kind: 'challenge')a fresh challengetruea new PaymentRequired
SettlementError thrown (relayer/facilitator failed)a “settlement failed” tool errortruea text note with the error code + an onchain-proof-fallback hint

A rejected or malformed proof returns the same isError + PaymentRequired shape as the first challenge — so a buyer retries on the identical seam, exactly as HTTP re-challenges with another 402.

gate.verifyObject(payload) — the shared verify seam

Section titled “gate.verifyObject(payload) — the shared verify seam”

The HTTP path verifies a base64 payment-signature header; MCP carries the payment as a raw JSON object under params._meta["x402/payment"]. createMcpPaymentTool hands that object straight to gate.verifyObject(rawObject) — the same object-level seam A2A uses. It runs the identical dispatch (upto → exact → onchain-proof), reads sig.payload.nonce off the object, and re-derives every trusted field (payTo / amount / asset / network) from your own config — never the client-supplied echo. The MCP transport writes no crypto and touches no driver: every family rides MCP for free.

Two properties matter, and createMcpPaymentTool relies on both:

  • Shared replay set. Pass the same gate instance to createMcpPaymentTool (and to your HTTP / A2A adapters), and all transports share one used-proof set. A proof settled over HTTP can’t be replayed over MCP, or vice-versa — the nonce is claimed once, in one set, regardless of transport.
  • Never throws on malformed input. A garbage, empty, or null payment object doesn’t crash — it yields kind: 'challenge', a fresh 402. (The only throw is a SettlementError on the exact rail, which the handler catches and maps to an isError tool result.)

Until the automatic McpPayer ships, a buyer reads and frames payments with the pure codec helpers. A buyer calls the tool, inspects the result, pays the challenge through its normal client, then retries the call with the framed proof:

import {
isMcpPaymentRequired, fromMcpPaymentRequired, fromMcpPaymentResponse, buildMcpPaymentMeta,
} from '@piprail/sdk'
// 1. Call the tool; if it's a 402, read the challenge:
const first = await callTool('forecast', { city: 'NYC' })
if (isMcpPaymentRequired(first)) {
const challenge = fromMcpPaymentRequired(first) // → the X402Challenge (PaymentRequired)
// 2. Pay it through the existing policy-gated machinery — the buyer signs/broadcasts here, NOT in
// the codec. (`accepted` is the chosen accepts[] entry; `payload` is the scheme payload.)
const { accepted, payload } = await payTheChallenge(challenge) // your PipRailClient flow
// 3. Frame the proof into the retry call's _meta and call again:
const meta = buildMcpPaymentMeta({ accepted, payload, resource: { url: 'mcp://forecast-server/forecast' } })
const settled = await callTool('forecast', { city: 'NYC' }, { _meta: meta })
// 4. Record the spend on success:
const response = fromMcpPaymentResponse(settled) // → SettleOutcome | null; record only on success:true
}

buildMcpPaymentMeta is a pure codec — it mints no signature and grants no spend authority. The buyer signs and broadcasts through the existing PipRailClient (policy-gated, budget-bound), then frames the already-produced proof for the MCP carrier.

MCP carries the buyer’s payment as the same { accepted, payload } object the HTTP path base64-encodes into the payment-signature header. Until the McpPayer buyer ships, the simplest way to produce one is to pay an HTTP 402 with a PipRailClient and decode the payment-signature it sends — identical to the A2A pattern:

import { decodeBase64Json, HEADER_SIGNATURE } from '@piprail/sdk'
// The decoded payment-signature is `{ x402Version, accepted, payload }` — exactly the pieces
// buildMcpPaymentMeta wants:
const { accepted, payload } = decodeBase64Json(req.headers[HEADER_SIGNATURE])

Co-resident with HTTP and A2A — one gate, one replay set

Section titled “Co-resident with HTTP and A2A — one gate, one replay set”

If you accept payments over HTTP, A2A and MCP, build one gate and pass it to all three adapters. A proof settled on any transport is then un-replayable on the others:

import { requirePayment, createA2APaymentHandler, createMcpPaymentTool, createPaymentGate } from '@piprail/sdk'
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.05', payTo: '0xMerchant…' })
app.use('/api', requirePayment({ gate })) // HTTP
const a2a = createA2APaymentHandler({ gate }) // A2A — SAME gate, SAME replay set
const mcp = createMcpPaymentTool({ gate, fulfill: async () => [] }) // MCP — SAME gate, SAME replay set

The payment state rides in two namespaced _meta keys, exported as constants so you never hard-code a typo. A standard MCP reader ignores them, and they coexist with any other _meta.

ConstantString valueCarries
MCP_PAYMENT_META_KEYx402/paymentthe raw payment payload object the buyer submits (fed to verifyObject), on a call’s params._meta
MCP_PAYMENT_RESPONSE_META_KEYx402/payment-responsethe { success, transaction, network, payer } settlement, on a result’s _meta

For hand-built adapters, the low-level codec is exported too — all pure, all chain-agnostic:

FunctionSidePurpose
toMcpPaymentRequired(challenge)sellerbuild the isError 402 tool result from an X402Challenge
toMcpPaymentResponse(content, receipt)sellerattach the settlement _meta to your tool output
fromMcpPayment(params)sellerread the raw payment object out of params._meta["x402/payment"] (or null)
fromMcpPaymentRequired(result)buyerread the X402Challenge back out of an isError result (or null)
isMcpPaymentRequired(result)buyeris this result a 402 challenge?
fromMcpPaymentResponse(result)buyerread the SettleOutcome back out (record spend on success: true)
buildMcpPaymentMeta({ accepted, payload, resource? })buyerframe a produced proof into the retry call’s _meta
import {
createMcpPaymentTool, toMcpPaymentRequired, toMcpPaymentResponse,
fromMcpPayment, fromMcpPaymentRequired, fromMcpPaymentResponse,
isMcpPaymentRequired, buildMcpPaymentMeta,
MCP_PAYMENT_META_KEY, MCP_PAYMENT_RESPONSE_META_KEY,
} from '@piprail/sdk'
createMcpPaymentTool({
gate, // the shared PaymentGate (RECOMMENDED — one replay set across transports)
fulfill: async (ctx) => [], // ({ receipt, params }) → McpContentBlock[] — your tool's real output
resourceUrl: 'mcp://…', // optional, cosmetic — stamped into the challenge's resource
// …or, instead of `gate`, any inline RequirePaymentOptions to build a fresh gate (see the trap above).
})
OptionTypePurpose
gatePaymentGateThe primary form. Pass the SAME gate your HTTP / A2A paths use → one shared replay set.
fulfill(ctx: { receipt: X402Receipt; params: McpToolCallParams }) => McpContentBlock[] | Promise<McpContentBlock[]>Required. Produce the tool’s real output after a verified settle — the merchant’s own work.
resourceUrlstringOptional cosmetic URL stamped into the challenge (MCP tools have no inherent URL).

The returned handler is { handleToolCall, gate }. The gate is re-exposed as an escape hatch for advanced flows (gate.describe(), gate.landingPage()).