My first MCP server took four hours. It should have taken forty minutes. I burned the rest debugging a problem that didn't exist, because I didn't understand what MCP actually is before I started typing. So let me save you that afternoon.
MCP — the Model Context Protocol — is just a way to hand Claude Code new tools. That's it. Your editor already gives Claude file access and a shell. An MCP server gives it more: a database it can query, an API it can call, your Linear board, your Postgres, whatever. The server runs as its own process. Claude talks to it over a simple protocol. When Claude decides it needs your tool, it calls it, gets a result, and keeps going.
That's the whole mental model. Hold onto it.
What you need before you start your first MCP server
Node 18 or newer. A terminal. Claude Code installed and working. That's the list. You don't need Docker, you don't need a cloud account, and you don't need to understand the protocol's wire format — the SDK handles all of it.
We're going to build a tiny server that exposes one tool: get the current weather for a city. Fake data, because the point is the plumbing, not the weather. Once the plumbing works, swapping in a real API is a five-minute change.
Step 1: Scaffold the project
mkdir weather-mcp && cd weather-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
That zod package isn't optional fluff. MCP tools need a schema so Claude knows what arguments to pass, and zod is the cleanest way to write one. You'll thank yourself later when Claude passes exactly the right shape every time.
Step 2: Write the server
Create index.js:
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: "weather", version: "1.0.0" });
server.tool(
"get_weather",
"Get the current weather for a city",
{ city: z.string().describe("City name, e.g. Lisbon") },
async ({ city }) => ({
content: [{ type: "text", text: `It's 22°C and sunny in ${city}.` }],
})
);
const transport = new StdioServerTransport();
await server.connect(transport);
Look at how little this is. One tool, one schema, one fake response. The StdioServerTransport part is the bit that confused me for an hour the first time — it means the server talks to Claude over standard input and output, not a network port. So your server has no console.log debugging, because anything you print to stdout corrupts the protocol stream. Print to console.error instead. Burn that into your memory.
Step 3: Register it with Claude Code
Claude Code reads MCP servers from a config file. The easiest way is the CLI:
claude mcp add weather -- node /full/path/to/weather-mcp/index.js
Use the absolute path. Relative paths look fine and then mysteriously fail when Claude launches the server from a different working directory. I've done this wrong three separate times. Absolute path. Every time.
Then check it registered:
claude mcp list
You should see weather in the output. If you don't, the add command failed silently — re-run it and read the error.
Step 4: Actually use it
Start a Claude Code session in any project and ask:
What's the weather in Lisbon?
Claude should recognize it has a get_weather tool, call it, and answer with your fake response. The first time this works it feels a little like magic — you wrote a function in a separate file and now your AI assistant can call it on its own.
If Claude doesn't call the tool, nine times out of ten the description is too vague. "Get the current weather for a city" tells Claude exactly when to reach for it. "Weather tool" does not. Tool descriptions are prompts. Write them like you're explaining the tool to a new teammate who'll decide when to use it.
The three things that will trip you up
Stdout pollution. Said it already, saying it again, because it's the number one first-server bug. No console.log. Ever. The protocol lives on stdout.
Stale process. If you edit index.js, Claude doesn't hot-reload it. Restart your Claude Code session so it relaunches the server. I once spent twenty minutes convinced my code change wasn't working when really I was running the old version.
Schema mismatches. If your zod schema says city is required and Claude passes nothing, you get a confusing error. Make optional things actually optional with .optional(), and give every field a .describe() so Claude knows what goes there.
From toy to real
Now the fun part. That fake weather string? Swap the function body for a real fetch to an actual weather API. The schema, the registration, the way Claude calls it — none of that changes. You've already built the hard part.
This is the thing nobody tells you about MCP: the first server is 90% of the learning. Every server after it is the same five steps with a different function in the middle. Want Claude to query your database? Same pattern. Hit your company's internal API? Same pattern. Read your Notion? You get it.
Build the weather server tonight. It'll take you forty minutes if you skip the four-hour detour I took. Then tomorrow, build the one you actually need.
