MCP_DEVELOPMENT_GUIDE.md•19.7 kB
# How to Build an MCP Server - Complete Guide
## Table of Contents
1. [What is MCP?](#what-is-mcp)
2. [Prerequisites](#prerequisites)
3. [Step-by-Step Tutorial](#step-by-step-tutorial)
4. [MCP Protocol Basics](#mcp-protocol-basics)
5. [Tool Development](#tool-development)
6. [Testing Your MCP Server](#testing-your-mcp-server)
7. [Common Patterns](#common-patterns)
8. [Troubleshooting](#troubleshooting)
9. [Best Practices](#best-practices)
---
## What is MCP?
**MCP (Model Context Protocol)** is a protocol that allows AI assistants like Claude to interact with external tools and services. It enables:
- **Tool Calling** - Claude can call functions you define
- **Resource Access** - Provide data Claude can read
- **Prompts** - Define reusable prompt templates
- **Sampling** - Request AI completions from your code
### Why Build an MCP Server?
- Extend Claude's capabilities with custom functionality
- Integrate with APIs, databases, or internal systems
- Create domain-specific tools for your organization
- Automate complex workflows through natural language
---
## Prerequisites
### Required Knowledge
- **TypeScript/JavaScript** - MCP servers are typically written in TypeScript
- **Node.js** - Runtime environment
- **Async/Await** - For handling asynchronous operations
- **HTTP/APIs** - If integrating with external services
### Required Tools
```bash
# Node.js 18 or later
node --version # Should be 18.x or higher
# npm (comes with Node.js)
npm --version
# TypeScript (will be installed as dependency)
# Claude Desktop (for testing)
```
---
## Step-by-Step Tutorial
### Step 1: Project Setup
```bash
# Create project directory
mkdir my-mcp-server
cd my-mcp-server
# Initialize npm project
npm init -y
# Install MCP SDK
npm install @modelcontextprotocol/sdk
# Install TypeScript and types
npm install -D typescript @types/node
# Install build tools
npm install -D ts-node nodemon
# Create tsconfig.json
npx tsc --init
```
### Step 2: Configure TypeScript
Edit `tsconfig.json`:
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
```
### Step 3: Create Basic Server Structure
Create `src/index.ts`:
```typescript
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
// Create MCP server instance
const server = new Server(
{
name: 'my-mcp-server',
version: '1.0.0',
},
{
capabilities: {
tools: {}, // We support tools
},
}
);
// Define available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'echo',
description: 'Echoes back the input text',
inputSchema: {
type: 'object',
properties: {
text: {
type: 'string',
description: 'Text to echo back',
},
},
required: ['text'],
},
},
],
};
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'echo') {
const text = args.text as string;
return {
content: [
{
type: 'text',
text: `You said: ${text}`,
},
],
};
}
throw new Error(`Unknown tool: ${name}`);
});
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('MCP server running on stdio');
}
main().catch((error) => {
console.error('Server error:', error);
process.exit(1);
});
```
### Step 4: Update package.json
Add scripts and make it executable:
```json
{
"name": "my-mcp-server",
"version": "1.0.0",
"type": "module",
"bin": {
"my-mcp-server": "./dist/index.js"
},
"scripts": {
"build": "tsc && chmod +x dist/index.js",
"dev": "npm run build && node dist/index.js",
"watch": "nodemon --watch src --ext ts --exec \"npm run dev\""
},
"dependencies": {
"@modelcontextprotocol/sdk": "^0.5.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"nodemon": "^3.0.0",
"ts-node": "^10.0.0",
"typescript": "^5.0.0"
}
}
```
### Step 5: Build and Test
```bash
# Build
npm run build
# The dist/index.js file should now exist and be executable
# Test manually (will wait for input on stdin)
node dist/index.js
```
### Step 6: Configure Claude Desktop
Edit Claude Desktop config file:
**Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
**Mac:** `~/Library/Application Support/Claude/claude_desktop_config.json`
```json
{
"mcpServers": {
"my-mcp-server": {
"command": "node",
"args": [
"C:\\absolute\\path\\to\\my-mcp-server\\dist\\index.js"
]
}
}
}
```
**Important:** Use absolute paths!
### Step 7: Restart Claude Desktop
1. Quit Claude Desktop completely
2. Start Claude Desktop
3. Test in chat: "Use the echo tool to say hello"
---
## MCP Protocol Basics
### Communication Flow
```
Claude Desktop ←→ MCP Server
(stdio)
```
MCP uses **JSON-RPC 2.0** over **stdio** (standard input/output).
### Message Types
1. **Request** - Claude sends to server
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}
```
2. **Response** - Server sends back to Claude
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [...]
}
}
```
3. **Notification** - One-way message (no response expected)
### Key Request Handlers
```typescript
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: [...] };
});
// Execute a tool
server.setRequestHandler(CallToolRequestSchema, async (request) => {
// Handle tool execution
});
// List resources (optional)
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return { resources: [...] };
});
// Read a resource (optional)
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
// Return resource content
});
// List prompts (optional)
server.setRequestHandler(ListPromptsRequestSchema, async () => {
return { prompts: [...] };
});
```
---
## Tool Development
### Anatomy of a Tool
```typescript
{
name: 'tool_name', // Unique identifier
description: 'What it does', // Clear description for Claude
inputSchema: { // JSON Schema for parameters
type: 'object',
properties: {
param1: {
type: 'string',
description: 'Parameter description'
}
},
required: ['param1']
}
}
```
### Example: API Integration Tool
```typescript
// Tool definition
{
name: 'get_weather',
description: 'Get current weather for a city',
inputSchema: {
type: 'object',
properties: {
city: {
type: 'string',
description: 'City name'
},
units: {
type: 'string',
enum: ['celsius', 'fahrenheit'],
description: 'Temperature units',
default: 'celsius'
}
},
required: ['city']
}
}
// Tool implementation
if (name === 'get_weather') {
const { city, units = 'celsius' } = args;
try {
// Call weather API
const response = await fetch(
`https://api.weather.com/current?city=${city}&units=${units}`
);
const data = await response.json();
return {
content: [
{
type: 'text',
text: JSON.stringify({
city: data.city,
temperature: data.temp,
conditions: data.conditions,
units: units
}, null, 2)
}
]
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Error fetching weather: ${error.message}`
}
],
isError: true
};
}
}
```
### Example: Database Query Tool
```typescript
import { Client } from 'pg'; // PostgreSQL client
const dbClient = new Client({
host: 'localhost',
database: 'mydb',
user: 'user',
password: 'password'
});
await dbClient.connect();
// Tool definition
{
name: 'query_customers',
description: 'Search customers by name or email',
inputSchema: {
type: 'object',
properties: {
search: {
type: 'string',
description: 'Search term'
},
limit: {
type: 'number',
description: 'Max results',
default: 10
}
},
required: ['search']
}
}
// Tool implementation
if (name === 'query_customers') {
const { search, limit = 10 } = args;
const result = await dbClient.query(
'SELECT id, name, email FROM customers WHERE name ILIKE $1 OR email ILIKE $1 LIMIT $2',
[`%${search}%`, limit]
);
return {
content: [
{
type: 'text',
text: JSON.stringify({
count: result.rows.length,
customers: result.rows
}, null, 2)
}
]
};
}
```
### Example: File System Tool
```typescript
import fs from 'fs/promises';
import path from 'path';
// Tool definition
{
name: 'list_files',
description: 'List files in a directory',
inputSchema: {
type: 'object',
properties: {
directory: {
type: 'string',
description: 'Directory path'
}
},
required: ['directory']
}
}
// Tool implementation
if (name === 'list_files') {
const { directory } = args;
try {
const files = await fs.readdir(directory);
const fileDetails = await Promise.all(
files.map(async (file) => {
const filePath = path.join(directory, file);
const stats = await fs.stat(filePath);
return {
name: file,
size: stats.size,
modified: stats.mtime,
isDirectory: stats.isDirectory()
};
})
);
return {
content: [
{
type: 'text',
text: JSON.stringify({
directory,
count: files.length,
files: fileDetails
}, null, 2)
}
]
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Error reading directory: ${error.message}`
}
],
isError: true
};
}
}
```
---
## Testing Your MCP Server
### 1. Manual Testing with stdio
```typescript
// test.ts
import { spawn } from 'child_process';
const server = spawn('node', ['./dist/index.js']);
// Send request
server.stdin.write(JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/list',
params: {}
}) + '\n');
// Listen for response
server.stdout.on('data', (data) => {
console.log('Response:', data.toString());
});
server.stderr.on('data', (data) => {
console.log('Log:', data.toString());
});
```
### 2. Claude Desktop Testing
Best way to test:
1. Configure in `claude_desktop_config.json`
2. Restart Claude Desktop
3. Open dev tools (View → Toggle Developer Tools)
4. Check console for MCP server logs
5. Test in chat: "Use [tool_name] to..."
### 3. Unit Testing
```typescript
// __tests__/tools.test.ts
import { describe, it, expect } from '@jest/globals';
import { myTool } from '../src/tools/my-tool';
describe('myTool', () => {
it('should return expected result', async () => {
const result = await myTool({ param: 'value' });
expect(result.content[0].text).toContain('expected');
});
});
```
---
## Common Patterns
### Pattern 1: Configuration Management
```typescript
// src/config.ts
import fs from 'fs';
import path from 'path';
interface Config {
apiKey: string;
baseUrl: string;
timeout: number;
}
export function loadConfig(): Config {
const configPath = path.join(__dirname, '../config.json');
const configData = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(configData);
// Replace environment variables
return {
apiKey: process.env.API_KEY || config.apiKey,
baseUrl: config.baseUrl,
timeout: config.timeout
};
}
```
### Pattern 2: Error Handling
```typescript
async function handleToolCall(name: string, args: any) {
try {
// Tool implementation
const result = await someTool(args);
return {
content: [{ type: 'text', text: JSON.stringify(result) }]
};
} catch (error) {
console.error(`Error in ${name}:`, error);
return {
content: [{
type: 'text',
text: `Error: ${error.message}`
}],
isError: true
};
}
}
```
### Pattern 3: Logging
```typescript
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// Use in tools
logger.info('Tool called', { name, args });
logger.error('Tool failed', { name, error: error.message });
```
### Pattern 4: Retry Logic
```typescript
async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
initialDelay: number = 1000
): Promise<T> {
let lastError: any;
let delay = initialDelay;
for (let i = 0; i <= maxRetries; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (i < maxRetries) {
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2;
}
}
}
throw lastError;
}
// Usage
const result = await retryWithBackoff(() => apiCall(), 3, 1000);
```
### Pattern 5: Input Validation
```typescript
import { z } from 'zod';
const WeatherArgsSchema = z.object({
city: z.string().min(1, 'City is required'),
units: z.enum(['celsius', 'fahrenheit']).default('celsius')
});
if (name === 'get_weather') {
// Validate input
const validatedArgs = WeatherArgsSchema.parse(args);
// Use validated args
const result = await getWeather(validatedArgs.city, validatedArgs.units);
}
```
---
## Troubleshooting
### Issue: MCP Server Not Appearing in Claude Desktop
**Symptoms:**
- Server configured in `claude_desktop_config.json`
- Claude doesn't show tools in chat
**Solutions:**
1. Check absolute path in config (no relative paths!)
2. Ensure `dist/index.js` has shebang: `#!/usr/bin/env node`
3. Verify file is executable: `chmod +x dist/index.js`
4. Restart Claude Desktop completely (quit, not just close window)
5. Check Claude Desktop logs: View → Toggle Developer Tools
### Issue: Server Crashes on Startup
**Symptoms:**
- Server starts then immediately exits
- Claude shows "Server disconnected"
**Solutions:**
1. Check `console.error()` logs (goes to Claude dev tools)
2. Run manually: `node dist/index.js` to see errors
3. Check for missing dependencies
4. Verify TypeScript compiled correctly: `npm run build`
### Issue: Tool Not Being Called
**Symptoms:**
- Claude acknowledges tool exists
- But doesn't use it when asked
**Solutions:**
1. Improve tool description (be specific!)
2. Add examples in description
3. Check inputSchema matches Claude's expectations
4. Try explicit: "Use the [tool_name] tool to..."
### Issue: JSON Parse Errors
**Symptoms:**
- "Unexpected token" errors
- Server crashes when Claude calls tool
**Solutions:**
1. Ensure all responses are valid JSON
2. Use `JSON.stringify()` for complex objects
3. Handle undefined/null values
4. Escape special characters in strings
---
## Best Practices
### 1. Tool Design
✅ **DO:**
- Give tools clear, descriptive names
- Write detailed descriptions with examples
- Use JSON Schema validation
- Return structured data
- Handle errors gracefully
❌ **DON'T:**
- Make tools too generic
- Return unstructured text when JSON is better
- Ignore error handling
- Use unclear parameter names
### 2. Performance
✅ **DO:**
- Cache frequent requests
- Use connection pooling for databases
- Implement timeouts
- Use streaming for large responses
❌ **DON'T:**
- Make synchronous blocking calls
- Load entire files into memory
- Retry indefinitely
- Skip resource cleanup
### 3. Security
✅ **DO:**
- Validate all inputs
- Use environment variables for secrets
- Sanitize file paths
- Rate limit expensive operations
- Log security events
❌ **DON'T:**
- Commit secrets to git
- Trust user input
- Allow arbitrary file access
- Expose internal errors to Claude
### 4. Maintainability
✅ **DO:**
- Organize code into modules
- Use TypeScript for type safety
- Write tests
- Document your tools
- Version your server
❌ **DON'T:**
- Put everything in one file
- Skip type definitions
- Ignore linting warnings
- Leave TODO comments forever
---
## Real-World Example: RS.ge Waybill MCP Server
This server integrates a SOAP API with Claude. Key learnings:
### 1. Complex API Integration
```typescript
// Bad: Direct SOAP calls in tool handlers
if (name === 'get_waybills') {
const xml = buildSoapXml(args); // Complex logic here
const response = await axios.post(url, xml);
return parseXml(response.data); // More complex logic
}
// Good: Separate API client
class ApiClient {
async getWaybills(start, end) {
const xml = this.buildRequest(start, end);
const response = await this.post(xml);
return this.parseResponse(response);
}
}
if (name === 'get_waybills') {
const result = await apiClient.getWaybills(args.start, args.end);
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
}
```
### 2. Date Handling
```typescript
// Learned through trial and error:
// - API requires ISO datetime (not just date)
// - End date needs +1 day to be inclusive
// - Format must be exactly YYYY-MM-DDTHH:MM:SS
function prepareDates(start: string, end: string) {
const startDateTime = `${start}T00:00:00`;
const endDate = new Date(end);
endDate.setDate(endDate.getDate() + 1);
const endDateTime = endDate.toISOString().slice(0, 19);
return { startDateTime, endDateTime };
}
```
### 3. XML Parsing Gotchas
```typescript
// Problem: XML attributes break simple parsers
// <Response xmlns="..."> ← This creates @_xmlns key!
const parser = new XMLParser({
ignoreAttributes: false, // Need to see them
attributeNamePrefix: '@_' // Prefix them
});
const parsed = parser.parse(xml);
// Filter out attributes when looking for data
const keys = Object.keys(parsed).filter(k => !k.startsWith('@_'));
```
---
## Summary
Building an MCP server:
1. **Setup** - Initialize Node.js project with TypeScript
2. **Define Tools** - Create clear, focused tools with good schemas
3. **Implement Handlers** - Write robust, error-handling tool implementations
4. **Test Thoroughly** - Test manually, with Claude, and with unit tests
5. **Deploy** - Configure in Claude Desktop and monitor logs
**Key Takeaways:**
- MCP uses JSON-RPC over stdio
- Tools must have clear descriptions and schemas
- Error handling is critical
- Test with real Claude Desktop
- Look at working examples (like this project!)
**Next Steps:**
- Study the code in `src/tools/` for real examples
- Read the MCP SDK documentation
- Build a simple server first, then add complexity
- Join the MCP community for help
---
**Resources:**
- MCP SDK: https://github.com/modelcontextprotocol/sdk
- This Project: Example of production MCP server
- Claude Desktop: For testing