Model Context Protocol (MCP): Building AI Tool Connections
Learn how to build MCP servers and clients to connect AI assistants like Claude to external tools, databases, and APIs with practical code examples.
// table of contents (24 sections)
The Model Context Protocol (MCP) is revolutionizing how AI assistants interact with external systems. Instead of building custom integrations for every tool, MCP provides a standardized way for AI models to discover and use tools, access resources, and execute actions. This guide covers everything you need to know to build MCP servers and integrate them with AI assistants.
What is MCP?
MCP is an open protocol that standardizes how AI applications connect to external data sources and tools. Think of it as a “USB-C for AI applications” — a universal connector that lets any AI assistant work with any tool or data source that implements the protocol.
┌─────────────────┐ ┌─────────────────┐
│ AI Client │ │ MCP Server │
│ (Claude, │◄───────►│ (Your Tool) │
│ Cursor, etc) │ MCP │ │
└─────────────────┘ Protocol│ • Tools │
│ • Resources │
│ • Prompts │
└─────────────────┘
Why MCP Matters
Before MCP, every AI assistant had its own integration format. Building a tool for Claude meant writing one integration, then rewriting it for GPT-4, then again for another assistant. MCP solves this with a single implementation that works everywhere.
Key benefits:
- Write once, use everywhere: Build one MCP server, use it with any MCP-compatible client
- Standardized discovery: Clients automatically discover available tools and their schemas
- Type-safe communication: JSON-RPC messages with well-defined schemas
- Bidirectional: Both client-to-server and server-to-client communication
MCP Architecture
MCP follows a client-server architecture using JSON-RPC 2.0 for communication:
Transport Layers
MCP supports multiple transport mechanisms:
- Stdio: For local tools (command-line, desktop apps)
- HTTP with SSE: For remote servers (web services, cloud APIs)
interface Transport {
start(): Promise<void>;
close(): Promise<void>;
send(message: JSONRPCMessage): Promise<void>;
onmessage?: (message: JSONRPCMessage) => void;
onerror?: (error: Error) => void;
onclose?: () => void;
}
Core Concepts
Resources: Data that can be read (files, database records, API responses)
Tools: Functions that can be executed (API calls, file operations, computations)
Prompts: Pre-defined templates for common interactions
Building an MCP Server
Let’s build a practical MCP server that provides database query capabilities. This server will allow AI assistants to query a SQLite database safely.
Project Setup
mkdir mcp-database-server
cd mcp-database-server
npm init -y
npm install @modelcontextprotocol/sdk sqlite3
npm install -D typescript @types/node
Server Implementation
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import sqlite3 from 'sqlite3';
const db = new sqlite3.Database('./data.db');
const server = new Server(
{
name: 'database-server',
version: '1.0.0',
},
{
capabilities: {
tools: {},
resources: {},
},
}
);
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'query',
description: 'Execute a read-only SQL query on the database',
inputSchema: {
type: 'object',
properties: {
sql: {
type: 'string',
description: 'The SQL query to execute (SELECT only)',
},
},
required: ['sql'],
},
},
{
name: 'list_tables',
description: 'List all tables in the database',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'describe_table',
description: 'Get the schema of a specific table',
inputSchema: {
type: 'object',
properties: {
table_name: {
type: 'string',
description: 'The name of the table to describe',
},
},
required: ['table_name'],
},
},
],
};
});
// Handle tool execution
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case 'query': {
const sql = args.sql as string;
// Safety check: only allow SELECT queries
if (!sql.trim().toUpperCase().startsWith('SELECT')) {
return {
content: [
{
type: 'text',
text: 'Error: Only SELECT queries are allowed for safety.',
},
],
isError: true,
};
}
return new Promise((resolve) => {
db.all(sql, [], (err, rows) => {
if (err) {
resolve({
content: [
{
type: 'text',
text: `Query error: ${err.message}`,
},
],
isError: true,
});
} else {
resolve({
content: [
{
type: 'text',
text: JSON.stringify(rows, null, 2),
},
],
});
}
});
});
}
case 'list_tables': {
return new Promise((resolve) => {
db.all(
"SELECT name FROM sqlite_master WHERE type='table'",
[],
(err, rows) => {
if (err) {
resolve({
content: [{ type: 'text', text: `Error: ${err.message}` }],
isError: true,
});
} else {
const tables = rows.map((r: any) => r.name).join('\n');
resolve({
content: [{ type: 'text', text: `Tables:\n${tables}` }],
});
}
}
);
});
}
case 'describe_table': {
const tableName = args.table_name as string;
return new Promise((resolve) => {
db.all(
`PRAGMA table_info(${tableName})`,
[],
(err, rows) => {
if (err) {
resolve({
content: [{ type: 'text', text: `Error: ${err.message}` }],
isError: true,
});
} else {
resolve({
content: [
{
type: 'text',
text: JSON.stringify(rows, null, 2),
},
],
});
}
}
);
});
}
default:
return {
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
isError: true,
};
}
});
// List available resources (tables as resources)
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return new Promise((resolve) => {
db.all(
"SELECT name FROM sqlite_master WHERE type='table'",
[],
(err, rows) => {
if (err) {
resolve({ resources: [] });
} else {
resolve({
resources: rows.map((row: any) => ({
uri: `database://table/${row.name}`,
name: row.name,
mimeType: 'application/json',
})),
});
}
}
);
});
});
// Read resource content
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
const match = uri.match(/^database:\/\/table\/(.+)$/);
if (!match) {
throw new Error(`Invalid resource URI: ${uri}`);
}
const tableName = match[1];
return new Promise((resolve) => {
db.all(`SELECT * FROM ${tableName} LIMIT 100`, [], (err, rows) => {
if (err) {
throw new Error(`Failed to read table: ${err.message}`);
}
resolve({
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify(rows, null, 2),
},
],
});
});
});
});
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Database MCP server running on stdio');
}
main().catch(console.error);
Configuration
Add build scripts to package.json:
{
"type": "module",
"bin": {
"mcp-database": "./build/index.js"
},
"scripts": {
"build": "tsc",
"start": "node build/index.js"
}
}
Connecting to Claude Desktop
To use your MCP server with Claude Desktop, add it to the configuration:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"database": {
"command": "node",
"args": ["/path/to/mcp-database-server/build/index.js"]
}
}
}
After restarting Claude Desktop, your database tools will be available in conversations.
Building an HTTP MCP Server
For remote access, use HTTP with Server-Sent Events:
import express from 'express';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
const app = express();
app.use(express.json());
const sessions = new Map<string, SSEServerTransport>();
app.get('/sse', async (req, res) => {
const server = new Server(
{ name: 'remote-mcp-server', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
const transport = new SSEServerTransport('/message', res);
sessions.set(transport.sessionId, transport);
await server.connect(transport);
});
app.post('/message', async (req, res) => {
const sessionId = req.query.sessionId as string;
const transport = sessions.get(sessionId);
if (!transport) {
res.status(404).send('Session not found');
return;
}
await transport.handlePostMessage(req, res);
});
app.listen(3000, () => {
console.log('MCP server running on http://localhost:3000');
});
Advanced Patterns
Dynamic Tool Discovery
Generate tools dynamically based on configuration:
interface ToolDefinition {
name: string;
description: string;
parameters: JSONSchema;
handler: (args: Record<string, any>) => Promise<any>;
}
class DynamicToolServer {
private tools: Map<string, ToolDefinition> = new Map();
registerTool(definition: ToolDefinition): void {
this.tools.set(definition.name, definition);
}
async listTools(): Promise<Tool[]> {
return Array.from(this.tools.values()).map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.parameters,
}));
}
async executeTool(name: string, args: Record<string, any>): Promise<any> {
const tool = this.tools.get(name);
if (!tool) {
throw new Error(`Unknown tool: ${name}`);
}
return tool.handler(args);
}
}
// Usage
const server = new DynamicToolServer();
server.registerTool({
name: 'weather',
description: 'Get current weather for a city',
parameters: {
type: 'object',
properties: {
city: { type: 'string', description: 'City name' },
},
required: ['city'],
},
handler: async (args) => {
const response = await fetch(
`https://api.weather.com/${args.city}`
);
return response.json();
},
});
Authentication and Authorization
Add authentication for remote MCP servers:
import jwt from 'jsonwebtoken';
interface AuthContext {
userId: string;
permissions: string[];
}
function authMiddleware(req: any, res: any, next: any) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
res.status(401).json({ error: 'Missing token' });
return;
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as AuthContext;
req.auth = decoded;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
}
// Check permissions before tool execution
async function checkPermission(
auth: AuthContext,
toolName: string
): Promise<boolean> {
const toolPermissions: Record<string, string[]> = {
query: ['database:read'],
insert: ['database:write'],
admin: ['database:admin'],
};
const required = toolPermissions[toolName] || [];
return required.every((p) => auth.permissions.includes(p));
}
Error Handling and Logging
Implement robust error handling:
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'mcp-error.log', level: 'error' }),
new winston.transports.File({ filename: 'mcp-combined.log' }),
],
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const startTime = Date.now();
try {
logger.info('Tool invocation started', { tool: name, args });
const result = await executeTool(name, args);
logger.info('Tool invocation completed', {
tool: name,
duration: Date.now() - startTime,
});
return result;
} catch (error) {
logger.error('Tool invocation failed', {
tool: name,
error: error.message,
stack: error.stack,
});
return {
content: [
{
type: 'text',
text: `Error executing ${name}: ${error.message}`,
},
],
isError: true,
};
}
});
Testing MCP Servers
Use the MCP Inspector for interactive testing:
npx @modelcontextprotocol/inspector node build/index.js
This opens a web interface where you can:
- List available tools and resources
- Test tool invocations
- Inspect JSON-RPC messages
- Debug connection issues
Unit Testing
import { describe, it, expect } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
describe('Database MCP Server', () => {
it('should list tables', async () => {
const client = new Client(
{ name: 'test-client', version: '1.0.0' },
{ capabilities: {} }
);
const transport = new StdioClientTransport({
command: 'node',
args: ['build/index.js'],
});
await client.connect(transport);
const tools = await client.request(
{ method: 'tools/list' },
ListToolsResultSchema
);
expect(tools.tools).toContainEqual(
expect.objectContaining({ name: 'list_tables' })
);
});
});
Best Practices
1. Tool Design
- Keep tools focused: Each tool should do one thing well
- Clear descriptions: Help the AI understand when to use each tool
- Validate inputs: Never trust AI-generated inputs blindly
- Return structured data: JSON is better than free-form text
2. Security
- Principle of least privilege: Grant minimal necessary permissions
- Validate and sanitize: All inputs from AI should be treated as untrusted
- Rate limiting: Prevent runaway AI from overwhelming your systems
- Audit logging: Track all tool invocations for debugging and compliance
3. Performance
- Cache expensive operations: Database schema, API responses
- Set timeouts: AI can get stuck waiting for slow operations
- Implement pagination: Don’t return millions of rows at once
4. Documentation
Document your tools clearly:
{
name: 'search_users',
description: `Search for users in the database.
Returns up to 100 matching users sorted by relevance.
Use this tool when you need to find specific users by name, email, or other attributes.
Examples:
- "Find users named John" -> search_users({ query: "John" })
- "Show active users in Jakarta" -> search_users({ query: "Jakarta", status: "active" })`,
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
status: { type: 'string', enum: ['active', 'inactive', 'all'] },
limit: { type: 'number', default: 100 },
},
},
}
Real-World Examples
Popular MCP servers already available:
- Filesystem: Read/write local files
- GitHub: Create issues, PRs, search repositories
- PostgreSQL/SQLite: Query databases
- Google Drive: Access documents and files
- Brave Search: Web search capabilities
- Slack: Send messages and read channels
For more on building AI-powered systems, check out my guide on production-ready LLM integration and AI agent architecture patterns.
Conclusion
MCP represents a significant step forward in AI tool integration. By standardizing how AI assistants connect to external systems, it enables a single implementation to work across multiple platforms while providing robust security, discovery, and error handling.
The protocol is still evolving, but the core concepts are stable and production-ready. Start with simple tools, test thoroughly with the MCP Inspector, and gradually add more sophisticated capabilities as you become comfortable with the protocol.
Building MCP servers is an investment that pays dividends: write once, and your tools become available to every MCP-compatible AI assistant now and in the future.
One protocol, infinite possibilities! 🚀
You might also like
Prompt Caching: Reduce LLM Costs by 90% with Smart Context Management
Learn how prompt caching can slash your LLM API costs by up to 90%. Compare Anthropic, OpenAI, and Google's caching strategies with practical implementation examples.
Production-Ready LLM Integration: Architecture & Best Practices
Learn production-ready LLM integration patterns, architecture best practices, and deployment strategies for building scalable AI applications in 2026.
Building AI Agents with LangChain and Claude
A practical guide to building autonomous AI agents with LangChain, Claude API, and tool calling — from simple chains to multi-agent systems with memory and planning.
More Posts
API Gateway Patterns: The Front Door to Your Microservices
Web Components 2026: Building Framework-Agnostic UI Libraries
Building Autonomous AI Workflows with LangGraph: A Practical Guide
Building Type-Safe APIs with tRPC in 2026: Full-Stack TypeScript Without Schemas
Database Connection Pooling: Patterns for High-Performance Applications
Prompt Caching: Reduce LLM Costs by 90% with Smart Context Management
Enjoyed This Post?
Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.
