MCP
Charge per call for MCP tools on servers built with @modelcontextprotocol/sdk, with paidTool().
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.
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 | |
|---|---|
server | An McpServer. |
name, config | As for server.registerTool: title, description, inputSchema, outputSchema, annotations, _meta. |
gate | toll.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.approval | Ask the person at the client before charging. See Approval. |
options.checkout | Send 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 | |
|---|---|
above | Charge 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 client | What it gets |
|---|---|
Declared capabilities.elicitation.url | JSON-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 url | The 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 session | The 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:
| Client | Declares | What 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.186 | elicitation: { form } | Showed the message as a dialog |
| Claude Desktop | nothing | Took 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
| Field | Value |
|---|---|
transport | "mcp" |
request | Under 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. |
principal | From options.principal, or null. |
resource | The gate's resource option, or tool:<name>. |
requestId | crypto.randomUUID() per call. |
idempotencyKey | String _meta["tollstile/idempotency-key"], otherwise the HTTP Idempotency-Key header, otherwise null. |
extras | The 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
| Denial | Rendered as |
|---|---|
402, a rail offers MPP, and the client declared capabilities.experimental.payment | JSON-RPC error -32042, data: { httpStatus: 402, challenges: [...], failure?: { reason } } |
402, a rail offers x402 | Tool 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 / 503 | Tool 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"].
| Rail | Proof in _meta | Receipt in the result's _meta |
|---|---|---|
| Test rail | tollstile/test-payment | tollstile/test-receipt |
| x402 | x402/payment | x402/payment-response |
| MPP | org.paymentauth/credential | org.paymentauth/receipt |
| L402 | l402/credential | l402/receipt |
| KYAPay | kyapay/token | kyapay/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 handler | Client gets |
|---|---|
| Settled | The result, with the receipt merged into _meta |
| Settlement rejected | Not the output. A fresh payment requirement, rendered as above, with error code settlement_rejected |
| Settlement unknown | The 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-32042through from a tool callback. The reason is indata.failure.reason, next to a fresh challenge. - x402 denials on tools with an
outputSchema. The x402 transport requiresstructuredContenton the payment-required result; a client that validates it against the tool'soutputSchemarejects it. - Do not call
RegisteredTool.update()with a newcallback(it bypasses the gate), or to add or removeinputSchema.
Verification status
- Tested with the real SDK
McpServerandClientoverInMemoryTransport, andWebStandardStreamableHTTPServerTransportfor the HTTP request and principal: quote round-trip, tampered quote, replay, retry after a released charge, handler throw,isError,outputSchemamismatch, provider outage (503),403, non-JSON_meta,credits()with a principal, and rejected settlement. - Approval is tested against the SDK
Clientansweringaccept,decline,cancel, and failing to answer at all, plus a client that cannot be asked: every path that is notacceptreleases 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-client0.153.4 declareselicitation.urland opened the page; Claude Code declares onlyformand 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.