The Model Context Protocol is how you give AI agents access to the real world. Your databases. Your APIs. Your file systems. Instead of every agent framework inventing its own tool interface, MCP provides one standard protocol that works across all of them.
Think of it like USB for AI tools. Build the server once, plug it into any MCP-compatible client.
## What We're Building
An MCP server that gives agents access to a project management system. Three tools: list tasks, create tasks, and update task status. Simple enough to understand, complex enough to be useful. For a deeper look, see [the Model Context Protocol spec](/blog/model-context-protocol-mcp).
## Project Setup
```bash
mkdir mcp-project-tools && cd mcp-project-tools
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
```
Create a `tsconfig.json`:
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
```
## The MCP Server Skeleton
Every MCP server follows the same pattern. Create the server, register your tools, start listening.
```typescript
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "project-tools",
version: "1.0.0",
});
// Tools go here
const transport = new StdioServerTransport();
await server.connect(transport);
```
That's the frame. Now let's fill it in.
## Define Your Data Layer
In a real app, this would be a database. We'll use an in-memory store to keep the focus on MCP.
```typescript
// src/store.ts
export interface Task {
id: string;
title: string;
description: string;
status: "todo" | "in_progress" | "done";
assignee: string | null;
createdAt: string;
updatedAt: string;
}
const tasks: Map = new Map();
export function listTasks(status?: string): Task[] {
const all = Array.from(tasks.values());
if (status) {
return all.filter((t) => t.status === status);
}
return all;
}
export function createTask(
title: string,
description: string,
assignee?: string
): Task {
const id = `task-${Date.now()}`;
const now = new Date().toISOString();
const task: Task = {
id,
title,
description,
status: "todo",
assignee: assignee ?? null,
createdAt: now,
updatedAt: now,
};
tasks.set(id, task);
return task;
}
export function updateTaskStatus(
id: string,
status: "todo" | "in_progress" | "done"
): Task | null {
const task = tasks.get(id);
if (!task) return null;
task.status = status;
task.updatedAt = new Date().toISOString();
return task;
}
```
## Register the Tools
Each tool gets a name, a description (the LLM reads this to decide when to use it), a parameter schema, and a handler function. For a deeper look, see [how agents use tools](/blog/tool-use-in-ai-agents).
```typescript
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { listTasks, createTask, updateTaskStatus } from "./store.js";
const server = new McpServer({
name: "project-tools",
version: "1.0.0",
});
// Tool 1: List tasks
server.tool(
"list_tasks",
"List all tasks, optionally filtered by status (todo, in_progress, done)",
{
status: z
.enum(["todo", "in_progress", "done"])
.optional()
.describe("Filter tasks by status"),
},
async ({ status }) => {
const tasks = listTasks(status);
return {
content: [
{
type: "text",
text: JSON.stringify(tasks, null, 2),
},
],
};
}
);
// Tool 2: Create a task
server.tool(
"create_task",
"Create a new task with a title and description",
{
title: z.string().describe("Task title"),
description: z.string().describe("Detailed task description"),
assignee: z.string().optional().describe("Person assigned to the task"),
},
async ({ title, description, assignee }) => {
const task = createTask(title, description, assignee);
return {
content: [
{
type: "text",
text: `Created task ${task.id}: "${task.title}" (assigned to: ${task.assignee ?? "unassigned"})`,
},
],
};
}
);
// Tool 3: Update task status
server.tool(
"update_task_status",
"Update the status of an existing task",
{
task_id: z.string().describe("The ID of the task to update"),
status: z
.enum(["todo", "in_progress", "done"])
.describe("New status for the task"),
},
async ({ task_id, status }) => {
const task = updateTaskStatus(task_id, status);
if (!task) {
return {
content: [{ type: "text", text: `Task ${task_id} not found` }],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Updated task ${task.id} status to: ${task.status}`,
},
],
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Project Tools MCP Server running on stdio");
```
The `isError: true` flag on the not-found response tells the client something went wrong without crashing the server. Graceful error handling matters.
## Adding Resources
MCP isn't just tools. You can also expose resources. Think of these as read-only data the agent can access without calling a tool.
```typescript
server.resource("task-summary", "project://task-summary", async (uri) => {
const tasks = listTasks();
const summary = {
total: tasks.length,
todo: tasks.filter((t) => t.status === "todo").length,
in_progress: tasks.filter((t) => t.status === "in_progress").length,
done: tasks.filter((t) => t.status === "done").length,
};
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(summary, null, 2),
},
],
};
});
```
Resources are perfect for dashboards, summaries, and reference data. The agent reads them for context without needing to invoke a tool. For a deeper look, see [agent communication standards](/blog/agent-communication-protocols).
## Connect to Claude Code
Add your server to `.mcp.json` in your project root:
```json
{
"mcpServers": {
"project-tools": {
"command": "npx",
"args": ["tsx", "./mcp-project-tools/src/index.ts"]
}
}
}
```
Now any Claude Code session in that project has access to your tools. The agent sees `list_tasks`, `create_task`, and `update_task_status` as available tools and uses them when relevant.
## Connect to Any MCP Client
The beauty of MCP is portability. Same server works with Claude Desktop too. Add it to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"project-tools": {
"command": "node",
"args": ["--loader", "tsx", "/absolute/path/to/src/index.ts"]
}
}
}
```
## Testing Your Server
The MCP SDK includes an inspector for debugging. It connects to your server and lets you call tools interactively.
```bash
npx @modelcontextprotocol/inspector npx tsx src/index.ts
```
This opens a web UI where you can see your tools, test them with different parameters, and inspect the responses. Use this before connecting to any client. Find the bugs in isolation.
## Adding Prompts
MCP servers can also expose prompt templates. These are reusable prompts the agent can use with your tools.
```typescript
server.prompt(
"sprint-planning",
"Generate a sprint planning summary from current tasks",
{},
async () => {
const tasks = listTasks();
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Here are the current tasks:\n${JSON.stringify(tasks, null, 2)}\n\nGenerate a sprint planning summary. Group by status, identify blockers, and suggest priorities for the next sprint.`,
},
},
],
};
}
);
```
## Production Considerations
This example uses stdio transport. For production, you'll likely want SSE (Server-Sent Events) for remote servers:
```typescript
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";
const app = express();
app.get("/sse", async (req, res) => {
const transport = new SSEServerTransport("/message", res);
await server.connect(transport);
});
app.post("/message", async (req, res) => {
// Handle incoming messages
});
app.listen(3001);
```
Add authentication. Add rate limiting. Add logging. The protocol handles the communication. You handle the operations.
## Why MCP Matters
Every agent framework has its own tool definition format. LangChain tools don't work in CrewAI. CrewAI tools don't work in LangGraph. MCP fixes that. Build the server once, connect it everywhere.
Your databases, your APIs, your internal tools. All accessible to any AI agent through one standard protocol. That's not a framework feature. That's infrastructure.