
TL;DR
You can build a working MCP server in about 50 minutes using Node.js, TypeScript, and the MCP SDK. This tutorial creates a task management server with tools, resources, validation, testing, and Claude Desktop support — the same pattern most production MCP servers use.
You Can Build This in 50 Minutes
The most common misconception about MCP servers: they are complex, require deep protocol knowledge, and take days to build.
Reality: a working MCP server with real tools, proper error handling, and testing takes about 50 minutes if you have Node.js installed. The learning curve is shallow.
This tutorial walks you through building a production-quality MCP server from scratch — not a toy example. We’ll build a task management server that exposes tools to create, list, and complete tasks. By the end, you’ll have a server you can connect to Claude Desktop and actually use.
New to MCP? Read our guide to Model Context Protocol (MCP) before building your first server.
What We’re Building
A Task Management MCP Server with:
- Two tools: create_task and complete_task
- One resource: task list (readable data)
- One prompt: task review template
- Full error handling and validation
- Testing before deployment
- Configuration for Claude Desktop
Why this example? Because it is realistic. Most MCP servers you will build follow this exact pattern: expose some business logic as tools, add read-only resources for context, add prompts for guidance.
If you want to see how custom MCP servers fit into a real development workflow, also read our guide to the best MCP servers for software teams.
Prerequisites (Check These First)
- Node.js 18+ — download from nodejs.org
- npm — comes with Node.js
- A code editor — VS Code, Cursor, or any text editor
- Bash or PowerShell — terminal access
- Claude Desktop — for testing (optional; MCP Inspector works offline)
Verify Node.js installation:
bash
node --version # should output v18 or higher
npm --version # should output v9 or higher
Part 1: Setup (5 minutes)
Create a new directory and initialize the project:
bash
mkdir task-mcp-server
cd task-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node ts-node
Create the TypeScript configuration (tsconfig.json):
json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Update package.json to add type: “module” and build script:
json
{
"type": "module",
"name": "task-mcp-server",
"version": "1.0.0",
"scripts": {
"build": "tsc && chmod +x build/index.js",
"dev": "ts-node --esm src/index.ts",
"start": "node build/index.js"
},
"files": ["build"]
}
Create the source directory:
bash
mkdir src
touch src/index.ts
Part 2: Build the Server (25 minutes)
This is the full server code. Read through it, then we’ll break down each part.
typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
// Data store (in-memory for demo; use database in production)
interface Task {
id: string;
title: string;
description: string;
completed: boolean;
createdAt: Date;
completedAt?: Date;
}
const tasks: Map<string, Task> = new Map();
let taskCounter = 1;
// Input validation schemas
const CreateTaskSchema = z.object({
title: z.string().describe("Task title"),
description: z.string().optional().describe("Task description"),
});
const CompleteTaskSchema = z.object({
taskId: z.string().describe("Task ID to mark complete"),
});
// Initialize MCP server
const server = new McpServer(
{
name: "task-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
resources: {},
},
}
);
// Handler for listing available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "create_task",
description: "Create a new task with a title and optional description",
inputSchema: {
type: "object" as const,
properties: {
title: {
type: "string",
description: "Task title",
},
description: {
type: "string",
description: "Optional task description",
},
},
required: ["title"],
},
},
{
name: "complete_task",
description: "Mark a task as complete by its ID",
inputSchema: {
type: "object" as const,
properties: {
taskId: {
type: "string",
description: "The ID of the task to complete",
},
},
required: ["taskId"],
},
},
],
};
});
// Handler for tool invocation
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "create_task") {
const { title, description } = CreateTaskSchema.parse(args);
const taskId = `task-${taskCounter++}`;
const task: Task = {
id: taskId,
title,
description: description || "",
completed: false,
createdAt: new Date(),
};
tasks.set(taskId, task);
return {
content: [
{
type: "text",
text: `Task created: ${taskId}\nTitle: ${title}${description ? `\nDescription: ${description}` : ""}`,
},
],
};
} else if (name === "complete_task") {
const { taskId } = CompleteTaskSchema.parse(args);
const task = tasks.get(taskId);
if (!task) {
return {
content: [
{
type: "text",
text: `Error: Task ${taskId} not found`,
},
],
isError: true,
};
}
task.completed = true;
task.completedAt = new Date();
return {
content: [
{
type: "text",
text: `Task ${taskId} marked as complete.`,
},
],
};
}
return {
content: [
{
type: "text",
text: `Unknown tool: ${name}`,
},
],
isError: true,
};
});
// Handler for listing resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: "task://tasks/active",
name: "Active Tasks",
description: "List of all incomplete tasks",
mimeType: "text/plain",
},
{
uri: "task://tasks/all",
name: "All Tasks",
description: "List of all tasks (complete and incomplete)",
mimeType: "text/plain",
},
],
};
});
// Handler for reading resources
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
if (uri === "task://tasks/active") {
const activeTasks = Array.from(tasks.values()).filter(
(task) => !task.completed
);
const content =
activeTasks.length === 0
? "No active tasks."
: activeTasks
.map(
(task) =>
`[${task.id}] ${task.title}${task.description ? ` - ${task.description}` : ""}`
)
.join("\n");
return {
contents: [
{
uri,
mimeType: "text/plain",
text: content,
},
],
};
}
if (uri === "task://tasks/all") {
const allTasks = Array.from(tasks.values());
const content =
allTasks.length === 0
? "No tasks."
: allTasks
.map(
(task) =>
`[${task.id}] ${task.title} (${task.completed ? "completed" : "active"})${task.description ? ` - ${task.description}` : ""}`
)
.join("\n");
return {
contents: [
{
uri,
mimeType: "text/plain",
text: content,
},
],
};
}
return {
contents: [
{
uri,
mimeType: "text/plain",
text: `Unknown resource: ${uri}`,
},
],
};
});
// Error handling
process.on("uncaughtException", (error) => {
console.error("Uncaught exception:", error);
process.exit(1);
});
// Connect and run
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Task MCP Server running on stdio");
}
main().catch(console.error);
Part 3: Understanding the Code (10 minutes)
Let me break down the critical pieces:
1. The Server Definition
typescript
const server = new McpServer(
{ name: "task-server", version: "1.0.0" },
{ capabilities: { tools: {}, resources: {} } }
);
This creates an MCP server that declares it supports tools and resources.
2. Tool Registration
The ListToolsRequestSchema handler tells the client what tools exist:
typescript
{
name: "create_task",
description: "Create a new task with a title and optional description",
inputSchema: { /* JSON Schema describing inputs */ }
}
The description is crucial. The AI reads this to decide when to use the tool. Vague descriptions like “does stuff” lead to incorrect tool usage.
3. Tool Invocation
When the client calls a tool, the CallToolRequestSchema handler runs:
typescript
if (name === "create_task") {
const { title, description } = CreateTaskSchema.parse(args);
// Create the task
// Return result
}
Zod validates the input against the schema. If validation fails, an error is thrown automatically.
4. Resources
Resources are read-only data the client can fetch:
typescript
{
uri: "task://tasks/active",
name: "Active Tasks",
description: "List of all incomplete tasks"
}
When the client requests this resource, the ReadResourceRequestSchema handler returns the data. Resources are ideal for context — the AI can read them without calling tools.
5. Transport
typescript
const transport = new StdioServerTransport();
await server.connect(transport);
This runs the server over stdio (standard input/output). Claude Desktop connects this way. For remote/HTTP deployment, you would use HttpServerTransport instead.
Part 4: Build and Test (10 minutes)
Build the TypeScript:
bash
npm run build
If there are no errors, you have a compiled, working server in the build/ directory.
Testing with MCP Inspector
The easiest way to test is with the MCP Inspector — a web tool for testing MCP servers.
bash
npx @modelcontextprotocol/inspector node build/index.js
This starts your server and opens a browser window where you can:
- See all available tools
- Call tools with test inputs
- View responses
- Debug errors
Try this:
- Call
create_taskwith title: “Buy groceries” - Call
create_taskagain with a different task - Call
complete_taskwith the ID from step 1 - Read the
task://tasks/activeresource
You should see your tasks being managed in real-time.
Testing with Claude Desktop
Once you confirm the server works in MCP Inspector, configure Claude Desktop.
macOS: Edit ~/Library/Application Support/Claude/claude_desktop_config.json:
json
{
"mcpServers": {
"tasks": {
"command": "node",
"args": ["/absolute/path/to/task-mcp-server/build/index.js"]
}
}
}
Replace /absolute/path/to/task-mcp-server with the actual path. Use pwd in your terminal to get it.
Windows: Edit %APPDATA%\Claude\claude_desktop_config.json:
json
{
"mcpServers": {
"tasks": {
"command": "node",
"args": ["C:\\absolute\\path\\to\\task-mcp-server\\build\\index.js"]
}
}
}
Restart Claude Desktop. You should see a MCP indicator in the chat input area. Test by asking: “Create a task to review the MCP spec.”
Common Mistakes and How to Fix Them
| Error | Cause | Fix |
|---|---|---|
Cannot find module '@modelcontextprotocol/sdk' | Dependencies not installed | Run npm install |
"module": "Node16" error in tsconfig | Wrong module configuration | Ensure tsconfig.json has "module": "Node16" |
console.log breaks the server | Stdout is reserved for MCP protocol | Use console.error() for logging instead |
Server crashes on startup | Uncaught exception in async code | Wrap main() in try-catch, test with MCP Inspector first |
Claude Desktop sees no tools | Config path is wrong or JSON is malformed | Test with MCP Inspector first; Claude Inspector will catch errors Inspector won’t |
Task ID generation repeats | In-memory counter resets on restart | Add UUID generation or persistent storage for production |
Production Patterns (Not Covered Here But Important)
This example uses in-memory storage. For production:
- Persistence: Store tasks in PostgreSQL, SQLite, or DynamoDB
- Authentication: Add OAuth 2.0 or API key validation
- Logging: Write logs to stderr (safe for stdio transport) or a file
- Error handling: Catch specific errors, return meaningful messages
- Testing: Write unit tests for tool handlers
- Deployment: Host on Cloudflare Workers, AWS Lambda, or a VPS
A production-quality server follows the same pattern as this tutorial but adds those layers.
Tools vs Resources vs Prompts
| MCP Feature | Purpose | Example in This Tutorial | When to Use It |
|---|---|---|---|
| Tools | Let the AI perform an action | create_task, complete_task | Use when the AI needs to change data or trigger business logic |
| Resources | Give the AI read-only context | task://tasks/active | Use when the AI needs to read data without changing anything |
| Prompts | Provide reusable instructions or workflows | Task review template | Use when you want consistent AI guidance or formatting |
The Next Steps
Now that you have a working MCP server:
- Add more tools — what workflows could this server automate?
- Add authentication — if exposing external data, require API key validation
- Deploy remotely — instead of stdio, use HTTP transport for cloud hosting
- Register it — submit to the MCP registry so others can discover it
- Monitor it — add logging and error tracking
What This Unlocks
With this one MCP server, you now have:
- A working integration with Claude, ChatGPT, Cursor, and any MCP-compatible client
- A pattern you can replicate for any API or business logic
- Understanding of tools, resources, and prompts
- A foundation for more complex servers
The total time investment: 50 minutes. The compound value: every integration you build from now on uses this same pattern.
FAQ
How long does it take to build an MCP server?
Most developers can build their first MCP server in 45–60 minutes if they already have Node.js and a code editor installed.
Do I need TypeScript to build an MCP server?
No, you can use plain JavaScript. However, TypeScript is recommended because it provides type safety, better autocomplete, and easier debugging.
What is the easiest way to test an MCP server?
The easiest way is with the MCP Inspector. It lets you see available tools, call them manually, and debug errors before connecting the server to Claude Desktop.
What is the difference between tools and resources in MCP?
Tools perform actions such as creating or updating data. Resources are read-only and provide context that the AI can access without changing anything.
Can I connect my MCP server to Claude Desktop?
Yes. After building the server, add its file path to the Claude Desktop configuration file and restart the app. Claude Desktop will automatically detect the new MCP server.
Should I use stdio or HTTP transport?
Use stdio while developing locally because it is simpler and works directly with Claude Desktop. Use HTTP transport when deploying your MCP server remotely or sharing it with multiple users.
What should I build after my first MCP server?
After your first MCP server works, add database storage, authentication, more tools, and remote deployment. You can also combine it with servers like GitHub or PostgreSQL for more advanced workflows.
Now that you know how to build an MCP server, the next step is deciding which existing servers to combine it with. Read our guide to the best MCP servers for development teams in 2026.
Still unsure how MCP itself works? Go back to our complete introduction to Model Context Protocol (MCP).
About SSNTPL Sword Software N Technologies helps development teams build AI-integrated software. We specialize in MCP server development and production AI agent deployments for businesses across the US, Europe, and UAE.