P245 min

Your First MCP Server

Start from an empty directory and build a working MCP server — project setup, tool registration, and your first request-response cycle.

On This Page

Key Concepts

  • McpServer class from the SDK
  • Tool registration with .tool()
  • Zod schemas for input validation
  • StdioServerTransport for local servers
  • JSON-RPC message flow over stdin/stdout
  • MCP Inspector for visual debugging

What You Will Build

In this module, you will build a working MCP server from an empty directory. By the end, you will have a server with one tool called echo that takes a message and returns it back. Simple on purpose — the goal is to understand every moving part before adding complexity.

Here is what the finished server does:

You send:    { "message": "Hello, MCP!" }
Server returns: "Echo: Hello, MCP!"

That's it. One tool, one input, one output.
Everything else is the protocol machinery that makes it work.

Prerequisites: You need Node.js 18+ and npm installed. If you completed the Development Environment module in Phase 0, you are ready. If not, run node --version in your terminal to confirm.


Step 1: Create the Project

Open your terminal. Create a new directory and initialize a Node.js project inside it.

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

This creates a package.json with default values. Now install the two dependencies you need:

npm install @modelcontextprotocol/sdk zod

Also install TypeScript as a dev dependency:

npm install -D typescript @types/node

What Just Happened?

  • @modelcontextprotocol/sdk — The official MCP SDK for TypeScript/JavaScript. It provides the McpServer class, transport implementations, and type definitions. This is the only MCP-specific dependency you need.
  • zod — A schema validation library. The MCP SDK uses Zod to define and validate tool input schemas. When you declare what parameters a tool accepts, you write it as a Zod schema.
  • typescript — The TypeScript compiler. MCP servers can be written in plain JavaScript, but TypeScript catches errors at compile time and the SDK has excellent type definitions.
  • @types/node — Type definitions for Node.js built-in modules like process and fs.

Your package.json should now list these under dependencies and devDependencies.


Step 2: Configure TypeScript

Create a file called tsconfig.json in the project root. Type this out — do not copy-paste. Understanding each option matters.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"]
}

What Each Option Does

  • target: "ES2022" — Compile to modern JavaScript. The MCP SDK uses modern features like top-level await.
  • module: "Node16" — Use Node.js's native module system. This matters because MCP servers run in Node, not in a browser.
  • moduleResolution: "Node16" — Tell TypeScript how to find imported modules. Matches the module setting.
  • outDir: "./build" — Compiled JavaScript goes into a build/ folder. Your source stays in src/.
  • rootDir: "./src" — TypeScript source files live in src/.
  • strict: true — Enable all strict type-checking options. Catches bugs early.

Step 3: Write the Server

Create the source directory and the main file:

mkdir src
touch src/index.ts

Now open src/index.ts in your editor and type the following. Each section is explained line by line.

Part A: Imports

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

Three imports, each with a specific purpose:

  • McpServer — The main class that represents your MCP server. You create one instance, register tools/resources on it, then connect it to a transport.
  • StdioServerTransport — A transport that reads JSON-RPC messages from stdin and writes responses to stdout. This is how local MCP servers communicate — the host (like Claude Desktop) launches your server as a subprocess and talks to it through pipes.
  • z — Zod's namespace. You use z.object(), z.string(), etc. to define what inputs your tools accept.

Part B: Create the Server

// Create the server instance
const server = new McpServer({
  name: "my-first-server",
  version: "1.0.0",
});

This creates your server with a name and version. These are sent to the client during the initialization handshake. The client uses the name to identify your server in logs and UI, and the version for compatibility checks.

Part C: Register a Tool

// Register the "echo" tool
server.tool(
  // 1. Tool name — what the AI model sees and calls
  "echo",

  // 2. Description — the AI reads this to decide when to use the tool
  "Returns the message you send it. Useful for testing that the server works.",

  // 3. Input schema — defines what parameters the tool accepts
  {
    message: z.string().describe("The message to echo back"),
  },

  // 4. Handler function — runs when the tool is called
  async ({ message }) => {
    return {
      content: [
        {
          type: "text",
          text: `Echo: ${message}`,
        },
      ],
    };
  }
);

The server.tool() method takes four arguments. Let us go through each one:

  1. Tool name ("echo") — A unique identifier for this tool. The AI model uses this name when it decides to call the tool. Keep it short, lowercase, and descriptive. Use underscores for multi-word names (e.g., get_weather).
  2. Description — A plain English description of what the tool does. This is critically important because the AI model reads this description to decide whether to use the tool. Write it for an AI, not a human developer. Be specific about what the tool does and when it is useful.
  3. Input schema — A Zod object describing what parameters the tool accepts. Here we define one required string parameter called message. The .describe() call adds a description that the AI model reads to understand what value to provide.
  4. Handler function — An async function that receives the validated inputs and returns a result. The return value must be an object with a content array. Each item in the array has a type (usually "text") and the actual data.

Why an array of content? MCP tool results can contain multiple pieces of content — text, images, embedded resources. Even when you are returning a single text string, you wrap it in the content array. This is part of the protocol spec.

Part D: Connect the Transport and Start

// Create a stdio transport — reads from stdin, writes to stdout
const transport = new StdioServerTransport();

// Connect the server to the transport and start listening
await server.connect(transport);

These two lines are the startup sequence. The transport handles the low-level I/O (reading bytes from stdin, parsing JSON-RPC messages, writing responses to stdout). The server handles the protocol logic (initialization handshake, tool dispatch, error handling). Calling server.connect(transport) wires them together and starts listening for incoming messages.

Why stdio? When an MCP host like Claude Desktop starts your server, it launches it as a child process. The host writes JSON-RPC messages to the process's stdin and reads responses from stdout. This is the simplest and most common transport for local servers. No HTTP, no ports, no networking — just pipes.


Step 4: Add the Build Script

Open package.json and update the scripts section. Also add the type field:

{
  "name": "my-first-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node build/index.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "...",
    "zod": "..."
  },
  "devDependencies": {
    "@types/node": "...",
    "typescript": "..."
  }
}

The key addition is "type": "module". This tells Node.js to treat .js files as ES modules (import/export) rather than CommonJS (require/module.exports). The MCP SDK uses ES module imports, so this is required.

Now build the project:

npm run build

If everything is correct, this creates a build/index.js file with the compiled JavaScript. If you see errors, check the Troubleshooting section below.


Step 5: Run It

Start the server:

npm run start

The terminal will appear to hang. This is expected. Your server is running and waiting for JSON-RPC messages on stdin. It is not a web server with a URL — it reads from the input stream and writes to the output stream. You will not see any output until something sends it a message.

Press Ctrl+C to stop it. We will test it properly next.


What Just Happened?

Let us trace the full lifecycle of what you just built:

1. You created a Node.js project with TypeScript
2. You imported the MCP SDK and Zod
3. You created an McpServer instance with a name and version
4. You registered one tool ("echo") with:
   - A name the AI sees
   - A description the AI reads
   - A Zod schema defining the input
   - An async handler that returns the result
5. You created a StdioServerTransport
6. You connected the server to the transport

When a host connects:
  Host  ---> [initialize request]  ---> Your Server
  Host <--- [server info + capabilities] <--- Your Server
  Host  ---> [initialized notification] ---> Your Server

When the AI calls your tool:
  Host  ---> [tools/call { name: "echo", args: { message: "hi" } }] ---> Your Server
  Your Server validates the input against the Zod schema
  Your Server runs the handler function
  Host <--- [{ content: [{ type: "text", text: "Echo: hi" }] }] <--- Your Server

You never write the JSON-RPC parsing code, the initialization handshake, or the tool dispatch logic. The SDK handles all of that. You just declare tools and write handlers.


Testing Manually via stdin

You can test your server without any special tools by piping JSON-RPC messages directly to it. This is useful for understanding the raw protocol.

The MCP protocol requires an initialization handshake before you can call tools. Here is the sequence of messages you need to send. Create a file called test-messages.jsonl in your project root:

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"message":"Hello from stdin!"}}}

Each line is a separate JSON-RPC message. The first is the initialize request, the second is the initialized notification (tells the server the client is ready), and the third calls your echo tool.

Now pipe this file into your server:

cat test-messages.jsonl | node build/index.js

You should see JSON-RPC responses printed to stdout, including:

{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Echo: Hello from stdin!"}]}}

That is your tool working. The server received a tools/call request, validated the input, ran your handler, and returned the result. Everything else in the output is protocol handshake messages.


Testing with MCP Inspector

Piping JSON to stdin works, but there is a much better tool for day-to-day development: the MCP Inspector. It provides a visual interface for testing your server.

Run the Inspector with your server:

npx @modelcontextprotocol/inspector node build/index.js

This opens a web interface (usually at http://localhost:5173). In the Inspector:

  1. Click Connect to establish a connection with your server.
  2. Go to the Tools tab. You should see your echo tool listed with its description and schema.
  3. Click on echo, enter a message in the input field, and click Run.
  4. You should see the response: Echo: [your message].

The Inspector is your best friend during development. It shows you exactly what your server exposes, lets you test every tool interactively, and displays the raw JSON-RPC messages so you can debug protocol issues. We will cover it in depth in a later module.


Try It Yourself

Now that you have a working server, extend it. Add a second tool called greet that takes a name parameter and returns a personalized greeting.

Here is the specification:

  • Tool name: greet
  • Description: "Returns a personalized greeting for the given name."
  • Input: name (string, required)
  • Output: Hello, [name]! Welcome to MCP.

Steps:

  1. Add another server.tool() call after the echo tool registration.
  2. Define the Zod schema with a name field.
  3. Write the handler to return the greeting.
  4. Rebuild with npm run build.
  5. Test it with MCP Inspector — you should see both tools listed.
Stuck? Here is the solution
server.tool(
  "greet",
  "Returns a personalized greeting for the given name.",
  {
    name: z.string().describe("The name to greet"),
  },
  async ({ name }) => {
    return {
      content: [
        {
          type: "text",
          text: `Hello, ${name}! Welcome to MCP.`,
        },
      ],
    };
  }
);

Troubleshooting

Build fails with "Cannot find module" errors

Make sure you have "type": "module" in your package.json. Without it, Node.js tries to use CommonJS and cannot resolve the ES module imports from the SDK.

Build fails with "Cannot use import statement outside a module"

Same fix: add "type": "module" to package.json.

Running the server shows "ERR_MODULE_NOT_FOUND"

Check that your import paths include the .js extension. With Node16 module resolution, TypeScript requires explicit extensions:

// Correct:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

// Wrong — will fail at runtime:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp";

Inspector cannot connect

Make sure you built the project first (npm run build) and that the path to the built file is correct. The Inspector runs node build/index.js as a subprocess.

No output when piping test messages

Make sure each JSON-RPC message is on its own line in the test file. Also ensure there is a newline at the end of the file. The transport reads line-delimited JSON.

TypeScript version conflicts

The MCP SDK requires TypeScript 5+. Check your version with npx tsc --version. If it is older than 5.0, run npm install -D typescript@latest.


Full Code Reference

Here is the complete src/index.ts for reference. If your code does not match, compare line by line.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// Create the server instance
const server = new McpServer({
  name: "my-first-server",
  version: "1.0.0",
});

// Register the "echo" tool
server.tool(
  "echo",
  "Returns the message you send it. Useful for testing that the server works.",
  {
    message: z.string().describe("The message to echo back"),
  },
  async ({ message }) => {
    return {
      content: [
        {
          type: "text",
          text: `Echo: ${message}`,
        },
      ],
    };
  }
);

// Create a stdio transport — reads from stdin, writes to stdout
const transport = new StdioServerTransport();

// Connect the server to the transport and start listening
await server.connect(transport);

Your project structure should look like this:

my-first-mcp-server/
  src/
    index.ts          <-- your server code
  build/
    index.js          <-- compiled output (after npm run build)
  package.json
  tsconfig.json
  test-messages.jsonl <-- optional, for manual testing
  node_modules/

What You Accomplished

  • Created a Node.js project with TypeScript from scratch
  • Installed the MCP SDK and Zod
  • Wrote a complete MCP server with one tool
  • Understood every line of code and why it exists
  • Tested the server both manually and with MCP Inspector
  • Extended the server with a second tool

In the next module, you will add more sophisticated tools to this server — tools with complex inputs, HTTP requests, different content types, and proper error handling.