Building Custom MCP Servers
A comprehensive guide to building remote MCP servers that connect your private data and APIs to Chipp
Building a custom MCP server lets you connect any data source or API to your Chipp-powered AI agents. This guide covers everything from protocol basics to production deployment, with complete code examples for the two most common patterns: REST API adapters and database servers.
What is MCP?
The Model Context Protocol (MCP) is an open standard introduced by Anthropic that defines how AI systems communicate with external tools and data sources. Think of it as a universal adapter layer between AI agents and your existing infrastructure.
MCP Server Architecture
Key benefits:
- Standardized interface: One protocol to connect any data source
- AI-optimized: Designed for how LLMs discover and use tools
- Secure: Built-in patterns for authentication and authorization
- Flexible: Works with REST APIs, databases, files, and more
How MCP Works
MCP uses JSON-RPC 2.0 for communication. There are two core methods your server needs to implement:
tools/list
Returns available tools that the AI agent can call.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "get_user",
"description": "Fetch a user by their ID",
"inputSchema": {
"type": "object",
"properties": {
"userId": {
"type": "string",
"description": "User ID"
}
},
"required": [
"userId"
]
}
}
]
}
}tools/call
Executes a tool with the provided arguments.
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_user",
"arguments": {
"userId": "123"
}
}
}{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "{\"id\": \"123\", \"name\": \"John Doe\", \"email\": \"mailto:john@example.com\"}"
}
],
"isError": false
}
}Choosing an SDK
The official MCP SDKs handle protocol details so you can focus on your business logic.
| SDK | Language | Key Features |
|---|---|---|
| @modelcontextprotocol/sdk | TypeScript/Node.js | McpServer classStreamable HTTPType safetyZod schemas |
| mcp-python-sdk | Python | Async supportFastAPI integrationType hints |
| mcp-rust-sdk | Rust | Axum examplesHigh performanceMemory safe |
For this guide, we'll use the TypeScript SDK as it's the most mature and has the best documentation.
Quick Start: Your First MCP Server
Let's build a minimal MCP server to understand the core concepts.
Project Setup
mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk express zod
npm install -D typescript @types/node @types/express ts-nodeMinimal Server Code
// src/server.ts
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { randomUUID } from "crypto";
const app = express();
app.use(express.json());
// Create MCP server
const server = new McpServer({
name: "my-first-mcp",
version: "1.0.0"
});
// Register a simple tool
server.tool(
"greet",
"Generate a personalized greeting",
{
name: z.string().describe("Person's name")
},
async ({ name }) => {
return {
content: [{ type: "text", text: `Hello, ${name}! Welcome to MCP.` }]
};
}
);
// HTTP transport setup
const transports: Record<string, StreamableHTTPServerTransport> = {};
app.post("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
let transport = transports[sessionId];
if (!transport) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID()
});
await server.connect(transport);
}
const newSessionId = (transport as any)._sessionId;
if (newSessionId) transports[newSessionId] = transport;
await transport.handleRequest(req, res, req.body);
});
app.get("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
const transport = transports[sessionId];
if (transport) await transport.handleRequest(req, res);
else res.status(404).json({ error: "Session not found" });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`MCP server running at http://localhost:${PORT}/mcp`);
});Run with:
npx ts-node src/server.tsThat's it! You now have a working MCP server. Let's build something more useful.
Pattern 1: REST API Adapter
The most common use case: wrapping your existing REST APIs as MCP tools.
REST API Adapter Pattern
When to Use This Pattern
- You have existing REST/GraphQL APIs
- You want to expose internal services to AI agents
- You need to add AI capabilities without rewriting backends
Complete Example: User Management API
This example shows how to wrap a typical REST API with CRUD operations.
// src/rest-adapter-server.ts
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { randomUUID } from "crypto";
const app = express();
app.use(express.json());
// Configuration from environment
const API_BASE = process.env.API_BASE_URL || "https://api.yourservice.com";
const API_KEY = process.env.API_KEY || "";
// Helper for API calls
async function apiCall(
endpoint: string,
options: RequestInit = {}
): Promise<{ ok: boolean; data?: any; error?: string }> {
try {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
return { ok: false, error: `API error (${response.status}): ${errorText}` };
}
const data = await response.json();
return { ok: true, data };
} catch (err: any) {
return { ok: false, error: `Network error: ${err.message}` };
}
}
// Create MCP server
const server = new McpServer({
name: "user-management-api",
version: "1.0.0"
});
// Tool 1: Search Users
server.tool(
"search_users",
"Search for users by name, email, or other criteria. Returns matching user profiles.",
{
query: z.string().describe("Search query (searches name and email)"),
limit: z.number().optional().describe("Max results to return (default: 10, max: 100)"),
offset: z.number().optional().describe("Number of results to skip for pagination")
},
async ({ query, limit = 10, offset = 0 }) => {
const safeLimit = Math.min(limit, 100);
const result = await apiCall(
`/users/search?q=${encodeURIComponent(query)}&limit=${safeLimit}&offset=${offset}`
);
if (!result.ok) {
return {
content: [{ type: "text", text: `Error: ${result.error}` }],
isError: true
};
}
return {
content: [{
type: "text",
text: JSON.stringify({
users: result.data.users,
total: result.data.total,
hasMore: result.data.total > offset + safeLimit
}, null, 2)
}]
};
}
);
// Tool 2: Get User by ID
server.tool(
"get_user",
"Fetch detailed information about a specific user by their ID.",
{
userId: z.string().describe("The unique user identifier")
},
async ({ userId }) => {
const result = await apiCall(`/users/${encodeURIComponent(userId)}`);
if (!result.ok) {
return {
content: [{ type: "text", text: `Error: ${result.error}` }],
isError: true
};
}
return {
content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }]
};
}
);
// Tool 3: Create User
server.tool(
"create_user",
"Create a new user in the system. Returns the created user with their ID.",
{
name: z.string().describe("User's full name"),
email: z.string().email().describe("User's email address (must be unique)"),
role: z.enum(["admin", "user", "viewer"]).optional().describe("User role (default: user)"),
department: z.string().optional().describe("User's department")
},
async ({ name, email, role = "user", department }) => {
const result = await apiCall("/users", {
method: "POST",
body: JSON.stringify({ name, email, role, department })
});
if (!result.ok) {
return {
content: [{ type: "text", text: `Error creating user: ${result.error}` }],
isError: true
};
}
return {
content: [{
type: "text",
text: `User created successfully:\n${JSON.stringify(result.data, null, 2)}`
}]
};
}
);
// Tool 4: Update User
server.tool(
"update_user",
"Update an existing user's information. Only provide fields you want to change.",
{
userId: z.string().describe("ID of the user to update"),
name: z.string().optional().describe("New name"),
email: z.string().email().optional().describe("New email address"),
role: z.enum(["admin", "user", "viewer"]).optional().describe("New role"),
department: z.string().optional().describe("New department"),
active: z.boolean().optional().describe("Whether the user account is active")
},
async ({ userId, ...updates }) => {
// Filter out undefined values
const cleanUpdates = Object.fromEntries(
Object.entries(updates).filter(([_, v]) => v !== undefined)
);
if (Object.keys(cleanUpdates).length === 0) {
return {
content: [{ type: "text", text: "Error: No fields provided to update" }],
isError: true
};
}
const result = await apiCall(`/users/${encodeURIComponent(userId)}`, {
method: "PATCH",
body: JSON.stringify(cleanUpdates)
});
if (!result.ok) {
return {
content: [{ type: "text", text: `Error updating user: ${result.error}` }],
isError: true
};
}
return {
content: [{
type: "text",
text: `User updated successfully:\n${JSON.stringify(result.data, null, 2)}`
}]
};
}
);
// Tool 5: List User Activity
server.tool(
"list_user_activity",
"Get recent activity history for a user.",
{
userId: z.string().describe("User ID"),
days: z.number().optional().describe("Number of days of history (default: 7, max: 30)")
},
async ({ userId, days = 7 }) => {
const safeDays = Math.min(days, 30);
const result = await apiCall(
`/users/${encodeURIComponent(userId)}/activity?days=${safeDays}`
);
if (!result.ok) {
return {
content: [{ type: "text", text: `Error: ${result.error}` }],
isError: true
};
}
return {
content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }]
};
}
);
// HTTP transport setup (same as before)
const transports: Record<string, StreamableHTTPServerTransport> = {};
app.post("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
let transport = transports[sessionId];
if (!transport) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID()
});
await server.connect(transport);
}
const newSessionId = (transport as any)._sessionId;
if (newSessionId) transports[newSessionId] = transport;
await transport.handleRequest(req, res, req.body);
});
app.get("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
const transport = transports[sessionId];
if (transport) await transport.handleRequest(req, res);
else res.status(404).json({ error: "Session not found" });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`REST API adapter running at http://localhost:${PORT}/mcp`);
});Authentication Pass-Through
If your REST API requires user-specific authentication, you can pass tokens through:
// Extract auth from MCP request context
server.tool(
"get_my_profile",
"Get the current user's profile",
{},
async (args, context) => {
// The client can pass user-specific tokens in the request
const userToken = context?.meta?.userToken as string;
const result = await apiCall("/me", {
headers: userToken ? { "Authorization": `Bearer ${userToken}` } : {}
});
// ... handle response
}
);Pattern 2: Database Server
Expose database access through MCP for AI-powered queries and data exploration.
Database Server Pattern
Security Warning: Never expose production databases directly to AI agents. Always use read replicas, sanitized copies, or restricted read-only connections.
Complete Example: PostgreSQL Server
// src/database-server.ts
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { randomUUID } from "crypto";
import { Pool } from "pg";
const app = express();
app.use(express.json());
// Database connection (USE READ REPLICA!)
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // Connection pool size
});
// Create MCP server
const server = new McpServer({
name: "database-explorer",
version: "1.0.0"
});
// Tool 1: List Database Tables
server.tool(
"list_tables",
"List all tables in the database with their row counts.",
{
schema: z.string().optional().describe("Schema name (default: public)")
},
async ({ schema = "public" }) => {
try {
const result = await pool.query(`
SELECT
table_name,
(SELECT COUNT(*) FROM information_schema.columns c
WHERE c.table_name = t.table_name AND c.table_schema = t.table_schema) as column_count
FROM information_schema.tables t
WHERE table_schema = $1 AND table_type = 'BASE TABLE'
ORDER BY table_name
`, [schema]);
return {
content: [{
type: "text",
text: JSON.stringify({
schema,
tables: result.rows,
count: result.rows.length
}, null, 2)
}]
};
} catch (err: any) {
return {
content: [{ type: "text", text: `Database error: ${err.message}` }],
isError: true
};
}
}
);
// Tool 2: Describe Table Schema
server.tool(
"describe_table",
"Get detailed column information for a specific table.",
{
tableName: z.string().describe("Name of the table to describe"),
schema: z.string().optional().describe("Schema name (default: public)")
},
async ({ tableName, schema = "public" }) => {
// Validate table name to prevent SQL injection
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return {
content: [{ type: "text", text: "Error: Invalid table name format" }],
isError: true
};
}
try {
const result = await pool.query(`
SELECT
column_name,
data_type,
character_maximum_length,
is_nullable,
column_default,
(SELECT COUNT(*) FROM information_schema.key_column_usage k
WHERE k.column_name = c.column_name
AND k.table_name = c.table_name) > 0 as is_key
FROM information_schema.columns c
WHERE table_schema = $1 AND table_name = $2
ORDER BY ordinal_position
`, [schema, tableName]);
if (result.rows.length === 0) {
return {
content: [{ type: "text", text: `Table '${tableName}' not found in schema '${schema}'` }],
isError: true
};
}
return {
content: [{
type: "text",
text: JSON.stringify({
table: tableName,
schema,
columns: result.rows
}, null, 2)
}]
};
} catch (err: any) {
return {
content: [{ type: "text", text: `Database error: ${err.message}` }],
isError: true
};
}
}
);
// Tool 3: Execute Read-Only Query
server.tool(
"query_data",
"Execute a read-only SQL SELECT query. Only SELECT statements are allowed.",
{
query: z.string().describe("SQL SELECT query to execute"),
limit: z.number().optional().describe("Max rows to return (default: 100, max: 1000)")
},
async ({ query, limit = 100 }) => {
const safeLimit = Math.min(limit, 1000);
// Normalize and validate query
const normalized = query.trim().toLowerCase();
// Only allow SELECT queries
if (!normalized.startsWith("select")) {
return {
content: [{ type: "text", text: "Error: Only SELECT queries are allowed" }],
isError: true
};
}
// Block dangerous patterns
const forbidden = [
"insert", "update", "delete", "drop", "alter", "truncate",
"create", "grant", "revoke", "execute", "exec"
];
for (const word of forbidden) {
if (normalized.includes(word)) {
return {
content: [{ type: "text", text: `Error: Query contains forbidden keyword: ${word}` }],
isError: true
};
}
}
try {
// Force read-only transaction and add limit
const client = await pool.connect();
try {
await client.query("SET TRANSACTION READ ONLY");
// Remove any existing LIMIT and add our safe limit
const limitedQuery = query.replace(/\s+limit\s+\d+/gi, "") + ` LIMIT ${safeLimit}`;
const result = await client.query(limitedQuery);
return {
content: [{
type: "text",
text: JSON.stringify({
rowCount: result.rowCount,
rows: result.rows,
fields: result.fields.map(f => ({ name: f.name, dataType: f.dataTypeID }))
}, null, 2)
}]
};
} finally {
client.release();
}
} catch (err: any) {
return {
content: [{ type: "text", text: `Query error: ${err.message}` }],
isError: true
};
}
}
);
// Tool 4: Get Table Sample
server.tool(
"sample_table",
"Get a random sample of rows from a table to understand its data.",
{
tableName: z.string().describe("Table name"),
sampleSize: z.number().optional().describe("Number of rows to sample (default: 5, max: 20)")
},
async ({ tableName, sampleSize = 5 }) => {
// Validate table name
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return {
content: [{ type: "text", text: "Error: Invalid table name format" }],
isError: true
};
}
const safeSampleSize = Math.min(sampleSize, 20);
try {
// Use TABLESAMPLE for efficient random sampling on large tables
const result = await pool.query(`
SELECT * FROM "${tableName}"
ORDER BY RANDOM()
LIMIT $1
`, [safeSampleSize]);
return {
content: [{
type: "text",
text: JSON.stringify({
table: tableName,
sampleSize: result.rows.length,
rows: result.rows
}, null, 2)
}]
};
} catch (err: any) {
return {
content: [{ type: "text", text: `Sample error: ${err.message}` }],
isError: true
};
}
}
);
// Tool 5: Get Table Statistics
server.tool(
"table_stats",
"Get statistics about a table: row count, size, and index information.",
{
tableName: z.string().describe("Table name")
},
async ({ tableName }) => {
// Validate table name
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return {
content: [{ type: "text", text: "Error: Invalid table name format" }],
isError: true
};
}
try {
const [countResult, sizeResult, indexResult] = await Promise.all([
pool.query(`SELECT COUNT(*) as row_count FROM "${tableName}"`),
pool.query(`
SELECT pg_size_pretty(pg_total_relation_size($1)) as total_size,
pg_size_pretty(pg_table_size($1)) as table_size,
pg_size_pretty(pg_indexes_size($1)) as index_size
`, [tableName]),
pool.query(`
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = $1
`, [tableName])
]);
return {
content: [{
type: "text",
text: JSON.stringify({
table: tableName,
rowCount: parseInt(countResult.rows[0].row_count),
size: sizeResult.rows[0],
indexes: indexResult.rows
}, null, 2)
}]
};
} catch (err: any) {
return {
content: [{ type: "text", text: `Stats error: ${err.message}` }],
isError: true
};
}
}
);
// HTTP transport setup
const transports: Record<string, StreamableHTTPServerTransport> = {};
app.post("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
let transport = transports[sessionId];
if (!transport) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID()
});
await server.connect(transport);
}
const newSessionId = (transport as any)._sessionId;
if (newSessionId) transports[newSessionId] = transport;
await transport.handleRequest(req, res, req.body);
});
app.get("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
const transport = transports[sessionId];
if (transport) await transport.handleRequest(req, res);
else res.status(404).json({ error: "Session not found" });
});
// Graceful shutdown
process.on("SIGTERM", async () => {
console.log("Shutting down...");
await pool.end();
process.exit(0);
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Database MCP server running at http://localhost:${PORT}/mcp`);
});Tool Design Best Practices
How you design your tools significantly impacts how well AI agents can use them.
Workflow-Focused Tools
Create tools that accomplish complete tasks rather than exposing individual operations. LLMs work better with fewer, more capable tools.
You have many related API endpoints that are typically used together
Progressive Discovery
Provide tools that help the AI understand what's available before diving into specifics. Schema exploration tools are invaluable.
Working with databases or APIs with many resources
Descriptive Schemas
Write tool and parameter descriptions as if explaining to a new team member. Include examples, constraints, and common use cases.
Always - good descriptions are the key to AI understanding
Safe Defaults
Use sensible defaults that prevent accidental damage. Limit result sizes, default to read-only, require confirmation for destructive actions.
Any tool that could return large datasets or modify data
Tool Count Guidelines
| Tool Count | Assessment | Recommendation |
|---|---|---|
| 3-10 | Ideal | Easy for AI to navigate |
| 10-20 | Good | Consider grouping related tools |
| 20-50 | Complex | AI may struggle to choose correctly |
| 50+ | Too many | Consolidate into workflow tools |
Adding Authentication
Your MCP server needs to verify that requests come from authorized sources.
Bearer Token Authentication
// Middleware to verify Bearer token
function authenticate(req: express.Request, res: express.Response, next: express.NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing or invalid Authorization header" });
}
const token = authHeader.substring(7);
// Verify token (implement your own logic)
if (!isValidToken(token)) {
return res.status(401).json({ error: "Invalid token" });
}
next();
}
// Apply to MCP endpoints
app.post("/mcp", authenticate, async (req, res) => {
// ... handler
});
app.get("/mcp", authenticate, async (req, res) => {
// ... handler
});API Key Authentication
function authenticateApiKey(req: express.Request, res: express.Response, next: express.NextFunction) {
const apiKey = req.headers["x-api-key"] as string;
if (!apiKey) {
return res.status(401).json({ error: "Missing X-API-Key header" });
}
// Verify API key
if (!isValidApiKey(apiKey)) {
return res.status(401).json({ error: "Invalid API key" });
}
next();
}Deployment Options
Choose a deployment method based on your needs:
Google Cloud Run
Fully managed serverless containers with auto-scaling
Cloudflare Workers
Edge deployment with global distribution
Docker/Kubernetes
Full control with container orchestration
ngrok/Cloudflare Tunnel
Expose local servers for development and testing
Cloud Run Deployment
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 8080
CMD ["node", "dist/server.js"]# Deploy to Cloud Run
gcloud run deploy my-mcp-server \
--source . \
--region us-central1 \
--no-allow-unauthenticated \
--set-env-vars="DATABASE_URL=..."Development with ngrok
# Start your local server
npm run dev
# In another terminal, expose it
ngrok http 3000
# Use the ngrok URL in Chipp
# https://abc123.ngrok-free.app/mcpSecurity Checklist
Before deploying to production, verify these security measures:
Security Checklist
0/8 completeConnecting to Chipp
Once your MCP server is deployed:
Connecting to Chipp
Configuration Fields
| Field | Description | Example |
|---|---|---|
| Server URL | Full URL to your MCP endpoint | https://my-server.run.app/mcp |
| Transport | Protocol type | HTTP (recommended) |
| Auth Type | How to authenticate | Bearer or API Key |
| Token/Key | Your authentication credential | sk-abc123... |
After saving, Chipp will call your server's tools/list method and display available tools. Enable the ones you want your AI agent to use.
Troubleshooting
Common Issues
"Connection refused" error
- Check your server is running and accessible from the internet
- Verify HTTPS is configured correctly
- Check firewall rules allow inbound traffic
"Authentication failed" error
- Verify your token/API key is correct
- Check the Authorization header format matches what your server expects
- Ensure tokens haven't expired
"Tool not found" error
- Run
tools/listmanually to verify your tools are registered - Check tool names don't contain special characters
- Verify the tool is enabled in Chipp
Slow response times
- Add connection pooling for database connections
- Implement caching for frequently-accessed data
- Consider moving to a region closer to Chipp's servers
Testing Your Server
Use curl to test your MCP server directly:
# Test tools/list
curl -X POST https://your-server.com/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# Test tools/call
curl -X POST https://your-server.com/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"your_tool","arguments":{}}}'Resources
Chipp Documentation:
- Pro Actions Getting Started - Use pre-built MCP integrations like Notion, HubSpot, and Zapier
- Pro Actions Examples - Real-world MCP-powered workflows
- Custom Actions Guide - Build simpler HTTP-based integrations
- API Reference - Complete API documentation
Official MCP Documentation:
Example Repositories:
- REST-to-MCP Adapter - Auto-generate tools from OpenAPI specs
- Postgres MCP Pro - Production-ready database server
- DBHub - Multi-database gateway
Deployment Guides:
Continue Reading
Advanced RAG Settings
Fine-tune how your AI retrieves and uses knowledge from your documents
User Memory
Let your AI remember facts about users across conversations for personalized experiences.
Selling Access to Your AI
Monetize your AI chatbot by selling credits or subscriptions powered by Stripe.