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.
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.
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.
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.
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.
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.
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.
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.