How to Set Up MCP: A Complete Guide to the Model Context Protocol
Build custom MCP servers that give AI assistants access to your tools, data, and APIs. Step-by-step with working code examples.
What is MCP?
The Model Context Protocol (MCP) is an open standard that lets you connect AI assistants like Claude to external data sources and tools. Instead of copying and pasting context into every conversation, MCP servers provide that context automatically.
Think of it like plugins for AI—but with a standardized protocol that works across different clients and servers.
Why MCP Matters
Without MCP, getting an AI to work with your tools requires:
- Custom integrations for each AI provider
- API key management in multiple places
- Re-implementing the same tool logic everywhere
With MCP, you build once and connect everywhere. Claude Desktop, VS Code extensions, custom applications—they all speak the same protocol.
MCP Architecture
MCP uses a client-server model over JSON-RPC:
┌─────────────────┐ JSON-RPC ┌─────────────────┐
│ MCP Client │ ←───────────────→ │ MCP Server │
│ (Claude Desktop)│ │ (Your Code) │
└─────────────────┘ └─────────────────┘
│ │
│ Requests tools, │ Provides tools,
│ resources, prompts │ resources, prompts
▼ ▼
User's Query Your Data/APIs
An MCP server can expose three types of capabilities:
- Tools: Functions the AI can call (like "search_database" or "send_email")
- Resources: Data the AI can read (like files, database records, or API responses)
- Prompts: Pre-defined prompt templates for common tasks
Setting Up Your First MCP Server
Let's build a practical MCP server that gives Claude access to a local SQLite database.
Step 1: Initialize the Project
mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk better-sqlite3
npm install -D typescript @types/node @types/better-sqlite3
Create your TypeScript config:
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
}
}
Step 2: Create the Server
// src/index.ts
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 Database from "better-sqlite3";
// Initialize database
const db = new Database("./data.db");
// Create sample table if it doesn't exist
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Create MCP server
const server = new Server(
{
name: "sqlite-mcp",
version: "1.0.0",
},
{
capabilities: {
tools: {},
resources: {},
},
}
);
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "query_database",
description: "Execute a read-only SQL query on the database",
inputSchema: {
type: "object",
properties: {
sql: {
type: "string",
description: "SQL SELECT query to execute",
},
},
required: ["sql"],
},
},
{
name: "insert_user",
description: "Insert a new user into the database",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "User's name" },
email: { type: "string", description: "User's email" },
},
required: ["name", "email"],
},
},
],
};
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case "query_database": {
const sql = args?.sql as string;
// Security: Only allow SELECT queries
if (!sql.trim().toLowerCase().startsWith("select")) {
return {
content: [
{
type: "text",
text: "Error: Only SELECT queries are allowed",
},
],
isError: true,
};
}
try {
const rows = db.prepare(sql).all();
return {
content: [
{
type: "text",
text: JSON.stringify(rows, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Query error: ${error.message}`,
},
],
isError: true,
};
}
}
case "insert_user": {
const { name, email } = args as { name: string; email: string };
try {
const stmt = db.prepare(
"INSERT INTO users (name, email) VALUES (?, ?)"
);
const result = stmt.run(name, email);
return {
content: [
{
type: "text",
text: `User created with ID: ${result.lastInsertRowid}`,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Insert error: ${error.message}`,
},
],
isError: true,
};
}
}
default:
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true,
};
}
});
// List available resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: "sqlite://tables",
name: "Database Tables",
description: "List of all tables in the database",
mimeType: "application/json",
},
{
uri: "sqlite://users",
name: "Users Table",
description: "All users in the database",
mimeType: "application/json",
},
],
};
});
// Read resources
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
switch (uri) {
case "sqlite://tables": {
const tables = db
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
)
.all();
return {
contents: [
{
uri,
mimeType: "application/json",
text: JSON.stringify(tables, null, 2),
},
],
};
}
case "sqlite://users": {
const users = db.prepare("SELECT * FROM users").all();
return {
contents: [
{
uri,
mimeType: "application/json",
text: JSON.stringify(users, null, 2),
},
],
};
}
default:
throw new Error(`Unknown resource: ${uri}`);
}
});
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("SQLite MCP server running on stdio");
}
main().catch(console.error);
Step 3: Build and Test
npx tsc
node dist/index.js
Step 4: Configure Claude Desktop
Add your server to Claude Desktop's config file:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"sqlite": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/dist/index.js"]
}
}
}
Restart Claude Desktop. You should now see your tools available.
Real-World MCP Examples
GitHub Integration
Connect Claude to your GitHub repositories:
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "search_issues",
description: "Search GitHub issues in a repository",
inputSchema: {
type: "object",
properties: {
repo: { type: "string", description: "owner/repo format" },
query: { type: "string", description: "Search query" },
},
required: ["repo", "query"],
},
},
{
name: "create_issue",
description: "Create a new GitHub issue",
inputSchema: {
type: "object",
properties: {
repo: { type: "string" },
title: { type: "string" },
body: { type: "string" },
},
required: ["repo", "title"],
},
},
],
}));
Slack Workspace Access
Let Claude read and send Slack messages:
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "list_channels",
description: "List Slack channels the bot has access to",
inputSchema: { type: "object", properties: {} },
},
{
name: "read_channel_history",
description: "Read recent messages from a channel",
inputSchema: {
type: "object",
properties: {
channel_id: { type: "string" },
limit: { type: "number", default: 20 },
},
required: ["channel_id"],
},
},
{
name: "send_message",
description: "Send a message to a Slack channel",
inputSchema: {
type: "object",
properties: {
channel_id: { type: "string" },
text: { type: "string" },
},
required: ["channel_id", "text"],
},
},
],
}));
Local File System
Give Claude access to your project files:
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const files = await glob("**/*.{ts,js,json,md}", {
cwd: projectRoot,
ignore: ["node_modules/**", "dist/**"],
});
return {
resources: files.map((file) => ({
uri: `file://${path.join(projectRoot, file)}`,
name: file,
mimeType: getMimeType(file),
})),
};
});
Security Best Practices
MCP servers have significant power. Follow these guidelines:
1. Validate All Inputs
Never trust data from the client:
function validateSqlQuery(sql: string): boolean {
const forbidden = ["drop", "delete", "update", "insert", "alter", "create"];
const lowerSql = sql.toLowerCase();
return !forbidden.some((word) => lowerSql.includes(word));
}
2. Use Environment Variables for Secrets
const apiKey = process.env.GITHUB_TOKEN;
if (!apiKey) {
throw new Error("GITHUB_TOKEN environment variable required");
}
3. Implement Rate Limiting
const rateLimiter = new Map<string, number>();
function checkRateLimit(tool: string): boolean {
const now = Date.now();
const lastCall = rateLimiter.get(tool) || 0;
if (now - lastCall < 1000) {
return false;
}
rateLimiter.set(tool, now);
return true;
}
4. Log Everything
server.setRequestHandler(CallToolRequestSchema, async (request) => {
console.error(
`Tool called: ${request.params.name}`,
JSON.stringify(request.params.arguments)
);
// ... handle request
});
Debugging Tips
Enable Verbose Logging
DEBUG=mcp:* node dist/index.js
Test with MCP Inspector
The MCP SDK includes an inspector tool:
npx @modelcontextprotocol/inspector node dist/index.js
This opens a web UI where you can test your tools and resources interactively.
Check Claude Desktop Logs
macOS: ~/Library/Logs/Claude/mcp*.log
Windows: %APPDATA%\Claude\logs\mcp*.log
Key Takeaways
- MCP is the standard for connecting AI to external tools and data
- Start simple: Tools and resources are the core primitives
- Security matters: Validate inputs, limit permissions, log everything
- Test locally before configuring Claude Desktop
Building MCP servers is one of the most practical skills for AI engineers. It's how you make AI assistants actually useful in real workflows—not just chat interfaces.
The protocol is still evolving, but the fundamentals are stable. Start building, and you'll be ahead of the curve.