Building Your First MCP Server
Introduction
While the getting started tutorial covered the basics, this guide dives deep into building production-ready MCP servers with proper architecture, error handling, testing, and deployment patterns. We will build a task management server that demonstrates real-world MCP development practices.
For the Python perspective, see our guide on building an MCP server in Python.
Architecture Overview
Production MCP Server Layers
A well-structured MCP server separates concerns into layers:
┌─────────────────────────────────────────┐
│ MCP Protocol Layer (McpServer) │
├─────────────────────────────────────────┤
│ Tool & Resource Handlers │
├─────────────────────────────────────────┤
│ Business Logic / Services │
├─────────────────────────────────────────┤
│ Data Access / Storage Layer │
└─────────────────────────────────────────┘
Design Principles
- Separation of Concerns: Protocol handling separate from business logic
- Type Safety: Zod schemas for runtime validation, TypeScript for compile-time checks
- Error Boundaries: Graceful error handling at each layer
- Testability: Business logic decoupled from MCP transport
- Observability: Logging to stderr for debugging
Project Structure
Recommended Directory Layout
my-mcp-server/
├── src/
│ ├── index.ts # Entry point - server startup
│ ├── server.ts # McpServer configuration and tool registration
│ ├── services/
│ │ └── task.service.ts # Business logic
│ ├── storage/
│ │ └── task.store.ts # Data persistence
│ ├── types/
│ │ └── task.ts # Type definitions and Zod schemas
│ └── utils/
│ └── logger.ts # Logging utility
├── tests/
│ ├── task.service.test.ts
│ └── server.test.ts
├── package.json
├── tsconfig.json
└── README.md
Core Implementation
1. Type Definitions and Schemas
Create src/types/task.ts:
import { z } from 'zod';
export const TaskPriority = z.enum(['low', 'medium', 'high', 'urgent']);
export const TaskStatus = z.enum(['todo', 'in_progress', 'review', 'done', 'archived']);
export const TaskSchema = z.object({
id: z.string(),
title: z.string().min(1).max(200),
description: z.string().max(5000).optional(),
status: TaskStatus,
priority: TaskPriority,
assignee: z.string().optional(),
dueDate: z.string().datetime().optional(),
tags: z.array(z.string()).default([]),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export type Task = z.infer<typeof TaskSchema>;
export type TaskPriorityType = z.infer<typeof TaskPriority>;
export type TaskStatusType = z.infer<typeof TaskStatus>;
2. Storage Layer
Create src/storage/task.store.ts:
import { readFileSync, writeFileSync, existsSync } from 'fs';
import type { Task } from '../types/task.js';
export class TaskStore {
private tasks = new Map<string, Task>();
private nextId = 1;
private filePath: string | null;
constructor(filePath?: string) {
this.filePath = filePath ?? null;
if (this.filePath && existsSync(this.filePath)) {
const data = JSON.parse(readFileSync(this.filePath, 'utf-8'));
for (const task of data.tasks) {
this.tasks.set(task.id, task);
}
this.nextId = data.nextId ?? this.tasks.size + 1;
}
}
private persist(): void {
if (!this.filePath) return;
writeFileSync(
this.filePath,
JSON.stringify({
tasks: [...this.tasks.values()],
nextId: this.nextId,
}, null, 2)
);
}
create(data: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Task {
const now = new Date().toISOString();
const task: Task = {
...data,
id: `task-${this.nextId++}`,
createdAt: now,
updatedAt: now,
};
this.tasks.set(task.id, task);
this.persist();
return task;
}
get(id: string): Task | undefined {
return this.tasks.get(id);
}
list(filter?: {
status?: string;
priority?: string;
assignee?: string;
search?: string;
}): Task[] {
let results = [...this.tasks.values()];
if (filter?.status) {
results = results.filter((t) => t.status === filter.status);
}
if (filter?.priority) {
results = results.filter((t) => t.priority === filter.priority);
}
if (filter?.assignee) {
results = results.filter((t) => t.assignee === filter.assignee);
}
if (filter?.search) {
const q = filter.search.toLowerCase();
results = results.filter(
(t) =>
t.title.toLowerCase().includes(q) ||
t.description?.toLowerCase().includes(q)
);
}
return results.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
}
update(id: string, data: Partial<Omit<Task, 'id' | 'createdAt'>>): Task | null {
const existing = this.tasks.get(id);
if (!existing) return null;
const updated: Task = {
...existing,
...data,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
this.tasks.set(id, updated);
this.persist();
return updated;
}
delete(id: string): boolean {
const result = this.tasks.delete(id);
if (result) this.persist();
return result;
}
count(): number {
return this.tasks.size;
}
}
3. Business Logic Service
Create src/services/task.service.ts:
import { TaskStore } from '../storage/task.store.js';
import type { Task, TaskPriorityType, TaskStatusType } from '../types/task.js';
export class TaskService {
constructor(private store: TaskStore) {}
createTask(params: {
title: string;
description?: string;
priority: TaskPriorityType;
status?: TaskStatusType;
assignee?: string;
dueDate?: string;
tags?: string[];
}): Task {
return this.store.create({
title: params.title,
description: params.description,
priority: params.priority,
status: params.status ?? 'todo',
assignee: params.assignee,
dueDate: params.dueDate,
tags: params.tags ?? [],
});
}
getTask(id: string): Task | null {
return this.store.get(id) ?? null;
}
listTasks(filter?: {
status?: string;
priority?: string;
assignee?: string;
search?: string;
}): { tasks: Task[]; total: number } {
const tasks = this.store.list(filter);
return { tasks, total: tasks.length };
}
updateTask(
id: string,
updates: Partial<Pick<Task, 'title' | 'description' | 'priority' | 'status' | 'assignee' | 'dueDate' | 'tags'>>
): Task | null {
return this.store.update(id, updates);
}
deleteTask(id: string): boolean {
return this.store.delete(id);
}
getStatistics(): {
total: number;
byStatus: Record<string, number>;
byPriority: Record<string, number>;
} {
const tasks = this.store.list();
const byStatus: Record<string, number> = {};
const byPriority: Record<string, number> = {};
for (const task of tasks) {
byStatus[task.status] = (byStatus[task.status] ?? 0) + 1;
byPriority[task.priority] = (byPriority[task.priority] ?? 0) + 1;
}
return { total: tasks.length, byStatus, byPriority };
}
}
4. MCP Server Setup (McpServer API)
Create src/server.ts - this is where we wire everything together using the McpServer class:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { TaskService } from './services/task.service.js';
export function createServer(taskService: TaskService): McpServer {
const server = new McpServer({
name: 'task-management-server',
version: '1.0.0',
});
// --- Tools ---
server.tool(
'create_task',
'Create a new task with title, priority, and optional details',
{
title: z.string().min(1).max(200).describe('Task title'),
description: z.string().max(5000).optional().describe('Detailed description'),
priority: z.enum(['low', 'medium', 'high', 'urgent']).describe('Priority level'),
status: z.enum(['todo', 'in_progress', 'review', 'done']).default('todo'),
assignee: z.string().optional().describe('Person assigned to the task'),
dueDate: z.string().optional().describe('Due date in ISO 8601 format'),
tags: z.array(z.string()).optional().describe('Tags for categorization'),
},
async (params) => {
const task = taskService.createTask(params);
return {
content: [{
type: 'text',
text: `Created task "${task.title}" (ID: ${task.id}, Priority: ${task.priority})`,
}],
};
}
);
server.tool(
'list_tasks',
'List tasks with optional filtering by status, priority, assignee, or search term',
{
status: z.enum(['todo', 'in_progress', 'review', 'done', 'archived']).optional(),
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
assignee: z.string().optional(),
search: z.string().optional().describe('Search in title and description'),
},
async (filter) => {
const { tasks, total } = taskService.listTasks(filter);
if (tasks.length === 0) {
return { content: [{ type: 'text', text: 'No tasks found matching your criteria.' }] };
}
const lines = tasks.map(
(t) => `- [${t.priority.toUpperCase()}] ${t.title} (${t.status}) - ID: ${t.id}`
);
return {
content: [{ type: 'text', text: `Found ${total} tasks:\n\n${lines.join('\n')}` }],
};
}
);
server.tool(
'get_task',
'Get detailed information about a specific task by ID',
{
id: z.string().describe('Task ID (e.g., task-1)'),
},
async ({ id }) => {
const task = taskService.getTask(id);
if (!task) {
return { content: [{ type: 'text', text: `Task ${id} not found.` }] };
}
const details = [
`# ${task.title}`,
`**Status:** ${task.status} | **Priority:** ${task.priority}`,
task.assignee ? `**Assignee:** ${task.assignee}` : null,
task.dueDate ? `**Due:** ${task.dueDate}` : null,
task.tags.length ? `**Tags:** ${task.tags.join(', ')}` : null,
'',
task.description || '_No description_',
].filter(Boolean).join('\n');
return { content: [{ type: 'text', text: details }] };
}
);
server.tool(
'update_task',
'Update fields of an existing task',
{
id: z.string().describe('Task ID'),
title: z.string().min(1).max(200).optional(),
description: z.string().max(5000).optional(),
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
status: z.enum(['todo', 'in_progress', 'review', 'done', 'archived']).optional(),
assignee: z.string().optional(),
dueDate: z.string().optional(),
tags: z.array(z.string()).optional(),
},
async ({ id, ...updates }) => {
const task = taskService.updateTask(id, updates);
if (!task) {
return { content: [{ type: 'text', text: `Task ${id} not found.` }] };
}
return { content: [{ type: 'text', text: `Updated task "${task.title}"` }] };
}
);
server.tool(
'delete_task',
'Delete a task by ID',
{
id: z.string().describe('Task ID to delete'),
},
async ({ id }) => {
const success = taskService.deleteTask(id);
return {
content: [{
type: 'text',
text: success ? `Deleted task ${id}` : `Task ${id} not found.`,
}],
};
}
);
server.tool(
'task_statistics',
'Get summary statistics of all tasks grouped by status and priority',
{},
async () => {
const stats = taskService.getStatistics();
const lines = [
`**Total tasks:** ${stats.total}`,
'',
'**By Status:**',
...Object.entries(stats.byStatus).map(([k, v]) => ` - ${k}: ${v}`),
'',
'**By Priority:**',
...Object.entries(stats.byPriority).map(([k, v]) => ` - ${k}: ${v}`),
];
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
);
// --- Resources ---
server.resource(
'task-list',
'task:///list',
'JSON list of all tasks',
async (uri) => ({
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(taskService.listTasks().tasks, null, 2),
}],
})
);
server.resource(
'task-stats',
'task:///stats',
'Task statistics as JSON',
async (uri) => ({
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(taskService.getStatistics(), null, 2),
}],
})
);
return server;
}
5. Entry Point
Create src/index.ts:
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { TaskStore } from './storage/task.store.js';
import { TaskService } from './services/task.service.js';
import { createServer } from './server.js';
// Configuration from environment
const storagePath = process.env.STORAGE_PATH ?? undefined;
// Initialize layers
const store = new TaskStore(storagePath);
const service = new TaskService(store);
const server = createServer(service);
// Handle graceful shutdown
process.on('SIGINT', () => {
console.error('Shutting down...');
process.exit(0);
});
process.on('SIGTERM', () => {
console.error('Shutting down...');
process.exit(0);
});
// Start server
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Task Management MCP Server running on stdio');
Testing Your Server
Unit Testing the Service Layer
import { describe, it, expect, beforeEach } from 'vitest';
import { TaskStore } from '../src/storage/task.store.js';
import { TaskService } from '../src/services/task.service.js';
describe('TaskService', () => {
let service: TaskService;
beforeEach(() => {
service = new TaskService(new TaskStore()); // In-memory, no file
});
it('should create a task', () => {
const task = service.createTask({
title: 'Test Task',
priority: 'high',
});
expect(task.id).toBeDefined();
expect(task.title).toBe('Test Task');
expect(task.status).toBe('todo');
});
it('should filter tasks by status', () => {
service.createTask({ title: 'Task 1', priority: 'high', status: 'todo' });
service.createTask({ title: 'Task 2', priority: 'low', status: 'done' });
const { tasks } = service.listTasks({ status: 'todo' });
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toBe('Task 1');
});
it('should search tasks by keyword', () => {
service.createTask({ title: 'Fix login bug', priority: 'urgent' });
service.createTask({ title: 'Add dark mode', priority: 'medium' });
const { tasks } = service.listTasks({ search: 'login' });
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toBe('Fix login bug');
});
it('should compute statistics', () => {
service.createTask({ title: 'T1', priority: 'high', status: 'todo' });
service.createTask({ title: 'T2', priority: 'high', status: 'done' });
service.createTask({ title: 'T3', priority: 'low', status: 'todo' });
const stats = service.getStatistics();
expect(stats.total).toBe(3);
expect(stats.byPriority['high']).toBe(2);
expect(stats.byStatus['todo']).toBe(2);
});
});
Integration Testing with JSON-RPC
Test the full server by piping JSON-RPC messages:
# Initialize the server
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | npx tsx src/index.ts
# List available tools
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | npx tsx src/index.ts
Python Equivalent with FastMCP
For comparison, here is the same task management server in Python using FastMCP. For a full walkthrough, read Build Your First MCP Server in Python.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("task-management-server")
tasks: dict[str, dict] = {}
next_id = 1
@mcp.tool()
def create_task(title: str, priority: str, description: str = "", tags: list[str] | None = None) -> str:
"""Create a new task with title and priority.
Args:
title: Task title (required)
priority: Priority level - low, medium, high, or urgent
description: Detailed task description
tags: Optional list of tags for categorization
"""
global next_id
task_id = f"task-{next_id}"
next_id += 1
tasks[task_id] = {
"id": task_id, "title": title, "priority": priority,
"description": description, "status": "todo",
"tags": tags or [],
}
return f'Created task "{title}" (ID: {task_id}, Priority: {priority})'
@mcp.tool()
def list_tasks(status: str | None = None, priority: str | None = None) -> str:
"""List tasks with optional filtering by status or priority."""
results = list(tasks.values())
if status:
results = [t for t in results if t["status"] == status]
if priority:
results = [t for t in results if t["priority"] == priority]
if not results:
return "No tasks found."
return "\n".join(f"- [{t['priority']}] {t['title']} ({t['status']}) - {t['id']}" for t in results)
@mcp.tool()
def update_task(task_id: str, status: str | None = None, priority: str | None = None) -> str:
"""Update a task's status or priority by ID."""
if task_id not in tasks:
return f"Task {task_id} not found."
if status:
tasks[task_id]["status"] = status
if priority:
tasks[task_id]["priority"] = priority
return f'Updated task "{tasks[task_id]["title"]}"'
@mcp.resource("task:///stats")
def task_stats() -> str:
"""Return task statistics as JSON."""
import json
return json.dumps({"total": len(tasks)})
if __name__ == "__main__":
mcp.run()
Connecting to Claude Desktop
Add your server to claude_desktop_config.json. For complete configuration guidance, see the Claude Desktop integration tutorial.
{
"mcpServers": {
"task-manager": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/my-mcp-server/src/index.ts"],
"env": {
"STORAGE_PATH": "/absolute/path/to/tasks.json"
}
}
}
}
Deployment Options
1. Local with Claude Desktop
The simplest option. Configure in claude_desktop_config.json and Claude Desktop manages the process. See Claude integration guide.
2. Docker
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY dist ./dist
ENV STORAGE_PATH=/data/tasks.json
VOLUME ["/data"]
CMD ["node", "dist/index.js"]
3. Streamable HTTP (Remote)
For remote deployment, serve your MCP server over Streamable HTTP instead of stdio. This enables network access from any MCP client. The MCP SDK provides transport helpers for this - consult the SDK documentation for StreamableHTTPServerTransport.
4. npm Package
Publish your server as an npm package so others can use it with npx:
{
"name": "@yourorg/mcp-task-server",
"bin": { "mcp-task-server": "./dist/index.js" }
}
Users can then add it to Claude Desktop:
{
"mcpServers": {
"tasks": {
"command": "npx",
"args": ["-y", "@yourorg/mcp-task-server"]
}
}
}
Submit your server to the MCPGee servers directory to help others discover it.
Best Practices Summary
Architecture
- Separate MCP protocol handling from business logic
- Use Zod schemas for input validation
- Keep tool handlers thin - delegate to service classes
- Use the
McpServer high-level API unless you need low-level control
Error Handling
- Return user-friendly error messages from tools, not stack traces
- Log errors to stderr for debugging
- Handle edge cases: empty inputs, missing IDs, invalid data
Security
- Validate all inputs with Zod schemas
- Limit file access to specific directories
- Never expose raw database connections
- Use environment variables for configuration
Testing
- Unit test service and storage layers independently
- Integration test via JSON-RPC messages piped to the server
- Test error cases and edge inputs
Performance
- Lazy-load heavy dependencies
- Implement caching for expensive operations
- Keep server startup fast for Claude Desktop restarts
Conclusion
Building a production-ready MCP server requires careful attention to architecture, error handling, and testing. The patterns in this tutorial - layered architecture, Zod validation, service classes, and proper error handling - scale from simple tools to complex enterprise integrations.
Key takeaways:
- Use
McpServer for clean, declarative tool registration
- Separate business logic from protocol handling for testability
- Write comprehensive Zod schemas for automatic validation
- Test at both unit and integration levels
- Deploy via Claude Desktop config, Docker, or npm package
Browse the servers directory for real-world examples, or continue learning with the Python MCP server guide.