# 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/)