Sell
Sell an MCP tool
@zanora/middleware-mcp: declare a price, get a wrapper for the handler. Publishing, reconciling on restart, pricing a whole server, showing prices in tools/list, and low-level servers.
npm install @zanora/middleware-mcp @modelcontextprotocol/sdksell(): publish and wrap in one call
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { zanoraSeller } from "@zanora/middleware-mcp";
import { z } from "zod";
const seller = zanoraSeller(); // ZANORA_API_KEY (a provider key); gateway defaults to api.zanora.dev
const paid = await seller.sell({
tool: "financial_analysis",
description: "Advanced AI-powered financial analysis",
category: "finance",
price: "$0.10",
url: "https://premium-api.example/mcp", // or set ZANORA_PUBLIC_URL
schema: { type: "object", properties: { ticker: { type: "string" } }, required: ["ticker"] },
});
const server = new McpServer({ name: "premium-api", version: "1.0.0" });
// Paid tool: wrap the handler.
server.tool("financial_analysis", "Advanced financial analysis. Costs $0.10.", { ticker: z.string() },
paid(async (args) => ({ content: [{ type: "text", text: await analyse(String(args.ticker)) }] })),
);
// Free tool: no wrapper.
server.tool("ping", "Health check", {}, async () => ({ content: [{ type: "text", text: "pong" }] }));That's the whole integration. There's no billing code, and no capability id in your source.
sell() options
| Option | Required | Meaning |
|---|---|---|
tool | yes | the MCP tool name. One capability per tool |
description | yes | what it does, in the words a buyer would search for. Discovery ranks on it |
category | yes | what buyers filter on, e.g. "OCR" or "finance" |
price or priceMinor | yes | "$0.10" (dollars, with the $) or "10" (cents). A plain number or price: "10" is refused as ambiguous |
url | one of these | the URL buyers' agents call. Defaults to ZANORA_PUBLIC_URL |
runsLocally | one of these | sell as a package buyers run: true reads name and version from your package.json, or pass "@acme/ocr-mcp@1.4.2". See Sell without hosting |
name | no | the display name in discovery. Defaults to the tool name |
schema | no, but recommended | JSON Schema for the arguments. Buyers' agents read it before paying |
tags, latencyP50Ms, currency | no | extra discovery signals |
What the wrapper does
- A call without payment returns an error result carrying a signed challenge, with the price quoted by Zanora, not by you.
- The buyer signs a payment proof and calls again with it in
_meta. - The proof is checked on the server: signature, replay, the buyer's policy and budget, their balance. Then your handler runs.
- Your result is hashed into a signed receipt that goes back in the result's
_meta. - If your handler throws or returns
isError: true, the call fails and the buyer is refunded. MCP has no status codes, soisErroris how you say a call failed. Without it, the buyer would be charged for an error message. Because MCP can't say whose fault anisErrorwas, it counts toward the circuit breaker like a thrown handler: three in a row pause that buyer's wallet from you. Validate arguments against your publishedschemaso bad input is rare.
Return structuredContent for anything a buyer might want to prove they received. The receipt's responseHash covers it (or the content blocks, if there's no structuredContent), and buyers check that hash.
Restarting reconciles the listing
sell() finds your existing capability by provider, tool and address. If your code has changed since the last start, it updates the listing to match and tells you what changed:
const { paid, describe, capability } = await seller.sellWithDetails({ /* … */ price: "$0.20" });
capability.changed; // ["priceMinor"]
capability.priceMinor; // "20", read back from the marketplace, not echoed from your input
capability.capabilityId; // unchanged: buyers who saved it keep workingLog changed and the stored priceMinor at startup, so you see what the marketplace will charge. If reconciling fails, sell() throws. Don't catch that and start anyway, or you'll serve a tool at a price your own code disagrees with.
A reconcile updates the price, name, description, category, tags, schema and latency. It never moves the address or renames the tool, because a saved capability id has to keep pointing at the same thing. To move a tool, deprecate it and publish again.
sellAll(): price a whole server
const catalogue = await seller.sellAll({
category: "finance",
url: "https://premium-api.example/mcp",
tools: [
{ tool: "financial_analysis", description: "Advanced AI-powered financial analysis" },
{ tool: "backtest", description: "Backtest a strategy against historical data" },
{ tool: "ping", description: "Health check" },
],
prices: { backtest: "$0.50", default: "$0.10" },
skip: ["ping"], // stays free
});
server.registerTool(
"backtest",
catalogue.describe("backtest", { description: "Backtest a strategy", inputSchema: { strategy: z.string() } }),
catalogue.paid("backtest")(async (args) => ({ content: [{ type: "text", text: await backtest(args) }] })),
);prices.defaultcovers every tool not named. A tool with no price is an error, never a free tool.- Nothing is published until every price is known, so a failed start never leaves half a catalogue up.
catalogue.paid("ping")throws for a skipped tool, rather than silently giving a paid tool away.- Instead of listing
tools, passfrom:anything withlistTools()(an MCP client or server) to reuse its descriptions and schemas.
Show the price in tools/list
MCP has no built-in idea of a paid tool. describe adds the price to a tool definition: a sentence in the description for the model, and _meta["zanora.dev/x402-price"] for clients:
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [describe({ name: "financial_analysis", description: "…", inputSchema })],
}));This is only information for the buyer. The price actually charged is always the one in the challenge Zanora signs when the call is made.
Low-level servers
With Server and setRequestHandler, use zanoraTool and pass the call's params:
import { zanoraTool } from "@zanora/middleware-mcp";
import { HttpBackend } from "@zanora/core";
const paid = zanoraTool({
backend: new HttpBackend({ apiKey: process.env.ZANORA_API_KEY }), // gateway defaults to https://api.zanora.dev
capabilityId: "cap_…",
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "financial_analysis") {
return paid(request.params, async (args, context) => {
// context: { transactionId, walletId, agentId }
return { content: [{ type: "text", text: await analyse(String(args.ticker)) }] };
});
}
// …free tools, unwrapped
});Serving it
Buyers' agents call your URL over Streamable HTTP, so it must be reachable from the public internet. Run a new server and transport for each request, with no session state. Payment state lives in Zanora, so this works behind a load balancer:
app.post("/mcp", async (req, res) => {
const server = buildServer(); // registers your tools with paid(...)
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => { void transport.close(); void server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});Your server only sends requests out to Zanora during payment. Zanora never calls in. That's why a stdio server can be sold without hosting.
Compatibility with generic x402 clients
The challenge is also placed in structuredContent, and _meta["x402/payment"] is accepted as an alternative name for the payment key, so a generic x402 MCP client can see that a tool needs payment and what it costs. Paying still needs a Zanora wallet: the proof is an ed25519 signature that Zanora authorizes, not an on-chain payment.