Paywall any MCP tool with an x402 v2 payment scheme.
Scheme-agnostic. Works with webcash (via x402-webcash), exact/USDC, or any future x402 scheme — you supply a Settler.
Note on naming: the unscoped
x402-mcppackage on npm is a separate, EVM-only / x402-v1 implementation by Vercel. This package is independent: x402 v2, scheme-agnostic, no framework lock-in. The wire dialect (org.x402/paymentrequest _meta,org.x402/challengeresult _meta mirror) is intentionally distinct.
npm install @feldmannn/x402-mcpPeer-depends on @modelcontextprotocol/sdk ^1.29.0.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createPaywall } from "@feldmannn/x402-mcp";
import { Facilitator, decimalToWats, webcashSettler } from "x402-webcash";
import { z } from "zod";
const server = new McpServer({ name: "premium-search", version: "0.1.0" });
const facilitator = new Facilitator({ url: "http://localhost:4021" });
const paywall = createPaywall({
settler: webcashSettler(facilitator),
scheme: "webcash",
asset: "webcash",
network: "webcash:mainnet",
payTo: "https://webcash.org",
onSettled: (out) => myWallet.put(out.secret),
});
server.registerTool(
"premium_search",
{ title: "Premium search", inputSchema: { query: z.string() } },
paywall.gate(
{
amount: decimalToWats("0.01").toString(),
resourceUrl: "mcp://premium-search/premium_search",
},
async ({ query }) => ({
content: [{ type: "text", text: await actuallyDoTheSearch(query) }],
}),
),
);Two-call flow, no MCP spec change:
- Caller invokes the tool with no payment metadata. The wrapper returns an MCP error result whose
contentcarries the x402 v2PaymentRequiredchallenge as JSON. The same challenge is mirrored into the result's_metaunderorg.x402/challengefor clients that prefer the structured path. - Caller re-invokes with the payment payload (base64-encoded x402 v2
PaymentPayload) in the request's_metaunderorg.x402/payment. The wrapper hands it to the configuredSettler, persists the output viaonSettled, then runs the wrapped handler.
Two reasons, neither of them "it's secret-safe by default":
- The tool's input schema stays clean.
tools/listdoes not surface a_paymentfield that LLM callers would otherwise be tempted to populate from prompt context. Payment is protocol-level, not domain-level. _metais the canonical MCP extensibility channel — progress tokens, related-task IDs, and other non-domain fields already live there. Future MCP tooling will route around_metacorrectly when the args-vs-metadata distinction matters.
_meta does not, by itself, keep payment proofs out of debug logs. Sellers and clients MUST scrub _meta["org.x402/payment"] from anything they ship to log aggregators. The improvement over args is real (cleaner schema, no LLM confusion) but log discipline remains the seller's responsibility.
org.x402/payment— request_metakey carrying the payment payload (base64 JSON).org.x402/challenge— result_metakey mirroring thePaymentRequiredchallenge on a 402 response.
DO NOT change these. They define the dialect.
A Settler is a function that takes an x402 PaymentPayload + PaymentRequirements and returns SettleResult<Output>. The Output is whatever the seller needs to persist (a new bearer secret for webcash, a tx receipt for an on-chain scheme, nothing for a scheme that settles directly to payTo).
const mySettler: Settler<MyPayload, MyOutput> = async (payload, requirements) => {
// ... talk to your facilitator / chain / issuer ...
if (!success) return { ok: false, reason: "...", retriable: false };
return { ok: true, transaction: "...", output: { ... } };
};The retriable flag matters: false means the caller MUST NOT submit a new payment for the same logical call (the input has moved or been definitively rejected); true means they can.
If onSettled throws, the wrapper writes a [x402-mcp][CRITICAL] persistence_failure ... line to stderr containing the full scheme-specific settler output. This is the deliberate last-resort witness — without it, a transient disk error during persistence would silently destroy funds.
The output is whatever the settler returned. For x402-webcash, that is the new bearer secret in plaintext. Anyone who reads the log line can spend it.
Operator responsibilities:
- Do NOT ship these lines to third-party log aggregators (Datadog, Loggly, Splunk Cloud, etc.) without redacting the
output=field. Treat stderr from a paywalled seller process the same way you'd treat a.envfile. - Provide an
onSettledRecoverycallback that writes to a sink independent of your primary persistence (e.g., an encrypted file on disk). IfonSettledRecoveryalso fails, you can still recover from stderr — but a healthy operator should never need to. - Search by transaction id, not by secret: the error result returned to the caller embeds
transaction=<id>so you can correlate the failed call with the stderr line without grepping for secret material.
MIT