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.
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/sdk1.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 zodRunnable example in the repository: examples/mcp.
1. Price a tool on the test rail
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.
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.tsunpaid: 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. pingnever asks for payment.
3. Accept x402
Replace the test rail with the x402 rail. The tool and handler do not change.
npm install @tollstile/x402import { 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 rail | x402 | |
|---|---|---|
| Payment required | isError 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 happens | Result |
|---|---|
| No payment | isError result with the payment requirement; the handler does not run |
| Tampered or expired quote | isError 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 outputSchema | The reservation is released; nothing is charged. A thrown error is rethrown for the SDK |
| Payment provider down during verification | isError result with payment_unavailable; the handler does not run |
| Settlement rejected after the tool ran | The output is withheld and the client gets a fresh payment requirement, error code settlement_rejected |
| Settlement outcome unknown | The 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
MCP adapter reference
Charge for usage
upTo() prices for tools whose cost is known after they run.
Add credits
Let signed-in callers draw down a balance.
x402 rail
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.
Using Tollstile with coding agents
Give Claude Code, Codex, Cursor, and other coding agents what they need to add payments with Tollstile.
Monetize an API with x402
Charge per request in USDC with x402 on a Hono API. Build on the test rail, switch to x402 exact on Base Sepolia, and use upto for variable prices.