A transport-safe, in-band payment extension for MCP servers.
Standard MCP payment approaches using HTTP headers (e.g. x402's X-PAYMENT-REQUIRED) are invisible to agents: the MCP client transport swallows non-2xx HTTP responses before the JSON-RPC layer and never exposes arbitrary response headers to the model. This package implements the MCP Payments Extension (MPX) — a payment handshake that lives entirely inside JSON-RPC message bodies, works identically over stdio and Streamable HTTP, and is visible to any MCP-capable agent.
All payment signaling travels in _meta fields on JSON-RPC messages, using the reserved namespace mpx/v1.*. No HTTP headers or status codes are used.
When a tool is called without a valid payment authorization, the server returns an isError result:
{
"isError": true,
"content": [{ "type": "text", "text": "payment_required: ... (1.50 USDC). Retry with _meta authorization." }],
"_meta": {
"mpx/v1.challenge": {
"mpxVersion": 1,
"paymentRequestId": "<uuid>",
"expiresAt": "<ISO-8601>",
"reason": { "tool": "<tool-name>", "description": "<human-readable purpose>" },
"amount": { "value": "1.50", "currency": "USDC", "decimals": 6 },
"accepts": [
{
"rail": "x402-evm-exact",
"payTo": "<payee address>",
"requirements": { "...": "rail-specific requirements" }
}
]
}
}
}The content text duplicates the key facts so agents that don't read _meta still see a meaningful error. Machine clients read _meta for the structured challenge.
The agent re-issues the same tools/call (identical arguments) and adds the signed authorization to params._meta:
{
"method": "tools/call",
"params": {
"name": "<tool>",
"arguments": { "...": "unchanged" },
"_meta": {
"mpx/v1.authorization": {
"mpxVersion": 1,
"paymentRequestId": "<uuid from challenge>",
"rail": "x402-evm-exact",
"payload": { "...": "rail-specific signed payload" }
}
}
}
}On success, the result carries a receipt in result._meta:
{
"_meta": {
"mpx/v1.receipt": {
"mpxVersion": 1,
"paymentRequestId": "<uuid>",
"rail": "x402-evm-exact",
"settlementRef": "<rail-specific reference>",
"amount": { "value": "1.50", "currency": "USDC", "decimals": 6 },
"settledAt": "<ISO-8601>"
}
}
}- Single-use on success — each
paymentRequestIdis consumed only after the handler completes successfully. Verification and handler failures release the challenge so the agent can retry with the same signed authorization untilexpiresAt. A replay after success is rejected even though the authorization still verifies cryptographically. - Expiry — challenges expire (default 300 seconds). The server rejects authorizations after
expiresAt. - Verify before settle — the package verifies the authorization before calling the tool handler. The handler's
settle()callback moves funds after any application-level validation (e.g. content signatures). Money never moves on an invalid request. - Conditional gating —
intent()can returnnullto skip the challenge entirely for calls that don't require payment.
A payment challenge semantically isn't a tool failure — it's a mid-execution pause
requesting input. A JSON-RPC error object with a fixed code and structured data
would be the right shape, but the installed MCP SDK (@modelcontextprotocol/sdk
v1.29.0, current latest) catches all McpError throws from tool handlers and
flattens them to a plain isError text result, dropping data entirely — except
for the single special code UrlElicitationRequired.
The isError: true + _meta approach is therefore the only channel that reliably
carries structured challenge data to the caller today. It is also consistent with the
MCP spec, which says tool-originated errors should live in isError results so the
LLM can see and react to them.
The long-term path is MCP elicitation (elicitation/create). When a
PaymentAuthorizationRequired error code is added to the SDK (following the same
carve-out as UrlElicitationRequired), withPayment can be updated to throw it
instead of returning an isError result. No tool handler or marketplace wiring
changes — the switch is entirely inside the wrapper. See the protocol design
notes
for a detailed analysis of why elicitation is not yet feasible.
npm install @overdraft-protocol/mpxThe x402-evm rail additionally requires x402 and viem as peer dependencies:
npm install x402 viemimport { createPaymentExtension, InMemoryChallengeStore } from '@overdraft-protocol/mpx';
import { consolePaymentLogger } from '@overdraft-protocol/mpx';
const withPayment = createPaymentExtension({
rails: [myRail], // PaymentRail[] — see Implementing a rail below
store: new InMemoryChallengeStore(), // or your durable ChallengeStore
settlement: mySettlement, // SettlementStrategy — what "settle" does in your app
challengeTtlSeconds: 300, // optional, default 300
logger: consolePaymentLogger, // optional, default no-op — see Logging below
});import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
server.registerTool(
'my_paid_tool',
{ inputSchema: { amount_usdc: z.number() } },
withPayment(
{
tool: 'my_paid_tool',
description: 'Service description shown in the challenge',
intent(args) {
return {
amount: { value: String(args.amount_usdc), currency: 'USDC', decimals: 6 },
payTo: '0x...',
binding: { myAppData: 'for settlement' },
};
},
},
async (args, extra) => {
// 1. Do your application validation here (content sig, nonce, etc.)
// 2. Call settle() after validation passes — this is when money moves.
const receipt = await extra.settle();
// extra.verifiedPayment is also available for accessing the raw rail payload.
return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }] };
},
),
);Standard LLM harnesses only let the model control arguments — params._meta is populated by the client host. withPayment automatically checks args.payment_authorization if _meta does not contain an authorization. The value must be a JSON string of the authorization object, or the object itself:
{ "arguments": { "..": "..", "payment_authorization": "{\"mpxVersion\":1,\"paymentRequestId\":\"...\",\"rail\":\"x402-evm-exact\",\"payload\":{...}}" } }To make payment_authorization reachable in the handler (Zod strips unknown fields), declare it in the tool's inputSchema:
inputSchema: {
amount_usdc: z.number(),
payment_authorization: z.string().optional().describe(
'JSON-encoded MPX authorization. Alternative to params._meta["mpx/v1.authorization"].'
),
}params._meta always takes priority if both are present.
Return null from intent() to skip payment for calls that don't require it:
{
tool: 'file_dispute',
description: 'Buyer dispute stake',
async intent(args) {
const needsPayment = await checkIfPaymentRequired(args.bid_id);
if (!needsPayment) return null; // no challenge issued, handler called directly
return { amount: ..., payTo: ..., binding: ... };
},
}intent() may be synchronous or async.
A PaymentRail has two required responsibilities: building the offer shown in the challenge, and verifying a signed authorization. It never settles — settlement is an injected SettlementStrategy. The core is fully rail-agnostic: it knows nothing about x402, EVM, cards, or any specific scheme.
import type { PaymentRail, PaymentIntent, VerifiedAuthorization } from '@overdraft-protocol/mpx';
import type { RailOffer } from '@overdraft-protocol/mpx';
const myRail: PaymentRail = {
id: 'my-rail',
buildOffer(intent: PaymentIntent): RailOffer {
return {
rail: 'my-rail',
payTo: intent.payTo,
requirements: {
// rail-specific fields the payer needs to sign
amount: intent.amount.value,
currency: intent.amount.currency,
},
};
},
async verify(payload: unknown, offer: RailOffer): Promise<VerifiedAuthorization> {
// verify the signed payload against the offer — throw if invalid
const verified = await myVerifyFn(payload, offer);
return {
rail: 'my-rail',
amount: { value: verified.amount, currency: 'USDC', decimals: 6 },
raw: verified, // passed to SettlementStrategy.settle()
};
},
};A rail can own its agent-facing details so they never leak into the core. All are optional — a rail that omits them still works, falling back to generic behaviour.
| Hook | Purpose |
|---|---|
coerceAuthorization(raw, hints) |
Normalize loosely-shaped agent input (the common case: a JSON blob in a payment_authorization argument because the harness can't write params._meta) into a schema-valid MPX authorization. The core tries each rail's coercer in order and keeps the first result that validates. |
retryInstructions(challenge) |
Rail-specific "how to pay" text appended to the challenge content. |
describePayload(payload) |
Redact a signed payload to a safe summary for the structured logger (never log secrets/signatures in full). |
authorizationArgDescription |
Description for the payment_authorization tool argument, surfaced by apps in their inputSchema. |
The cleanest reference implementation of all of these is the dev-signature rail (src/rails/dev-signature/index.ts) — it's zero-dependency and self-contained; copy it as a starting point.
Three rails ship with the package: dev-signature (zero-dependency, for dev/CI/reference), x402-evm-exact (EVM stablecoin payments), and stripe-card (card payments via Stripe) — proof the core is rail-agnostic across both crypto and traditional rails.
A zero-dependency rail at @overdraft-protocol/mpx/rails/dev-signature. "Authorization" is an HMAC-SHA256 over the offer terms with a shared secret, standing in for a wallet signature. It moves no real funds — use it for local development, demos, CI, and as the template for a real rail.
import { createDevSignatureRail, signDevAuthorization } from '@overdraft-protocol/mpx/rails/dev-signature';
const rail = createDevSignatureRail({ secret: process.env.DEV_PAY_SECRET! });
// A payer signs an offer with: signDevAuthorization(secret, challenge.accepts[0])import type { SettlementStrategy, VerifiedAuthorization, SettlementRef } from '@overdraft-protocol/mpx';
const mySettlement: SettlementStrategy = {
async settle(verified: VerifiedAuthorization, binding: unknown): Promise<SettlementRef> {
const b = binding as MyAppBinding; // narrow the opaque binding here
const ref = await myOnChainDeposit(verified.raw, b.orderId);
return { ref };
},
};The binding parameter is exactly what you returned in intent(). The package treats it as opaque (unknown) so it never leaks application concerns into the generic layer.
The default InMemoryChallengeStore is suitable for single-process servers and tests. For production, implement ChallengeStore backed by a database:
import type { ChallengeStore, ChallengeRecord } from '@overdraft-protocol/mpx';
class MyDurableChallengeStore implements ChallengeStore {
async save(record: ChallengeRecord): Promise<void> {
await db.insert('payment_challenges', {
id: record.challenge.paymentRequestId,
expires_at: record.challenge.expiresAt,
data: JSON.stringify(record),
});
}
async get(paymentRequestId: string): Promise<ChallengeRecord | undefined> {
const row = await db.get('payment_challenges', { id: paymentRequestId, consumed: false });
if (!row || new Date(row.expires_at) < new Date()) return undefined;
return JSON.parse(row.data);
}
async consume(paymentRequestId: string): Promise<ChallengeRecord | undefined> {
// Must be atomic — only one caller should succeed
const record = await this.get(paymentRequestId);
if (!record) return undefined;
await db.update('payment_challenges', { consumed: true }, { id: paymentRequestId });
return record;
}
async release(record: ChallengeRecord): Promise<void> {
if (new Date(record.challenge.expiresAt) < new Date()) return;
await db.update('payment_challenges', { consumed: false, data: JSON.stringify(record) }, {
id: record.challenge.paymentRequestId,
});
}
}A generic x402 EVM rail at the @overdraft-protocol/mpx/rails/x402-evm subpath. It requires x402 and viem as peer dependencies, imported dynamically inside verify() so the core compiles and runs without them when this rail isn't used.
import { createX402EvmRail } from '@overdraft-protocol/mpx/rails/x402-evm';
import { createPublicClient, http } from 'viem';
import { base } from 'viem/chains';
const rail = createX402EvmRail({
publicClient: createPublicClient({ chain: base, transport: http() }),
assetAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base
network: 'base',
chainId: 8453, // optional — included in offers for EIP-712 wallets
currencySymbol: 'USDC',
decimals: 6,
// Optional EIP-712 token metadata (defaults shown — for USDC):
assetName: 'USD Coin',
assetVersion: '2',
});This rail handles buildOffer (constructs x402 PaymentRequirements) and verify (exact.evm.verify — no on-chain action). It also wires the optional hooks (coerceAuthorization, retryInstructions, describePayload, authorizationArgDescription), exported individually as coerceX402Authorization, x402RetryInstructions, and X402_AUTHORIZATION_ARG_DESCRIPTION. Settlement is always injected: the rail has no depositBid, no Escrow.sol, no application knowledge.
A card rail at @overdraft-protocol/mpx/rails/stripe, proving the core works for traditional payments, not just crypto. stripe is an optional peer dependency, imported dynamically only when you pass a secretKey (inject a stripe client directly for tests).
import { createStripeRail } from '@overdraft-protocol/mpx/rails/stripe';
const rail = createStripeRail({
secretKey: process.env.STRIPE_SECRET_KEY!, // or: stripe: new Stripe(key)
currency: 'usd',
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY, // optional, for client confirmation
});It maps cleanly onto the small rail interface, and shows the two enhancements that make non-crypto rails possible:
buildOffer(intent)is async — it creates a Stripe PaymentIntent (capture_method: 'manual') and returns itsid+client_secretinrequirements. (buildOffermay return a promise; the core awaits it.)verify(payload, offer)—payloadcarries the confirmed PaymentIntent id; the rail retrieves it and assertsstatus === 'requires_capture'and that amount/currency match the offer. No money moves — an uncaptured authorization is just a hold.- Settlement stays injected — your
SettlementStrategy.settle()callsstripe.paymentIntents.capture(id). This is what preserves verify-before-settle: the hold is verified before the handler runs, capture happens only after the handler validates and callsextra.settle(). - Optional hooks are wired:
coerceAuthorizationaccepts{ paymentRequestId, paymentIntentId }shorthand;retryInstructionstells the agent how to confirm the PaymentIntent;describePayloadlogs only the id.
The single invariant every rail must uphold: verify() proves funds are committed but moves nothing; only the injected settle() moves money. Everything else is rail-specific.
Try it against Stripe test mode — examples/stripe-integration.ts runs the whole loop (create → confirm with a test card → verify → capture) against real Stripe test APIs:
npm i stripe
export STRIPE_SECRET_KEY=sk_test_... # test mode only; the script refuses live keys
npx tsx examples/stripe-integration.tsSettlement stays injected into createPaymentExtension — the core never moves money itself, and apps with bespoke settlement (escrow, ledgers, the marketplace) implement SettlementStrategy directly. But for the common cases you don't have to write it: each rail ships an opt-in settlement strategy and a payer-side helper (signing/confirming an offer), so both ends are batteries-included.
| Rail | Payer helper (client side) | Settlement strategy (server side) |
|---|---|---|
dev-signature |
signDevAuthorization(secret, offer) |
devSignatureSettlement (no-op) |
x402-evm-exact |
signX402Authorization({ account, offer }) |
createX402TransferSettlement({ wallet }) — bare USDC transfer via exact.evm.settle |
stripe-card |
confirmStripePaymentIntent(stripe, offer, { paymentMethod }) |
createStripeCaptureSettlement(stripe) — captures the hold |
import { createStripeRail, createStripeCaptureSettlement } from '@overdraft-protocol/mpx/rails/stripe';
const stripe = new Stripe(key);
const withPayment = createPaymentExtension({
rails: [createStripeRail({ stripe, currency: 'usd' })],
store,
settlement: createStripeCaptureSettlement(stripe), // ← shipped, no custom code
});Settlement strategies that need the original offer (e.g. x402 needs the verified PaymentRequirements) receive it via the third settle(verified, binding, context) argument — context.offer. The argument is additive: existing two-parameter strategies keep working unchanged.
examples/stdio-server.ts is a minimal MCP server with one paid tool, using the zero-dependency dev-signature rail — no chain, keys, or network. Run it as a real stdio server, or self-contained:
# real stdio MCP server (connect any MCP client / inspector):
npx tsx examples/stdio-server.ts
# self-contained demo — drives itself and prints challenge → sign → pay → receipt:
npx tsx examples/stdio-server.ts --demoSwapping in a real rail is the same wiring: replace the rail + settlement with createX402EvmRail/createX402TransferSettlement or createStripeRail/createStripeCaptureSettlement.
The core never writes to console — it emits structured PaymentLogEvents to an injected PaymentLogger:
import { createPaymentExtension, consolePaymentLogger, type PaymentLogger } from '@overdraft-protocol/mpx';
// Built-ins: noopPaymentLogger (default), consolePaymentLogger.
// Or forward to your own structured logger:
const logger: PaymentLogger = {
log(event) { myLogger.info({ mcpPayment: event }); },
};
const withPayment = createPaymentExtension({ rails, store, settlement, logger });Event types: challenge_issued, authorization_received, authorization_parse_failed, verify_started, verify_succeeded, verify_failed, challenge_not_found, settled. Payload fields are redacted by the rail's describePayload — the core never logs raw signatures.
Returns a withPayment(spec, handler) function. Config:
| Field | Type | Description |
|---|---|---|
rails |
PaymentRail[] |
Supported payment rails, in preference order |
store |
ChallengeStore |
Challenge persistence (use InMemoryChallengeStore for dev/tests) |
settlement |
SettlementStrategy |
What settle() does — injected by the application |
challengeTtlSeconds |
number? |
Challenge TTL in seconds (default: 300) |
logger |
PaymentLogger? |
Structured event sink (default: no-op). See Logging |
Returns an AnyToolHandler suitable for passing to server.registerTool().
The handler receives (args, extra) where extra is augmented with:
| Field | Type | Description |
|---|---|---|
extra.verifiedPayment |
VerifiedAuthorization | undefined |
Verified rail payload; undefined when call is not gated |
extra.settle() |
() => Promise<SettlementRef | undefined> |
Call after validation to move funds; idempotent; returns undefined when not gated |
| Field | Type | Description |
|---|---|---|
tool |
string |
Tool name (shown in challenge reason) |
description |
string |
Human-readable payment purpose |
intent(args) |
PaymentIntent | null | Promise<...> |
Return a PaymentIntent to gate the call; null to skip payment |
| Field | Type | Description |
|---|---|---|
amount |
MpxAmount |
{ value, currency, decimals } |
payTo |
string |
Payee address/account |
binding |
unknown |
App-specific data passed unchanged to SettlementStrategy.settle() |
railHints |
Record<string, unknown>? |
Opaque hints forwarded to rail.buildOffer() |
This package has a hard dependency boundary: it must never import from application code. It depends only on @modelcontextprotocol/sdk and zod (plus x402/viem inside the optional x402 rail subpath). All application concerns — persistence, settlement, gating logic, chain access — are injected through interfaces.
The package tsconfig.json has no baseUrl/paths aliases so it is structurally impossible to import application modules. The eslint.config.js adds a no-restricted-imports rule as a second line of defense.
MIT