Building Custom MCP Servers for Claude Code
Learn to build custom Model Context Protocol servers for Claude Code. Includes architecture, TypeScript examples, and deployment strategies.

Introduction
Claude Code's power multiplies when you extend it with custom tools. Building an mcp server claude code integration enables specialized services that provide Claude with access to your unique data, systems, and capabilities. The Model Context Protocol (MCP) is the standardized interface that allows you to create these custom servers.
Whether you need Claude to interact with proprietary databases, manage your infrastructure, query specialized APIs, or execute custom business logic, building a custom MCP server bridges the gap between Claude's general intelligence and your domain-specific requirements. If you are new to Claude Code, start with our getting started with Claude Code guide first.
This comprehensive guide walks you through building production-ready MCP servers that extend Claude Code's capabilities and unlock new levels of automation in your development workflow.
Understanding the Model Context Protocol
What Is MCP?
The Model Context Protocol is a standardized specification for how AI systems communicate with external tools and data sources. Rather than requiring each AI application to implement custom integrations, MCP provides a common language that any AI system can use to interact with any MCP server.
Think of MCP as the "HTTP of AI": a universal protocol that enables Claude Code to discover, understand, and use custom tools without needing specialized connectors for each integration.
Why Custom MCP Servers Matter
Off-the-shelf integrations have limitations. They can't access your proprietary systems, they can't understand your business logic, and they can't adapt to your unique workflows. Custom MCP servers solve this by allowing you to:
- Extend Claude's capabilities with domain-specific functionality
- Connect to proprietary systems that lack public APIs
- Maintain security and control over how Claude accesses your data
- Implement business logic specific to your organization
- Create intelligent workflows that combine multiple systems
MCP Architecture Overview
MCP servers operate on a client-server model:
Claude Code (Client)
↓
MCP Protocol
↓
Custom MCP Server
↓
Your Systems, APIs, DatabasesThe client (Claude Code) sends requests via the MCP protocol to your server, which handles the request logic and returns results. This separation of concerns allows Claude Code to remain agnostic about implementation details while your server can use any technology stack.
Core MCP Concepts
Resources
Resources represent data or functionality your server exposes. Think of them as endpoints that Claude Code can read or interact with.
// Example resources
/database/users // Access user data
/api/analytics // Query analytics
/files/documentation // Browse documentation
/system/infrastructure // View system metricsTools
Tools are actions that Claude Code can execute through your server. These are RPC-style functions with defined input and output schemas.
// Example tools
query_database(sql: string) // Execute database queries
deploy_service(name: string, version: string) // Deploy services
generate_report(format: string, dateRange: string) // Generate reports
update_config(service: string, config: object) // Modify configurationsSampling
Sampling describes how Claude Code can request new capabilities or resources dynamically from your server, enabling discovery of available tools at runtime.
Building Your First MCP Server
Project Setup
Initialize a TypeScript project for your MCP server:
# Create project directory
mkdir my-mcp-server
cd my-mcp-server
# Initialize npm
npm init -y
# Install dependencies
npm install @modelcontextprotocol/sdk typescript ts-node
npm install -D @types/node
# Create TypeScript configuration
cat > tsconfig.json << 'EOF'
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
EOFBasic Server Structure
Here's a minimal but functional MCP server:
// src/index.ts
import {
Server,
StdioServerTransport,
} from "@modelcontextprotocol/sdk/server/index.js";
import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
ListToolsRequestSchema,
CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
// Create the server
const server = new Server({
name: "my-custom-server",
version: "1.0.0",
});
// Define available resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: "file:///data/users",
name: "User Database",
description: "Access to user records",
mimeType: "application/json",
},
{
uri: "file:///data/config",
name: "Configuration",
description: "System configuration",
mimeType: "application/json",
},
],
};
});
// Handle resource reading
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
if (uri === "file:///data/users") {
return {
contents: [
{
uri,
mimeType: "application/json",
text: JSON.stringify([
{ id: 1, name: "Alice", role: "admin" },
{ id: 2, name: "Bob", role: "user" },
]),
},
],
};
}
throw new Error(`Unknown resource: ${uri}`);
});
// Define available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "query_database",
description: "Execute SQL queries against the database",
inputSchema: {
type: "object",
properties: {
sql: {
type: "string",
description: "SQL query to execute",
},
},
required: ["sql"],
},
},
{
name: "create_user",
description: "Create a new user record",
inputSchema: {
type: "object",
properties: {
name: { type: "string" },
email: { type: "string" },
role: { type: "string", enum: ["admin", "user", "viewer"] },
},
required: ["name", "email", "role"],
},
},
],
};
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "query_database") {
// In production, execute actual database queries
return {
content: [
{
type: "text",
text: `Query executed: ${args.sql}`,
},
],
};
}
if (name === "create_user") {
return {
content: [
{
type: "text",
text: `User created: ${args.name} (${args.email}) with role ${args.role}`,
},
],
};
}
throw new Error(`Unknown tool: ${name}`);
});
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP server running on stdio");
}
main().catch(console.error);Real-World MCP Server Examples
Example 1: Database Query Server
A practical server for executing database queries:
// src/servers/database-server.ts
import Database from "better-sqlite3";
import {
Server,
StdioServerTransport,
} from "@modelcontextprotocol/sdk/server/index.js";
const db = new Database("app.db");
const server = new Server({
name: "database-server",
version: "1.0.0",
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "execute_query",
description: "Execute a SQL SELECT query",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "SQL SELECT query",
},
params: {
type: "array",
description: "Query parameters for parameterized queries",
},
},
required: ["query"],
},
},
{
name: "get_schema",
description: "Get database schema information",
inputSchema: {
type: "object",
properties: {
table: {
type: "string",
description: "Table name (optional)",
},
},
},
},
],
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "execute_query") {
try {
const stmt = db.prepare(args.query);
const results = args.params
? stmt.all(...args.params)
: stmt.all();
return {
content: [
{
type: "text",
text: JSON.stringify(results, null, 2),
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: `Query error: ${error.message}`,
isError: true,
},
],
};
}
}
if (name === "get_schema") {
const query = args.table
? `SELECT sql FROM sqlite_master WHERE type='table' AND name=?`
: `SELECT name, sql FROM sqlite_master WHERE type='table'`;
const results = args.table
? db.prepare(query).all(args.table)
: db.prepare(query).all();
return {
content: [
{
type: "text",
text: JSON.stringify(results, null, 2),
},
],
};
}
throw new Error(`Unknown tool: ${name}`);
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch(console.error);Example 2: API Wrapper Server
A server that wraps multiple external APIs:
// src/servers/api-wrapper-server.ts
import fetch from "node-fetch";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
const server = new Server({
name: "api-wrapper-server",
version: "1.0.0",
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "fetch_github_user",
description: "Fetch GitHub user information",
inputSchema: {
type: "object",
properties: {
username: {
type: "string",
description: "GitHub username",
},
},
required: ["username"],
},
},
{
name: "get_weather",
description: "Get current weather for a location",
inputSchema: {
type: "object",
properties: {
city: {
type: "string",
description: "City name",
},
},
required: ["city"],
},
},
],
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "fetch_github_user") {
try {
const response = await fetch(
`https://api.github.com/users/${args.username}`,
{
headers: {
Authorization: `token ${process.env.GITHUB_TOKEN}`,
},
}
);
const data = await response.json();
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2),
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: `Error fetching user: ${error.message}`,
isError: true,
},
],
};
}
}
if (name === "get_weather") {
try {
const response = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=${args.city}&appid=${process.env.WEATHER_API_KEY}`
);
const data = await response.json();
return {
content: [
{
type: "text",
text: `Weather in ${args.city}: ${data.main.temp}°K, ${data.weather[0].description}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: `Error fetching weather: ${error.message}`,
isError: true,
},
],
};
}
}
throw new Error(`Unknown tool: ${name}`);
});Example 3: File System Server
A server providing intelligent file system access:
// src/servers/filesystem-server.ts
import fs from "fs/promises";
import path from "path";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
const SAFE_BASE_PATH = "/app/documents";
const server = new Server({
name: "filesystem-server",
version: "1.0.0",
});
server.setRequestHandler(ListResourcesRequestSchema, async () => {
try {
const files = await fs.readdir(SAFE_BASE_PATH);
return {
resources: files.map((file) => ({
uri: `file://${SAFE_BASE_PATH}/${file}`,
name: file,
description: `File: ${file}`,
mimeType: "text/plain",
})),
};
} catch (error) {
return { resources: [] };
}
});
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
const filePath = uri.replace("file://", "");
// Security: ensure path is within safe base
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(SAFE_BASE_PATH)) {
throw new Error("Access denied: path outside safe directory");
}
try {
const content = await fs.readFile(resolvedPath, "utf-8");
return {
contents: [
{
uri,
mimeType: "text/plain",
text: content,
},
],
};
} catch (error: any) {
throw new Error(`Cannot read file: ${error.message}`);
}
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "write_file",
description: "Write content to a file",
inputSchema: {
type: "object",
properties: {
filename: { type: "string" },
content: { type: "string" },
},
required: ["filename", "content"],
},
},
{
name: "list_directory",
description: "List files in a directory",
inputSchema: {
type: "object",
properties: {
directory: { type: "string" },
},
required: ["directory"],
},
},
],
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "write_file") {
const filePath = path.join(SAFE_BASE_PATH, args.filename);
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(SAFE_BASE_PATH)) {
return {
content: [
{
type: "text",
text: "Error: Access denied",
isError: true,
},
],
};
}
try {
await fs.writeFile(resolvedPath, args.content, "utf-8");
return {
content: [
{
type: "text",
text: `File written: ${args.filename}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: `Error writing file: ${error.message}`,
isError: true,
},
],
};
}
}
throw new Error(`Unknown tool: ${name}`);
});Connecting Your MCP Server to Claude Code
Configuration
Register your MCP server in Claude Code's configuration:
// claude-mcp-servers.json
{
"servers": {
"my-database": {
"type": "command",
"command": "ts-node",
"args": ["src/servers/database-server.ts"],
"env": {
"DATABASE_URL": "sqlite:app.db"
}
},
"my-apis": {
"type": "command",
"command": "ts-node",
"args": ["src/servers/api-wrapper-server.ts"],
"env": {
"GITHUB_TOKEN": "${GITHUB_TOKEN}",
"WEATHER_API_KEY": "${WEATHER_API_KEY}"
}
}
}
}Testing Your Server
Before deploying, thoroughly test your server:
// test/server.test.ts
import { test } from "@jest/globals";
import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
test("should execute database query", async () => {
const request = {
params: {
name: "execute_query",
arguments: {
query: "SELECT * FROM users WHERE id = ?",
params: [1],
},
},
};
// Call your server handler
const response = await server.handleRequest(request);
expect(response.content).toBeDefined();
expect(response.content[0].type).toBe("text");
});
test("should deny unauthorized file access", async () => {
const request = {
params: {
name: "write_file",
arguments: {
filename: "../../etc/passwd",
content: "hack",
},
},
};
const response = await server.handleRequest(request);
expect(response.content[0].isError).toBe(true);
});Security Best Practices
Input Validation
Always validate and sanitize user input:
// Validate input types and constraints
function validateQueryInput(query: string): boolean {
// Check length
if (query.length > 10000) return false;
// Prevent dangerous operations
const dangerous = ["DROP", "DELETE", "TRUNCATE"];
return !dangerous.some((cmd) => query.toUpperCase().includes(cmd));
}
// Use parameterized queries to prevent SQL injection
const stmt = db.prepare("SELECT * FROM users WHERE id = ?");
const results = stmt.all(userId); // Safe parameter bindingAuthentication and Authorization
Implement proper access control:
function authenticate(token: string): User | null {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
return decoded as User;
} catch {
return null;
}
}
function authorize(user: User, resource: string): boolean {
const permissions = userPermissions[user.id] || [];
return permissions.includes(resource);
}Rate Limiting
Protect against abuse with rate limiting:
import rateLimit from "express-rate-limit";
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (!limiter.isAllowed(request.clientId)) {
throw new Error("Rate limit exceeded");
}
// ... handle request
});Deployment Strategies
Docker Deployment
Package your server for reliable deployment:
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY src ./src
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]Systemd Service
Run your server as a system service:
# /etc/systemd/system/mcp-server.service
[Unit]
Description=Custom MCP Server
After=network.target
[Service]
Type=simple
User=mcp-user
WorkingDirectory=/opt/mcp-server
ExecStart=/usr/bin/node /opt/mcp-server/dist/index.js
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.targetKubernetes Deployment
Deploy at scale with Kubernetes:
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-server
spec:
replicas: 3
selector:
matchLabels:
app: mcp-server
template:
metadata:
labels:
app: mcp-server
spec:
containers:
- name: mcp-server
image: myregistry/mcp-server:1.0.0
ports:
- containerPort: 3000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: mcp-secrets
key: database-urlBuild Your First MCP Server Today
Custom MCP servers unlock the full potential of Claude Code by extending it with your domain-specific capabilities. From querying proprietary databases to integrating with internal APIs, MCP servers enable seamless AI integration with your infrastructure.
The path from basic concept to production server is straightforward:
- Start small with a simple resource or tool
- Test thoroughly before deployment
- Implement security from the beginning
- Monitor and iterate based on usage patterns
Key Takeaways
- MCP is the universal protocol for AI systems to interact with custom tools and data
- Custom servers extend Claude Code with domain-specific functionality unavailable through standard integrations
- Basic architecture includes resources (data), tools (actions), and proper client-server communication
- Real-world examples include database queries, API wrappers, and file system access
- Security is paramount: validate input, authenticate users, and implement rate limiting
- Testing and validation should happen before any production deployment
- Multiple deployment options range from simple Docker containers to Kubernetes clusters
- Start simple and iterate rather than building perfect-but-complex servers initially

Keyur Patel is the founder of AiPromptsX and an AI engineer with extensive experience in prompt engineering, large language models, and AI application development. After years of working with AI systems like ChatGPT, Claude, and Gemini, he created AiPromptsX to share effective prompt patterns and frameworks with the broader community. His mission is to democratize AI prompt engineering and help developers, content creators, and business professionals harness the full potential of AI tools.
Related Articles
Explore Related Frameworks
A.P.E Framework: A Simple Yet Powerful Approach to Effective Prompting
Action, Purpose, Expectation - A powerful methodology for designing effective prompts that maximize AI responses
RACE Framework: Role-Aligned Contextual Expertise
A structured approach to AI prompting that leverages specific roles, actions, context, and expectations to produce highly targeted outputs
R.O.S.E.S Framework: Crafting Prompts for Strategic Decision-Making
Use the R.O.S.E.S framework (Role, Objective, Style, Example, Scenario) to develop prompts that generate comprehensive strategic analysis and decision support.
Try These Related Prompts
Security Code Auditor
Adversarial-first security code review prompt for Claude Opus 4.7. 5-phase audit with OWASP Top 10, CWE Top 25, and CVSS 3.1 output. Paste code, get patch-ready findings with exploit scenarios and test cases.
Brutal Honest Advisor
Get unfiltered, direct feedback from an AI advisor who cuts through self-deception and provides harsh truths needed for breakthrough growth and clarity.
Competitor Analyzer
Perform competitive intelligence analysis to uncover competitors' strategies, weaknesses, and opportunities with actionable recommendations for dominance.