Why Build Your Own MCP Server?
When I first heard about MCP (Model Context Protocol), I thought "great, another acronym to learn." But once I realized I could build custom tools that Claude could actually use — not just talk about, but literally execute — everything clicked.
Think of an MCP server as your personal assistant's toolkit. Sure, the pre-built ones are nice, but sometimes you need something specific to your workflow. Maybe you want Claude to check your company's internal API, or parse data from a custom format, or integrate with that weird legacy system nobody talks about.
Today, we're building a simple but practical MCP server from scratch. It'll be a "Task Manager" that lets Claude create, read, and update tasks in a local JSON file. Nothing fancy, but you'll understand every piece of it.
What We're Building
Our MCP server will give Claude three new superpowers:
1. Create tasks — Claude can add new tasks with descriptions and priorities
2. List tasks — Show all current tasks with their status
3. Complete tasks — Mark tasks as done
The data lives in a simple JSON file, so you can see exactly what's happening behind the scenes.
Start Simple
I learned this the hard way — resist the urge to build something complex on your first try. Get the basics working first.
Prerequisites and Setup
Before we dive in, you'll need:
• Node.js installed (version 18 or higher)
• Claude Desktop with MCP support
• A text editor (VS Code, Cursor, whatever you prefer)
• Basic JavaScript knowledge (don't worry, we keep it simple)
First, let's create our project directory and initialize it:
mkdir my-task-mcp-server
cd my-task-mcp-server
npm init -y
→ Created package.jsonNow install the MCP SDK:
npm install @modelcontextprotocol/sdk
→ Installing MCP SDK...Building the Server Core
Create a file called server.js. This is where the magic happens:
// Import the MCP SDK
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import fs from 'fs/promises';
import path from 'path';
// Our task storage file
const TASKS_FILE = './tasks.json';
// Create the server
const server = new Server({
name: 'task-manager',
version: '1.0.0'
}, {
capabilities: {
tools: {}
}
});This sets up the basic server structure. The capabilities object tells Claude what our server can do — in our case, we'll provide tools.
Adding Helper Functions
Before we define the tools, let's create some helper functions to manage our tasks:
// Helper function to load tasks
async function loadTasks() {
try {
const data = await fs.readFile(TASKS_FILE, 'utf8');
return JSON.parse(data);
} catch (error) {
// If file doesn't exist, return empty array
return [];
}
}
// Helper function to save tasks
async function saveTasks(tasks) {
await fs.writeFile(TASKS_FILE, JSON.stringify(tasks, null, 2));
}These functions handle the boring file I/O stuff so our tool handlers stay clean.
Defining Our Tools
Now for the fun part — telling Claude what tools are available. Add this to your server:
// List available tools
server.setRequestHandler('tools/list', async () => {
return {
tools: [
{
name: 'create_task',
description: 'Create a new task',
inputSchema: {
type: 'object',
properties: {
description: { type: 'string' },
priority: { type: 'string', enum: ['low', 'medium', 'high'] }
},
required: ['description']
}
},
{
name: 'list_tasks',
description: 'List all tasks',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'complete_task',
description: 'Mark a task as completed',
inputSchema: {
type: 'object',
properties: {
taskId: { type: 'number' }
},
required: ['taskId']
}
}
]
};
});This is like a menu for Claude. Each tool has a name, description, and schema that defines what parameters it accepts.
Implementing Tool Logic
Now we need to handle what happens when Claude actually calls these tools:
// Handle tool execution
server.setRequestHandler('tools/call', async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case 'create_task': {
const tasks = await loadTasks();
const newTask = {
id: Date.now(), // Simple ID generation
description: args.description,
priority: args.priority || 'medium',
completed: false,
created: new Date().toISOString()
};
tasks.push(newTask);
await saveTasks(tasks);
return {
content: [{
type: 'text',
text: `Created task: "${newTask.description}" with priority ${newTask.priority}`
}]
};
}
case 'list_tasks': {
const tasks = await loadTasks();
const taskList = tasks.map(t =>
`${t.id}: ${t.description} [${t.priority}] ${t.completed ? '✓' : '○'}`
).join('\n');
return {
content: [{
type: 'text',
text: taskList || 'No tasks found'
}]
};
}
case 'complete_task': {
const tasks = await loadTasks();
const task = tasks.find(t => t.id === args.taskId);
if (!task) {
throw new Error(`Task ${args.taskId} not found`);
}
task.completed = true;
await saveTasks(tasks);
return {
content: [{
type: 'text',
text: `Completed task: "${task.description}"`
}]
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
});Starting the Server
Finally, we need to actually start the server and handle the connection:
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Task Manager MCP Server running');
}
main().catch(console.error);Don't forget to update your package.json to use ES modules:
{
"name": "task-mcp-server",
"version": "1.0.0",
"type": "module",
"main": "server.js",
"dependencies": {
"@modelcontextprotocol/sdk": "^0.4.0"
}
}Connecting to Claude
Now we need to tell Claude about our new server. Open Claude Desktop's config file (usually at ~/Library/Application Support/Claude/claude_desktop_config.json on Mac) and add:
{
"mcpServers": {
"task-manager": {
"command": "node",
"args": ["/full/path/to/your/server.js"]
}
}
}Replace /full/path/to/your/server.js with the actual path to your server file.
Path Troubles?
Run pwd in your project directory to get the full path, then add /server.js to the end.
Testing Your Server
Restart Claude Desktop and start a new conversation. Try asking Claude to:
"Create a task to review the quarterly reports with high priority"
If everything's working, Claude should respond with something like "Created task: 'review the quarterly reports' with priority high" and you'll see a tasks.json file appear in your project directory.
Then try: "Show me all my tasks"
Claude should list your tasks with their IDs and status.
When I first got this working, I felt like a wizard. Claude wasn't just talking about managing tasks — it was actually doing it, using code I wrote.
Debugging Common Issues
Things not working? Here's what I usually check:
1. Check the logs — Claude Desktop shows MCP server errors in the developer console
2. Verify the path — Wrong paths in the config are the #1 culprit
3. Test the server standalone — Run node server.js to catch syntax errors
4. Check file permissions — Make sure Claude can write to your project directory
The error messages are usually pretty helpful once you know where to look.
Next Steps and Ideas
Congratulations! You've built a working MCP server. But this is just the beginning. Here are some ideas to extend it:
• Add due dates — Include date handling for deadlines
• Categories or tags — Organize tasks by project or type
• Search functionality — Let Claude find tasks by keyword
• Export features — Generate reports or sync with other tools
• Database storage — Replace JSON with SQLite or PostgreSQL
The pattern you've learned here — define tools, implement handlers, connect to Claude — applies to any MCP server you want to build.
Want Claude to check your company's API? Same pattern. Need it to process special file formats? Same pattern. The possibilities are endless once you understand the basics.
Now go build something cool. And when you do, remember that every expert was once a beginner who built their first simple task manager and felt like they'd conquered the world.
Want to go deeper?
Check out more tutorials in this category, or explore the full site.