Your First MCP Tool
Build a minimal MCP server in Node.js, register a tool with Zod validation, and understand why tool descriptions determine how an LLM uses your server.
Here's the minimal setup I used to get started:
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zodSet "type": "module" in package.json so Node treats .js files as ES modules — the MCP SDK uses import syntax and requires it.
{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}The Minimal Server
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: "my-mcp-server",
version: "1.0.0",
});
server.tool(
"add",
"Add two numbers together. Use this when the user wants to sum two numeric values.",
{
a: z.number().describe("First number"),
b: z.number().describe("Second number"),
},
async ({ a, b }) => ({
content: [{ type: "text", text: String(a + b) }],
})
);
const transport = new StdioServerTransport();
await server.connect(transport);One thing that tripped me up early: notice the direct path imports (/server/mcp.js, /server/stdio.js). The MCP SDK requires these — barrel imports from the package root don't work.
What Zod Is Doing
Zod validates the input schema for each tool. When you write:
{ a: z.number().describe("First number") }You're defining:
- The parameter name (
a) - The expected type (
number) - A description the LLM can read to understand what to pass
The MCP SDK converts this Zod schema into JSON Schema, which gets sent to the LLM as part of the tool's definition.
The Three Parts of a Tool
Every server.tool() call takes four arguments:
- Name — machine-readable identifier (
add,get_weather,create_issue) - Description — natural language explanation for the LLM
- Input schema — Zod object defining parameters
- Handler — the async function that runs when the tool is called
Here's what I've found after building several tools: the description is more important than it looks. The LLM reads it to decide which tool fits the user's request. Too vague and it might misfire. Too verbose and it can hallucinate edge cases. Aim for one clear sentence: what the tool does and when to use it.
Why Determinism Matters
LLMs are inherently inconsistent. They'll produce different outputs on different runs. That's fine for generating text, but terrible for operations that have side effects — creating a database record, calling a billing API, deleting a file.
MCP tools let you control those operations deterministically. The LLM decides when to call your tool. Your code decides what happens. You get the intelligence of the model for routing decisions, and the reliability of regular code for execution.
A classic example: ask an LLM to "fix the database schema" without constraints, and it might decide the cleanest fix is to drop and recreate the table — taking your data with it. With an MCP tool, you define the exact operations available. The LLM can't invent ones you didn't expose.
Running Locally
Build and test before connecting to any client:
npx tsc && node dist/index.jsYou can also call it directly with the MCP Inspector or test with raw JSON-RPC — more on that in the next post.
Further Reading
Practice what you just read.
Keep reading
Enjoyed this? Get more like it.
Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.