> ## 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 Server Integration Guide

> Protect Model Context Protocol (MCP) tools with SolvaPay using createSolvaPayMcpServer and registerPayable — the recommended factory that wires the transport, intent tools, paywall, and UI resource for you.

## Table of Contents

* [Installation](#installation)
* [Basic Setup](#basic-setup)
* [Protecting Tools](#protecting-tools)
* [Authentication](#authentication)
* [Response Format](#response-format)
* [Plan Activation](#plan-activation)
* [Error Handling](#error-handling)
* [Complete Example](#complete-example)
* [Low-level Adapter (escape hatch)](#low-level-adapter-escape-hatch)

## Installation

Install the SDK packages plus the official MCP SDK and Zod:

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

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

```typescript theme={null}
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](#low-level-adapter-escape-hatch).

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

```typescript theme={null}
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:

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

```typescript theme={null}
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:

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

```typescript theme={null}
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:

* **Silent** — `ctx.respond(data)`. The merchant's data is the hero. No iframe, no upsell. This is the 90% path for a paying customer.
* **Nudge** — `ctx.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.

```typescript theme={null}
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:

```typescript theme={null}
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`](https://github.com/solvapay/solvapay-sdk/tree/main/examples/mcp-checkout-app): the OAuth bridge, one `registerPayable` tool, and the streamable HTTP transport.

```typescript theme={null}
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`:

```typescript theme={null}
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:

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

* [MCP App integration](/sdks/typescript/guides/mcp-app) - Render a custom checkout/account UI inside the host iframe
* [Express.js Integration Guide](./express) - HTTP framework integration patterns
* [Usage Events](./usage-events) - Track and bill tool usage
* [Custom Authentication Adapters](./custom-auth) - Build custom auth adapters
* [API Reference](/sdks/typescript/intro) - Full API documentation
