The x402 MCP transport
lets AI agents pay for MCP tool calls: a paid tool advertises its price in a
payment-required tool result, the client retries the call with a signed
payment in request _meta, and the settlement receipt comes back in result
_meta. This guide covers both halves — charging for a tool you serve, and
paying for a tool you call.
How a paid tool call happens
- The client calls a paid tool without payment. The server returns a tool
result with
isError: truewhosestructuredContent(and JSON-encodedcontent[0].text) carry thePaymentRequiredobject — the price list. - The client picks a payment option, signs it (EIP-3009 for
exacton EVM networks — an off-chain signature, no gas), and retries the tool call with thePaymentPayloadin request params_meta["x402/payment"]. - The server verifies the payment through its facilitator, runs the tool,
settles, and attaches the settlement receipt to result
_meta["x402/payment-response"].
The SDK implements this as library-agnostic pure functions over plain
maps — X402.MCP, X402.MCP.Server, and X402.MCP.Client work with any
Elixir MCP library (Anubis/Hermes, Phantom, gen_mcp, a hand-rolled JSON-RPC
loop) because MCP tool-call requests and results are just maps.
Serving a paid tool
Compile the pricing once (at boot or module level), then wrap your tool
handler with X402.MCP.Server.call/3:
# In your application supervision tree:
children = [
{X402.Facilitator, name: MyApp.Facilitator, finch: MyApp.Finch},
{X402.Extensions.PaymentIdentifier.ETSCache, name: MyApp.PaymentCache}
]
config =
X402.MCP.Server.init(
tool: "premium_search",
description: "Premium search with fresh data",
accepts: [
%{
price: "10000", # atomic units
network: "eip155:84532", # Base Sepolia
asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", # USDC
pay_to: "0xYourReceivingWallet",
extra: %{"name" => "USDC", "version" => "2"}
}
],
facilitator: MyApp.Facilitator,
payment_identifier_cache: MyApp.PaymentCache
)
def handle_tool_call("premium_search", params) do
X402.MCP.Server.call(params, config, fn request ->
results = MyApp.Search.run(request["arguments"]["query"])
%{"content" => [%{"type" => "text", "text" => results}]}
end)
endcall/3 always returns a tool result map, so it drops straight into any
dispatch function:
- No payment (or an invalid one) → the spec's payment-required result. The wrapped handler never runs.
- Valid payment → verified against the facilitator, the handler runs,
the payment is settled, and the receipt lands in
_meta["x402/payment-response"]. - Handler returns
"isError" => true→ returned unchanged, nothing is settled, and the replay claim is released so the client can retry with the same payment. - Settlement fails after execution → only the payment error is returned, never the tool's content (per the spec).
Validation is as strict as X402.Plug.PaymentGate: the payload must be
x402 v2, its accepted must match an advertised option exactly (including
extra preservation), and advertised extensions must be echoed without
dropping values.
payment_identifier_cache: enables replay protection — an
X402.Extensions.PaymentIdentifier.ETSCache server or any
X402.Extensions.PaymentIdentifier.Cache adapter, the same option the Plug
gate takes. Each payment proof is atomically claimed before settlement, so
the same signed payment cannot be settled twice. The claim key is a
deterministic hash of the signed scheme payload — never the
client-controlled paymentId extension — so neither re-encoding the
payment envelope nor varying paymentId can mint a fresh claim for the
same signed authorization.
To advertise the price outside a rejection (for example in a tools/list
response), use X402.MCP.Server.payment_required_result/2.
Wiring into an MCP library
The wrapper needs the raw tools/call params map including _meta —
that is where the payment travels. Run it at whatever layer of your MCP stack
sees those params, and return the resulting map through the library's
tool-result path (results are plain maps on the wire, and _meta on a result
is standard MCP). The wrapper never touches the transport, so stdio, SSE, and
streamable HTTP all work unchanged.
With a hand-rolled JSON-RPC loop (or any library that hands you the request):
def handle_request(%{"method" => "tools/call", "params" => params} = rpc) do
result =
case params["name"] do
"premium_search" ->
X402.MCP.Server.call(params, MyServer.Pricing.premium_search(), fn req ->
%{"content" => [%{"type" => "text", "text" => search(req["arguments"])}]}
end)
other ->
free_tool(other, params)
end
%{"jsonrpc" => "2.0", "id" => rpc["id"], "result" => result}
endSupport for exposing per-call _meta to tool handlers varies across the
current Elixir MCP libraries — as of Anubis MCP 2.0 (anubis_mcp, the
successor to hermes_mcp), component callbacks receive the initialize-time
_meta (frame.context.init_meta) but not the tool call's own _meta, so
the wrapper belongs in a lower-level handler or plug in front of tool
dispatch. If your library of choice surfaces the raw tools/call params
anywhere, the integration is the one-liner above.
Paying for a paid tool
X402.MCP.Client.call/3 drives any tool-call function through the
detect → sign → retry-once loop. Add ex_secp256k1 and ex_keccak for
signing:
{:ok, signer} = X402.Signer.LocalKey.new(System.fetch_env!("PAYER_PRIVATE_KEY"))
request = %{"name" => "premium_search", "arguments" => %{"query" => "x402"}}
{:ok, %{result: result, payment_response: receipt, paid: true}} =
X402.MCP.Client.call(request, &MyMCP.call_tool/1,
signer: signer,
max_amount: "10000",
on_payment_required: fn payment_required ->
Logger.info("paying for tool", accepts: payment_required["accepts"])
:ok
end
)
IO.inspect(receipt["transaction"], label: "settlement tx")The tool-call function receives the (possibly payment-carrying) request map
and may return the tool result map directly, {:ok, result}, or
{:error, reason}. Payment challenges are detected in payment-required tool
results and in 402/-32042 JSON-RPC errors (the SEP-1036 elicitation code
some MCP stacks use for payment flows).
Guardrails, matching X402.Client.Finch:
max_amount:— the budget guard; options above it are never selected.network:,scheme:, andasset:filter selection the same way.on_payment_required:— a veto hook invoked with the decodedPaymentRequiredbefore anything is signed. Return:cancelto abort with{:error, :payment_cancelled}.- Never pays twice — at most one payment retry per call; a second
payment-required response is returned as-is, and requests that already
carry
_meta["x402/payment"]are refused with{:error, :payment_already_attempted}.
If your MCP client library exposes request _meta but you want to drive the
retry yourself, X402.MCP.Client.build_payment_meta/3 turns a
payment-required response (tool result or bare PaymentRequired) into the
_meta entries for the retried call:
with {:ok, result} <- MyMCP.call_tool(request),
{:ok, payment_required} <- X402.MCP.fetch_payment_required(result),
{:ok, meta} <- X402.MCP.Client.build_payment_meta(payment_required, signer) do
MyMCP.call_tool(request, meta: meta)
endTelemetry
[:x402, :mcp, :payment_required]— server advertised payment requirements[:x402, :mcp, :payment_verified]— server verified and settled a payment[:x402, :mcp, :payment_rejected]— server rejected a payment (:reason)[:x402, :mcp, :call]— client drove a tool call (:status,:paid)
All events carry %{count: 1} measurements.
References
- MCP transport specification
- x402 v2 specification
- Paying for x402 Resources from Elixir — the HTTP payer client
- Plug/Phoenix Integration — the HTTP server half