# 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.
**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.
### tools/call
Executes a tool with the provided arguments.
## Choosing an SDK
The official MCP SDKs handle protocol details so you can focus on your business logic.
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
```bash
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-node
```
### Minimal Server Code
```typescript
// 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 = {};
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:
```bash
npx ts-node src/server.ts
```
That'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.
### 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.
```typescript
// 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 = {};
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:
```typescript
// 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.
**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
```typescript
// 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 = {};
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.
### 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
```typescript
// 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
```typescript
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:
### Cloud Run Deployment
```dockerfile
# 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"]
```
```bash
# 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
```bash
# 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/mcp
```
---
## Security Checklist
Before deploying to production, verify these security measures:
---
## Connecting to Chipp
Once your MCP server is deployed:
### 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/list` manually 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:
```bash
# 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](/docs/pro-actions/getting-started) - Use pre-built MCP integrations like Notion, HubSpot, and Zapier
- [Pro Actions Examples](/docs/pro-actions/examples) - Real-world MCP-powered workflows
- [Custom Actions Guide](/docs/custom-actions/overview) - Build simpler HTTP-based integrations
- [API Reference](/docs/api/reference) - Complete API documentation
**Official MCP Documentation:**
- [MCP Specification](https://modelcontextprotocol.io/specification/2025-06-18)
- [TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
- [Python SDK](https://github.com/modelcontextprotocol/python-sdk)
**Example Repositories:**
- [REST-to-MCP Adapter](https://github.com/pawneetdev/rest-to-mcp-adapter) - Auto-generate tools from OpenAPI specs
- [Postgres MCP Pro](https://github.com/crystaldba/postgres-mcp) - Production-ready database server
- [DBHub](https://github.com/bytebase/dbhub) - Multi-database gateway
**Deployment Guides:**
- [Google Cloud Run Tutorial](https://cloud.google.com/blog/topics/developers-practitioners/build-and-deploy-a-remote-mcp-server-to-google-cloud-run-in-under-10-minutes)
- [Cloudflare Workers Guide](https://developers.cloudflare.com/agents/guides/remote-mcp-server/)