The Model Context Protocol explained practically — with a real working example you can deploy today
Most Claude Code tutorials tell you to install MCP servers.
This one shows you how to build one.
Because once you understand that MCP is just a JSON-RPC server with a specific protocol, the entire ecosystem opens up. You can connect Claude Code to any internal tool, any database, any API — not just the pre-built connectors.
Let me walk you through building a real MCP server from scratch.
What MCP Actually Is
MCP (Model Context Protocol) is Anthropic's open standard for connecting AI assistants to external tools and data sources.
In practice: it's a server that Claude Code can call. The server exposes "tools" that Claude can invoke. Claude decides when and how to use them based on the conversation.
The protocol itself is simple:
- Server starts and announces its tools
- Claude sends tool calls as JSON-RPC requests
- Server executes and returns results
- Claude uses the results to respond
That's genuinely all there is to it.
Want to use pre-built MCP servers and AI tools without coding? Check out NEXTOOLS — ready-to-use AI tools for builders.
What We're Building
A simple MCP server that gives Claude Code access to your project's TODO list. You'll be able to:
- Ask Claude "what's left on my todo list?"
- Have Claude add new tasks automatically
- Mark tasks as complete through conversation
Not fancy. But genuinely useful, and the pattern scales to anything.
Prerequisites
- Node.js 18+
- Claude Code installed
- 30-60 minutes
Step 1: Set Up the Project
mkdir my-todo-mcp
cd my-todo-mcp
npm init -y
npm install @modelcontextprotocol/sdk
The MCP SDK handles the protocol layer. You write the business logic.
Step 2: Create the Server
Create index.js:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { readFileSync, writeFileSync, existsSync } from "fs";
const TODO_FILE = "./todos.json";
function loadTodos() {
if (!existsSync(TODO_FILE)) return [];
return JSON.parse(readFileSync(TODO_FILE, "utf-8"));
}
function saveTodos(todos) {
writeFileSync(TODO_FILE, JSON.stringify(todos, null, 2));
}
const server = new Server(
{ name: "todo-mcp", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "list_todos",
description: "List all TODO items",
inputSchema: { type: "object", properties: {} },
},
{
name: "add_todo",
description: "Add a new TODO item",
inputSchema: {
type: "object",
properties: {
text: { type: "string", description: "The TODO item text" },
},
required: ["text"],
},
},
{
name: "complete_todo",
description: "Mark a TODO item as complete by index",
inputSchema: {
type: "object",
properties: {
index: { type: "number", description: "0-based index of the item" },
},
required: ["index"],
},
},
],
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const todos = loadTodos();
if (name === "list_todos") {
if (todos.length === 0) {
return { content: [{ type: "text", text: "No todos yet." }] };
}
const text = todos
.map((t, i) => `${i + 1}. [${t.done ? "x" : " "}] ${t.text}`)
.join("\n");
return { content: [{ type: "text", text }] };
}
if (name === "add_todo") {
todos.push({ text: args.text, done: false });
saveTodos(todos);
return { content: [{ type: "text", text: `Added: ${args.text}` }] };
}
if (name === "complete_todo") {
if (args.index < 0 || args.index >= todos.length) {
return { content: [{ type: "text", text: "Invalid index." }] };
}
todos[args.index].done = true;
saveTodos(todos);
return {
content: [{ type: "text", text: `Completed: ${todos[args.index].text}` }],
};
}
throw new Error(`Unknown tool: ${name}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);
Step 3: Register with Claude Code
Add to your ~/.claude/claude_desktop_config.json (or the equivalent for your setup):
{
"mcpServers": {
"todo": {
"command": "node",
"args": ["/absolute/path/to/my-todo-mcp/index.js"],
"type": "stdio"
}
}
}
Restart Claude Code. The server loads automatically.
Ready to see how MCP can transform your workflow? Explore NEXTOOLS — built with MCP integrations.
Step 4: Use It
Now in Claude Code:
You: "Add a todo: fix the authentication bug"
Claude: [calls add_todo] Added: fix the authentication bug
You: "What's on my list?"
Claude: [calls list_todos]
1. [ ] fix the authentication bug
2. [ ] update the README
3. [x] deploy to staging
You: "Mark item 1 as done"
Claude: [calls complete_todo with index 0] Completed: fix the authentication bug
It just works. Claude decides when to use the tools based on context.
The Pattern Scales to Anything
Once you understand this pattern, the sky is the limit:
Internal database queries:
// Tool: query_customers
// Returns: customer data from your PostgreSQL
Slack notifications:
// Tool: send_slack_message
// Triggers: webhook to your team channel
Custom analytics:
// Tool: get_conversion_rate
// Returns: live data from your analytics database
File operations:
// Tool: read_env_var
// Returns: specific env values without exposing the whole .env
Any tool you build becomes a natural language interface. "Get me the conversion rate for this week" just works.
Making It Production-Ready
A few things to add before real use:
Error handling:
try {
// your tool logic
} catch (error) {
return {
content: [{ type: "text", text: `Error: ${error.message}` }],
isError: true,
};
}
Input validation:
if (!args.text || typeof args.text !== "string") {
return { content: [{ type: "text", text: "text is required" }] };
}
Logging:
process.stderr.write(`Tool called: ${name}\n`);
Note: always log to stderr, not stdout. The MCP protocol uses stdout for JSON-RPC messages.
Common Pitfalls
1. Logging to stdout
This will corrupt the protocol. Always use stderr for logs.
2. Async without await
MCP handlers must be properly async. Missing an await causes silent failures.
3. Relative paths in config
Claude Desktop spawns your server from a different working directory. Use absolute paths everywhere.
4. Not restarting after changes
Claude Code only loads MCP servers at startup. After any change to the server or config, restart.
What to Build Next
Now that you have the pattern, here's a useful progression:
- File reader - Give Claude access to specific directories without it being able to touch others
- Git wrapper - Let Claude query your git log in structured ways
- API proxy - Wrap internal APIs with simplified interfaces
- Database reader - Read-only access to production data with guardrails
Each one takes about an hour to build. Each one becomes a permanent part of your Claude Code setup.
The Bigger Picture
MCP is why Claude Code is different from every other AI coding tool.
Other tools: you prompt the AI, hope it knows the right answer, fix its mistakes.
Claude Code with MCP: Claude has real tools, real data, real capabilities. It's not guessing — it's executing.
The developers who figure this out early are going to have a serious edge.
Want to see more AI dev tools and workflows? Follow along at NEXTOOLS.
I write about building real things with Claude Code. Follow for weekly posts on AI-powered development workflows.
Code from this article is available at NEXTOOLS.










