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

```bash
npm install @zanora/middleware-mcp @modelcontextprotocol/sdk
```

## `sell()`: publish and wrap in one call

```ts
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](https://docs.zanora.dev/sellers/packages.md) |
| `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

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:

```ts
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](https://docs.zanora.dev/sellers/listings.md).

## `sellAll()`: price a whole server

```ts
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:

```ts
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:

```ts
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:

```ts
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](https://docs.zanora.dev/sellers/packages.md).

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