Skip to content

feldmannn/x402-mcp

Repository files navigation

@feldmannn/x402-mcp

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-mcp package 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/payment request _meta, org.x402/challenge result _meta mirror) is intentionally distinct.

Install

npm install @feldmannn/x402-mcp

Peer-depends on @modelcontextprotocol/sdk ^1.29.0.

Use

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) }],
    }),
  ),
);

How it works

Two-call flow, no MCP spec change:

  1. Caller invokes the tool with no payment metadata. The wrapper returns an MCP error result whose content carries the x402 v2 PaymentRequired challenge as JSON. The same challenge is mirrored into the result's _meta under org.x402/challenge for clients that prefer the structured path.
  2. Caller re-invokes with the payment payload (base64-encoded x402 v2 PaymentPayload) in the request's _meta under org.x402/payment. The wrapper hands it to the configured Settler, persists the output via onSettled, then runs the wrapped handler.

Why _meta and not a tool arg

Two reasons, neither of them "it's secret-safe by default":

  1. The tool's input schema stays clean. tools/list does not surface a _payment field that LLM callers would otherwise be tempted to populate from prompt context. Payment is protocol-level, not domain-level.
  2. _meta is the canonical MCP extensibility channel — progress tokens, related-task IDs, and other non-domain fields already live there. Future MCP tooling will route around _meta correctly 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.

Namespace keys (wire-protocol constants)

  • org.x402/payment — request _meta key carrying the payment payload (base64 JSON).
  • org.x402/challenge — result _meta key mirroring the PaymentRequired challenge on a 402 response.

DO NOT change these. They define the dialect.

Writing a Settler for a new scheme

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.

Operator security: the recovery log

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 .env file.
  • Provide an onSettledRecovery callback that writes to a sink independent of your primary persistence (e.g., an encrypted file on disk). If onSettledRecovery also 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.

License

MIT

About

Paywall any MCP tool with an x402 v2 payment scheme. Scheme-agnostic — works with webcash, USDC (exact), or any future x402 scheme.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors