Skip to main content

Table of Contents

Installation

Install the SDK packages plus the official MCP SDK and Zod:
npm install @solvapay/mcp @solvapay/server @modelcontextprotocol/sdk zod
# or
pnpm add @solvapay/mcp @solvapay/server @modelcontextprotocol/sdk zod
# or
yarn add @solvapay/mcp @solvapay/server @modelcontextprotocol/sdk 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

import { createSolvaPay } from '@solvapay/server'

const solvaPay = createSolvaPay({
  apiKey: process.env.SOLVAPAY_SECRET_KEY,
})

2. Create the MCP server

createSolvaPayMcpServer returns a fully wired McpServer. It auto-registers the transport and intent tools (upgrade, manage_account, topup, activate_plan), the UI resource referenced by those tools, the slash-command prompts, the narrated docs://solvapay/overview.md resource, and the Stripe CSP baseline. You only supply your product and where the UI bundle lives.
import path from 'node:path'
import { createSolvaPayMcpServer } from '@solvapay/mcp'

const server = createSolvaPayMcpServer({
  solvaPay,
  productRef: process.env.SOLVAPAY_PRODUCT_REF,
  resourceUri: 'ui://my-app/mcp-app.html',
  htmlPath: path.join(import.meta.dirname, 'mcp-app.html'),
  publicBaseUrl: 'https://my-mcp.example.com',
})
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.
import { z } from 'zod'
import { createSolvaPayMcpServer } from '@solvapay/mcp'

const server = createSolvaPayMcpServer({
  solvaPay,
  productRef: process.env.SOLVAPAY_PRODUCT_REF,
  resourceUri: 'ui://my-app/mcp-app.html',
  htmlPath: path.join(import.meta.dirname, 'mcp-app.html'),
  publicBaseUrl: 'https://my-mcp.example.com',
  additionalTools: ({ registerPayable }) => {
    registerPayable('search_knowledge', {
      description: 'Search the knowledge base.',
      schema: { query: z.string().min(1) },
      annotations: { readOnlyHint: true, idempotentHint: true },
      handler: async ({ query }, ctx) => ctx.respond({ query, results: [] }),
    })
  },
})
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:
registerPayable('submit_order', {
  description: 'Place an order.',
  schema: { sku: z.string(), quantity: z.number().int().positive() },
  annotations: { readOnlyHint: false, destructiveHint: true },
  handler: async ({ sku, quantity }, ctx) => ctx.respond(await placeOrder(sku, quantity)),
})

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

import express from 'express'
import { createMcpOAuthBridge } from '@solvapay/mcp/express'

const app = express()
app.use(express.json())
// OAuth token + revoke endpoints submit application/x-www-form-urlencoded
// per RFC 6749 — express.json() alone is not enough.
app.use(express.urlencoded({ extended: false }))
app.use(
  ...createMcpOAuthBridge({
    publicBaseUrl: 'https://my-mcp.example.com',
    apiBaseUrl: 'https://api.solvapay.com',
    productRef: process.env.SOLVAPAY_PRODUCT_REF,
    requireAuth: true,
    mcpPath: '/mcp',
  }),
)

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:
import { createSolvaPayMcpFetch } from '@solvapay/mcp/fetch'

Deno.serve(
  createSolvaPayMcpFetch({
    solvaPay,
    productRef: Deno.env.get('SOLVAPAY_PRODUCT_REF'),
    resourceUri: 'ui://my-app/mcp-app.html',
    readHtml: () => Deno.readTextFile('./mcp-app.html'),
    publicBaseUrl,
    apiBaseUrl,
    mode: 'json-stateless',
  }),
)

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.
import {
  decodeJwtPayload,
  getCustomerRefFromJwtPayload,
  McpBearerAuthError,
} from '@solvapay/mcp-core'

registerPayable('search_knowledge', {
  schema: { query: z.string().min(1) },
  getCustomerRef: (_args, extra) => {
    const token = extra?.authInfo?.token
    if (!token) throw new Error('Unauthorized')
    try {
      return getCustomerRefFromJwtPayload(decodeJwtPayload(token))
    } catch (error) {
      if (error instanceof McpBearerAuthError) throw new Error('Unauthorized')
      throw error
    }
  },
  handler: async ({ query }, ctx) => ctx.respond({ query, results: [] }),
})

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 the recovery intent tool (upgrade / topup / activate_plan) 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.
registerPayable('query_sales_trends', {
  description: 'Return sales rows for a date range.',
  schema: { range: z.string().min(1) },
  annotations: { readOnlyHint: true, idempotentHint: true },
  handler: async ({ range }, ctx) => {
    const results = await loadSalesRows(range)

    // Silent success when the balance is healthy.
    if (ctx.customer.balance >= 1000) {
      return ctx.respond({ range, results }, { units: results.length })
    }

    // Same data, plus an inline low-balance nudge.
    return ctx.respond(
      { range, results },
      {
        units: results.length,
        nudge: {
          kind: 'low-balance',
          message: 'Running low on credits — call the `topup` tool to add more.',
        },
      },
    )
  },
})
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.
  • 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.

Plan Activation

Plan activation and upgrades are handled by the built-in intent tools that createSolvaPayMcpServer registers — you rarely implement them by hand. activate_plan, upgrade, and topup already map to the underlying flows:
  • Free plan — activates immediately; the customer can start calling paid tools.
  • Usage-based plan — activates immediately even at a zero balance; top-up is optional and flows through the topup tool.
  • 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:
handler: async ({ query }, ctx) => {
  if (isPremiumQuery(query) && !ctx.customer.plan) {
    ctx.gate('This query requires an active plan.')
  }
  return ctx.respond({ query, results: await search(query) })
}

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.
import 'dotenv/config'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import express, { type Request, type Response } from 'express'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'
import { createSolvaPayMcpServer } from '@solvapay/mcp'
import { createMcpOAuthBridge } from '@solvapay/mcp/express'
import { createSolvaPay } from '@solvapay/server'
import { z } from 'zod'

const PRODUCT_REF = process.env.SOLVAPAY_PRODUCT_REF
const PUBLIC_BASE_URL = process.env.MCP_PUBLIC_BASE_URL ?? 'http://localhost:3006'
const API_BASE_URL = process.env.SOLVAPAY_API_BASE_URL ?? 'https://api.solvapay.com'

const solvaPay = createSolvaPay({ apiKey: process.env.SOLVAPAY_SECRET_KEY })

function createServer() {
  return createSolvaPayMcpServer({
    solvaPay,
    productRef: PRODUCT_REF,
    resourceUri: 'ui://my-app/mcp-app.html',
    htmlPath: path.join(import.meta.dirname, 'mcp-app.html'),
    publicBaseUrl: PUBLIC_BASE_URL,
    additionalTools: ({ registerPayable }) => {
      registerPayable('search_knowledge', {
        description: 'Search the knowledge base.',
        schema: { query: z.string().min(1) },
        annotations: { readOnlyHint: true, idempotentHint: true },
        handler: async ({ query }, ctx) =>
          ctx.respond({ query, results: await search(query) }),
      })
    },
  })
}

const sessions: Record<string, StreamableHTTPServerTransport> = {}

const app = express()
app.use(express.json())
app.use(express.urlencoded({ extended: false }))
app.use(
  ...createMcpOAuthBridge({
    publicBaseUrl: PUBLIC_BASE_URL,
    apiBaseUrl: API_BASE_URL,
    productRef: PRODUCT_REF,
    requireAuth: true,
    mcpPath: '/mcp',
  }),
)

app.post('/mcp', async (req: Request, res: Response) => {
  const sessionId = req.headers['mcp-session-id'] as string | undefined
  let transport = sessionId ? sessions[sessionId] : undefined

  if (!transport && isInitializeRequest(req.body)) {
    transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: sid => {
        sessions[sid] = transport!
      },
    })
    transport.onclose = () => {
      const sid = transport?.sessionId
      if (sid) delete sessions[sid]
    }
    await createServer().connect(transport)
  }

  if (!transport) {
    res.status(400).json({
      jsonrpc: '2.0',
      id: (req.body as { id?: string | number | null })?.id ?? null,
      error: { code: -32000, message: 'No valid session ID provided' },
    })
    return
  }

  await transport.handleRequest(req, res, req.body)
})

app.listen(3006, () => {
  console.error(`MCP server listening on ${PUBLIC_BASE_URL}`)
})

Stateless and text-only deployments

  • Stateless edge runtimes — use createSolvaPayMcpFetch({ ..., mode: 'json-stateless' }) from @solvapay/mcp/fetch so each request is handled without a persistent session store.
  • Text-only hosts — pass hideToolsByAudience: ['ui'] to keep the LLM-facing tools/list narrow to the four intent tools 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:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { paywallToolResult } from '@solvapay/mcp-core'

const server = new McpServer({ name: 'my-server', version: '1.0.0' })

server.registerTool('create_task', { /* ... */ }, async args => {
  const decision = await solvaPay.paywall.decide(args, { product: 'prd_myapi' })

  if (decision.outcome === 'gate') {
    return paywallToolResult(decision.gate, {
      resourceUri: 'ui://my-app/mcp-app.html',
    })
  }

  return await createTask(args)
})
Legacy consumers that still try/catch a PaywallError keep working — it is exported from @solvapay/server as a compat shim:
import { PaywallError } from '@solvapay/server'

try {
  return await payable.mcp(createTask)(args)
} catch (error) {
  if (error instanceof PaywallError) {
    return paywallToolResult(error, { resourceUri: 'ui://my-app/mcp-app.html' })
  }
  throw error
}

Next Steps