P130 min

Building Your First Server

Create a minimal MCP server from scratch — register a tool, handle requests, return results.

On This Page

Key Concepts

  • McpServer class from the SDK
  • Tool registration with .tool()
  • Zod schemas for input validation
  • StdioServerTransport for local connections
  • server.connect() to start listening
  • TextContent return format
  • MCP Inspector for debugging

What You Will Build

In this module, you will build a working MCP server from scratch. Not by copying a template — by understanding every line of code you write. By the end, you will have a server that registers a tool, validates inputs, returns results, and can be tested with the MCP Inspector.

The server will do something simple: a greet tool that takes a name and returns a personalized greeting. The logic is trivial on purpose. The point is to learn the MCP server structure, not build something fancy. Fancy comes later.

What we are building:

  +-----------------------+
  |    MCP Inspector      |    (or Claude Desktop, or any MCP client)
  |    (MCP Client)       |
  +-----------+-----------+
              |
              | stdio (stdin/stdout)
              |
  +-----------+-----------+
  |    Your MCP Server    |
  |                       |
  |  Tool: "greet"        |
  |  Input: { name }      |
  |  Output: "Hello, X!"  |
  +-----------------------+

Project Setup

Before writing server code, you need a project with the right dependencies. Here is the minimal setup:

# Create a new directory and initialize it
mkdir my-mcp-server
cd my-mcp-server
npm init -y

# Install the two packages you need
npm install @modelcontextprotocol/sdk zod

# Install TypeScript for development
npm install -D typescript @types/node

# Initialize TypeScript config
npx tsc --init

Let us break down the two runtime dependencies:

  • @modelcontextprotocol/sdk — the official MCP SDK for TypeScript. This provides the McpServer class, transport implementations, and all the types you need.
  • zod — a schema validation library. The MCP SDK uses Zod to define and validate tool input schemas. When you describe what parameters a tool accepts, you write a Zod schema.

You also need to configure your tsconfig.json for the correct module system. The important settings:

// tsconfig.json — key settings for MCP servers
{
  "compilerOptions": {
    "target": "ES2022",        // Modern JS features
    "module": "Node16",        // Node.js module resolution
    "moduleResolution": "Node16",
    "outDir": "./build",       // Compiled output goes here
    "rootDir": "./src",        // Source files live here
    "strict": true,            // Catch errors at compile time
    "esModuleInterop": true,   // Smooth imports
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

Anatomy of an MCP Server

Every MCP server has the same basic structure, regardless of what it does. Think of it like a restaurant:

The Restaurant Analogy:

  1. Open the restaurant       = Create the McpServer instance
  2. Write the menu             = Register tools, resources, prompts
  3. Seat guests at tables      = Connect a transport
  4. Take orders and serve      = Handle incoming requests
  5. Close at end of day        = Graceful shutdown

No matter what cuisine you serve (what your tools do),
the restaurant operates the same way.

In code, this translates to three steps that every server follows:

  1. Create an McpServer instance with metadata about your server
  2. Register your capabilities (tools, resources, prompts)
  3. Connect a transport so clients can communicate with your server

Let us build each step.

Registering Your First Tool

Start by creating src/index.ts. The first thing you need is the server instance:

// src/index.ts

// Import the server class from the SDK
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

// Create a server instance
// The first argument is metadata that clients will see
const server = new McpServer({
  name: "my-first-server",     // Unique identifier for your server
  version: "1.0.0",            // Semantic version — clients may check this
});

The name and version fields are not cosmetic. During the initialization handshake, the client receives this metadata. Some clients display the server name in their UI. The version can be used for capability checking. Always set meaningful values.

Now register a tool. The .tool() method takes three arguments:

import { z } from "zod";

// Register a tool named "greet"
server.tool(
  // 1st argument: the tool name
  //    This is what the AI model sees. Make it descriptive but concise.
  //    Use snake_case — it is the convention in MCP.
  "greet",

  // 2nd argument: a human-readable description
  //    The AI model reads this to decide WHEN to use the tool.
  //    Write it like you are explaining to a coworker what this function does.
  "Generate a personalized greeting for someone. Use this when the user wants to say hello to a specific person.",

  // 3rd argument: the input schema using Zod
  //    This defines what parameters the tool accepts.
  //    The SDK converts this to JSON Schema under the hood.
  {
    name: z.string().describe("The name of the person to greet"),
  },

  // 4th argument: the handler function
  //    This runs when a client calls the tool.
  //    It receives the validated input as its first argument.
  async ({ name }) => {
    // The return value MUST follow the MCP content format.
    // For text results, return an object with a "content" array
    // containing objects with type "text".
    return {
      content: [
        {
          type: "text" as const,
          text: `Hello, ${name}! Welcome to MCP.`,
        },
      ],
    };
  }
);

Let us unpack what each piece does:

  • Tool name ("greet") — this is the identifier the AI model uses to call your tool. When Claude decides to use this tool, it sends a tools/call request with {"name": "greet"}.
  • Description — this is critically important. The AI model has no other way to understand what your tool does. A bad description means the model will either never use your tool or use it at the wrong time. Write descriptions for an AI audience.
  • Zod schema — defines the shape of valid input. The SDK validates incoming requests against this schema automatically. If a client sends {"name": 42}, the SDK rejects it before your handler ever runs.
  • Handler — your actual logic. It receives already-validated input. It must return an object with a content array. Each item in the array is a content block (text, image, or embedded resource).

Handling Tool Calls

The handler function deserves a closer look. When a client calls your tool, here is what happens behind the scenes:

Request/Response Flow:

  Client sends:
  {
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "name": "greet",
      "arguments": { "name": "Alice" }
    }
  }

  SDK validates "arguments" against your Zod schema
         |
         v
  Your handler receives: { name: "Alice" }
         |
         v
  Your handler returns: { content: [{ type: "text", text: "Hello, Alice!" }] }
         |
         v
  SDK wraps this in a JSON-RPC response:
  {
    "jsonrpc": "2.0",
    "id": 1,
    "result": {
      "content": [{ "type": "text", "text": "Hello, Alice! Welcome to MCP." }]
    }
  }

Notice that you never deal with JSON-RPC directly. The SDK handles the protocol layer. You write a function that takes validated input and returns content. That is it.

The content array can contain multiple items. For example, a tool could return both text and an image:

// A tool that returns multiple content blocks
return {
  content: [
    { type: "text", text: "Here is the chart you requested:" },
    { type: "image", data: base64EncodedPng, mimeType: "image/png" },
  ],
};

Connecting the Transport

Your server is defined but not yet listening. You need a transport — the communication channel between your server and clients. For local development, use StdioServerTransport:

import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

// Create a stdio transport
// This reads from process.stdin and writes to process.stdout
const transport = new StdioServerTransport();

// Connect the server to the transport
// This starts the server — it will now respond to incoming requests
await server.connect(transport);

The stdio transport works by reading JSON-RPC messages from standard input and writing responses to standard output. This is how Claude Desktop and the MCP Inspector communicate with local servers — they launch your server as a child process and pipe messages through stdin/stdout.

A critical detail: never write to stdout yourself when using the stdio transport. Any console.log() call will corrupt the JSON-RPC message stream and crash the connection. Use console.error() for debug output instead — stderr is not part of the transport.

Full Server Code

Here is the complete server in one file. Read through it line by line and make sure you understand each part:

// src/index.ts — A complete, minimal MCP server

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

// Step 1: Create the server with identifying metadata
const server = new McpServer({
  name: "my-first-server",
  version: "1.0.0",
});

// Step 2: Register a tool
server.tool(
  "greet",
  "Generate a personalized greeting for someone",
  {
    name: z.string().describe("The name of the person to greet"),
  },
  async ({ name }) => {
    return {
      content: [
        {
          type: "text" as const,
          text: `Hello, ${name}! Welcome to MCP.`,
        },
      ],
    };
  }
);

// Step 3: Connect a transport and start listening
const transport = new StdioServerTransport();
await server.connect(transport);

// That is it. The server is now running and waiting for requests.
// It will handle:
//   - Initialize handshake (automatic)
//   - tools/list requests (automatic — returns your registered tools)
//   - tools/call requests (routes to your handler based on tool name)
//   - Shutdown (automatic — clean exit on stdin close)

Twelve lines of meaningful code. That is the core of an MCP server. Everything else you will learn — resources, prompts, error handling, transports — builds on top of this same pattern.

Testing with the MCP Inspector

You should not test your server by guessing. The MCP Inspector is a visual debugging tool that connects to your server and lets you browse its capabilities and call tools interactively.

# Build your TypeScript first
npx tsc

# Launch the Inspector pointing at your server
npx @modelcontextprotocol/inspector node build/index.js

The Inspector opens a web UI where you can:

  1. See the initialization handshake and capability negotiation
  2. Browse all registered tools and their schemas
  3. Call a tool with custom arguments and see the result
  4. View the raw JSON-RPC messages going back and forth

Try calling the greet tool with different names. Try sending an empty string. Try sending a number instead of a string. Watch how the SDK validates input and returns errors for invalid requests.

What you should see in the Inspector:

  Tools tab:
  +---------+------------------------------------------+
  | greet   | Generate a personalized greeting for ... |
  +---------+------------------------------------------+

  Call "greet" with { "name": "Alice" }:
  +------------------------------------------------+
  | Result:                                        |
  | Hello, Alice! Welcome to MCP.                  |
  +------------------------------------------------+

  Call "greet" with { "name": 42 }:
  +------------------------------------------------+
  | Error: Expected string, received number        |
  +------------------------------------------------+

What Just Happened

Take a step back and understand the full sequence of events when you launched the Inspector:

Timeline:

1. Inspector starts your server as a child process
   $ node build/index.js
   (stdin and stdout are piped to the Inspector)

2. Inspector sends "initialize" request
   → Your server responds with its name, version, capabilities

3. Inspector sends "initialized" notification
   → Server knows the handshake is complete

4. Inspector sends "tools/list" request
   → Server responds with [{ name: "greet", description: "...", inputSchema: {...} }]

5. You click "Call" in the UI
   → Inspector sends "tools/call" with { name: "greet", arguments: { name: "Alice" } }
   → Server validates input, runs handler, returns content
   → Inspector displays the result

6. You close the Inspector
   → stdin closes → transport detects EOF → server shuts down gracefully

All of steps 2, 3, and 4 happened automatically. The SDK handled the initialization handshake and capability listing. You only wrote the tool handler.

Try It Yourself

Do not just read this — build it. Then extend it:

  1. Add a second tool called add that takes two numbers (a and b, both z.number()) and returns their sum. Register it the same way as greet.
  2. Add a third tool called current_time that takes no arguments and returns the current date and time. For no arguments, pass an empty object {} as the schema.
  3. Test all three in the Inspector. Make sure they show up in the tools list and return correct results.
  4. Break it on purpose. Add a console.log() in your handler and watch the connection fail. Then fix it by changing to console.error().

These exercises are not optional if you want to understand MCP. Reading is not learning — building is.

Key Takeaway

An MCP server is three things: an McpServer instance, registered capabilities (tools/resources/prompts), and a connected transport. The SDK handles the protocol layer — handshakes, JSON-RPC, validation — so you focus on your tool logic. Start simple, test with the Inspector, and build up from there.

Common Mistakes

  • Using console.log() with stdio transport. This writes to stdout, which is the transport channel. Your log messages will be interpreted as JSON-RPC messages and break everything. Use console.error() for debugging.
  • Forgetting await server.connect(transport). Without this call, your server is defined but not listening. It will exit immediately.
  • Writing vague tool descriptions. The AI model picks tools based on descriptions. “Does stuff” is useless. “Generate a personalized greeting for a person by name” is useful.
  • Not using .describe() on Zod fields. The field descriptions become part of the JSON Schema that the AI model reads. Without them, the model has to guess what each parameter means.
  • Returning a plain string instead of content blocks. The handler must return { content: [{ type: "text", text: "..." }] }. Returning a bare string will throw an error.
  • Not testing with the Inspector. If you skip the Inspector and go straight to Claude Desktop, debugging problems is much harder. The Inspector shows you the raw protocol messages.