ZanoraBetaDocs

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: "extract_invoice",
  description: "Extract text and structured line items from scanned invoices",
  category: "OCR",
  price: "$1.00",
  url: "https://acme.example/mcp",           // or set ZANORA_PUBLIC_URL
  schema: { type: "object", properties: { imageUrl: { type: "string" } }, required: ["imageUrl"] },
});

const server = new McpServer({ name: "acme-ocr", version: "1.0.0" });

// Paid tool: wrap the handler.
server.tool("extract_invoice", "Extract text and line items from a scanned invoice. Costs $1.00.", { imageUrl: z.string() },
  paid(async (args) => {
    const answer = await extractInvoice(String(args.imageUrl));
    return { content: [{ type: "text", text: JSON.stringify(answer) }], structuredContent: answer };
  }),
);

// 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 priceMicrosyes"$0.10" or "$0.001" (dollars, with the $, up to six decimals) or "100000" (micro-dollars). A plain number or price: "100000" is refused as ambiguous. Free, or at least $0.0001
urlone of thesethe URL buyers' agents call. Defaults to ZANORA_PUBLIC_URL
runsLocallyone of theselist a package instead (discoverable, not payable): true reads name and version from your package.json, or pass "@acme/ocr-mcp@1.4.2". See Stdio servers and packages
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

Or create the capability in the console

You can publish the listing by hand instead of from code. In the console↗, go to Seller → Publish and choose Protocol: MCP tool:

Console
Seller → Publish → Protocol: MCP tool
  Name              Invoice OCR
  Category          OCR        (pick a suggestion or type your own)
  MCP server URL    https://acme.example/mcp
  Tool name         extract_invoice
  What it does      Extract text and structured line items from scanned invoices
  Price per call    1.00
  Version           1.0.0
→ Publish

The new capability appears under Seller → Capabilities with its cap_… id. Your server still has to charge for the tool, and there are two ways to connect it:

  • Keep calling sell() with the same tool name and URL. It finds the listing you created by provider, tool and address, and reconciles it to your code instead of publishing a second one. Your code wins wherever the two disagree.
  • Use the id directly with the low-level wrapper: zanoraTool({ backend, capabilityId: "cap_…" }).

What the console form doesn't set

The form has no field for the argument schema, tags or latency. Buyers' agents read the schema before paying, so add one with sell() or the API. The console form only takes a URL. A package listing, which isn't payable, comes from runsLocally.

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: "$1.20" });
capability.changed;       // ["priceMicros"]
capability.priceMicros;    // "1200000", read back from the marketplace, not echoed from your input
capability.capabilityId;  // unchanged: buyers who saved it keep working

Log changed and the stored priceMicros 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: "OCR",
  url: "https://acme.example/mcp",
  tools: [
    { tool: "extract_invoice", description: "Extract text and structured line items from scanned invoices" },
    { tool: "classify_document", description: "Say what kind of document a scan is" },
    { tool: "ping", description: "Health check" },
  ],
  prices: { extract_invoice: "$1.00", default: "$0.05" },
  skip: ["ping"],                                     // stays free
});

server.registerTool(
  "classify_document",
  catalogue.describe("classify_document", { description: "Say what kind of document a scan is", inputSchema: { imageUrl: z.string() } }),
  catalogue.paid("classify_document")(async (args) => {
    const answer = await classifyDocument(String(args.imageUrl));
    return { content: [{ type: "text", text: JSON.stringify(answer) }], structuredContent: answer };
  }),
);
  • 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: "extract_invoice", 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 === "extract_invoice") {
    return paid(request.params, async (args, context) => {
      // context: { transactionId, walletId, agentId }
      const answer = await extractInvoice(String(args.imageUrl));
      return { content: [{ type: "text", text: JSON.stringify(answer) }], structuredContent: answer };
    });
  }
  // …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. To sell a stdio server, put an HTTP transport in front of it like the one above. See Stdio servers and packages.

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.

Accepting standard x402 payments from those clients is in preview and not enabled on api.zanora.dev. When it is and you opt in, the same structuredContent becomes a valid x402 offer. See Standard x402 payments (preview).