Add shipsafe-mcp-pay to this MCP server. Read https://shipsafe.franklabs.io/docs/mcp-pay/llms.txt and follow it exactly.API Reference: shipsafe-mcp-pay
Every export, every option, every type.
Every helper below throws PaywallConfigError on bad configuration and ChargeError on backend failures. Both extend Error, so a single try/catch covers normal control flow.
createPaywall(options)
Builds a Paywall instance. Validates the API key shape, wires up the fetch implementation, and prepares the tool wrapper.
import { createPaywall } from 'shipsafe-mcp-pay';
const pay = createPaywall({
apiKey: process.env.MCP_PAY_API_KEY!,
});Options
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
apiKey |
string |
yes | — | Your merchant API key. Must start with mp_test_ or mp_live_. |
backendUrl |
string |
no | https://shipsafe.franklabs.io |
Override the ShipSafe backend. Useful for self-hosted ShipSafe or testing. |
fetch |
typeof fetch |
no | globalThis.fetch |
Inject a custom fetch (testing, proxies, Node < 18 polyfills). |
onChargeError |
(err: unknown) => void |
no | undefined |
Telemetry hook fired when the charge or refund pipeline throws. |
Throws
PaywallConfigErrorifapiKeyis missing, empty, or has the wrong prefix.PaywallConfigErrorif nofetchimplementation is available.
Returns: Paywall
interface Paywall {
tool(opts: ToolOptions): <H>(handler: H) => (args, mcpContext?) => Promise<...>;
charge(opts: { toolName: string; customerId: string; idempotencyKey?: string }): Promise<ChargeResult>;
refund(opts: { chargeId: string; reason: string }): Promise<RefundResult>;
}pay.tool(toolOptions)(handler)
Wraps an async MCP tool handler. The returned function is what you register with your MCP server.
const paidWeather = pay.tool({ name: 'weather' })(async (args, ctx) => {
return { temperature: 72, city: args.city };
});ToolOptions
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string |
yes | — | Tool name. Must match a tool registered in your ShipSafe dashboard. The dashboard holds the per-call price. |
customerIdFrom |
'context' | 'header' | (req: any) => string |
no | 'context' |
How to extract the customer id from the incoming MCP request. |
customerIdFrom values
| Value | Reads from |
|---|---|
'context' (default) |
mcpContext._meta.customerId or mcpContext.customerId |
'header' |
mcpContext._meta.headers['x-customer-id'] or mcpContext.headers['x-customer-id'] |
| Function | Arbitrary extractor: (req) => string. Throw or return empty string to signal "no customer". |
Handler signature
(args: TArgs, ctx: ToolContext) => Promise<TResult>args is whatever the MCP client sent in params.arguments. ctx is the post-debit context (see below).
Returned wrapper
(args: any, mcpContext?: any) => Promise<TResult | McpPaymentRequiredEnvelope>If the wallet has funds, returns the handler's result. If not, returns an MCP-shaped payment-required envelope. Always check result?._meta?.payment_required before treating the value as your handler's return type.
Throws
PaywallConfigErrorifnameis missing or empty.MissingCustomerIdErrorif the customer id cannot be extracted.ChargeError(subclass ofError) if the backend returns a 4xx (non-402) or 5xx after one retry.- The handler's own error, if it throws. The wrapper attempts a refund first.
pay.charge(charge)
Direct charge. Useful for tests, custom transports, or non-MCP code paths.
const result = await pay.charge({
toolName: 'weather',
customerId: 'cus_abc',
idempotencyKey: 'req_123',
});
if (result.ok) {
console.log(result.chargeId, result.balanceCents);
} else {
console.log(result.status, result.body.accepts);
}Options
| Field | Type | Required | Description |
|---|---|---|---|
toolName |
string |
yes | Tool name registered in the dashboard. |
customerId |
string |
yes | Customer id to debit. |
idempotencyKey |
string |
no | Custom dedup key. Same key + same toolName + same customerId returns the original chargeId without double-debiting. |
Returns: ChargeResult
Discriminated union on ok:
type ChargeResult =
| { ok: true; customerId: string; walletId: string; balanceCents: number; chargeId: string }
| { ok: false; status: 402; body: PaymentRequiredBody; headers: Record<string, string> };Throws
ChargeError on 4xx (non-402), 5xx after one retry, or malformed responses.
pay.refund(refund)
Reverse a previously successful charge.
await pay.refund({ chargeId: 'ch_1', reason: 'manual_refund' });Options
| Field | Type | Required | Description |
|---|---|---|---|
chargeId |
string |
yes | Ledger entry id from a successful charge. |
reason |
string |
yes | Free-text reason, stored on the refund ledger entry. Convention: handler_error, manual_refund, disputed, etc. |
Returns: RefundResult
type RefundResult = {
ok: true;
refundId: string;
chargeId: string;
balanceCents: number;
};Throws
ChargeError on any non-200 response or malformed body.
Note: the tool wrapper calls refund automatically when your handler throws. You only need to call this directly for manual reversals.
build402Response(input)
Lower-level helper. Build a 402 envelope yourself, for HTTP-only paywalls outside MCP.
import { build402Response } from 'shipsafe-mcp-pay';
const { status, headers, body } = build402Response({
priceCents: 25,
stripeCheckoutUrl: 'https://checkout.stripe.com/pay/cs_abc',
usdcPayTo: '0xabc...',
usdcNetwork: 'base',
});Input
| Field | Type | Required | Description |
|---|---|---|---|
priceCents |
number |
yes | Price in cents (USD). Used for the fiat amount; converted to USDC for the x402 scheme. |
stripeCheckoutUrl |
string |
no | Hosted Stripe Checkout URL. Set to enable stripe-topup and ap2-cards. |
usdcPayTo |
string |
no | USDC payout address. Set to enable x402-usdc. |
usdcNetwork |
'base' | 'base-sepolia' |
no | USDC network. Default 'base'. |
error |
'insufficient_funds' | 'payment_required' |
no | Error tag included in the body. |
message |
string |
no | Human-readable message included in the body. |
Returns
{ status: 402; headers: Record<string, string>; body: PaymentRequiredBody }Headers include X-Payment-Required: x402; version=1, ap2; version=0.2.
Types
ToolContext
The second argument to your handler, after a successful debit.
type ToolContext = {
customerId: string;
walletId: string;
balanceCents: number; // Wallet balance AFTER the debit
chargeId: string; // Ledger entry id, for manual refunds
};PaymentRequiredBody
The x402-v1 body returned on 402.
type PaymentRequiredBody = {
x402Version: 1;
accepts: PaymentMethodOption[];
error?: 'insufficient_funds' | 'payment_required';
message?: string;
};
type PaymentMethodOption =
| { scheme: 'stripe-topup'; topUpUrl: string; amount: { value: number; currency: 'USD' } }
| { scheme: 'x402-usdc'; network: 'base' | 'base-sepolia'; payTo: string; amount: { value: number; currency: 'USDC' } }
| { scheme: 'ap2-cards'; checkoutUrl: string; amount: { value: number; currency: 'USD' } };McpPaymentRequiredEnvelope
The MCP-compatible envelope returned by pay.tool wrappers on 402.
type McpPaymentRequiredEnvelope = {
isError: true;
content: Array<{ type: 'text'; text: string }>;
_meta: {
payment_required: PaymentRequiredBody;
'x-payment-required': string;
};
};ChargeResult, ChargeSuccess, ChargePaymentRequired, RefundResult
See src/types.ts for the full source.
Error classes
All error classes extend Error and are exported from the package.
| Class | When |
|---|---|
PaywallConfigError |
apiKey missing or wrong prefix; tool({ name }) missing; no fetch implementation. |
MissingCustomerIdError |
Could not extract a customer identifier from the MCP request. Fix by setting _meta.customerId upstream or passing customerIdFrom. |
ChargeError (internal, surfaced as Error) |
Backend returned 4xx (non-402) or 5xx after one retry, or returned a malformed body. Carries status and responseBody properties. |
Network errors and 5xx responses are retried once with a 150ms backoff before surfacing.
Environment variables
The package itself reads no env vars. Your code reads:
| Var | Required | Used for |
|---|---|---|
MCP_PAY_API_KEY |
yes | Passed to createPaywall({ apiKey }). |
MCP_PAY_BACKEND_URL |
no | Override the backend if self-hosting ShipSafe. Passed to createPaywall({ backendUrl }). |
Backend endpoints called
For reference, the package POSTs to:
| Endpoint | Body | Returns |
|---|---|---|
POST {backendUrl}/api/mcp-pay/charge |
{ toolName, customerId, idempotencyKey? } |
200 (ChargeSuccess) or 402 (PaymentRequiredBody) |
POST {backendUrl}/api/mcp-pay/refund |
{ chargeId, reason } |
200 (RefundResult) |
Both requests use Authorization: Bearer {apiKey} and User-Agent: shipsafe-mcp-pay.