Building MCP Servers for Claude: A Complete Guide
Learn how to extend Claude's capabilities by building custom MCP (Model Context Protocol) servers that provide tools, resources, and prompts.
Robert Fridzema
Fullstack Developer

The Model Context Protocol (MCP) lets you extend Claude's capabilities with custom tools, data sources, and prompts. Instead of copying data into prompts manually, MCP servers provide Claude with direct access to your systems. Here's how to build one.
What is MCP?
MCP is a protocol that allows AI assistants like Claude to:
- Use tools - Execute functions and return results
- Access resources - Read files, databases, APIs
- Use prompts - Pre-defined prompt templates
┌─────────────────┐ MCP Protocol ┌─────────────────┐ │ Claude │◄───────────────────►│ MCP Server │ │ (AI Client) │ │ (Your Code) │ └─────────────────┘ └─────────────────┘ │ ▼ ┌─────────────────┐ │ Your Systems │ │ - Databases │ │ - APIs │ │ - Files │ └─────────────────┘
When to Build an MCP Server
Good use cases:
- Documentation access - Let Claude search your docs
- Database queries - Safe, read-only data access
- API integration - Connect Claude to your services
- Code generation - Provide context-aware templates
- Workflow automation - Multi-step processes
Project Setup
TypeScript Setup (Recommended)
mkdir my-mcp-server cd my-mcp-server npm init -y npm install @modelcontextprotocol/sdk zod npm install -D typescript @types/node tsx
// tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./dist", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src/**/*"] }
// package.json { "name": "my-mcp-server", "version": "1.0.0", "type": "module", "main": "dist/index.js", "scripts": { "build": "tsc", "start": "node dist/index.js", "dev": "tsx watch src/index.ts" } }
Basic Server Structure
// 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' // Create server instance const server = new Server( { name: 'my-mcp-server', version: '1.0.0', }, { capabilities: { tools: {}, resources: {}, }, } ) // 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)
Adding Tools
Tools let Claude execute functions. Define them with clear schemas:
// src/tools/search.ts import { z } from 'zod' // Define input schema export const SearchInputSchema = z.object({ query: z.string().describe('The search query'), limit: z.number().optional().default(10).describe('Max results to return'), }) export type SearchInput = z.infer<typeof SearchInputSchema> // Implement the tool export async function searchDocs(input: SearchInput): Promise<string> { const { query, limit } = input // Your search logic here const results = await performSearch(query, limit) return JSON.stringify(results, null, 2) }
Register tools with the server:
// src/index.ts import { searchDocs, SearchInputSchema } from './tools/search.js' // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'search_docs', description: 'Search the documentation for relevant content', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'The search query', }, limit: { type: 'number', description: 'Max results to return', default: 10, }, }, required: ['query'], }, }, { name: 'get_component', description: 'Get details about a UI component', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Component name (e.g., "Button", "Modal")', }, }, required: ['name'], }, }, ], } }) // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params switch (name) { case 'search_docs': { const input = SearchInputSchema.parse(args) const result = await searchDocs(input) return { content: [{ type: 'text', text: result }] } } case 'get_component': { const { name: componentName } = args as { name: string } const result = await getComponent(componentName) return { content: [{ type: 'text', text: result }] } } default: throw new Error(`Unknown tool: ${name}`) } })
Adding Resources
Resources provide data that Claude can read:
// List available resources server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: [ { uri: 'docs://components/button', name: 'Button Component', description: 'Documentation for the Button component', mimeType: 'text/markdown', }, { uri: 'docs://components/modal', name: 'Modal Component', description: 'Documentation for the Modal component', mimeType: 'text/markdown', }, { uri: 'config://theme', name: 'Theme Configuration', description: 'Current theme settings', mimeType: 'application/json', }, ], } }) // Read resource content server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params if (uri.startsWith('docs://components/')) { const componentName = uri.replace('docs://components/', '') const content = await loadComponentDocs(componentName) return { contents: [ { uri, mimeType: 'text/markdown', text: content, }, ], } } if (uri === 'config://theme') { const theme = await loadThemeConfig() return { contents: [ { uri, mimeType: 'application/json', text: JSON.stringify(theme, null, 2), }, ], } } throw new Error(`Unknown resource: ${uri}`) })
Real-World Example: Documentation Server
Here's a complete example that serves documentation with search:
// 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 Fuse from 'fuse.js' import { readFile, readdir } from 'fs/promises' import { join } from 'path' interface DocEntry { path: string title: string content: string } class DocsServer { private docs: DocEntry[] = [] private fuse: Fuse<DocEntry> | null = null async initialize(docsPath: string) { // Load all markdown files const files = await this.findMarkdownFiles(docsPath) for (const file of files) { const content = await readFile(file, 'utf-8') const title = this.extractTitle(content) this.docs.push({ path: file, title, content }) } // Initialize search index this.fuse = new Fuse(this.docs, { keys: ['title', 'content'], threshold: 0.3, includeScore: true, }) console.error(`Loaded ${this.docs.length} documents`) } search(query: string, limit: number = 10): DocEntry[] { if (!this.fuse) return [] return this.fuse .search(query) .slice(0, limit) .map((r) => r.item) } getDoc(path: string): DocEntry | undefined { return this.docs.find((d) => d.path === path) } listDocs(): DocEntry[] { return this.docs } private async findMarkdownFiles(dir: string): Promise<string[]> { const files: string[] = [] const entries = await readdir(dir, { withFileTypes: true }) for (const entry of entries) { const fullPath = join(dir, entry.name) if (entry.isDirectory()) { files.push(...(await this.findMarkdownFiles(fullPath))) } else if (entry.name.endsWith('.md')) { files.push(fullPath) } } return files } private extractTitle(content: string): string { const match = content.match(/^#\s+(.+)$/m) return match ? match[1] : 'Untitled' } } // Main async function main() { const docsPath = process.env.DOCS_PATH || './docs' const docsServer = new DocsServer() await docsServer.initialize(docsPath) const server = new Server( { name: 'docs-server', version: '1.0.0' }, { capabilities: { tools: {}, resources: {} } } ) // Tools server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'search_docs', description: 'Search documentation by keyword', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query' }, limit: { type: 'number', description: 'Max results', default: 5 }, }, required: ['query'], }, }, ], })) server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params if (name === 'search_docs') { const { query, limit = 5 } = args as { query: string; limit?: number } const results = docsServer.search(query, limit) const text = results .map((r) => `## ${r.title}\n\n${r.content.slice(0, 500)}...`) .join('\n\n---\n\n') return { content: [{ type: 'text', text }] } } throw new Error(`Unknown tool: ${name}`) }) // Resources server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: docsServer.listDocs().map((doc) => ({ uri: `docs://${doc.path}`, name: doc.title, mimeType: 'text/markdown', })), })) server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const path = request.params.uri.replace('docs://', '') const doc = docsServer.getDoc(path) if (!doc) throw new Error(`Document not found: ${path}`) return { contents: [{ uri: request.params.uri, mimeType: 'text/markdown', text: doc.content }], } }) const transport = new StdioServerTransport() await server.connect(transport) } main().catch(console.error)
Configuring Claude Desktop
Add your server to Claude Desktop's configuration:
// ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) // %APPDATA%\Claude\claude_desktop_config.json (Windows) { "mcpServers": { "docs": { "command": "node", "args": ["/path/to/my-mcp-server/dist/index.js"], "env": { "DOCS_PATH": "/path/to/documentation" } } } }
Restart Claude Desktop to load the server.
Testing Your Server
Test tools manually with the MCP Inspector:
npx @modelcontextprotocol/inspector node dist/index.js
This opens a web UI where you can:
- List and call tools
- Browse and read resources
- View server logs
Error Handling
Always handle errors gracefully:
server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params // Validate inputs if (!args || typeof args !== 'object') { throw new Error('Invalid arguments') } // Tool logic... const result = await executeTool(name, args) return { content: [{ type: 'text', text: result }] } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true, } } })
Security Considerations
- Validate all inputs - Never trust data from the client
- Limit capabilities - Only expose what's necessary
- Use read-only access - Avoid write operations when possible
- Sanitize outputs - Don't leak sensitive information
- Rate limit - Prevent abuse of expensive operations
// Example: Read-only database access const ALLOWED_TABLES = ['products', 'categories', 'docs'] async function queryDatabase(table: string, conditions: object) { if (!ALLOWED_TABLES.includes(table)) { throw new Error(`Access to table '${table}' is not allowed`) } // Use parameterized queries return db.select().from(table).where(conditions).limit(100) }
Deployment Options
Local Development
npm run dev # Uses tsx watch for hot reload
Production (systemd)
[Unit] Description=My MCP Server After=network.target [Service] Type=simple ExecStart=/usr/bin/node /opt/mcp-server/dist/index.js Restart=on-failure Environment=DOCS_PATH=/var/docs [Install] WantedBy=multi-user.target
Docker
FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --production COPY dist ./dist CMD ["node", "dist/index.js"]
Key Takeaways
- Start with tools - They're the most useful capability
- Use clear descriptions - Claude uses them to decide when to call tools
- Validate everything - Input validation prevents crashes and security issues
- Test with Inspector - Manual testing before Claude integration
- Keep it focused - One server per domain/purpose
MCP servers unlock Claude's potential by connecting it to your systems. Start simple, iterate based on what's useful, and you'll build something genuinely helpful.
Building AI tools? Let's talk - I've built several MCP servers and happy to share more patterns.
Related Articles

Implementing RAG with Laravel and pgvector
A practical guide to building Retrieval-Augmented Generation systems in Laravel using PostgreSQL's pgvector extension for semantic search.

Laravel 12 Starter Kit: Testing, Static Analysis & Quality Gates
How to set up a modern Laravel 12 project with comprehensive testing, static analysis, and automated quality gates from day one.

n8n Workflow Patterns for Business Automation
Battle-tested patterns for building reliable, maintainable business automation workflows with n8n, from error handling to complex orchestration.