The Model Context Protocol (MCP) is the USB-C for AI applications. It standardizes how Large Language Models connect to external tools, APIs, and data sources. Instead of writing custom integrations for every service, you build one MCP server — and any MCP-compatible client (Claude, Cursor, Windsurf, etc.) can use it.
This guide covers everything from protocol basics to building production-ready MCP servers.
:::note[TL;DR]
- MCP is an open protocol for connecting LLMs to tools, APIs, and data sources
- Build an MCP server once, use it with Claude, Cursor, Windsurf, and more
- Three core primitives: Tools (actions), Resources (data), Prompts (templates)
- Use the MCP SDK (TypeScript/Python) for rapid development
- Deploy via stdio (local) or SSE (remote) transports :::
What Is MCP?
MCP (Model Context Protocol) solves a simple problem: every AI tool has its own way of calling external services. OpenAI has function calling. Claude has tool use. LangChain has its own abstractions. MCP unifies these into a standard protocol.
Think of it like HTTP for web services — but specifically designed for LLM context and tool interactions.
The Scenario: Your team uses a proprietary internal API for customer data. You want Claude Code, Cursor, and your custom AI dashboard to all access it. Without MCP, you write three different integrations. With MCP, you write one MCP server and all three clients connect automatically.
Core Concepts
The Three Primitives
| Primitive | Purpose | Example |
|---|---|---|
| Tools | Actions the LLM can invoke | create_ticket, send_email, query_database |
| Resources | Data the LLM can read | Customer records, documentation, files |
| Prompts | Reusable prompt templates | ”Analyze this code for security issues” |
Architecture
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ MCP Client │◄───────►│ MCP Server │◄───────►│ External API │
│ (Claude/Cursor)│ JSON │ (Your Bridge) │ HTTP │ (Your Service) │
│ │ RPC │ │ /DB │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Building Your First MCP Server
Setup
# Install MCP SDK
npm install @modelcontextprotocol/sdk
# Or for Python
pip install mcp
Basic Server (TypeScript)
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
// Define your tools
const TOOLS = [
{
name: 'get_weather',
description: 'Get current weather for a location',
inputSchema: {
type: 'object',
properties: {
location: {
type: 'string',
description: 'City name or coordinates'
}
},
required: ['location']
}
},
{
name: 'search_docs',
description: 'Search internal documentation',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query'
}
},
required: ['query']
}
}
];
// Create server
const server = new Server(
{
name: 'my-mcp-server',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
);
// Handle tool list requests
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: TOOLS };
});
// Handle tool execution
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case 'get_weather':
return await handleGetWeather(args.location);
case 'search_docs':
return await handleSearchDocs(args.query);
default:
throw new Error(`Unknown tool: ${name}`);
}
});
// Tool implementations
async function handleGetWeather(location: string) {
// Call your weather API
const response = await fetch(`https://api.weather.com/v1/current?city=${location}`);
const data = await response.json();
return {
content: [
{
type: 'text',
text: `Weather in ${location}: ${data.temperature}°F, ${data.condition}`
}
]
};
}
async function handleSearchDocs(query: string) {
// Search your documentation
const results = await searchInternalDocs(query);
return {
content: [
{
type: 'text',
text: results.map(r => `- ${r.title}: ${r.url}`).join('\n')
}
]
};
}
// Start server with stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('MCP Server running on stdio');
Running Your Server
# Direct execution
node server.js
# Or via npx (for distribution)
npx my-mcp-server
Advanced Patterns
1. Resources (Read-Only Data)
Resources expose data that LLMs can reference but not modify:
import { ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
// Define available resources
const RESOURCES = [
{
uri: 'docs://api-reference',
name: 'API Reference',
mimeType: 'text/markdown',
description: 'Complete API documentation'
},
{
uri: 'config://app-settings',
name: 'Application Settings',
mimeType: 'application/json',
description: 'Current app configuration'
}
];
// List resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return { resources: RESOURCES };
});
// Read resource content
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
if (uri === 'docs://api-reference') {
const content = await loadApiDocs();
return {
contents: [
{
uri,
mimeType: 'text/markdown',
text: content
}
]
};
}
throw new Error(`Resource not found: ${uri}`);
});
2. Prompts (Reusable Templates)
Prompts provide structured starting points for common tasks:
import { ListPromptsRequestSchema, GetPromptRequestSchema } from '@modelcontextprotocol/sdk/types.js';
const PROMPTS = [
{
name: 'code_review',
description: 'Review code for best practices',
arguments: [
{
name: 'language',
description: 'Programming language',
required: true
},
{
name: 'code',
description: 'Code to review',
required: true
}
]
}
];
server.setRequestHandler(ListPromptsRequestSchema, async () => {
return { prompts: PROMPTS };
});
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'code_review') {
return {
description: 'Code review prompt',
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Review this ${args.language} code for best practices, bugs, and performance issues:\n\n${args.code}`
}
}
]
};
}
throw new Error(`Prompt not found: ${name}`);
});
3. Streaming Progress
For long-running operations, stream progress updates:
async function handleLongTask(args: any) {
const progressToken = args._meta?.progressToken;
// Send progress updates
for (let i = 0; i < 10; i++) {
await server.notification({
method: 'notifications/progress',
params: {
progressToken,
progress: i * 10,
total: 100
}
});
await doSomeWork(i);
}
return {
content: [{ type: 'text', text: 'Task completed!' }]
};
}
Transport Options
stdio (Local)
Best for local development and CLI tools:
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
const transport = new StdioServerTransport();
await server.connect(transport);
SSE (Server-Sent Events)
For remote servers and web applications:
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import express from 'express';
const app = express();
app.get('/sse', async (req, res) => {
const transport = new SSEServerTransport('/messages', res);
await server.connect(transport);
});
app.post('/messages', async (req, res) => {
// Handle incoming messages
});
app.listen(3000);
Client Configuration
Claude Desktop
Add to ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["/path/to/your/server.js"],
"env": {
"API_KEY": "your-api-key"
}
},
"weather": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-weather"]
}
}
}
Cursor
Add to Cursor settings (~/.cursor/mcp.json):
{
"servers": [
{
"name": "my-server",
"command": "node /path/to/server.js",
"type": "command"
}
]
}
Windsurf
Add to Windsurf MCP config:
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["/path/to/server.js"]
}
}
}
Real-World Example: Database MCP Server
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { Client } from 'pg';
// PostgreSQL MCP Server
class DatabaseMCPServer {
private server: Server;
private db: Client;
constructor(connectionString: string) {
this.db = new Client({ connectionString });
this.server = new Server(
{ name: 'postgres-mcp', version: '1.0.0' },
{ capabilities: { tools: {}, resources: {} } }
);
this.setupHandlers();
}
private setupHandlers() {
// Tool: Execute query
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'query',
description: 'Execute a SQL SELECT query',
inputSchema: {
type: 'object',
properties: {
sql: { type: 'string', description: 'SQL query to execute' }
},
required: ['sql']
}
},
{
name: 'get_tables',
description: 'List all tables in the database',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'get_table_schema',
description: 'Get schema for a specific table',
inputSchema: {
type: 'object',
properties: {
table: { type: 'string', description: 'Table name' }
},
required: ['table']
}
}
]
};
});
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'query':
const result = await this.db.query(args.sql);
return {
content: [{
type: 'text',
text: JSON.stringify(result.rows, null, 2)
}]
};
case 'get_tables':
const tables = await this.db.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
`);
return {
content: [{
type: 'text',
text: tables.rows.map(r => r.table_name).join('\n')
}]
};
case 'get_table_schema':
const schema = await this.db.query(`
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = $1
`, [args.table]);
return {
content: [{
type: 'text',
text: JSON.stringify(schema.rows, null, 2)
}]
};
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
return {
content: [{
type: 'text',
text: `Error: ${error.message}`
}],
isError: true
};
}
});
// Resource: Database schema
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
const tables = await this.db.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
`);
return {
resources: tables.rows.map(row => ({
uri: `table://${row.table_name}`,
name: row.table_name,
mimeType: 'application/json',
description: `Schema and data for ${row.table_name}`
}))
};
});
}
async start() {
await this.db.connect();
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('PostgreSQL MCP Server running');
}
}
// Start server
const server = new DatabaseMCPServer(process.env.DATABASE_URL);
server.start();
Security Best Practices
1. Input Validation
import { z } from 'zod';
const QuerySchema = z.object({
sql: z.string().max(1000),
limit: z.number().max(1000).default(100)
});
async function handleQuery(rawArgs: unknown) {
const args = QuerySchema.parse(rawArgs);
// Now safe to use args.sql
}
2. Read-Only Mode
// Reject dangerous operations
const FORBIDDEN_KEYWORDS = ['DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER'];
function validateReadOnly(sql: string) {
const upper = sql.toUpperCase();
for (const keyword of FORBIDDEN_KEYWORDS) {
if (upper.includes(keyword)) {
throw new Error(`Forbidden operation: ${keyword}`);
}
}
}
3. Rate Limiting
import { RateLimiter } from 'limiter';
const limiter = new RateLimiter({ tokensPerInterval: 10, interval: 'minute' });
async function handleRequest(req: Request) {
if (!await limiter.removeTokens(1)) {
throw new Error('Rate limit exceeded');
}
// Process request
}
Testing Your MCP Server
Manual Testing
# Test with mcp-cli
npx @modelcontextprotocol/inspector node server.js
# Or use the MCP Inspector UI
open http://localhost:5173
Automated Tests
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
describe('Database MCP Server', () => {
let client: Client;
beforeEach(async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = new DatabaseMCPServer('postgres://localhost/test');
await server.connect(serverTransport);
client = new Client({ name: 'test-client', version: '1.0.0' });
await client.connect(clientTransport);
});
test('lists available tools', async () => {
const tools = await client.listTools();
expect(tools.tools).toContainEqual(
expect.objectContaining({ name: 'query' })
);
});
test('executes query', async () => {
const result = await client.callTool('query', { sql: 'SELECT 1 as num' });
expect(result.content[0].text).toContain('1');
});
});
Deployment Options
Local Development
{
"mcpServers": {
"dev-server": {
"command": "node",
"args": ["./server.js"],
"env": { "NODE_ENV": "development" }
}
}
}
Production (Docker)
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
# docker-compose.yml
version: '3.8'
services:
mcp-server:
build: .
environment:
- DATABASE_URL=${DATABASE_URL}
ports:
- "3000:3000"
Summary
- MCP standardizes how LLMs connect to tools and data
- Three primitives: Tools (actions), Resources (data), Prompts (templates)
- Build once, use everywhere: Claude, Cursor, Windsurf, and more
- Transports: stdio for local, SSE for remote
- Security: Validate inputs, use read-only modes, implement rate limiting
MCP turns your custom integrations into reusable, composable AI infrastructure.
What to Read Next
- AI Agents Architecture Patterns — Design patterns for building agents
- Claude API Cheat Sheet — Claude SDK and parameters
- Build Your First MCP Server — Step-by-step tutorial