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