The Model Context Protocol (MCP) is Anthropic's open standard for connecting LLMs to external tools and data sources. Instead of building tool integrations separately for every AI product, you write one MCP server and it works with Claude Desktop, Claude Code, and any other MCP-compatible client.
Think of it as a universal plugin system for AI assistants.
What MCP Gives You
Without MCP, giving Claude access to a custom API requires prompt engineering hacks or bespoke integrations. With MCP, you define a server that exposes:
- Tools — functions the LLM can call (like
search_database,create_ticket,send_email) - Resources — read-only data sources the LLM can access (like files, database records, API responses)
- Prompts — reusable prompt templates your server exposes
The LLM negotiates with your server over a well-defined protocol. You write the server once; it works everywhere.
Project Setup
mkdir my-mcp-server && cd my-mcp-server
bun init -y
bun add @modelcontextprotocol/sdk zod
bun add -d typescript @types/node// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
},
"include": ["src"]
}Building a Task Manager MCP Server
We'll build an MCP server that manages a simple task list — realistic enough to show the patterns, simple enough to understand in one reading.
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// In-memory task store (replace with a DB in production)
interface Task {
id: string;
title: string;
done: boolean;
createdAt: string;
}
const tasks: Task[] = [];
let nextId = 1;
// Create the MCP server
const server = new McpServer({
name: "task-manager",
version: "1.0.0",
});
// Tool: Add a task
server.tool(
"add_task",
"Add a new task to the list",
{ title: z.string().describe("The task description") },
async ({ title }) => {
const task: Task = {
id: String(nextId++),
title,
done: false,
createdAt: new Date().toISOString(),
};
tasks.push(task);
return {
content: [{ type: "text", text: `Created task #${task.id}: "${title}"` }],
};
}
);
// Tool: List tasks
server.tool(
"list_tasks",
"List all tasks, optionally filtered by status",
{
filter: z
.enum(["all", "pending", "done"])
.default("all")
.describe("Which tasks to show"),
},
async ({ filter }) => {
const filtered = tasks.filter((t) => {
if (filter === "pending") return !t.done;
if (filter === "done") return t.done;
return true;
});
if (filtered.length === 0) {
return { content: [{ type: "text", text: "No tasks found." }] };
}
const list = filtered
.map((t) => `${t.done ? "✅" : "⬜"} [${t.id}] ${t.title}`)
.join("\n");
return { content: [{ type: "text", text: list }] };
}
);
// Tool: Complete a task
server.tool(
"complete_task",
"Mark a task as done",
{ id: z.string().describe("Task ID to mark as complete") },
async ({ id }) => {
const task = tasks.find((t) => t.id === id);
if (!task) {
return {
content: [{ type: "text", text: `Task #${id} not found.` }],
isError: true,
};
}
task.done = true;
return {
content: [{ type: "text", text: `Marked task #${id} as done: "${task.title}"` }],
};
}
);
// Expose tasks as a resource
server.resource(
"tasks",
"tasks://all",
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(tasks, null, 2),
},
],
})
);
// Start the server over stdio (standard for local MCP servers)
const transport = new StdioServerTransport();
await server.connect(transport);Testing Locally with Claude Desktop
Add your server to Claude Desktop's config (~/Library/Application Support/Claude/claude_desktop_config.json on macOS):
{
"mcpServers": {
"task-manager": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/dist/index.js"]
}
}
}Or with Bun (no build step needed):
{
"mcpServers": {
"task-manager": {
"command": "bun",
"args": ["run", "/absolute/path/to/my-mcp-server/src/index.ts"]
}
}
}Restart Claude Desktop. You'll see a hammer icon (🔨) in the chat interface — click it to see your tools listed. Now ask Claude: "Add a task: finish the MCP blog post" and watch it call your tool.
Adding Input Validation and Error Handling
Zod handles validation automatically — if the LLM passes a bad input, it gets a descriptive error instead of a crash. But you still need to handle errors from your business logic:
server.tool(
"get_task",
"Get details of a specific task",
{ id: z.string() },
async ({ id }) => {
try {
const task = await db.tasks.findById(id); // your real DB call
if (!task) {
return {
content: [{ type: "text", text: `Task ${id} does not exist.` }],
isError: true, // signals to the LLM this is an error state
};
}
return {
content: [{ type: "text", text: JSON.stringify(task, null, 2) }],
};
} catch (error) {
return {
content: [
{ type: "text", text: `Database error: ${(error as Error).message}` },
],
isError: true,
};
}
}
);Always use isError: true rather than throwing — this lets the LLM recover gracefully instead of crashing the conversation.
Connecting to Real APIs
Here's the same pattern connecting to a real external API — a GitHub issues tool:
import { Octokit } from "@octokit/rest";
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
server.tool(
"create_github_issue",
"Create a GitHub issue in the specified repository",
{
owner: z.string().describe("Repository owner"),
repo: z.string().describe("Repository name"),
title: z.string().describe("Issue title"),
body: z.string().optional().describe("Issue description"),
labels: z.array(z.string()).optional().describe("Labels to apply"),
},
async ({ owner, repo, title, body, labels }) => {
const response = await octokit.issues.create({
owner,
repo,
title,
body,
labels,
});
return {
content: [
{
type: "text",
text: `Created issue #${response.data.number}: ${response.data.html_url}`,
},
],
};
}
);Using with Claude Code
Add your server to Claude Code's settings (~/.claude/settings.json):
{
"mcpServers": {
"task-manager": {
"command": "bun",
"args": ["run", "/path/to/my-mcp-server/src/index.ts"],
"env": {
"DATABASE_URL": "postgresql://localhost/mydb"
}
}
}
}The env field lets you pass secrets without hardcoding them in your server.
What to Build Next
Some MCP server ideas that are immediately useful:
- Codebase search — expose
grep, AST search, or semantic search over your repo - Database explorer — let Claude read schema and run read-only queries
- Deployment tools — wrap your CI/CD CLI in MCP tools
- Documentation fetcher — scrape and cache docs from libraries you use
- Issue tracker — bidirectional Linear/Jira integration
The pattern is always the same: take a thing you want Claude to do, wrap it in an MCP tool with good Zod validation and error handling, and wire it up. Once it's an MCP server, it works in every context automatically.
