Tollstile

MCP

Charge per call for MCP tools on servers built with @modelcontextprotocol/sdk, with paidTool().

Public Beta · early access
npm install tollstile @tollstile/mcp @modelcontextprotocol/sdk

@modelcontextprotocol/sdk 1.23 or later is required: earlier versions turn every error thrown from a tool callback into a tool result, so MPP's JSON-RPC error could not reach the client.

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";

const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger() });
const server = new McpServer({ name: "weather", version: "1.0.0" });

paidTool(server, "forecast", { description: "Tomorrow in one word" }, toll.price("$0.01"), (_args, { payment }) => ({
  content: [{ type: "text", text: `clear (paid via ${payment.via})` }],
}));

await server.connect(new StdioServerTransport());

Tools registered with server.registerTool stay free. Tutorial: Charge for MCP tool calls.

API

paidTool(server, name, config, gate, handler, options?): RegisteredTool
Parameter
serverAn McpServer.
name, configAs for server.registerTool: title, description, inputSchema, outputSchema, annotations, _meta.
gatetoll.price(...).
handler(args, extra) => CallToolResult. args is validated against inputSchema (undefined without one). extra is the SDK's request context plus payment.
options.principal(extra) => Principal | null | Promise<…>. Resolves the caller for subscriber() and credits(), e.g. from extra.authInfo or extra.requestInfo.headers.
options.approvalAsk the person at the client before charging. See Approval.
options.checkoutSend that person somewhere to pay when a call is denied. See Checkout.

Approval

A tool call is charged without anyone seeing the price: the client approves the tool, not the amount, and "always allow" removes even that. options.approval puts the amount in front of a person, over MCP elicitation, before any money moves.

paidTool(server, "summarize", { inputSchema: { text: z.string() } }, toll.price(upTo("$0.50")), summarize, {
  approval: { above: "$0.05" },
});

The question is asked after the payment is verified and reserved, and before the handler runs, so the amount asked about is the amount authorized. Nothing is charged unless they accept: a declined, dismissed, or unanswered call releases the reservation (or refunds it, on an upfront rail) and answers access_denied, with detail naming what happened — approval_declined, approval_cancelled, or approval_unavailable.

Option
aboveCharge without asking at or below this amount, e.g. "$0.05". Omit to ask before every charge. Parsed when the tool is registered; comparing against a price in another currency throws.
message(payment) => string. Default: Approve $0.05 for "summarize"?
unsupported"deny" (default) refuses a call the client cannot ask about; "charge" charges it anyway.

A client that did not declare capabilities.elicitation.form is never asked — one that can only open a URL cannot be shown a question, and takes the unsupported path. Measured against the live demo on 2026-09-16: Claude Code 2.1.186 declares elicitation: { form: {} } and shows the question as an Accept / Decline dialog — accepting settled the charge, and the ledger recorded it. OpenAI's codex-mcp-client declares form and url. Claude Desktop declares no elicitation at all, and is refused instead of charged. What each client that connects to the demo declares is published at /api/clients. A stateless HTTP server cannot ask either: the person's answer arrives on a later POST, which a server rebuilt per request is not waiting for. Keep the session — a Durable Object per session on Workers, one server per session elsewhere — or the call is refused with approval_unavailable and nothing is charged. The live demo does the former, and its agent answers the request.

Approval is consent to this charge. A client that pays on its own should also hold a budget it will not spend past; Tollstile is the merchant's side of that conversation, not the wallet's. See Consent for which of those agreements a merchant can enforce.

Checkout

Some denials are not the agent's to fix. Out of credit, no subscription, a card that failed — the model can only report them, and reporting them to a model is how a user ends up told "the tool is broken".

checkout turns such a denial into an errand for the person at the client:

paidTool(server, "forecast", config, gate, handler, {
  principal,
  checkout: (denial) => (denial.error.code === "access_denied" ? { url: "https://weather.example/account/credits", message: "Add credit to keep calling." } : null),
});

Return null for denials a page would not help with. The URL must be http: or https:; anything else refuses the call rather than handing the client something to open.

The text row is the one that matters most in practice today. A client that cannot open a page usually does not render _meta either, so its user is told "the tool failed" and never sees the link — measured against the live demo, that is what Claude Desktop shows. The only channel left is the text the model reads, so that is where the sentence goes: the model can repeat it. The denial body keeps its own block, and nothing that reads _meta changes.

The clientWhat it gets
Declared capabilities.elicitation.urlJSON-RPC error -32042 carrying the page, per MCP's URL elicitation — the client shows its user the link and stops
Declared elicitation.form but not urlThe message and the URL as a question the client puts on screen, then the denial. Whatever they press, the call was not paid for
Anything else, including a server that keeps no sessionThe message and the URL as the result's first content block, so the model can pass it on; the denial body follows in the next block, and _meta["tollstile/checkout"] carries the page
Can pay for itself (declared experimental.payment, and a rail offers MPP)Its own payment challenge. A client that can pay is never sent to a person

Which row fires is not a guess. Measured on 2026-09-16 against the live demo, from the clients' own initialize:

ClientDeclaresWhat happened
codex-mcp-client 0.153.4 (OpenAI)elicitation: { form, url }Opened the page. URL mode has no buttons: it sends the person somewhere
claude-code 2.1.186elicitation: { form }Showed the message as a dialog
Claude DesktopnothingTook the text path; its model read the address out

What every client that connects to the demo declares is published at /api/clients.

Unlike approval, this needs no session: the page is carried by the answer to the call, not by a question the server has to wait on. It works on a stateless HTTP server.

Context passed to the gate

FieldValue
transport"mcp"
requestUnder Streamable HTTP and SSE, a Request rebuilt from the URL and headers of the HTTP POST that carried the call, without a body. null for stdio and in-memory transports.
mcp{ tool, arguments, meta, clientCapabilities }: the tool name, its arguments, params._meta, and the client's declared capabilities, each checked to be plain JSON. A call whose _meta or arguments are not JSON is refused with invalid_request before the gate runs.
principalFrom options.principal, or null.
resourceThe gate's resource option, or tool:<name>.
requestIdcrypto.randomUUID() per call.
idempotencyKeyString _meta["tollstile/idempotency-key"], otherwise the HTTP Idempotency-Key header, otherwise null.
extrasThe SDK's extra.

Dynamic prices commit to the tool name and its canonical arguments, so a quote for one set of arguments cannot pay for another.

Denials

DenialRendered as
402, a rail offers MPP, and the client declared capabilities.experimental.paymentJSON-RPC error -32042, data: { httpStatus: 402, challenges: [...], failure?: { reason } }
402, a rail offers x402Tool result isError: true, structuredContent = the x402 PaymentRequired (with error set to the failure reason, if any), content[0].text = its JSON
Any other 402, and 400 / 403 / 409 / 422 / 429 / 503Tool result isError: true, content[0].text = Tollstile's denial body

Every denial rendered as a tool result also carries Tollstile's full denial body — every rail's offer and the signed quote — in _meta["tollstile/payment-required"].

RailProof in _metaReceipt in the result's _meta
Test railtollstile/test-paymenttollstile/test-receipt
x402x402/paymentx402/payment-response
MPPorg.paymentauth/credentialorg.paymentauth/receipt
L402l402/credentiall402/receipt
KYAPaykyapay/tokenkyapay/receipt

Outcome

The call succeeded — and settles, on the authorization flow — when the handler returns a result without isError: true whose structuredContent matches outputSchema, if the tool has one. paidTool checks the schema before the SDK does, so output the SDK would reject is not charged.

Otherwise the call failed: a handler that throws, returns isError: true, or returns output that does not match outputSchema releases the reservation (or refunds, on upfront). A thrown error is rethrown for the SDK to render.

After the handlerClient gets
SettledThe result, with the receipt merged into _meta
Settlement rejectedNot the output. A fresh payment requirement, rendered as above, with error code settlement_rejected
Settlement unknownThe result without a receipt; reconciliation resolves the charge. Withholding it would charge for a service never delivered if the charge later reconciles as settled
No rail receipt (subscriber grant, credit payment, or completion with no settlement)The handler's own result

For work that exists before the handler returns, call extra.payment.fulfill(); a later failure then does not undo the charge.

Known limitations

  • MPP verification failures use -32042, not -32043. McpServer passes only -32042 through from a tool callback. The reason is in data.failure.reason, next to a fresh challenge.
  • x402 denials on tools with an outputSchema. The x402 transport requires structuredContent on the payment-required result; a client that validates it against the tool's outputSchema rejects it.
  • Do not call RegisteredTool.update() with a new callback (it bypasses the gate), or to add or remove inputSchema.

Verification status

  • Tested with the real SDK McpServer and Client over InMemoryTransport, and WebStandardStreamableHTTPServerTransport for the HTTP request and principal: quote round-trip, tampered quote, replay, retry after a released charge, handler throw, isError, outputSchema mismatch, provider outage (503), 403, non-JSON _meta, credits() with a principal, and rejected settlement.
  • Approval is tested against the SDK Client answering accept, decline, cancel, and failing to answer at all, plus a client that cannot be asked: every path that is not accept releases the charge. The live demo runs it over Streamable HTTP with a session.
  • Checkout is tested for a client that declared URL elicitation, one that did not, a resolver that declines to send anyone, a URL a browser could not open, and a client that pays with MPP instead.
  • Run against real clients on 2026-09-16, over Streamable HTTP with a session. Approval: Claude Code 2.1.186 rendered it as an Accept / Decline dialog, and accepting settled the charge — it works end to end in a shipping client. Checkout: OpenAI's codex-mcp-client 0.153.4 declares elicitation.url and opened the page; Claude Code declares only form and was shown the message as a dialog; Claude Desktop declares neither and its model read the address out. All three rungs have now been seen working. Cursor and Windsurf are unmeasured.
  • The x402 and MPP renderings are tested against fake rails shaped like the x402 MCP transport and the MPP MCP transport draft. They are not tested against @x402/mcp, mppx, or a real paying client.

Idempotent tool retries

Send _meta["tollstile/idempotency-key"] on the first paid call and every retry. Keep the tool and arguments unchanged; JSON-RPC request IDs may change. A string metadata key takes precedence over the HTTP header.

Retries are tool errors containing Tollstile's denial body in content[0].text and _meta["tollstile/payment-required"]: already_paid, request_in_progress, payment_outcome_unknown, or idempotency_key_reused. These are not HTTP status responses or MPP -32042 challenges. Record extra.payment.fulfill({ resultRef }) to return a stored result reference in already_paid. See Idempotency.

On this page