npm packages are not installable yet: [email protected] can be published after 2026-09-16 10:30 UTC. Details
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.

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