Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.solvapay.com/llms.txt

Use this file to discover all available pages before exploring further.

An MCP App is a UI resource that an MCP host loads inside a sandboxed iframe. Host sandboxes typically block direct HTTP calls to arbitrary backends, so the UI cannot hit your API the way a normal React app would. The TypeScript SDK ships a dedicated adapter for this environment. createMcpAppAdapter returns a SolvaPayTransport that tunnels every data call through app.callServerTool instead of HTTP. Mount it on SolvaPayProvider and every hook (usePurchase, useMerchant, <CurrentPlanCard>, <LaunchCustomerPortalButton>, etc.) works unchanged.

Prerequisites

  • An MCP host such as basic-host
  • A SolvaPay product with at least one active plan
  • An MCP server that implements the SolvaPay tool surface — see MCP Server integration for the server-side paywall patterns
  • @solvapay/react and @modelcontextprotocol/ext-apps installed in your MCP App bundle

Install

pnpm add @solvapay/react @modelcontextprotocol/ext-apps

Quick start

Wire the adapter into SolvaPayProvider and you’re done — every SDK hook routes through the MCP transport.
import { App } from '@modelcontextprotocol/ext-apps'
import { SolvaPayProvider, CurrentPlanCard } from '@solvapay/react'
import { createMcpAppAdapter } from '@solvapay/react/mcp'
import '@solvapay/react/styles.css'

const app = new App({ name: 'my-app', version: '1.0.0' })
const transport = createMcpAppAdapter(app)

export function Root() {
  return (
    <SolvaPayProvider config={{ transport }}>
      <CurrentPlanCard />
    </SolvaPayProvider>
  )
}
app.connect() still has to run once before the provider mounts — do it in a top-level bootstrap effect alongside whatever host-context handling you need.

Tool contract

The adapter maps each transport method to a single MCP tool name. Export MCP_TOOL_NAMES in your server so the two stay in lockstep.
Transport methodMCP tool nameReturns
checkPurchasecheck_purchase{ customerRef, email, name, purchases: [...] }
createCheckoutSessioncreate_checkout_session{ sessionId, checkoutUrl }
createCustomerSessioncreate_customer_session{ sessionId, customerUrl }
getPaymentMethodget_payment_method{ kind: 'card', brand, last4, expMonth, expYear } | { kind: 'none' }
getMerchantget_merchantMerchant
getProductget_productProduct
listPlanslist_plansPlan[]
getBalanceget_customer_balance{ credits, displayCurrency, creditsPerMinorUnit, displayExchangeRate }
createPaymentcreate_payment_intentPaymentIntentResult
processPaymentprocess_paymentProcessPaymentResult
createTopupPaymentcreate_topup_payment_intentTopupPaymentResult
activatePlanactivate_planActivatePlanResult
cancelRenewalcancel_renewalCancelResult
reactivateRenewalreactivate_renewalReactivateResult
Your server only needs to implement the tools the UI actually uses. Unimplemented tools surface as a thrown error from the adapter — catch and feature-detect in your component.

Server side

On the server, register each tool with the canonical name so the client adapter can find it. Import the constants so you never hand-type a string.
import { registerAppTool } from '@modelcontextprotocol/ext-apps/server'
import { MCP_TOOL_NAMES } from '@solvapay/mcp'
import { checkPurchaseCore, createCheckoutSessionCore } from '@solvapay/server'

registerAppTool(
  server,
  MCP_TOOL_NAMES.checkPurchase,
  { description: 'Fetch the active purchase for the authenticated customer.', inputSchema: {} },
  async (_args, extra) => {
    const result = await checkPurchaseCore(buildRequest(extra), { solvaPay })
    return toolResult(result)
  },
)

registerAppTool(
  server,
  MCP_TOOL_NAMES.createCheckoutSession,
  { description: 'Mint a hosted checkout URL.', inputSchema: { productRef: z.string().optional() } },
  async (args, extra) => {
    const result = await createCheckoutSessionCore(buildRequest(extra, { method: 'POST' }), args, { solvaPay })
    return toolResult(result)
  },
)
Pair this with createMcpOAuthBridge from @solvapay/mcp/fetch (or /express) to surface customer_ref on extra.authInfo — the core helpers read it from the synthesised request headers. For the full batteries-included setup use createSolvaPayMcpServer from @solvapay/mcp. A complete working server lives at examples/mcp-checkout-app/src/server.ts.

Authentication

Because the real identity lives server-side on the OAuth bridge’s customer_ref, the provider only needs a sentinel token to flip isAuthenticated true. Supply a lightweight auth adapter alongside the transport:
const mcpAuthAdapter = {
  getToken: async () => 'mcp-session',
  getUserId: async () => null,
}

<SolvaPayProvider config={{ auth: { adapter: mcpAuthAdapter }, transport }}>
  <CheckoutPage />
</SolvaPayProvider>
Without this, the provider short-circuits the fetch pipeline and the transport never runs.

Hosted checkout from inside the iframe

Open checkout in a new browser tab. Pre-fetch the session URL on mount and render a real <a target="_blank"> anchor — scripted window.open after an async round-trip is blocked by typical host sandboxes, but anchor clicks are permitted.
import { useEffect, useState } from 'react'
import { useSolvaPay } from '@solvapay/react'

function UpgradeButton({ productRef }: { productRef: string }) {
  const { _config } = useSolvaPay()
  const [href, setHref] = useState<string | null>(null)

  useEffect(() => {
    if (!_config?.transport) return
    _config.transport
      .createCheckoutSession({ productRef })
      .then(({ checkoutUrl }) => setHref(checkoutUrl))
      .catch(err => console.error('checkout session failed', err))
  }, [_config, productRef])

  if (!href) return <button disabled>Loading…</button>
  return (
    <a href={href} target="_blank" rel="noopener noreferrer">
      <button>Upgrade</button>
    </a>
  )
}
On focus / visibilitychange, call refetch() from usePurchase so returning from the hosted tab flips the card to its new state automatically.

Account management

Once a customer has paid, drop <CurrentPlanCard /> into the tree and the SDK does the rest — plan name, next-billing line, payment-method summary, Update card and Cancel plan actions. The card returns null when there is no active purchase, so you can render it unconditionally.
import { CurrentPlanCard, LaunchCustomerPortalButton } from '@solvapay/react'

function Account() {
  return (
    <>
      <CurrentPlanCard />
      <LaunchCustomerPortalButton>Manage billing</LaunchCustomerPortalButton>
    </>
  )
}
  • <CurrentPlanCard /> renders the active plan, mirrored card brand/last4, and inline Update card / Cancel plan actions.
  • <LaunchCustomerPortalButton /> opens the hosted customer portal in a new tab. It pre-fetches createCustomerSession on hover so the portal link is ready the moment the user clicks (anchor-click semantics are preserved for sandboxed hosts).
  • usePaymentMethod() exposes the mirrored card under { paymentMethod, loading, refetch } when you need to build a custom account view. The card brand and last4 come from the payment_intent.succeeded webhook persisted on the Customer — no card-element iframe required inside the MCP App sandbox.

Text-only paywall

The MCP App surface uses SolvaPay’s text-only paywall. payable.mcp emits a plain-text Purchase required response — no embedded UI meta, no structured checkout payload — so the host model can read the copy, call create_checkout_session, and surface the returned URL however it likes. There is no McpPaywallView / McpNudgeView / McpUpsellStrip component anymore; render checkout through <PaymentForm> or the hosted URL instead.

Complete example

A full working example — server, client, OAuth bridge, polling, and the five-state purchase flow — lives in the SDK repo at examples/mcp-checkout-app. Clone it, set SOLVAPAY_SECRET_KEY and SOLVAPAY_PRODUCT_REF, point basic-host at http://localhost:3006/mcp, and you have an end-to-end paywalled MCP App running locally.

ChatGPT host caveats

ChatGPT’s Custom Connector is the most permissive MCP host for SolvaPay’s iframe surface, but two of its behaviours are easy to trip over.

Iframe tools/call must appear in tools/list

ChatGPT’s gateway re-validates every iframe-initiated tools/call against the connector’s cached tools/list catalog. If a tool isn’t in the catalog, the gateway returns MCP error -32000: MCP Resource not found without forwarding the request to your server. The iframe sees the error as Top-up initialization failed (or the equivalent message for the active flow) and the worker tail shows nothing. This collides with hideToolsByAudience: ['ui'] — the SDK option that hides the seven UI transport tools (create_payment_intent, create_topup_payment_intent, process_payment, create_checkout_session, create_customer_session, cancel_renewal, reactivate_renewal) from tools/list so the model only sees the four intent tools (upgrade, manage_account, activate_plan, topup). On every other host the iframe can still invoke the hidden tools because the host proxies postMessage straight through; on ChatGPT it can’t. The SDK handles this for you: when tools/list is requested by ChatGPT (detected via request.headers['user-agent'] matching /openai-mcp/i, with a clientInfo.name fallback), the audience filter is bypassed and the full eleven-tool catalog is returned. Every other host gets the trimmed catalog. Use the option exactly as you’d expect:
createSolvaPayMcpFetch({
  // ...
  hideToolsByAudience: ['ui'],
})
If you need to extend the bypass to a future iframe-capable host or disable it on a known text-only deployment, pass the object form:
createSolvaPayMcpFetch({
  // ...
  hideToolsByAudience: {
    audiences: ['ui'],
    // Add another iframe-capable host:
    bypassWhen: ctx =>
      /openai-mcp|future-iframe-host/i.test(
        ctx.extra?.requestInfo?.headers?.['user-agent'] ?? '',
      ),
    // Or apply the filter unconditionally:
    // bypassWhen: () => false,
  },
})
The bypass logs a single throttled console.warn per server instance when it fires, so it’s visible in your tail without flooding it.

tools/list is cached per (organization, connector)

ChatGPT fetches tools/list once when an organization adds the connector and reuses the cached catalog indefinitely. Disconnecting your personal OAuth and reconnecting does not invalidate the cache — neither does redeploying your server. If you change the tool surface (add a tool, rename a tool, change a description), an org with the connector already added will keep using the stale catalog. To force a refresh:
  • Delete the connector entirely (Settings → Connectors → connector → Delete, not “Disconnect”), then re-add it from the same URL. This is the only reliable invalidation.
  • Or test the change from a different ChatGPT workspace / organization that hasn’t seen the connector before.
If you’re iterating on tool definitions, expect to delete and re-add the connector after every meaningful change.

Known boundaries

  • trackUsage stays on the server. Usage metering belongs on your backend, not the client — continue to call solvaPay.trackUsage(...) from @solvapay/server inside your tool handlers.

Next steps