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.

April 1, 20268 min read1 / 7

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:

Bash
mkdir my-mcp-server cd my-mcp-server npm init -y

Then install the MCP SDK and Zod:

Bash
npm install @modelcontextprotocol/sdk@1.16 zod@3.25.76
⚠️ Pin these versions. The MCP SDK moves fast. Brian pins to @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":

JSON
{ "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:

JavaScript
// ✅ 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.

JavaScript · Live Editor
Loading editor...

In MCP, Zod does two jobs:

  1. Validates the input the LLM sends (prevents bad data reaching your code)
  2. Generates the JSON Schema that gets sent to the LLM (so it knows what to pass)

Building the Add Server

Create mcp.js:

JavaScript
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

Diagram

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?"

JavaScript
// ❌ 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:

JavaScript
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

JavaScript
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

JavaScript
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.

Diagram

Running the Server

Bash
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:

  1. Forgot "type": "module" in package.json
  2. Wrong import path (missing the .js extension in the path)
  3. zod not installed

Lab 1 -- Extend the Tool Registry

JavaScript · Live Editor
Loading editor...
✅ Exercise: Write a 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

JavaScript · Live Editor
Loading editor...

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.

JavaScript · Live Editor
Loading editor...
ℹ️ The classic failure without MCP: Ask an LLM to do complex arithmetic -- it occasionally gets it wrong. Ask it to call a tool that does arithmetic -- the tool gets it right 100% of the time. Use the LLM for reasoning, use tools for computation.

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.

Build Your First MCP Tool
1 exercise