ZZanoraDocs

Sell

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

Terminal
npm install @zanora/middleware-mcp @modelcontextprotocol/sdk

sell(): publish and wrap in one call

TypeScript
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

OptionRequiredMeaning
toolyesthe MCP tool name. One capability per tool
descriptionyeswhat it does, in the words a buyer would search for. Discovery ranks on it
categoryyeswhat buyers filter on, e.g. "OCR" or "finance"
price or priceMinoryes"$0.10" (dollars, with the $) or "10" (cents). A plain number or price: "10" is refused as ambiguous
urlone of thesethe URL buyers' agents call. Defaults to ZANORA_PUBLIC_URL
runsLocallyone of thesesell 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
namenothe display name in discovery. Defaults to the tool name
schemano, but recommendedJSON Schema for the arguments. Buyers' agents read it before paying
tags, latencyP50Ms, currencynoextra discovery signals

What the wrapper does

  1. A call without payment returns an error result carrying a signed challenge, with the price quoted by Zanora, not by you.
  2. The buyer signs a payment proof and calls again with it in _meta.
  3. The proof is checked on the server: signature, replay, the buyer's policy and budget, their balance. Then your handler runs.
  4. Your result is hashed into a signed receipt that goes back in the result's _meta.
  5. If your handler throws or returns isError: true, the call fails and the buyer is refunded. MCP has no status codes, so isError is how you say a call failed. Without it, the buyer would be charged for an error message. Because MCP can't say whose fault an isError was, 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 published schema so 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:

TypeScript
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 working

Log 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

TypeScript
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.default covers 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, pass from: anything with listTools() (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:

TypeScript
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:

TypeScript
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:

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