payable

MCP Adapter

@akira-io/payable/mcp exposes the Payable facade as a Model Context Protocol server, so an AI client (Claude Desktop, Claude Code, or any MCP host) can read and operate billing state through tools, resources, and prompts. It follows the same factory pattern as the HTTP adapters: createPayableMcpServer(payable, options).

Purpose

Turn billing queries, resources, and builders into MCP tools. The adapter contains no business logic: every tool is a thin call into the facade. A payable-mcp bin makes it runnable as a standalone server over stdio or streamable HTTP.

Factory

import { createPayableMcpServer } from '@akira-io/payable/mcp';

const server = createPayableMcpServer(payable, options);
interface McpPayableOptions {
  serverInfo?: { name?: string; version?: string };
  defaultProvider?: string;
  defaultTenantId?: string | null;
  allowTenantOverride?: boolean;
  policy?: McpPolicy;
}

interface McpPolicy {
  readOnly?: boolean; // default false
  allowMoneyMovement?: boolean; // default false
  requireAuthorization?: boolean; // default false
  enabledTools?: string[]; // default: every tool the other flags permit
  authorization?: (toolName: string, args: Record<string, unknown>) => AuthorizationContext;
}

@modelcontextprotocol/sdk is an optional peer dependency; install it to use this adapter.

Tools

General tools accept an optional tenantId and provider. Canonical tools do not accept a provider, and canonical subscription price migration tools require a non-empty tenant. Money amounts are minor units, passed as { amount, currency } and converted to the Money value object. List tools that accept a limit cap it at MAX_LIST_LIMIT = 100 (src/presentation/mcp/schemas.ts).

ToolKindBacking call
providers_listreadpayable.providers().names()
customer_getreadpayable.customers().get(billable)
customer_syncmutatepayable.customers(provider).sync(billable)
subscriptions_listreadper-billable or global payable.subscriptions()
subscription_getreadpayable.customer().subscription(name).get()
payments_listreadper-billable or global payable.payments()
invoices_listreadpayable.customer().invoices(limit)
invoice_pdfreadpayable.invoices().downloadPdf(id)
refunds_listreadpayable.refunds().list(paymentId)
audit_logs_queryreadpayable.auditLogs(tenantId).run(filter)
webhooks_listreadpayable.webhookEvents(tenantId).list(filter)
webhook_getreadpayable.webhookEvents(tenantId).get(id)
product_getreadpayable.providerCatalog().products.retrieve(id)
products_listreadpayable.providerCatalog().products.list({ limit, cursor, active })
price_getreadpayable.providerCatalog().prices.retrieve(id)
prices_listreadpayable.providerCatalog().prices.list({ limit, cursor, active, providerProductId })
canonical_customer_getreadpayable.customers().find(id)
canonical_customers_listreadpayable.customers().list(filters)
canonical_product_getreadpayable.products().retrieve(id)
canonical_products_listreadpayable.products().list(filters)
canonical_price_getreadpayable.prices().retrieve(id)
canonical_prices_listreadpayable.prices().list(filters)
canonical_subscription_getreadpayable.canonicalSubscriptions().retrieve(id)
canonical_subscriptions_listreadpayable.canonicalSubscriptions().list(filters)
canonical_payment_getreadpayable.storedPayments().retrieve(id)
canonical_payments_listreadpayable.storedPayments().list(filters)
canonical_invoices_listreadpayable.canonicalInvoices().list(filters)
canonical_invoice_getreadpayable.canonicalInvoices().retrieve(id)
canonical_subscription_price_migration_createmutatepayable.subscriptionPriceMigrations().preview(input)
canonical_subscription_price_migrations_listreadpayable.subscriptionPriceMigrations().list(input)
canonical_subscription_price_migration_getreadpayable.subscriptionPriceMigrations().retrieve(id)
canonical_subscription_price_migration_approvemutatepayable.subscriptionPriceMigrations().approve(id, input)
canonical_subscription_price_migration_cancelmutatepayable.subscriptionPriceMigrations().cancel(id, input)
canonical_subscription_price_migration_retrymutatepayable.subscriptionPriceMigrations().retry(id, input)
product_createmutatepayable.providerCatalog().products.create(...)
product_updatemutatepayable.providerCatalog().products.update(...)
product_activatemutatepayable.providerCatalog().products.activate(id)
product_archivemutatepayable.providerCatalog().products.archive(id)
price_createmutatepayable.providerCatalog().prices.create(...)
price_activatemutatepayable.providerCatalog().prices.activate(id)
price_archivemutatepayable.providerCatalog().prices.archive(id)
subscription_createmutatesubscription builder
subscription_cancelmutatesubscription(name).cancel(...)
subscription_cancel_nowmutatesubscription(name).cancelNow(...)
subscription_resumemutatesubscription(name).resume(...)
subscription_swapmutatesubscription(name).swap(...)
subscription_update_quantitymutatesubscription(name).updateQuantity(...)
checkout_createmutatesubscription checkout builder
billing_portalmutatepayable.customer().billingPortal(returnUrl)
chargemoneypayable.customer().charge(...)
refundmoneypayable.refund(...)
webhook_replaymutatepayable.replayWebhook(id, context, provider)

Subscription swap and quantity tools require effectiveTiming, prorationPolicy, and paymentFailurePolicy; adapters do not invent provider defaults.

Canonical subscription price migration tools

The create tool accepts canonical subscriptionId, targetPriceId, optional canonical itemId, optional positive quantity, explicit timing and policies, tenantId, and idempotencyKey. scheduled requires an RFC 3339 effectiveAt; other timings reject it. The list supports bounded limit, opaque cursor, canonical subscriptionId, and status filters. Every schema is strict and rejects unknown keys.

Create, approve, cancel, and retry each require a durable operation-specific idempotencyKey. All six tools require the authorization callback to return an allowed actor whose tenantId matches the resolved tenant, even when global requireAuthorization is false. Responses contain only the allow-listed canonical DTO. Provider identifiers, execution ownership, request hashes, internal evidence, and provider diagnostics are not returned.

There is no MCP execute, due-page, scheduler, worker, or queue tool. A host worker uses the core resource for due execution. reconciliation_required is terminal for automatic work and must be resolved explicitly before a new migration is attempted.

Catalog list tools return { data, nextCursor }, treat cursors as opaque, default to active entries, and accept limits from 1 through 100. prices_list also accepts providerProductId. MCP exposes activation and archival instead of product or price delete tools. Changing price monetary terms requires creating a replacement price.

Canonical list tools return { items, nextCursor, hasMore }, default to 25 items, and accept at most 100. They use tenantId, never accept or resolve a provider, and query only local storage. Binding metadata is opt-in for customers, products, prices, and subscriptions. The existing products_list and prices_list tools remain provider-native. The existing subscriptions_list and payments_list tools retain their array-returning compatibility behavior.

Catalog idempotency

Every catalog mutation tool accepts an optional idempotencyKey. The adapter validates the caller key and forwards it through CatalogMutationOptions after the policy authorization callback runs.

{
  "name": "product_create",
  "arguments": {
    "name": "Pro",
    "provider": "stripe-primary",
    "tenantId": "tenant-acme",
    "idempotencyKey": "catalog-product-pro-v1"
  }
}

Reuse the key only for the same tenant, provider, catalog operation, and arguments. Invalid keys return INVALID_IDEMPOTENCY_KEY in the structured tool error. A provider without native catalog idempotency requires an engine store; otherwise the tool returns CATALOG_IDEMPOTENCY_STORAGE_REQUIRED. After an ambiguous non-native provider failure, reconcile the remote entity before responding to IDEMPOTENCY_RECONCILIATION_REQUIRED. See Idempotency for the complete contract.

Resources and prompts

  • Resource payable://schema/entities returns entity field names and status enums.
  • Resource payable://config/providers returns the configured provider names.
  • Prompt diagnose_subscription guides an investigation of a subscription and its recent activity.

Policy and tool gating

Each tool is registered with a kind - read, mutate, or money (see the Kind column above). At registration the adapter resolves the policy and runs isToolEnabled(name, kind, policy) per tool; a tool is skipped entirely when it returns false. The checks apply in this order (source: src/presentation/mcp/policy.ts):

  1. enabledTools - when set, a tool whose name is not in the allow-list is dropped regardless of kind.
  2. readOnly - when true, any tool whose kind is not read is dropped. This is what hides every mutating and money tool.
  3. allowMoneyMovement - a tool of kind money (charge, refund) is dropped unless this is true.
  4. requireAuthorization - when true, any non-read tool is dropped unless policy.authorization is a function.

readOnly and allowMoneyMovement are orthogonal: readOnly gates the read-vs-everything-else split, while allowMoneyMovement gates only the money group. With readOnly: false, allowMoneyMovement: false (the defaults) the read and mutate groups are exposed but the money group is not; setting allowMoneyMovement: true adds charge and refund on top. readOnly: true collapses the surface to the read group no matter what allowMoneyMovement says.

policy.authorization is a function (toolName, args) => AuthorizationContext. It must derive its result from the trusted MCP host identity, because Payable does not authenticate the MCP caller. For each catalog mutation, policy.authorization runs once. MCP forwards the returned object unchanged in CatalogMutationOptions; the core resource makes the final authorization decision. A denied catalog context returns AUTHORIZATION_DENIED before capability validation or provider calls, even when global authorization is disabled. When requireAuthorization is set but no authorization function is given, the non-read tools are not registered at all.

When tenancy is enabled, every tool requires a tenantId (the facade throws TENANT_REQUIRED).

Running the bin

The payable-mcp bin is the only place that reads a config file, preserving the core principle that the library never reads the environment. The config module composes a Payable instance:

// payable.config.ts
import { createPayable } from '@akira-io/payable';

const payable = createPayable({ providers: { stripe: stripeProvider }, storage });

export default { payable, mcp: { policy: { allowMoneyMovement: false } } };

stdio (spawned by an MCP host):

payable-mcp --config ./payable.config.ts

Claude Desktop / Claude Code configuration:

{
  "mcpServers": {
    "payable": {
      "command": "payable-mcp",
      "args": ["--config", "./payable.config.ts"]
    }
  }
}

Streamable HTTP:

payable-mcp --config ./payable.config.ts --http 127.0.0.1:3333

The HTTP transport is stateless (JSON responses). Set PAYABLE_MCP_TOKEN to require a Authorization: Bearer <token> header. The transport applies no TLS, rate limiting, or OAuth; terminate TLS and add network controls at your edge.

DNS-rebinding and origin protection

serveHttp enables DNS-rebinding protection by default (enableDnsRebindingProtection ?? true), which validates the Host and Origin headers on each request (source: src/presentation/mcp/transports/http.ts):

  • allowedHosts defaults, per request, to 127.0.0.1:<port>, localhost:<port>, and [::1]:<port> (the local port the connection arrived on). Pass allowedHosts to allow other hostnames when fronting the server with a proxy.
  • allowedOrigins restricts the accepted Origin header; pass it when browser clients connect.
  • Set enableDnsRebindingProtection: false to disable the host/origin checks (only do this behind a trusted proxy).

Body-size cap

Requests are bounded by maxBodyBytes (default 1 MiB). A request whose Content-Length exceeds the cap, or whose streamed body grows past it, is rejected with HTTP 413.

Embedding the server

import { createPayableMcpServer, serveStdio, serveHttp } from '@akira-io/payable/mcp';

await serveStdio(createPayableMcpServer(payable, { policy: { readOnly: true } }));

await serveHttp(() => createPayableMcpServer(payable), { port: 3333 });

serveHttp takes a factory because each request gets its own server and transport.