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 - 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/reactand@modelcontextprotocol/ext-appsinstalled in your MCP App bundle
Install
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.
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.
Custom shell
To own the provider mount, wire the adapter yourself. Every SDK hook then routes through the MCP transport unchanged.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 theaccount 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.
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.
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’scustomer_ref, the provider only needs a sentinel token to flip isAuthenticated true. Supply a lightweight auth adapter alongside the transport:
Display modes
Display mode is host state, not routing. The host publishesdisplayMode 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:
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 at36rem.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.safeAreaInsetsfrom 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 realhrefon 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 anawait.
<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 throughuseExternalLinkClick.
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.
<CurrentPlanCard />renders the active plan, mirrored card brand/last4, and inline Update card / Cancel plan actions. The MCPaccountview 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-flightcreateCustomerSessionround-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 towindow.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 thepayment_intent.succeededwebhook persisted on the Customer — no card-element iframe required inside the MCP App sandbox. A card also reportsreusable, 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.
Usage and history
The bootstrap payload seedslimits, 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. AregisterPayable 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 atexamples/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
trackUsagestays on the server. Usage metering belongs on your backend, not the client — continue to callsolvaPay.trackUsage(...)from@solvapay/serverinside your tool handlers.- Inline card entry on restricted hosts. Any MCP host whose sandbox chain does not deliver the declared
_meta.ui.csp.frameDomainsto the widget document cannot mount Stripe’s Payment Element inline — Stripe’s card surfaces load in a nested iframe and require bothframe-srcand (via inherited sandbox flags)allow-same-originon the whole chain. Claude is the named example: it hardcodesframe-src 'self' blob: data:and ignoresframeDomains. 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), 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 loggedoriginalPolicynames 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 — server-side paywall patterns with
createSolvaPayMcpServerandregisterPayable - React SDK guide — hooks and components used under
createMcpAppAdapter - Purchase management — cancel, reactivate, renewal semantics