npm packages are not installable yet: [email protected] can be published after 2026-09-16 10:30 UTC. Details
Tollstile

Charge for MCP tool calls

Put a price on MCP tools with @tollstile/mcp. Build it on the test rail, call it from an MCP client, then accept x402 stablecoin payments.

Public Beta · early access

Goal: an MCP server where forecast costs $0.01 per call, free tools stay free, and a failed call costs nothing.

Prerequisites

  • Node.js 22.18 or later, which runs TypeScript files directly. On older versions, run the files with npx tsx.
  • An MCP server built with @modelcontextprotocol/sdk 1.23 or later.
  • For the x402 step: a receiving address on Base Sepolia and a JSON-RPC URL. No wallet is needed for the test rail.
npm install tollstile @tollstile/mcp @modelcontextprotocol/sdk zod

Runnable example in the repository: examples/mcp.

1. Price a tool on the test rail

server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { paidTool } from "@tollstile/mcp";
import { createTollstile, memoryLedger, testRail } from "tollstile";
import { z } from "zod";

const toll = createTollstile({
  rails: [testRail()],
  ledger: memoryLedger(),
  // stdout carries the MCP protocol, so log to stderr.
  onEvent: (event) => {
    if (event.type === "charge.moved") {
      console.error(`${event.charge.id}: ${event.charge.payment}/${event.charge.fulfillment}`);
    }
  },
});

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

// A free tool: registered as usual, never gated.
server.registerTool("ping", { description: "Checks the server is up" }, () => ({
  content: [{ type: "text", text: "pong" }],
}));

// A paid tool: same config as registerTool, plus a price.
paidTool(
  server,
  "forecast",
  { description: "Tomorrow's forecast for a city", inputSchema: { city: z.string() } },
  toll.price("$0.01"),
  ({ city }, { payment }) => ({
    content: [{ type: "text", text: `${city}: clear (paid via ${payment.via})` }],
  }),
);

await server.connect(new StdioServerTransport());

paidTool(server, name, config, gate, handler, options?) registers the tool with McpServer.registerTool and puts the gate in front of the handler. The handler receives the validated arguments and the SDK's extra, plus payment.

2. Call it from a client

This script plays the agent: it calls the tool, reads the payment requirement, and pays with the quote.

client.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const client = new Client({ name: "test-agent", version: "1.0.0" });
await client.connect(new StdioClientTransport({ command: "node", args: ["server.ts"] }));

// 1. Call without paying.
const unpaid = await client.callTool({ name: "forecast", arguments: { city: "Oslo" } });
const required = unpaid._meta?.["tollstile/payment-required"] as { price: string; quote: string };
console.log("unpaid:", unpaid.isError, required.price);

// 2. Pay with the quote the server offered.
const paid = await client.callTool({
  name: "forecast",
  arguments: { city: "Oslo" },
  _meta: { "tollstile/test-payment": `test quote=${required.quote}` },
});
console.log("paid:", paid.content, paid._meta);

await client.close();
node client.ts
unpaid: true $0.01
chg_c2fa…: settling/completed
chg_c2fa…: settled/completed
paid: [ { type: 'text', text: 'Oslo: clear (paid via rail)' } ] {
  'tollstile/test-receipt': 'test_settlement_chg_c2fa…'
}
  • The unpaid call returns isError: true. Tollstile's full denial body — price, signed quote, and every rail's offer — is in _meta["tollstile/payment-required"].
  • The paid call runs the handler, settles, and adds the receipt to the result's _meta.
  • ping never asks for payment.

3. Accept x402

Replace the test rail with the x402 rail. The tool and handler do not change.

npm install @tollstile/x402
server.ts
import { x402 } from "@tollstile/x402";

const toll = createTollstile({
  rails: [
    x402({
      network: "eip155:84532", // Base Sepolia
      payTo: process.env.PAY_TO!,
      denomination: "USD", // 1 USDC = 1 USD, stated explicitly
      rpcUrl: process.env.RPC_URL!, // e.g. https://sepolia.base.org, used by reconciliation
    }),
  ],
  ledger: memoryLedger(), // use a database ledger in production
  secret: process.env.TOLLSTILE_SECRET!, // 32+ random characters; required with live rails
});

setInterval(() => void toll.reconcile(), 60_000);

On Base Sepolia the rail uses the x402.org facilitator by default. On mainnet and every other network, pass facilitator: { url, headers }.

What changes for the client:

Test railx402
Payment requiredisError result, body in _meta["tollstile/payment-required"]isError result, structuredContent = the x402 PaymentRequired (the Tollstile body is still in _meta)
Proof_meta["tollstile/test-payment"]_meta["x402/payment"]
Receipt_meta["tollstile/test-receipt"]_meta["x402/payment-response"]

An x402 MCP client pays from structuredContent and retries with _meta["x402/payment"]. If the client also declares capabilities.experimental.payment and you add an MPP rail, it receives MPP's JSON-RPC error -32042 instead.

Verification status

The MCP adapter is tested with the real SDK McpServer and Client. Its x402 and MPP renderings are tested against fake rails shaped like each protocol's MCP transport, not against @x402/mcp, mppx, or a live facilitator.

Verify

With the test rail, node client.ts must print unpaid: true and then a result with tollstile/test-receipt. The stderr log must show one charge ending settled/completed.

With x402, call the tool from an x402 MCP client funded with Base Sepolia USDC. The client must pay from structuredContent, and the result must carry _meta["x402/payment-response"] with a transaction hash you can find on https://sepolia.basescan.org.

When things fail

What happensResult
No paymentisError result with the payment requirement; the handler does not run
Tampered or expired quoteisError result with a fresh quote
Same proof twice (test proof=p1)The second call is refused: proof_already_used
Handler throws, returns isError: true, or returns output that fails outputSchemaThe reservation is released; nothing is charged. A thrown error is rethrown for the SDK
Payment provider down during verificationisError result with payment_unavailable; the handler does not run
Settlement rejected after the tool ranThe output is withheld and the client gets a fresh payment requirement, error code settlement_rejected
Settlement outcome unknownThe output is returned without a receipt; reconciliation resolves the charge

Retry after a failure with the same proof: a released charge does not consume it.

Tools with an outputSchema

x402's MCP transport puts the payment requirement in structuredContent. A client that validates it against the tool's outputSchema rejects it. That is the protocol's shape; prefer tools without outputSchema for x402.

Next

Retries

Send _meta["tollstile/idempotency-key"] with the first paid call and its retries, alongside the rail proof. Preserve tool arguments. A paid retry returns already_paid in the denial metadata, with chargeId, settlement, and result; it does not run the tool again. See MCP retries.

On this page