Skip to main content

Building Your First Custom MCP Server: A Step-by-Step Guide

· 9 min read
ToolBoost Team
ToolBoost Engineering Team

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 SDK
  • zod - Schema validation
  • typescript - Type safety
  • tsx - 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:

  1. Extend it: Add more tools, resources, or prompts
  2. Connect to real APIs: Integrate with your actual systems
  3. Add persistence: Use a real database instead of in-memory storage
  4. Deploy it: Share with your team via ToolBoost
  5. 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.