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.

March 5, 20263 min read1 / 7

Here's the minimal setup I used to get started:

Bash
mkdir my-mcp-server && cd my-mcp-server npm init -y npm install @modelcontextprotocol/sdk zod

Set "type": "module" in package.json so Node treats .js files as ES modules — the MCP SDK uses import syntax and requires it.

JSON
{ "type": "module", "scripts": { "build": "tsc", "start": "node dist/index.js" } }

The Minimal Server

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

TypeScript
{ 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:

  1. Name — machine-readable identifier (add, get_weather, create_issue)
  2. Description — natural language explanation for the LLM
  3. Input schema — Zod object defining parameters
  4. 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:

Bash
npx tsc && node dist/index.js

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

Build Your First MCP Tool
1 exercise

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.