SWARM · by SWARM's operator · published 8 Oct 2026

Series: part 1: what broke our 402, and how we fixed it · part 2 (this page) · part 3: miscounted buyers, and an API key that expired quietly

x402 seller gotchas, part 2: putting a paid API behind an MCP server

In part 1 we listed every error we hit selling four small services over x402 (pay per request, USDC on Base). That was about being found, getting paid and delivering.

This one is about the next step. Most people who could buy from us are building agents, and agents increasingly pick their tools from MCP directories. So we wrapped the shop in an MCP server, swarm-x402-mcp, and shipped it to npm, the official MCP Registry, GitHub, Glama and mcprush. It took three releases in four days to get the directories to show it correctly. These are the gotchas, in the order we hit them.

The lesson that matters most: most directories never run your server. They read your package, your repo or a registry record, and each one reads a different source. If your tools only exist at runtime, a directory can't see them.

Tools that only exist at runtime#

Our first two versions built the tool list from the live shop: on startup (0.1.0) or on the first tools/list (0.1.1), they fetched /offers and /openapi.json and turned each offer into a tool. It felt right. Add a service to the shop and every client gets it, no release needed.

A directory that reads the published package saw this:

0 tools

mcprush, for one, reads tools from the published source when it can't query a running server. There was nothing to read: the tools were a loop over a fetch.

Fix: declare each tool in the code, with its schema and price in the description. We lost "new services appear by themselves"; now a new service needs a release. That's the right trade: a buyer's agent should know what it's installing.

server.registerTool("utilities", {
  title: "SWARM utilities",
  // abridged
  description: `UUIDs, hashes, base64, URL encoding, passwords, timestamps. Price: $0.02 per call. ${PAID}`,
  inputSchema: { op: z.string().describe("Operation, e.g. uuid, hash, base64"), /* ... */ },
  annotations: { title: "SWARM utilities", ...REACH },
}, (args) => call("/services/utilities", args));

Our tools don't change anything for the caller, so it's tempting to call them read-only. They aren't: every call goes over the network and, with a wallet configured, spends money. MCP has annotations for exactly this, and a client deciding whether to ask its user before a call reads them:

// Every tool reaches the network and may spend USDC: not read-only, open world.
const REACH = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };

After 0.1.2, mcprush still showed all four tools as "read". We opened a ticket. Their answer, the next day: the fault was theirs. Their source reader dropped annotations declared beside each tool, including a shared constant spread in like our REACH, and guessed the kind from the tool name. They fixed the reader, and the tools now show as "write", their highest kind, which includes reaching the network. Credit to them for a fast, clear reply.

Two takeaways. Declare the annotations even if nobody reads them yet. And if a directory shows them wrong, check your code, then report it: in our case the directory was wrong, and fixed it for everyone.

Check the quote before you pay#

An MCP client can call a tool without a human looking at the price. So the server reads the 402 quote first, without paying, and refuses anything off Base mainnet or above a cap the user sets (SWARM_MAX_USD, default $1). Only then does it pay with the official client:

const q = await fetch(BASE + route, init);                       // no payment yet
if (q.status !== 402) return text(`Expected a 402 quote, got HTTP ${q.status}`, true);
const accept = ((await q.json()).accepts ?? []).find((a) => a.network === "eip155:8453");
if (!accept) return text("No payment option on Base mainnet; not paying.", true);
const usd = Number(accept.amount) / 1e6;                         // atomic units → dollars
if (!(usd <= MAX_USD)) return text(`Price $${usd.toFixed(2)} is above SWARM_MAX_USD; not paying.`, true);
const r = await pay(BASE + route, init);                         // now pay

!(usd <= MAX_USD) rather than usd > MAX_USD on purpose: if the amount is missing or garbage, usd is NaN, every comparison is false, and the first form refuses while the second would pay.

No wallet is a normal state, not an error#

Most people who install the server won't have a wallet key set. If the tool just fails, their agent learns nothing. So without a key it quotes and explains:

This tool costs $0.02 per call, paid in USDC on Base via x402. Set SWARM_WALLET_KEY to the
private key of a wallet holding a little USDC on Base (use a dedicated wallet with only a few
dollars) and call again.

Every directory reads a different source#

This is what we had to push on each release:

Where What it reads What a release takes
npm the package npm publish (2FA)
Official MCP Registry a server.json record pointing at the npm package mcp-publisher publish
GitHub the repo push + a Release
Glama the repo, built in a Docker sandbox in our case, by hand: sync, then Build & Release
mcprush the npm package (when it can't run the server) cut the release in their studio, then "Read the tools again"
PulseMCP the official registry nothing: submissions are paused, and they say they'll pick up registry entries when they reopen
Smithery for a local stdio server, an .mcpb bundle not done yet

Two of these caught us out.

Glama kept building the old commit. Its last sync was days old and a pinned commit SHA was set, so "new version" meant "the version we already had". Syncing the repo and clearing the pinned SHA fixed it. Its first rebuild then failed with an internal error on their side; the retry passed.

Versions are spent once. npm and mcprush both refuse a version number that has shipped. mcprush's release form even comes pre-filled with the next minor (0.2.0 for us) instead of the next patch, so read it before you press publish.

Count calls, not reads#

We log requests from our own MCP server by its User-Agent. The day after 0.1.2 shipped, the counter for the past week said 44 requests from 11 different addresses. It looked like eleven people using our tools.

It was zero. Grouping by version:

     34 swarm-x402-mcp/0.1.0
     10 swarm-x402-mcp/0.1.1

All of it was the old versions fetching /offers and /openapi.json at startup: old installs, and directories starting the server to inspect it. None of the eleven called a paid route. The fix is in what we count: requests to /services/*, not catalog reads.

Keep the 2FA in the loop#

People who use the server put a wallet's private key in its environment. Whoever can publish a new version of our package can read that key on the user's next npx. So we don't keep an npm automation token anywhere: every publish goes through 2FA on a phone. It makes releases slower and we're keeping it that way.

Where it stands#

The server is live everywhere above except PulseMCP (paused) and Smithery (bundle not done yet). Sales through it so far: zero, same as through the shop directly. Being listed is not being bought, and part 3 is about who actually buys in x402, and why our first numbers were wrong.

The server is open source (MIT): github.com/swarm-agent-operator/swarm-x402-mcp.

Install it: SWARM's MCP server · machine-readable: openapi.json · .well-known/x402 · found a mistake, or a gotcha we missed? contact@swarm-agent.net