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.

Table of Contents

Installation

Install the required packages:
npm install @solvapay/server @modelcontextprotocol/sdk
# or
pnpm add @solvapay/server @modelcontextprotocol/sdk
# or
yarn add @solvapay/server @modelcontextprotocol/sdk

Basic Setup

1. Initialize SolvaPay

Create a SolvaPay instance in your MCP server:
import { createSolvaPay } from '@solvapay/server'
import { Server } from '@modelcontextprotocol/sdk/server/index'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio'

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

// Create payable handler for your product
const payable = solvaPay.payable({
  product: 'prd_YOUR_PRODUCT_ID',
})

2. Create MCP Server

Set up your MCP server:
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
  Tool,
} from '@modelcontextprotocol/sdk/types'

// Create MCP server
const server = new Server(
  {
    name: 'solvapay-protected-server',
    version: '1.0.0',
  },
  {
    capabilities: {
      tools: {},
    },
  },
)

3. Define Tools

Define your MCP tools:
const tools: Tool[] = [
  {
    name: 'create_task',
    description: 'Create a new task (requires purchase)',
    inputSchema: {
      type: 'object',
      properties: {
        title: {
          type: 'string',
          description: 'Title of the task',
        },
        auth: {
          type: 'object',
          description: 'Authentication information',
          properties: {
            customer_ref: { type: 'string' },
          },
          required: ['customer_ref'],
        },
      },
      required: ['title', 'auth'],
    },
  },
]

Protecting MCP Tools

Basic Tool Protection

Wrap your tool handlers with payable.mcp():
// Your business logic function
async function createTask(args: { title: string; auth: { customer_ref: string } }) {
  const { title } = args

  // Your business logic here
  const task = {
    id: Date.now().toString(),
    title,
    createdAt: new Date().toISOString(),
  }

  return {
    success: true,
    task,
  }
}

// Handle tool execution
server.setRequestHandler(CallToolRequestSchema, async request => {
  const { name, arguments: args } = request.params

  switch (name) {
    case 'create_task': {
      // Protect the tool with paywall
      const handler = payable.mcp(createTask)
      return await handler(args)
    }

    default:
      throw new Error(`Unknown tool: ${name}`)
  }
})

Multiple Tools with Same Plan

Create one payable handler and apply it to all protected tools:
const payable = solvaPay.payable({
  product: 'prd_myapi',
})

server.setRequestHandler(CallToolRequestSchema, async request => {
  const { name, arguments: args } = request.params

  switch (name) {
    case 'create_task': {
      const handler = payable.mcp(createTask)
      return await handler(args)
    }

    case 'get_task': {
      const handler = payable.mcp(getTask)
      return await handler(args)
    }

    case 'list_tasks': {
      const handler = payable.mcp(listTasks)
      return await handler(args)
    }

    default:
      throw new Error(`Unknown tool: ${name}`)
  }
})
Plans are managed on the product in SolvaPay Console. Customers select a plan during activation and the SDK resolves the correct plan from their purchase automatically.

Authentication

Customer Reference in Tool Arguments

The MCP adapter expects customer reference in the auth object:
const tools: Tool[] = [
  {
    name: 'create_task',
    description: 'Create a new task',
    inputSchema: {
      type: 'object',
      properties: {
        title: { type: 'string' },
        auth: {
          type: 'object',
          properties: {
            customer_ref: { type: 'string' },
          },
          required: ['customer_ref'],
        },
      },
      required: ['title', 'auth'],
    },
  },
]

Extract Customer Reference

The MCP adapter automatically extracts customer_ref from args.auth.customer_ref:
async function createTask(args: { title: string; auth: { customer_ref: string } }) {
  // customer_ref is automatically extracted by the adapter
  // Your business logic here
  return { success: true, task: {} }
}

const handler = payable.mcp(createTask)

Custom Customer Reference Extraction

Override customer reference extraction:
const handler = payable.mcp(createTask, {
  getCustomerRef: (args: any) => {
    // Custom logic to extract customer reference
    return args.auth?.customer_ref || args.userId || null
  },
})

OAuth Bearer Token Helper

For MCP servers that authenticate with bearer tokens, you can use the server SDK helper utilities:
import {
  getCustomerRefFromBearerAuthHeader,
  McpBearerAuthError,
} from '@solvapay/server'

const handler = payable.mcp(createTask, {
  getCustomerRef: args => {
    const header = args._authHeader as string | undefined
    try {
      return getCustomerRefFromBearerAuthHeader(header)
    } catch (error) {
      if (error instanceof McpBearerAuthError) {
        throw new Error('Unauthorized')
      }
      throw error
    }
  },
})
These helpers only decode claims (customerRef, customer_ref, sub) and do not verify signatures. Use them after token validation (for example with /v1/customer/auth/userinfo). Fail closed on auth failures. Do not substitute fallback identities such as anonymous.

Non-hosted OAuth Bridge Pattern

If your MCP server runs on localhost/custom domain (not SolvaPay hosted MCP proxy):
  1. Serve local discovery endpoints:
    • /.well-known/oauth-protected-resource
    • /.well-known/oauth-authorization-server
  2. Point discovery metadata to SolvaPay OAuth endpoints (/v1/customer/auth/*)
  3. Ensure registration_endpoint includes mcp_server_id
  4. Protect /mcp with bearer auth and return RFC9728 WWW-Authenticate challenge when missing
Reference implementation: examples/mcp-oauth-bridge.

Plan activation from MCP tools

Expose activate_plan (backed by activatePlanCore from @solvapay/server or the @solvapay/next wrapper) so the agent can upgrade the customer without a browser round-trip. The result shape depends on plan type:
  • Free plan{ status: 'activated', purchaseRef }.
  • Usage-based plan{ status: 'activated', purchaseRef, creditBalance } even when creditBalance === 0. The customer can start calling paid tools immediately; top-up is optional and flows through create_topup_payment_intent.
  • Recurring / hybrid plan{ status: 'topup_required' | 'payment_required', checkoutUrl } when the customer has no credit balance and no card on file. Return the checkoutUrl so the agent can surface it via a hosted checkout link.
In the default createSolvaPayMcpServer tool surface, activate_plan, upgrade, and topup already map to these flows — you rarely need to implement them by hand.

Error Handling

Basic Error Handling

The MCP adapter routes paywall gate outcomes through a typed formatGate channel — the transport emits isError: false, a plain-string narration in content[0].text, and the machine-readable gate payload on structuredContent. Merchants don’t need to try/catch for the happy path:
server.setRequestHandler(CallToolRequestSchema, async request => {
  const { name, arguments: args } = request.params

  try {
    switch (name) {
      case 'create_task': {
        const handler = payable.mcp(createTask)
        return await handler(args)
      }

      default:
        throw new Error(`Unknown tool: ${name}`)
    }
  } catch (error) {
    // Paywall gate outcomes are handled by the adapter before this
    // catch block runs. Anything that lands here is a genuine
    // handler failure — re-throw it.
    throw error
  }
})

Custom Paywall Responses

Use paywall.decide() directly when you want full control over the paywall response shape (e.g. a hand-rolled MCP handler that doesn’t use payable().mcp()):
import { paywallToolResult } from '@solvapay/mcp'

server.setRequestHandler(CallToolRequestSchema, async request => {
  const { name, arguments: args } = request.params

  if (name === 'create_task') {
    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)
  }

  throw new Error(`Unknown tool: ${name}`)
})
Legacy consumers that still try/catch a PaywallError keep working — PaywallError is exported 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
}

Complete Example

Here’s a complete MCP server with SolvaPay integration:
import 'dotenv/config'
import { Server } from '@modelcontextprotocol/sdk/server/index'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio'
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
  Tool,
} from '@modelcontextprotocol/sdk/types'
import { createSolvaPay, PaywallError } from '@solvapay/server'

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

// Create payable handler
const payable = solvaPay.payable({
  product: 'prd_myapi',
})

// Define tools
const tools: Tool[] = [
  {
    name: 'create_task',
    description: 'Create a new task (requires purchase)',
    inputSchema: {
      type: 'object',
      properties: {
        title: {
          type: 'string',
          description: 'Title of the task',
        },
        description: {
          type: 'string',
          description: 'Optional description of the task',
        },
        auth: {
          type: 'object',
          description: 'Authentication information',
          properties: {
            customer_ref: { type: 'string' },
          },
          required: ['customer_ref'],
        },
      },
      required: ['title', 'auth'],
    },
  },
  {
    name: 'get_task',
    description: 'Get a task by ID (requires purchase)',
    inputSchema: {
      type: 'object',
      properties: {
        id: {
          type: 'string',
          description: 'ID of the task to retrieve',
        },
        auth: {
          type: 'object',
          description: 'Authentication information',
          properties: {
            customer_ref: { type: 'string' },
          },
          required: ['customer_ref'],
        },
      },
      required: ['id', 'auth'],
    },
  },
  {
    name: 'list_tasks',
    description: 'List all tasks (requires purchase)',
    inputSchema: {
      type: 'object',
      properties: {
        limit: {
          type: 'number',
          description: 'Maximum number of tasks to return (default: 10)',
        },
        offset: {
          type: 'number',
          description: 'Number of tasks to skip (default: 0)',
        },
        auth: {
          type: 'object',
          description: 'Authentication information',
          properties: {
            customer_ref: { type: 'string' },
          },
          required: ['customer_ref'],
        },
      },
    },
  },
]

// Business logic functions
async function createTask(args: {
  title: string
  description?: string
  auth: { customer_ref: string }
}) {
  const { title, description } = args

  const task = {
    id: Date.now().toString(),
    title,
    description,
    createdAt: new Date().toISOString(),
  }

  return {
    success: true,
    message: 'Task created successfully',
    task,
  }
}

async function getTask(args: { id: string; auth: { customer_ref: string } }) {
  const { id } = args

  // Simulate fetching from database
  const task = {
    id,
    title: 'Sample Task',
    description: 'Task description',
    createdAt: new Date().toISOString(),
  }

  return {
    success: true,
    task,
  }
}

async function listTasks(args: {
  limit?: number
  offset?: number
  auth: { customer_ref: string }
}) {
  const { limit = 10, offset = 0 } = args

  // Simulate fetching from database
  const tasks = Array.from({ length: limit }, (_, i) => ({
    id: (offset + i + 1).toString(),
    title: `Task ${offset + i + 1}`,
    description: `Description for task ${offset + i + 1}`,
    createdAt: new Date().toISOString(),
  }))

  return {
    success: true,
    tasks,
    total: tasks.length,
    limit,
    offset,
  }
}

// Create MCP server
const server = new Server(
  {
    name: 'solvapay-protected-server',
    version: '1.0.0',
  },
  {
    capabilities: {
      tools: {},
    },
  },
)

// Handle tool listing
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return { tools }
})

// Handle tool execution
server.setRequestHandler(CallToolRequestSchema, async request => {
  const { name, arguments: args } = request.params

  try {
    switch (name) {
      case 'create_task': {
        const handler = payable.mcp(createTask)
        const result = await handler(args)
        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify(result),
            },
          ],
        }
      }

      case 'get_task': {
        const handler = payable.mcp(getTask)
        const result = await handler(args)
        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify(result),
            },
          ],
        }
      }

      case 'list_tasks': {
        const handler = payable.mcp(listTasks)
        const result = await handler(args)
        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify(result),
            },
          ],
        }
      }

      default:
        throw new Error(`Unknown tool: ${name}`)
    }
  } catch (error) {
    // Paywall gate outcomes are handled by `payable.mcp(...)`
    // before this catch block runs — they come back as a normal
    // tool result with `isError: false`, a narration text, and the
    // machine-readable gate on `structuredContent`. Anything that
    // lands here is a genuine handler failure.
    throw error
  }
})

// Start the server
async function main() {
  const transport = new StdioServerTransport()
  await server.connect(transport)

  console.error('SolvaPay Protected MCP Server started')
  console.error('Available tools: create_task, get_task, list_tasks')
  console.error('Paywall protection enabled')
}

main().catch(error => {
  console.error('Failed to start MCP server:', error)
  process.exit(1)
})

Testing the Example

  1. Save the code to src/index.ts
  2. Set environment variable: SOLVAPAY_SECRET_KEY=sk_...
  3. Run the server: node dist/index.js
The server will listen on stdio and respond to MCP protocol messages.

Tool Response Format

The MCP adapter automatically formats responses. Your business logic should return:
  • Object: Automatically converted to MCP response format
  • Error: Thrown as exception (PaywallError handled automatically)
async function createTask(args: any) {
  // Return object - automatically formatted
  return { success: true, task: {} }

  // Or throw error - automatically handled
  if (!args.title) {
    throw new Error('Title is required')
  }
}

Best Practices

  1. Customer Reference: Always require customer_ref in the auth object for tool arguments.
  2. Error Handling: Handle PaywallError appropriately to provide clear error messages to MCP clients.
  3. Tool Documentation: Provide clear descriptions in tool schemas so users understand what each tool does.
  4. Type Safety: Use TypeScript for better type safety and developer experience.
  5. Environment Variables: Store API keys in environment variables, not in code.
  6. Tool Naming: Use clear, descriptive names for your tools.

Next Steps