> ## 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.

# MCP App integration guide

> Render SolvaPay checkout and account management inside an MCP host iframe using @solvapay/react and the MCP App adapter.

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 calls through `app.callServerTool` instead of HTTP, and the data the UI needs at mount arrives on the bootstrap payload rather than over the wire a second time. Mount the transport on `SolvaPayProvider` and every hook (`usePurchase`, `useMerchant`, `<CurrentPlanCard>`, `<LaunchCustomerPortalButton>`, etc.) works unchanged.

## Prerequisites

* An MCP host such as [`basic-host`](https://github.com/modelcontextprotocol/basic-host)
* A SolvaPay product with at least one active plan
* An MCP server that implements the SolvaPay tool surface — see [MCP Server integration](/sdks/typescript/guides/mcp) for the server-side paywall patterns
* `@solvapay/react` and `@modelcontextprotocol/ext-apps` installed in your MCP App bundle

## Install

```bash theme={null}
pnpm add @solvapay/react @modelcontextprotocol/ext-apps
```

## Quick start

`<McpApp>` is the turnkey path: it connects the app, applies the host theme, fetches the bootstrap payload, mounts `SolvaPayProvider` over the MCP transport, and routes the server-stamped `view` (`checkout` / `account` / `topup`) into the matching SolvaPay surface.

```tsx theme={null}
import { createRoot } from 'react-dom/client'
import {
  App,
  applyDocumentTheme,
  applyHostFonts,
  applyHostStyleVariables,
  type McpUiHostContext,
} from '@modelcontextprotocol/ext-apps'
import { McpApp, SOLVAPAY_MCP_APP_CAPABILITIES } from '@solvapay/react/mcp'
import '@solvapay/react/styles.css'
import '@solvapay/react/mcp/styles.css'

function applyContext(ctx: McpUiHostContext | undefined) {
  if (!ctx) return
  if (ctx.theme) applyDocumentTheme(ctx.theme)
  if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables)
  if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts)
}

const app = new App(
  { name: 'my-app', version: '1.0.0' },
  { availableDisplayModes: [...SOLVAPAY_MCP_APP_CAPABILITIES.availableDisplayModes] },
)

createRoot(document.getElementById('root')!).render(
  <McpApp app={app} applyContext={applyContext} />,
)
```

Pass `SOLVAPAY_MCP_APP_CAPABILITIES` as the second argument to `new App(...)` so the host only offers display modes this surface can handle — see [Display modes](#display-modes).

### Custom shell

To own the provider mount, wire the adapter yourself. Every SDK hook then routes through the MCP transport unchanged.

```tsx theme={null}
import { SolvaPayProvider, CurrentPlanCard } from '@solvapay/react'
import { createMcpAppAdapter } from '@solvapay/react/mcp'
import '@solvapay/react/styles.css'

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. `<McpApp>` does this for you.

## Tool contract

The adapter does **not** have a tool per hook. Everything the UI reads at mount — purchase, merchant, product, plans, payment method, balance, usage, and limits — arrives on the `account` viewer's bootstrap payload, and `seedMcpCaches` hydrates the provider caches from it. The adapter only calls a tool when the UI needs to write, or needs data that bootstrap does not carry.

| Transport method        | MCP tool name             | Args the adapter adds | Returns                       |
| ----------------------- | ------------------------- | --------------------- | ----------------------------- |
| `createPayment`         | `create_payment_intent`   | `purpose: 'plan'`     | `PaymentIntentResult`         |
| `createTopupPayment`    | `create_payment_intent`   | `purpose: 'topup'`    | `TopupPaymentResult`          |
| `processPayment`        | `process_payment`         | —                     | `ProcessPaymentResult`        |
| `attachBusinessDetails` | `attach_business_details` | —                     | `{ taxBreakdown }`            |
| `activatePlan`          | `activate_plan`           | —                     | `ActivatePlanResult`          |
| `cancelRenewal`         | `set_renewal`             | `enabled: false`      | `CancelResult`                |
| `reactivateRenewal`     | `set_renewal`             | `enabled: true`       | `ReactivateResult`            |
| `createCheckoutSession` | `create_hosted_session`   | `kind: 'checkout'`    | `{ sessionId, checkoutUrl }`  |
| `createCustomerSession` | `create_hosted_session`   | `kind: 'portal'`      | `{ sessionId, customerUrl }`  |
| `getHistory`            | `get_history`             | —                     | `{ charges, creditActivity }` |

`get_history` is the one read the adapter still makes: the bootstrap payload only carries the **active** purchase, so past charges and credit-ledger rows have to be fetched when the history section mounts.

Import `MCP_TOOL_NAMES` from `@solvapay/mcp-core` on both sides so a rename edits one file. Unimplemented tools surface as a thrown error from the adapter — catch and feature-detect in your component.

## Server side

`createSolvaPayMcpServer` registers all of these for you. Register them by hand only when you maintain your own `McpServer` wiring, and always use the canonical name from `MCP_TOOL_NAMES`.

```ts theme={null}
import { MCP_TOOL_NAMES, registerAppTool } from '@solvapay/mcp'
import {
  createCheckoutSessionCore,
  createCustomerSessionCore,
  getHistoryCore,
} from '@solvapay/server'

registerAppTool(
  server,
  MCP_TOOL_NAMES.createHostedSession,
  {
    description: 'Mint a hosted checkout or portal URL.',
    inputSchema: {
      kind: z.enum(['checkout', 'portal']),
      productRef: z.string().optional(),
    },
  },
  async (args, extra) => {
    const result =
      args.kind === 'portal'
        ? await createCustomerSessionCore(buildRequest(extra, { method: 'POST' }), { solvaPay })
        : await createCheckoutSessionCore(buildRequest(extra, { method: 'POST' }), args, { solvaPay })
    return toolResult(result)
  },
)

registerAppTool(
  server,
  MCP_TOOL_NAMES.getHistory,
  {
    description: 'Past charges and credit activity for the authenticated customer.',
    inputSchema: { productRef: z.string(), limit: z.number().int().positive().optional() },
  },
  async (args, extra) => {
    const result = await getHistoryCore(buildRequest(extra), 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`](https://github.com/solvapay/solvapay-sdk/blob/main/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:

```tsx theme={null}
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.

## Display modes

Display mode is host state, not routing. The host publishes `displayMode` and `availableDisplayModes` on its context; the SDK reads them and reacts. The **host owns the expand affordance** — the SDK never calls `requestDisplayMode`.

Advertise what the surface can handle when you construct the app:

```tsx theme={null}
import { SOLVAPAY_MCP_APP_CAPABILITIES } from '@solvapay/react/mcp'

const app = new App(
  { name: 'my-app', version: '1.0.0' },
  { availableDisplayModes: [...SOLVAPAY_MCP_APP_CAPABILITIES.availableDisplayModes] },
)
```

That advertises `inline` and `fullscreen` only — `pip` is excluded because checkout and account are not live sessions. Both modes render the same React tree:

* **`inline`** — a compact, content-height block centred at `36rem`.
* **`fullscreen`** — a hosted 1000px column with no SolvaPay header, since the host owns the window chrome. Below 1000px of measured host width it falls back to the inline stack. `safeAreaInsets` from the host context are applied as root padding.

`<McpApp>` wires this up. In a custom shell, read the state with `useDisplayMode()` (under `<McpDisplayModeProvider>`) or `readDisplayModeState(hostContext)`.

## Theming

The widget blends into the host canvas rather than painting its own surface. It reads the MCP Apps spec token names the host publishes (`--color-*`, `--font-*`, `--border-radius-*`) and uses `--solvapay-*` only for branding extras — apply the host context with `applyDocumentTheme` / `applyHostStyleVariables` / `applyHostFonts` and the rest follows.

The Stripe Payment Element is themed from those same live CSS variables, so card fields match the host's type and colour without a Stripe `appearance` object of your own. Set `color-scheme` on the widget document so native controls and the Stripe iframe agree on light or dark.

## Outbound links

**Never render a bare `<a target="_blank">` and never call `window.open()` directly.** Claude's iframe sandbox omits `allow-popups`, so both are dropped silently — the control looks alive and does nothing.

Every outbound link goes through one of two hooks, which prefer the host's `ui/open-link` when the host declares the `openLinks` capability and fall back to native navigation everywhere else. The capability is read at click time, not render time.

* **`useExternalLinkClick()`** — for anchors. Keep the real `href` on the element so the link is still meaningful to assistive tech and to hosts that do navigate.
* **`useOpenExternal()`** — for flows that resolve a URL after an `await`.

`<McpApp>` mounts the opener automatically. In a custom shell inside some other sandboxed host, mount `<ExternalLinkProvider>` yourself.

## Hosted checkout from inside the iframe

Open checkout in a new browser tab. Pre-fetch the session URL on mount and render an anchor whose click goes through `useExternalLinkClick`.

```tsx theme={null}
import { useEffect, useState } from 'react'
import { useExternalLinkClick, useSolvaPay } from '@solvapay/react'

function UpgradeButton({ productRef }: { productRef: string }) {
  const { _config } = useSolvaPay()
  const onLinkClick = useExternalLinkClick()
  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" onClick={onLinkClick}>
      Upgrade
    </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, plus **Update card** and **Cancel plan** actions. The card returns `null` when there is no active purchase, so you can render it unconditionally. The MCP `account` view (`view: "account"`) passes `hideUpdatePaymentButton` and `hideCancelButton` and pairs the card with a single **Manage account** customer-portal CTA — both flows run through the portal there.

```tsx theme={null}
import { CurrentPlanCard, LaunchCustomerPortalButton } from '@solvapay/react'

function Account() {
  return (
    <>
      <CurrentPlanCard />
      <LaunchCustomerPortalButton />
    </>
  )
}
```

* **`<CurrentPlanCard />`** renders the active plan, mirrored card brand/last4, and inline **Update card** / **Cancel plan** actions. The MCP `account` view hides both — card updates and cancellation route through the **Manage account** customer-portal CTA — and surfaces a one-line hint pointing at it.
* **`<LaunchCustomerPortalButton />`** opens the hosted customer portal in a new tab (default label: "Manage account"). The button renders enabled from first paint and fetches the portal URL in the background; multiple instances under the same provider share a single in-flight `createCustomerSession` round-trip. When the URL has resolved, click is a real `<a target="_blank">` navigation (sandbox-safe). On a cache-miss click the handler awaits the in-flight promise and falls back to `window.open`.
* **`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. A card also reports `reusable`, which tells a chargeable saved card apart from a card that is only on file — read it before offering an off-session action such as auto-recharge.
* **Buyer identity** renders as `<McpPayingAs>` inside the payment form. The MCP surfaces have no sidebar, so there is no separate identity card.

### Migrating from the sidebar layout

These MCP-only exports were removed with the sidebar. Turnkey `<McpApp>` integrators are unaffected; custom shells that imported them must migrate.

| Removed                                 | Replacement                                                                  |
| --------------------------------------- | ---------------------------------------------------------------------------- |
| `McpSellerDetailsCard`                  | Seller identity renders in the fullscreen account view's identity footer     |
| `McpCustomerDetailsCard`                | `<McpPayingAs>`, inside the payment form                                     |
| `McpAccountView` prop `hideDetailCards` | No longer needed — the cards are gone                                        |
| `McpLimitReached`                       | Gates are plain-text narrations; see [Text-only paywall](#text-only-paywall) |
| `CloseButton`                           | The host owns window chrome in fullscreen                                    |

## Usage and history

The bootstrap payload seeds `limits`, so `useLimits()` and `useUsage()` render remaining allowance on first paint without a second round-trip. When the backend measured a finite cap, `used` and `limit` come back on the limits result and are authoritative — do not reconstruct the cap as `used + remaining`, and do not read consumption off the purchase snapshot.

History is the exception to the seed-from-bootstrap rule: the bootstrap payload only carries the **active** purchase, so past charges and credit-ledger rows are fetched through `get_history` when the history section mounts. On the server, `getHistoryCore(request, { productRef, limit })` from `@solvapay/server` returns `{ charges, creditActivity }`; fetch-runtime servers can mount the `getHistory` handler from `@solvapay/server/fetch` at `GET /api/history` instead.

## Text-only paywall

The MCP App surface uses SolvaPay's **text-only paywall**. A `registerPayable` tool 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_hosted_session` with `kind: "checkout"`, 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`](https://github.com/solvapay/solvapay-sdk/tree/main/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.

## 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.
* **Inline card entry on restricted hosts.** Any MCP host whose sandbox chain does not deliver the declared `_meta.ui.csp.frameDomains` to the widget document cannot mount Stripe's Payment Element inline — Stripe's card surfaces load in a nested iframe and require both `frame-src` and (via inherited sandbox flags) `allow-same-origin` on the whole chain. Claude is the named example: it hardcodes `frame-src 'self' blob: data:` and ignores `frameDomains`. MCPJam reads the declaration but its double-iframe sandbox can still report a runtime mismatch between the effective CSP model and the browser's enforced policy. The SDK detects this at runtime (`useStripeProbe`) and falls back to SolvaPay-hosted checkout in a new tab (see [Hosted checkout from inside the iframe](#hosted-checkout-from-inside-the-iframe)), so checkout still completes and the payment stays on SolvaPay's rail. When the probe blocks, check the widget console for `[solvapay-mcp] host CSP refused the Stripe iframe` — the logged `originalPolicy` names the policy that refused the frame. On hosts that honour the spec end-to-end (e.g. `basic-host`, ChatGPT), the inline Payment Element renders directly — no configuration needed.

## Next steps

* [MCP Server integration](/sdks/typescript/guides/mcp) — server-side paywall patterns with `createSolvaPayMcpServer` and `registerPayable`
* [React SDK guide](/sdks/typescript/guides/react) — hooks and components used under `createMcpAppAdapter`
* [Purchase management](/sdks/typescript/guides/purchase-management) — cancel, reactivate, renewal semantics
