An MCP server that charges USDC for a single feed call, end to end. An agent discovers it via the Agent Card, reads the x402 challenge, signs a payment header, gets the directory feed back. No human in the loop.
This walkthrough uses a live endpoint on global-chat.io. Base L2 USDC, chain ID 8453, price 0.10 USDC per call. Every curl line below actually runs.
The problem
Paid APIs assume a human with a credit card. Agents do not have one. They have wallets.
The x402 protocol returns HTTP 402 with a payment challenge instead of a 401 auth prompt. The agent inspects the challenge, signs an EIP-3009 transferWithAuthorization payload with its wallet key, and resubmits with an X-PAYMENT header. The server verifies on-chain and returns the resource. No accounts, no API keys, no OAuth dance.
MCP servers are a natural fit. An MCP tool call that returns paid data is just a request that speaks x402 on cache miss.
Step 1: agent probes the agent card
Every A2A-compatible service exposes /.well-known/agent-card.json. This is the discovery entry point.
curl -s https://global-chat.io/.well-known/agent-card.json | jq .
Response (abbreviated):
{
"name": "Global Chat Directory",
"description": "Paid agent directory. Agents list and discover via x402-gated feeds.",
"endpoints": {
"directory": "/api/feeds/directory",
"verify": "/api/payments/verify"
},
"payment": {
"protocol": "x402",
"chain": "base",
"chainId": 8453,
"asset": "USDC",
"assetDecimals": 6,
"recipient": "0xce90931a854a26262bA31631918ca76b21D92ad2"
}
}
The card tells the agent which chain, which asset, which wallet. Everything it needs to prepare a payment.
Step 2: hit the paid endpoint, receive a 402
curl -i https://global-chat.io/api/feeds/directory
Response:
HTTP/1.1 402 Payment Required
Content-Type: application/json
X-Payment-Required: x402
{
"error": "payment_required",
"protocol": "x402",
"price": "100000",
"priceDisplay": "0.10 USDC",
"chainId": 8453,
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"recipient": "0xce90931a854a26262bA31631918ca76b21D92ad2",
"validUntil": 1713300000
}
price is in atomic units (6 decimals, so 100000 = 0.10 USDC). asset is the Base L2 USDC contract address. validUntil is a Unix timestamp after which the challenge expires.
Step 3: sign a payment header in Node.js
This is where most tutorials wave their hands. The actual signing uses viem plus EIP-3009 typed data.
import { createWalletClient, http } from "viem";
import { base } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount(process.env.AGENT_PRIVATE_KEY as `0x${string}`);
const client = createWalletClient({
account,
chain: base,
transport: http(),
});
async function signX402Payment(challenge: {
asset: `0x${string}`;
recipient: `0x${string}`;
price: string;
validUntil: number;
}) {
const nonce = crypto.getRandomValues(new Uint8Array(32));
const nonceHex = `0x${Buffer.from(nonce).toString("hex")}` as `0x${string}`;
const signature = await client.signTypedData({
domain: {
name: "USD Coin",
version: "2",
chainId: 8453,
verifyingContract: challenge.asset,
},
types: {
TransferWithAuthorization: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" },
{ name: "nonce", type: "bytes32" },
],
},
primaryType: "TransferWithAuthorization",
message: {
from: account.address,
to: challenge.recipient,
value: BigInt(challenge.price),
validAfter: 0n,
validBefore: BigInt(challenge.validUntil),
nonce: nonceHex,
},
});
return Buffer.from(
JSON.stringify({
from: account.address,
to: challenge.recipient,
value: challenge.price,
validAfter: "0",
validBefore: String(challenge.validUntil),
nonce: nonceHex,
signature,
})
).toString("base64");
}
The server takes that header, calls transferWithAuthorization on the USDC contract, and the funds settle in one transaction.
Step 4: replay the request with the payment header
const challengeRes = await fetch("https://global-chat.io/api/feeds/directory");
const challenge = await challengeRes.json();
const paymentHeader = await signX402Payment(challenge);
const feedRes = await fetch("https://global-chat.io/api/feeds/directory", {
headers: { "X-PAYMENT": paymentHeader },
});
const directory = await feedRes.json();
console.log(directory.entries.length, "agents discovered");
Full curl trace for the replay:
curl -s https://global-chat.io/api/feeds/directory \
-H "X-PAYMENT: $PAYMENT_HEADER_BASE64" \
| jq '.entries | length'
# 47
Response body (truncated):
{
"entries": [
{
"id": "agent-042",
"name": "weather-agent",
"tags": ["weather", "forecast"],
"endpoint": "https://weather.example.com/mcp",
"pricing": { "protocol": "x402", "price": "50000" }
}
],
"paidUntil": 1713343200,
"txHash": "0xabc123..."
}
The server returns a txHash so the agent can independently verify settlement. The feed is cached per wallet for the paidUntil window, so subsequent calls from the same agent in that window skip the 402.
Step 5: wire it into an MCP tool
If you are shipping this as an MCP server, the payment flow lives inside the tool handler. The @globalchatadsapp/mcp-server package on npm does exactly this for the global-chat directory. Agents connecting via MCP call directory_feed and the server handles the x402 dance transparently.
// Simplified from @globalchatadsapp/mcp-serverserver.tool("directory_feed", async () => {
try {
return await fetchDirectory();
} catch (err) {
if (err instanceof X402Required) {
const header = await signX402Payment(err.challenge);
return await fetchDirectory({ "X-PAYMENT": header });
}
throw err;
}
});
A newer build (v0.2.3) is shipping in parallel as I write this. If the latest on npm is still v0.2.0 when you read this, v0.2.3 is imminent and the x402 tool handler looks the same. Pin whatever is current.
What breaks in practice
Three things tripped me up when I first shipped this:
- USDC on Base uses
name: "USD Coin"andversion: "2"in the EIP-712 domain. Get either string wrong and the signature verifies to the wrong address. The Base USDC contract is at0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913and this never changes. -
validBeforemust be a future timestamp when the server callstransferWithAuthorization. If the server takes 40 seconds to verify and yourvalidUntilwas 30 seconds out, the on-chain call reverts. Use at least a 5-minute window. - Nonce must be 32 bytes of high-entropy randomness. Reusing a nonce for the same
from/topair makes the second call revert. Generate fresh every request.
Why this matters
Most MCP examples today return free data from a local stdio transport. That works for single-developer tooling but not for paid services. x402 closes that gap, so the same MCP tool that your local Claude Desktop hits can also charge a USDC fee when called by another agent.
The result is a protocol stack where discovery (agent card), capability (MCP), and payment (x402) all work without a human logging into anything. That is the substrate the directory at global-chat.io runs on.
Try it
The endpoint is live. If you have an agent wallet funded with 0.10 USDC on Base, the curl sequence in this post will get you a directory feed back. The mcp-server package takes a wallet key and a target endpoint; the rest is the code above.
If you hit a signing error I did not cover, drop a comment with the viem version and the exact revert message. I will debug from there.
Top comments (0)