Skip to main content

Table of Contents

Installation

Install the SDK packages plus the official MCP SDK and Zod:
@solvapay/mcp is the only package that imports @modelcontextprotocol/*. The framework-neutral contracts (bearer helpers, paywall envelope) live in @solvapay/mcp-core, which is installed transitively.

Basic Setup

1. Initialize SolvaPay

2. Create the MCP server

createSolvaPayMcpServer returns a fully wired McpServer. It auto-registers the transport and intent tools (account, activate_plan), the UI resource referenced by the account viewer, slash-command prompts (/upgrade, /manage_account, /topup, /activate_plan), the narrated docs://solvapay/overview.md resource, and the MCP Apps CSP baseline. You only supply your product and where the UI bundle lives.
You rarely need to hand-roll a new McpServer(), ListToolsRequestSchema handler, or JSON-Schema Tool[] array — the factory handles all of it. If you genuinely need that level of control, see the low-level adapter.

Protecting Tools

Register your paywall-protected tools inside the additionalTools hook. Each registerPayable call takes a Zod schema and a handler that returns the response envelope via ctx.respond(...). The SDK runs the paywall pre-check before your handler — if the customer is out of credits or needs to upgrade, your handler never runs and SolvaPay returns the gate automatically.
The schema shape flows through to the handler’s args, so query is typed as string without a second declaration. Register as many tools as you like inside the same hook — they all protect against the server’s productRef unless you pass a per-tool product.

Annotations are required

Every tool advertises MCP annotations. registerPayable defaults to { readOnlyHint: true, openWorldHint: true } — the right shape for a paywalled data tool that reads from your backend. Override for state-mutating tools:

Usage is metered for you

registerPayable records the usage event itself — you do not call trackUsage from the handler. Two things come free with that:
  • The tool name is recorded on the event, so consumption is attributable per tool in the Console rather than being one undifferentiated count.
  • Retries do not double-count. The paywall mints a request id during its pre-check and reuses it as the idempotencyKey on the usage event, so a retried tool call books once. Concurrent checkLimits calls for the same customer, product, and meter are coalesced into one backend round-trip, and each caller claims a distinct unit of the shared allowance.

Authentication

The recommended path is the OAuth bridge. It serves the RFC 9728 discovery endpoints, proxies the OAuth flow to SolvaPay, and validates the bearer token on /mcp. Once mounted, the SDK reads the customer reference from extra.authInfo.extra.customer_ref automatically — your handlers never parse tokens or read a customer_ref argument.

Node / Express

Edge / fetch runtimes

For Deno, Supabase Edge, Cloudflare Workers, Bun, or Next.js edge, use the turnkey fetch factory from @solvapay/mcp/fetch. It bundles the OAuth bridge, transport, and server into one (Request) => Response handler:

Advanced: custom customer-ref extraction

If your server validates tokens itself, override getCustomerRef on a tool and decode claims with the bearer helpers from @solvapay/mcp-core. The validated token is available on extra.authInfo.token. These helpers only decode claims (customerRef, customer_ref, sub) — they do not verify signatures, so call them after token validation (for example against /v1/customer/auth/userinfo). Fail closed; never substitute a fallback identity such as anonymous.

Response Format

Handlers return the ctx.respond(data, options?) envelope — never a raw object and never a hand-built content: [{ type: 'text', ... }]. The envelope drives the three SolvaPay response modes:
  • Silentctx.respond(data). The merchant’s data is the hero. No iframe, no upsell. This is the 90% path for a paying customer.
  • Nudgectx.respond(data, { nudge }). Data is returned and something is worth flagging (low balance, cycle ending). The nudge message is appended to content[0].text as a plain-text suffix that names the recovery intent tool. Never blocks.
  • Gate — fired automatically by the paywall pre-check when the customer can’t be served. The transport emits a text-only narration naming `account` with the appropriate view (or `activate_plan` when a planRef is known) with the machine-readable gate on structuredContent. No iframe opens for a gate — the model reads the narration and calls the recovery tool, which mounts the UI.
options carries:
  • text — override content[0].text (use this to give the host a render instruction or a one-line summary instead of the SDK’s JSON.stringify(data) default).
  • nudge — the inline upsell suffix shown above.
  • dataInText — append the serialized payload as a trailing text block. Defaults to true. Set false only when the payload is large enough that duplicating it is not worth the tokens.
  • units — reserved for V1.1 variable billing. V1 accepts the field for forward-compatible code but bills a fixed one unit per call.
The handler ctx also exposes ctx.customer (balance, remaining, withinLimits, plan, and .fresh() for a non-cached round-trip) and ctx.product so you can make your own nudge decisions without building any UI.

Every response has to work without an iframe

Hosts disagree about which field reaches the model: some read only content, some prefer structuredContent and drop text blocks. So every lane is independently complete. content[0].text names the plan, the remaining included usage, the credit balance, the reset date, the recovery tool, and a pasteable https checkout URL — never "see the panel". structuredContent still carries the bootstrap payload and the gate for the widget and for programmatic consumers. The account viewer takes a mode argument for this. mode: 'auto' (the default) returns the self-sufficient text summary and opens the iframe on UI-capable hosts. mode: 'text' strips the iframe; mode: 'ui' returns a one-line placeholder that still carries the checkout URL.

Declaring structured output

registerPayable never derives an outputSchema for you — declaring one turns a nicety into a spec MUST, because the server then has to return conforming structuredContent on every call. Pass outputSchema explicitly when you want it, and the SDK unions your schema with the paywall gate shape so a gated call still validates.

Plan Activation

Plan activation and upgrades are handled by the built-in intent tools that createSolvaPayMcpServer registers — you rarely implement them by hand. The account viewer (with optional view: 'checkout' | 'account' | 'topup') and activate_plan already map to the underlying flows:
  • Free plan — activates immediately; the customer can start calling paid tools.
  • Usage-based plan — topup-first: a customer with credits activates immediately, while a zero-balance customer gets topup_required and is routed through the account viewer with view: "topup". The plan activates as part of the successful top-up.
  • Recurring / hybrid plan — returns a hosted checkoutUrl when the customer has no balance and no card on file, which the agent surfaces as a checkout link.
Plans are managed on the product in the SolvaPay Console. Customers select a plan during activation and the SDK resolves the correct plan from their purchase automatically.

Error Handling

Paywall gate outcomes are not exceptions. The paywall pre-check inside registerPayable resolves a gate into a normal tool result (isError: false, a narration in content[0].text, and the gate on structuredContent) before your handler runs — there is nothing to try/catch for the happy path. Anything your handler throws (other than an explicit ctx.gate(...)) surfaces as a genuine tool-level error. To trigger a paywall yourself mid-handler — rare, since the pre-check normally fires first — call ctx.gate(reason?). Handler execution stops and the adapter routes the gate through the same text-only channel:

Complete Example

A compact, copy-paste-safe Express server mirroring examples/mcp-checkout-app: the OAuth bridge, one registerPayable tool, and the streamable HTTP transport.

Stateless and text-only deployments

  • Stateless edge runtimes — use createSolvaPayMcpFetch({ ..., responseMode: 'json' }) from @solvapay/mcp/fetch so each request returns a single JSON body (required on Workers / Supabase Edge).
  • Text-only hosts — pass hideToolsByAudience: ['ui'] to keep the LLM-facing tools/list narrow to the two intent tools (account, activate_plan) plus your own data tools, while leaving the UI transport tools callable from the SolvaPay iframe. ChatGPT-originated tools/list requests are auto-detected and still receive the full catalog.

Low-level Adapter (escape hatch)

Prefer createSolvaPayMcpServer + registerPayable. Reach for the low-level adapter only when you maintain your own McpServer wiring and can’t adopt the factory. solvaPay.payable({ product }).mcp(fn) wraps a single business-logic function with the paywall and returns an MCP tool result. When you want full control over the gate response shape, call solvaPay.paywall.decide(...) and format the gate with paywallToolResult from @solvapay/mcp-core:
Legacy consumers that still try/catch a PaywallError keep working — it is exported from @solvapay/server as a compat shim:

Next Steps