DEV Community

bot bot
bot bot

Posted on

How to Build a Pay-Per-Call MCP Server with x402 and USDC

If you've built MCP servers for Claude Code, you know the distribution problem: anyone who installs your server gets unlimited free access. There's no clean way to charge per call without rolling your own auth + billing.

x402 solves this at the HTTP layer. Here's how to build a pay-per-call MCP server where every tool call costs USDC — settled instantly on Base.

What is x402?

x402 is an HTTP payment protocol that revives the abandoned 402 Payment Required status code. The flow:

  1. Client requests a resource
  2. Server returns 402 with a PAYMENT-REQUIRED header containing payment details (price, wallet, network)
  3. Client signs a USDC transfer using EIP-3009 (no gas needed from the client)
  4. Client retries with a PAYMENT-SIGNATURE header
  5. Coinbase facilitator validates and settles on-chain
  6. Server returns the resource

No API keys. No billing infrastructure. No subscriptions. Just HTTP + crypto.

The Stack

@modelcontextprotocol/sdk  — MCP server
@x402/core                 — x402 client protocol
@x402/evm                  — EVM payment signing
viem                       — wallet/account management
Enter fullscreen mode Exit fullscreen mode

Building the Payment Client

The key is wiring up the x402 client correctly. The v2 API (current as of @x402/core 2.11) looks like this:

const { x402Client, x402HTTPClient } = require("@x402/core/client");
const { ExactEvmScheme } = require("@x402/evm/exact/client");
const { toClientEvmSigner } = require("@x402/evm");
const { privateKeyToAccount } = require("viem/accounts");

function buildHttpClient() {
  const key = process.env.WALLET_PRIVATE_KEY;
  const pk = key.startsWith("0x") ? key : "0x" + key;
  const account = privateKeyToAccount(pk);
  const signer = toClientEvmSigner(account);
  const coreClient = new x402Client().register("eip155:*", new ExactEvmScheme(signer));
  return new x402HTTPClient(coreClient);
}
Enter fullscreen mode Exit fullscreen mode

Important: pass account to toClientEvmSigner, not walletClient. In viem v2, walletClient.address is undefined — the address lives on account.address.

Making a Paid Request

The http client doesn't have a .fetch() method in v2. You handle the 402 flow manually:

async function paidFetch(httpClient, url) {
  const res = await fetch(url);

  if (res.status !== 402) {
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  }

  // Extract payment requirements from header
  let body;
  try { body = await res.clone().json(); } catch (_) {}
  const paymentRequired = httpClient.getPaymentRequiredResponse(
    (name) => res.headers.get(name),
    body
  );

  // Sign and send payment
  const paymentPayload = await httpClient.createPaymentPayload(paymentRequired);
  const paidRes = await fetch(url, {
    headers: httpClient.encodePaymentSignatureHeader(paymentPayload),
  });

  const raw = await paidRes.text();
  if (!paidRes.ok) throw new Error(`Payment rejected (${paidRes.status}): ${raw}`);
  return JSON.parse(raw);
}
Enter fullscreen mode Exit fullscreen mode

Wiring it into an MCP Tool

const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
const { CallToolRequestSchema, ListToolsRequestSchema } = require("@modelcontextprotocol/sdk/types.js");

const BASE_URL = "https://your-x402-endpoint.com";

const TOOLS = [{
  name: "get_data",
  description: "Get premium data. Costs $0.01 USDC.",
  inputSchema: { type: "object", properties: {} }
}];

async function main() {
  const httpClient = buildHttpClient();
  const server = new Server(
    { name: "my-mcp", version: "1.0.0" },
    { capabilities: { tools: {} } }
  );

  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));

  server.setRequestHandler(CallToolRequestSchema, async (req) => {
    try {
      const data = await paidFetch(httpClient, `${BASE_URL}/api/data`);
      return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
    } catch (e) {
      return { content: [{ type: "text", text: "Error: " + e.message }], isError: true };
    }
  });

  const transport = new StdioServerTransport();
  await server.connect(transport);
}

main();
Enter fullscreen mode Exit fullscreen mode

Building the Server Side

On the server, use @x402/express with paymentMiddleware:

const { paymentMiddleware } = require("@x402/express");
const { x402ResourceServer, HTTPFacilitatorClient } = require("@x402/core/server");
const { ExactEvmScheme } = require("@x402/evm/exact/server");

const facilitator = new HTTPFacilitatorClient({ url: "https://x402.org/facilitator/" });
const server = new x402ResourceServer(facilitator);
server.register("eip155:8453", new ExactEvmScheme());

app.use(paymentMiddleware({
  "GET /api/data": {
    accepts: {
      scheme: "exact",
      price: "$0.01",
      network: "eip155:8453",
      payTo: "0xYourWalletAddress"
    },
    description: "Premium data endpoint"
  }
}, server));

app.get("/api/data", (req, res) => {
  res.json({ result: "your data here" });
});
Enter fullscreen mode Exit fullscreen mode

Every request to /api/data now requires a $0.01 USDC payment on Base before the route handler fires.

The CDP Bazaar Bonus

If you add @x402/extensions/bazaar to your server, Coinbase's Bazaar protocol automatically indexes your service on agentic.market. Autonomous agents with funded wallets can discover and pay for your endpoint without any human involvement.

const { declareDiscoveryExtension } = require("@x402/extensions/bazaar");

app.use(paymentMiddleware({
  "GET /api/data": {
    accepts: { scheme: "exact", price: "$0.01", network: "eip155:8453", payTo: "0x..." },
    extensions: { ...declareDiscoveryExtension({ output: { example: { result: "..." } } }) }
  }
}, server));
Enter fullscreen mode Exit fullscreen mode

Publishing

// package.json
{
  "name": "my-mcp",
  "bin": { "my-mcp": "index.js" },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.10.1",
    "@x402/core": "^2.11.0",
    "@x402/evm": "^2.11.0",
    "viem": "^2.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode
npm publish --access public
Enter fullscreen mode Exit fullscreen mode

Users install with:

{
  "mcpServers": {
    "my-mcp": {
      "command": "npx",
      "args": ["my-mcp"],
      "env": { "WALLET_PRIVATE_KEY": "0x..." }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

A Working Example

I built this for coinopai-mcp — 6 tools covering agent automation prompts and crypto signals, all pay-per-call via x402.

npm install coinopai-mcp — source on GitHub at coinopai/coinopai-mcp.

The whole server is under 150 lines. x402 does the hard part.

Top comments (0)