How to Build Your First MCP Server: A Practitioner's Guide
MCP (Model Context Protocol) is how you give Claude structured access to external tools and data — databases, files, APIs — without stuffing raw content into the context window. The server is simpler than it looks: install the SDK, define your tools as JSON schema, implement the handlers, connect via stdio. You can have Claude calling your custom tools in under 30 minutes.
Every Wednesday. 28,400+ operators. Zero fluff.
✓ Check your inbox — click the confirmation link to complete sign-up.
✓ You're subscribed!
✓ You're already on the list.
Table of contents
Open Table of contents
- What MCP actually is
- The three pieces of every MCP server
- Prerequisites (2 minutes)
- Step 1: Set up the project (3 minutes)
- Step 2: Write the minimal server (5 minutes)
- Step 3: Build and register with Claude Desktop (5 minutes)
- Step 4: Build a useful tool
- The gotchas I hit (so you don’t)
- How I use MCP servers in production
- What to build next
- FAQ
What MCP actually is
The Model Context Protocol is an open protocol that standardizes how AI models connect to external context and tools. Think of it as a USB-C standard for AI integrations: before it, every app that wanted Claude to read a database or call an API had to invent its own plumbing. After it, you build one MCP server and any compliant host can use it.
MCP defines three things a server can offer:
- Tools — functions Claude can call (read a file, query a DB, send a Slack message)
- Resources — data Claude can read (documents, database rows, file trees)
- Prompts — reusable prompt templates the host can inject
For most operator use cases, you’re building tool servers. Resources and prompts come later once you have the basics running.
The architecture is client-server, with the client (Claude Desktop, Claude Code, your custom app) controlling everything. The server is dumb — it just listens for tool call requests and returns results. The client decides when to call which tool based on Claude’s output.
The three pieces of every MCP server
Every MCP server you build has the same structure:
- The server object — declares your server’s name, version, and which capabilities it offers (tools, resources, prompts)
- Tool definitions — a list of tools with names, descriptions, and JSON schemas for their inputs
- Request handlers — the functions that run when Claude calls a tool
That’s it. No database, no HTTP stack, no auth layer required to start. The minimal server is under 30 lines of TypeScript.
Prerequisites (2 minutes)
- Node.js 18+ — check with
node --version - TypeScript 5+ (included below as a dev dependency)
- An MCP client to test with — Claude Desktop is free and the easiest way to see your server working
No Anthropic API key required to run an MCP server itself. The API key lives in the client (Claude Desktop), not in your server.
Step 1: Set up the project (3 minutes)
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk
npm install -D typescript tsx @types/nodeAdd to package.json:
{
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts"
}
}Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"strict": true
},
"include": ["src/**/*"]
}Step 2: Write the minimal server (5 minutes)
Create src/index.ts:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const server = new Server(
{ name: "my-mcp-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// Declare which tools this server offers
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "get_word_count",
description: "Counts the words in a block of text.",
inputSchema: {
type: "object",
properties: {
text: {
type: "string",
description: "The text to count words in",
},
},
required: ["text"],
},
},
],
}));
// Handle tool calls from the client
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "get_word_count") {
const { text } = args as { text: string };
const count = text.trim().split(/\s+/).filter(Boolean).length;
return {
content: [{ type: "text", text: `Word count: ${count}` }],
};
}
throw new Error(`Unknown tool: ${name}`);
});
// Connect via stdio — this is how Claude Desktop talks to the server
const transport = new StdioServerTransport();
await server.connect(transport);This is the full server. It registers one tool (get_word_count) and implements it. The tool counts words in any text Claude sends. It’s trivial on purpose — the structure is what matters.
Step 3: Build and register with Claude Desktop (5 minutes)
Build the TypeScript:
npm run buildNow register it in Claude Desktop’s config file.
On macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
On Windows: %APPDATA%\Claude\claude_desktop_config.json
If the file doesn’t exist, create it:
{
"mcpServers": {
"my-mcp-server": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/build/index.js"]
}
}
}Use the absolute path. Restart Claude Desktop after saving. You’ll see a hammer icon (🔨) in the message input — that means Claude has discovered your tools.
Ask Claude: “How many words are in this sentence?” — it will call get_word_count and return the result.
Step 4: Build a useful tool
Word counting is for illustration. Here’s a more useful tool: reading files from a project directory, which is what I use for context-injection agents that summarize codebases, changelogs, or config files.
Replace the tools array and handler in src/index.ts:
import { readFileSync, readdirSync } from "fs";
import { join, extname } from "path";
const ALLOWED_EXTENSIONS = [".md", ".txt", ".ts", ".json", ".yaml"];
const PROJECT_DIR = process.env.PROJECT_DIR ?? process.cwd();
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "list_files",
description:
"Lists files in the project directory with their extensions.",
inputSchema: {
type: "object",
properties: {},
required: [],
},
},
{
name: "read_file",
description:
"Reads a file from the project directory. Only safe text extensions are allowed.",
inputSchema: {
type: "object",
properties: {
filename: {
type: "string",
description: "The filename to read (relative to project dir)",
},
},
required: ["filename"],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "list_files") {
const entries = readdirSync(PROJECT_DIR, { withFileTypes: true });
const files = entries
.filter(
(e) => e.isFile() && ALLOWED_EXTENSIONS.includes(extname(e.name))
)
.map((e) => e.name);
return { content: [{ type: "text", text: files.join("\n") }] };
}
if (name === "read_file") {
const { filename } = args as { filename: string };
// Prevent path traversal
if (filename.includes("..") || filename.includes("/")) {
throw new Error("Only flat filenames are allowed — no paths");
}
const allowed = ALLOWED_EXTENSIONS.includes(extname(filename));
if (!allowed) {
throw new Error(
`Extension not allowed. Permitted: ${ALLOWED_EXTENSIONS.join(", ")}`
);
}
const content = readFileSync(join(PROJECT_DIR, filename), "utf-8");
return { content: [{ type: "text", text: content }] };
}
throw new Error(`Unknown tool: ${name}`);
});Pass PROJECT_DIR when registering:
{
"mcpServers": {
"file-reader": {
"command": "node",
"args": ["/path/to/build/index.js"],
"env": {
"PROJECT_DIR": "/path/to/your/project"
}
}
}
}Claude can now list what’s in your project and read any text file you point it at — without copying files into the chat manually.
The gotchas I hit (so you don’t)
The path must be absolute. Relative paths in Claude Desktop config don’t resolve the way you’d expect — the working directory isn’t your project. Always use the full /home/user/... or C:\Users\... path.
Stdio means no console.log in your server. Claude Desktop communicates with your server over stdin/stdout. If you console.log debug output, it corrupts the JSON-RPC stream and breaks the connection. Log to stderr instead:
process.stderr.write(`Debug: ${message}\n`);Restart Claude Desktop after every config change. MCP servers are loaded at startup. An edited config file does nothing until you quit and reopen the app.
Tool descriptions are the product. Claude decides whether to call your tool based on its description field. A vague description means Claude won’t know when to use it. A precise one — “Returns the live balance for a Stripe account, given an account ID” — means Claude reaches for it at the right moment. Spend more time on descriptions than on implementation.
Input schemas must be valid JSON Schema. Required fields go in the required array, not in the property definition. Claude sends arguments that match your schema exactly.
How I use MCP servers in production
The stdio pattern works great for Claude Desktop and Claude Code (local). For production agents — the 30+ I run on Cloudflare Workers — I use the Anthropic SDK’s tool-use API directly instead of MCP, because I need the flexibility to route to Haiku vs Sonnet per step and the Workers environment doesn’t run MCP servers natively.
The patterns I actually ship:
- Local dev tooling — MCP servers for Claude Code that expose project-specific tools (read build logs, query local DB) that aren’t useful in production
- Context injection — MCP servers that pre-load relevant docs into the conversation without manual pasting
- Prototype-to-API bridge — I build MCP first (faster to iterate on tool definitions), then port the logic to direct SDK tool-use for production agents
The multi-agent orchestration patterns post covers the production side: durable queues, externalized state, and idempotent handoffs. MCP is the prototyping layer; those patterns are what happens when a tool goes to scale.
What to build next
Once the server structure clicks, the useful tools are the ones that reach outside Claude’s native context:
- Database reader — run a read-only SQL query and return results as JSON
- Slack reader — fetch the last N messages from a channel
- GitHub reader — list open PRs, read a file at a specific commit
- Internal API wrapper — call your own REST API with auth headers baked in
Each follows the same pattern: one or two handlers, a JSON schema per tool, a process.env variable for the secret. The implementation changes; the structure doesn’t.
If your goal is to get Claude working against your own data today, the first AI agent tutorial covers the direct Anthropic SDK path — useful to read alongside this, since the tool-use API and MCP serve the same goal from different angles.
FAQ
Do I need an Anthropic API key to build an MCP server?
No. Your MCP server doesn’t call the Anthropic API. It just responds to tool call requests from whatever client is running Claude. The API key lives in the client, not in the server.
Can my MCP server call external APIs?
Yes — the handler is just async TypeScript code. Fetch a weather API, query a database, write to a file. The server doesn’t care what the handler does internally, as long as it returns a response in the MCP format.
What’s the difference between stdio and HTTP transports?
Stdio is for local servers — same machine as Claude Desktop or Claude Code. HTTP with SSE is for remote servers you can deploy as a web service and share across multiple clients. Start with stdio; it’s simpler to debug. Add HTTP transport when you need multi-user or remote access.
Do I need to rebuild after every code change?
For development, no — use tsx src/index.ts directly in your Claude Desktop config instead of the compiled build:
{ "command": "npx", "args": ["tsx", "/path/to/src/index.ts"] }For production, build and point Claude at the compiled output.
How does Claude know when to call my tool?
Claude decides based on the tool’s description and the conversation context. You can’t force Claude to call a specific tool — you influence it through precise descriptions and by narrowing what each tool does. If Claude keeps ignoring your tool, tighten the description.
Every Wednesday. 28,400+ operators. Zero fluff.
✓ Check your inbox — click the confirmation link to complete sign-up.
✓ You're subscribed!
✓ You're already on the list.
Get the AI playbook in your inbox
Every Wednesday. 28,400+ operators. Zero fluff.
Check your inbox.
We sent you a confirmation email — click the link inside to complete your subscription. Check spam if you don't see it within a minute.
You're subscribed.
Welcome — the next edition lands in your inbox soon.
You're already on the list — look for it every Wednesday.