Building Your First Custom MCP Server: A Step-by-Step Guide
Ready to create your own MCP server? While ToolBoost offers 5,000+ pre-built MCPs, sometimes you need something custom for your specific workflow.
In this tutorial, we'll build a complete MCP server from scratch, covering tools, resources, and prompts. You'll learn the fundamentals and be ready to create any integration you need.
What We're Building
We'll create a Task Management MCP that integrates with a simple task API. It will demonstrate:
- Tools: Create, update, delete, and list tasks
- Resources: Access individual tasks as resources
- Prompts: Reusable prompt templates for common workflows
By the end, you'll have a working MCP server you can deploy and use with any MCP client.
Prerequisites
- Node.js 18+ installed
- Basic TypeScript knowledge
- Familiarity with async/await
- A code editor (VS Code recommended)
Project Setup
1. Initialize the Project
mkdir task-mcp-server
cd task-mcp-server
npm init -y
2. Install Dependencies
npm install @modelcontextprotocol/sdk zod
npm install -D @types/node typescript tsx
Dependencies explained:
@modelcontextprotocol/sdk- Official MCP SDKzod- Schema validationtypescript- Type safetytsx- TypeScript execution
3. Configure TypeScript
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
4. Update package.json
Add build and dev scripts:
{
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"start": "node dist/index.js"
}
}
Building the MCP Server
Step 1: Create the Server Foundation
Create src/index.ts:
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
ListPromptsRequestSchema,
GetPromptRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
// In-memory task storage (in production, use a real database)
interface Task {
id: string;
title: string;
description: string;
status: 'todo' | 'in_progress' | 'done';
priority: 'low' | 'medium' | 'high';
createdAt: string;
updatedAt: string;
}
const tasks = new Map<string, Task>();
// Create server instance
const server = new Server(
{
name: 'task-manager',
version: '1.0.0',
},
{
capabilities: {
tools: {},
resources: {},
prompts: {},
},
}
);
// We'll add handlers here in the next steps
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Task Manager MCP Server running on stdio');
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
Step 2: Implement Tools
Tools are functions your AI can call. Let's implement CRUD operations:
// Add after server creation
// List all available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'create_task',
description: 'Create a new task',
inputSchema: {
type: 'object',
properties: {
title: {
type: 'string',
description: 'Task title',
},
description: {
type: 'string',
description: 'Task description',
},
priority: {
type: 'string',
enum: ['low', 'medium', 'high'],
description: 'Task priority',
default: 'medium',
},
},
required: ['title'],
},
},
{
name: 'list_tasks',
description: 'List all tasks, optionally filtered by status',
inputSchema: {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['todo', 'in_progress', 'done'],
description: 'Filter by status (optional)',
},
},
},
},
{
name: 'update_task',
description: 'Update an existing task',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Task ID',
},
title: {
type: 'string',
description: 'New title (optional)',
},
description: {
type: 'string',
description: 'New description (optional)',
},
status: {
type: 'string',
enum: ['todo', 'in_progress', 'done'],
description: 'New status (optional)',
},
priority: {
type: 'string',
enum: ['low', 'medium', 'high'],
description: 'New priority (optional)',
},
},
required: ['id'],
},
},
{
name: 'delete_task',
description: 'Delete a task',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Task ID to delete',
},
},
required: ['id'],
},
},
],
};
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case 'create_task': {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const task: Task = {
id,
title: args.title as string,
description: args.description as string || '',
status: 'todo',
priority: (args.priority as Task['priority']) || 'medium',
createdAt: now,
updatedAt: now,
};
tasks.set(id, task);
return {
content: [
{
type: 'text',
text: `Task created successfully!\n\n${JSON.stringify(task, null, 2)}`,
},
],
};
}
case 'list_tasks': {
let filteredTasks = Array.from(tasks.values());
if (args.status) {
filteredTasks = filteredTasks.filter(t => t.status === args.status);
}
return {
content: [
{
type: 'text',
text: filteredTasks.length > 0
? JSON.stringify(filteredTasks, null, 2)
: 'No tasks found',
},
],
};
}
case 'update_task': {
const task = tasks.get(args.id as string);
if (!task) {
throw new Error(`Task not found: ${args.id}`);
}
const updatedTask: Task = {
...task,
title: (args.title as string) ?? task.title,
description: (args.description as string) ?? task.description,
status: (args.status as Task['status']) ?? task.status,
priority: (args.priority as Task['priority']) ?? task.priority,
updatedAt: new Date().toISOString(),
};
tasks.set(args.id as string, updatedTask);
return {
content: [
{
type: 'text',
text: `Task updated successfully!\n\n${JSON.stringify(updatedTask, null, 2)}`,
},
],
};
}
case 'delete_task': {
const deleted = tasks.delete(args.id as string);
if (!deleted) {
throw new Error(`Task not found: ${args.id}`);
}
return {
content: [
{
type: 'text',
text: `Task ${args.id} deleted successfully`,
},
],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
});
Step 3: Implement Resources
Resources provide read-only access to data:
// List available resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const resources = Array.from(tasks.values()).map(task => ({
uri: `task:///${task.id}`,
mimeType: 'application/json',
name: task.title,
description: `Task: ${task.title} (${task.status})`,
}));
return { resources };
});
// Read a specific resource
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
// Parse task ID from URI (format: task:///task-id)
const match = uri.match(/^task:\/\/\/(.+)$/);
if (!match) {
throw new Error(`Invalid task URI: ${uri}`);
}
const taskId = match[1];
const task = tasks.get(taskId);
if (!task) {
throw new Error(`Task not found: ${taskId}`);
}
return {
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify(task, null, 2),
},
],
};
});
Step 4: Implement Prompts
Prompts are reusable templates for common workflows:
// List available prompts
server.setRequestHandler(ListPromptsRequestSchema, async () => {
return {
prompts: [
{
name: 'daily_standup',
description: 'Generate a daily standup report from tasks',
},
{
name: 'sprint_planning',
description: 'Help plan a sprint from available tasks',
arguments: [
{
name: 'sprint_duration',
description: 'Sprint duration in weeks',
required: false,
},
],
},
],
};
});
// Get a specific prompt
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case 'daily_standup': {
const allTasks = Array.from(tasks.values());
const inProgress = allTasks.filter(t => t.status === 'in_progress');
const done = allTasks.filter(t => t.status === 'done');
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Generate a daily standup report based on these tasks:
**In Progress (${inProgress.length}):**
${inProgress.map(t => `- ${t.title} (${t.priority} priority)`).join('\n') || 'None'}
**Completed Today (${done.length}):**
${done.map(t => `- ${t.title}`).join('\n') || 'None'}
Please summarize what was accomplished and what's currently being worked on.`,
},
},
],
};
}
case 'sprint_planning': {
const sprintDuration = args?.sprint_duration || '2';
const todoTasks = Array.from(tasks.values()).filter(t => t.status === 'todo');
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Help me plan a ${sprintDuration}-week sprint from these available tasks:
${todoTasks.map(t => `- [${t.priority.toUpperCase()}] ${t.title}\n ${t.description}`).join('\n\n')}
Please:
1. Group tasks by priority
2. Suggest which tasks fit in a ${sprintDuration}-week sprint
3. Identify any dependencies or risks
4. Recommend task ordering`,
},
},
],
};
}
default:
throw new Error(`Unknown prompt: ${name}`);
}
});
Testing Your MCP Server
1. Build the Server
npm run build
2. Test with MCP Inspector
Install the MCP Inspector (official testing tool):
npm install -g @modelcontextprotocol/inspector
Run your server with the inspector:
mcp-inspector node dist/index.js
This opens a web UI where you can:
- List available tools
- Call tools with parameters
- Browse resources
- Test prompts
3. Test with Claude Desktop
Add to your Claude Desktop config:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"tasks": {
"command": "node",
"args": ["/absolute/path/to/task-mcp-server/dist/index.js"]
}
}
}
Restart Claude Desktop and try:
"Create a task: Implement user authentication"
"List all tasks"
"Update task abc-123 to in_progress status"
"Give me a daily standup report"
Advanced Features
Adding Environment Variables
// src/config.ts
export const config = {
apiKey: process.env.API_KEY || '',
apiUrl: process.env.API_URL || 'https://api.example.com',
maxTasks: parseInt(process.env.MAX_TASKS || '100', 10),
};
// Validate required env vars
if (!config.apiKey) {
throw new Error('API_KEY environment variable is required');
}
Error Handling
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
// Tool implementation
} catch (error) {
console.error(`Error in ${request.params.name}:`, error);
return {
content: [
{
type: 'text',
text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
],
isError: true,
};
}
});
Adding Logging
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' }),
],
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
logger.info('Tool called', {
tool: request.params.name,
args: request.params.arguments,
});
// Implementation
});
Connecting to Real APIs
import fetch from 'node-fetch';
async function createTaskInAPI(task: Task): Promise<Task> {
const response = await fetch(`${config.apiUrl}/tasks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
},
body: JSON.stringify(task),
});
if (!response.ok) {
throw new Error(`API error: ${response.statusText}`);
}
return await response.json();
}
Deploying to ToolBoost
Want to share your custom MCP with your team? Deploy to ToolBoost:
1. Package Your MCP
Create Dockerfile:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY dist ./dist
CMD ["node", "dist/index.js"]
2. Contact ToolBoost
Email custom-mcps@toolboost.dev with:
- Your Docker image or GitHub repo
- Environment variables needed
- Brief description
- Target users (private/public)
We'll review and deploy your MCP to the platform!
Best Practices
1. Input Validation
Always validate inputs with Zod:
import { z } from 'zod';
const CreateTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
priority: z.enum(['low', 'medium', 'high']).default('medium'),
});
// In tool handler
const validated = CreateTaskSchema.parse(args);
2. Descriptive Tool Names
- ✅
create_task,list_tasks,update_task_status - ❌
ct,task1,doThing
3. Detailed Descriptions
{
name: 'search_tasks',
description: 'Search tasks by title, description, or tags. Returns matching tasks sorted by relevance.',
// Not just: "Search tasks"
}
4. Sensible Defaults
{
priority: {
type: 'string',
enum: ['low', 'medium', 'high'],
default: 'medium', // Provide defaults when reasonable
}
}
5. Clear Error Messages
if (!task) {
throw new Error(`Task not found: ${taskId}. Use list_tasks to see all available tasks.`);
}
Complete Example Repository
Find the complete code at: github.com/toolboost/example-task-mcp
Includes:
- Full TypeScript implementation
- Unit tests
- Docker setup
- CI/CD configuration
- Documentation
Next Steps
Now that you've built a custom MCP:
- Extend it: Add more tools, resources, or prompts
- Connect to real APIs: Integrate with your actual systems
- Add persistence: Use a real database instead of in-memory storage
- Deploy it: Share with your team via ToolBoost
- Publish it: Open source and share with the community
Conclusion
Building custom MCPs unlocks unlimited possibilities for AI-powered workflows. With the MCP SDK, you can integrate any system, API, or data source.
Key Takeaways:
- Tools = Actions AI can take
- Resources = Data AI can read
- Prompts = Reusable templates
- Use TypeScript for type safety
- Validate inputs thoroughly
- Test with MCP Inspector
- Deploy to ToolBoost to share
Happy building! 🚀
Questions about building MCPs? Join our Discord community or email dev@toolboost.dev
Want to deploy your custom MCP? Contact ToolBoost about Enterprise plans.