ShipSafe
Vibe-coder installPaste this into Claude Code, Cursor, or Codex. Your AI will do the rest.
Add shipsafe-mcp-pay to this MCP server. Read https://shipsafe.franklabs.io/docs/mcp-pay/llms.txt and follow it exactly.
Spec your AI will read: /docs/mcp-pay/llms.txtNo signup required — a sandbox key is provisioned automatically.

Architecture: how shipsafe-mcp-pay works

How a paid tool call flows from agent to wallet ledger, and what happens when things go sideways.

Request flow

Architecture diagram: Agent (LLM) -> MCP Server (your code) -> mcp-pay wrapper (this package) -> /api/mcp-pay/charge (ShipSafe backend) -> Wallet ledger (Postgres). Below the wrapper, two branches: 200 OK runs the handler; 402 returns a payment_required envelope to the agent.

The wrapper sits between your MCP server and your handler. It:

  1. Pulls customerId out of the incoming MCP request (_meta.customerId by default).
  2. POSTs /api/mcp-pay/charge with { toolName, customerId }.
  3. On 200, runs your handler with a ctx object holding the post-debit balance and ledger id.
  4. On 402, returns an MCP-shaped payment-required envelope. Your handler never runs.
  5. If your handler throws, calls /api/mcp-pay/refund with the chargeId and reason handler_error, then re-throws the original error.

The package has zero runtime dependencies. The only network call is to the ShipSafe backend.

AP2 vs. x402 compatibility

mcp-pay speaks both major agent-payment standards from one config surface. The merchant configures which schemes are enabled in the dashboard. The 402 response advertises all of them; the caller picks one.

Standard Scheme tag Settlement When it fires
Anthropic AP2 ap2-cards Hosted Stripe Checkout Agents already account-attached to a Stripe customer.
Coinbase x402 (HTTP 402) x402-usdc USDC on Base or Base-Sepolia Arbitrary callers with a crypto wallet.
Direct Stripe top-up stripe-topup Hosted Stripe Checkout Same Checkout URL exposed as a fallback for non-AP2 agents.

Every 402 response sets the header:

X-Payment-Required: x402; version=1, ap2; version=0.2

And the body is an x402-v1 envelope:

{
  "x402Version": 1,
  "accepts": [
    {
      "scheme": "ap2-cards",
      "checkoutUrl": "https://checkout.stripe.com/pay/cs_...",
      "amount": { "value": 5, "currency": "USD" }
    },
    {
      "scheme": "x402-usdc",
      "network": "base",
      "payTo": "0xabc...",
      "amount": { "value": 0.05, "currency": "USDC" }
    },
    {
      "scheme": "stripe-topup",
      "topUpUrl": "https://checkout.stripe.com/pay/cs_...",
      "amount": { "value": 5, "currency": "USD" }
    }
  ],
  "error": "insufficient_funds",
  "message": "Wallet balance insufficient for this tool"
}

MCP clients see the same data wrapped in an MCP-compatible envelope:

{
  "isError": true,
  "content": [{ "type": "text", "text": "Payment required..." }],
  "_meta": {
    "payment_required": { /* the body above */ },
    "x-payment-required": "x402; version=1, ap2; version=0.2"
  }
}

Stripe Connect Express: where the money lives

Each merchant has a Stripe Connect Express account, created during onboarding at /merchants/new. When a customer tops up a wallet:

  1. Customer pays via the merchant's hosted Stripe Checkout URL.
  2. Funds land in the merchant's Stripe Connect account (not ShipSafe's).
  3. ShipSafe credits the customer's wallet in the ledger.
  4. Per-call debits draw down the ledger balance; settled funds remain in the merchant's Stripe account.

ShipSafe is the ledger and rails, not the custodian. Money sits with the merchant.

Note

Funds settle into the merchant's Stripe Connect Express account, never ShipSafe's. If ShipSafe disappears tomorrow, the merchant's money is still in their Stripe account. The wallet ledger is a routing layer, not a custody layer.

Wallet model

The wallets table is keyed by (merchant_id, customer_id), with balance_cents as the running balance:

Column Type Notes
merchant_id uuid Composite key
customer_id text Composite key
balance_cents integer Running balance after debits and top-ups

A wallet is keyed by (merchant_id, customer_id). Funds are isolated by merchant: a top-up against merchant A's Stripe account credits merchant A's wallet for that customer only. The same customer with merchant B has a separate wallet, separate balance.

balanceCents returned in ToolContext is the wallet balance after the debit.

Idempotency: source_ref dedup

Every charge writes a ledger_entries row with a source_ref. The default source_ref is ${toolName}:${customerId}:${requestId} where requestId is the MCP request id. If you pass idempotencyKey to pay.charge, that overrides the default.

If two charges arrive with the same source_ref, the backend returns the original chargeId and balance without double-debiting. This protects against:

  • Network retries from the MCP host.
  • Duplicate MCP calls when the agent re-sends a request.
  • Retries inside mcp-pay itself (one retry on 5xx, see below).

Failure modes and fallbacks

Insufficient funds

Backend returns 402 with error: "insufficient_funds". The wrapper converts to an MCP envelope and returns to the agent. Your handler does not run. Nothing is debited.

Network failure to ShipSafe backend

The wrapper retries once with a 150ms backoff. If both attempts fail:

  • pay.charge (used inside pay.tool) throws.
  • onChargeError (if configured) is called with the error.
  • The handler does not run. No debit.

5xx from ShipSafe backend

Same as network failure. One retry, then throw.

Handler throws after successful charge

The wrapper calls /api/mcp-pay/refund with { chargeId, reason: 'handler_error' }, then re-throws the original handler error. The customer is not charged for a failed handler.

Important

If the refund call itself fails, onChargeError is invoked with the refund error. The original handler error still propagates. The refund attempt is best-effort; for hard guarantees, run a reconciliation job against the ledger.

Malformed responses

If the backend returns 200 but with missing fields, or 402 with a non-x402-v1 body, the wrapper throws a ChargeError. Treat this as a backend bug, not a payment failure.

Customer id missing

If the wrapper cannot extract a customerId from mcpContext, it throws MissingCustomerIdError before making any network call. No debit, no risk. Fix by setting _meta.customerId upstream or passing customerIdFrom: 'header' / a custom function.

What lives where

Concern Location
Per-tool price ShipSafe dashboard (merchant settings)
API key Your env (MCP_PAY_API_KEY)
Customer wallet balance ShipSafe Postgres (wallets table)
Ledger entries (charges, refunds, top-ups) ShipSafe Postgres (ledger_entries)
Settled funds Merchant's Stripe Connect Express account
Customer identity Your application (you tell mcp-pay the id)

The package itself is stateless. All state lives server-side in the ShipSafe backend or in your merchant Stripe account.