Your First MCP Server
Build a working MCP server from scratch -- npm init, installing the SDK and Zod, registering your first tool with input validation, and running it. Simpler than you expect.
Here's how Brian Holt describes it:
"It's like one of those things I came into thinking I'm about to summit this mountain of complexity. And then you get into it and it's like -- what the hell was this? Why was I so afraid?"
MCP servers are actually simple. This post walks you through setup, your first tool, and why Zod is the backbone of every tool's input schema.
Project Setup
Open a terminal, create a folder, and initialize a Node project:
mkdir my-mcp-server
cd my-mcp-server
npm init -yThen install the MCP SDK and Zod:
npm install @modelcontextprotocol/sdk@1.16 zod@3.25.76@1.16 for this course. The latest version will likely exist by the time you read this -- if you want to upgrade, ask your AI assistant to update the code to the latest API. Don't just bump the version number blindly.One more change -- open package.json and add "type": "module":
{
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.16.0",
"zod": "^3.25.76"
}
}This tells Node.js to treat .js files as ES modules -- required because the MCP SDK uses import syntax.
The Annoying Import Paths
When Brian first used the MCP SDK, he noted:
"This was not packaged by a Node person."
The SDK requires direct subpath imports, not barrel imports:
// ✅ Correct -- use the full path
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
// ❌ Wrong -- this doesn't work
import { McpServer, StdioServerTransport } from "@modelcontextprotocol/sdk";Don't let this trip you up. It's a packaging quirk, not a conceptual issue.
What Is Zod?
Zod is a schema validation library. It lets you describe what type of data you expect, and it'll throw an error if the data doesn't match.
In MCP, Zod does two jobs:
- Validates the input the LLM sends (prevents bad data reaching your code)
- Generates the JSON Schema that gets sent to the LLM (so it knows what to pass)
Building the Add Server
Create mcp.js:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// 1. Create the server
const server = new McpServer({
name: "add-server",
version: "1.0.0",
});
// 2. Register a tool
server.registerTool("add", {
title: "Addition Tool",
description: "Add two numbers together. Use when the user wants to sum two numeric values.",
inputSchema: {
a: z.number().describe("First number"),
b: z.number().describe("Second number"),
}
}, async ({ a, b }) => ({
content: [{ type: "text", text: String(a + b) }]
}));
// 3. Connect transport
const transport = new StdioServerTransport();
await server.connect(transport);That's it. The entire MCP server. Let's understand each piece.
Anatomy of registerTool
The Name
Machine-readable. Used in JSON-RPC messages. Convention: lowercase with underscores. Keep it descriptive.
The Description
This is the most important part. The LLM reads this description to decide: "Should I use this tool right now?"
// ❌ Too vague -- LLM won't know when to use it
description: "Does math"
// ❌ Too verbose -- triggers hallucinations about edge cases
description: "Add two real numbers together using standard mathematical addition. This tool accepts any real number including integers, floats, negative numbers, and zero. Do not use for complex arithmetic involving multiple operations or for subtraction, multiplication, or division."
// ✅ Just right -- clear intent, when to use it
description: "Add two numbers together. Use when the user wants to sum two numeric values."Brian's rule: write enough to make the intent unambiguous. Then stop.
The Input Schema
Zod objects define each parameter:
inputSchema: {
a: z.number().describe("First number"),
b: z.number().describe("Second number"),
}The .describe() call is important -- it gives the LLM per-parameter guidance. The MCP SDK converts this Zod schema into JSON Schema and sends it to the LLM as part of the tool's definition.
The Handler
async ({ a, b }) => ({
content: [{ type: "text", text: String(a + b) }]
})Destructure the validated inputs. Return content -- an array of { type, text } objects. String() converts the number to a string because everything going back to the LLM is text.
The Transport
const transport = new StdioServerTransport();
await server.connect(transport);Transports are how messages move between client and server. StdioServerTransport uses standard input/output -- the Unix way of piping data between processes.
When you run node mcp.js, the process starts and hangs. That's correct. It's waiting for input. Claude Desktop will connect to this process and send it messages.
Running the Server
node mcp.js
# The process will appear to hang -- that's correct.
# It's running and waiting for input.
# Ctrl+C to exit.If you see an error, the most common causes:
- Forgot
"type": "module"inpackage.json - Wrong import path (missing the
.jsextension in the path) zodnot installed
Lab 1 -- Extend the Tool Registry
subtract tool and a divide tool. Pay attention to the description -- what happens if someone divides by zero? Should the description mention that?Lab 2 -- Understand the Handler Contract
Why Determinism Is the Point
LLMs are brilliant routers -- they understand user intent, pick the right tool, and extract the right arguments. But they're inconsistent executors.
Key Takeaways
- Setup is 4 commands:
mkdir,npm init,npm install sdk + zod, add"type": "module" - Import paths must be full subpaths -- packaging quirk of the SDK
- Zod validates input AND generates JSON Schema -- both serve the LLM
- Description is the most important part of a tool -- it's how the LLM decides when to use it
- Handler returns
{ content: [{ type: "text", text: "..." }] }-- text string, always - Run with
node mcp.js-- the hanging process is correct, it's waiting for input
What's Next
The server is running. Now let's talk to it directly -- using raw JSON-RPC messages -- before connecting to any client. This is how you understand exactly what messages flow between client and server.
Practice what you just read.
Keep reading