MCP Masterclass — Part 2 of 5

Your First MCP Server — Hello World to Production

Time to get your hands dirty. In this part, you'll build a complete MCP server from scratch using Node.js and the official SDK. You'll define tools with Zod validation, expose resources, test with MCP Inspector, and connect everything to Claude Code.

Hands-On Code Node.js SDK MCP Inspector Claude Code
MCP from A to Z2/5
Project SetupFirst ToolZod ValidationResourcesTestingConnect to Claude
Getting Started

Project Setup

Let's build a weather MCP server — a classic first project that demonstrates all the core concepts. By the end of this section, you'll have a working server that any MCP host can connect to.

Prerequisites

Step 1: Initialize the Project

Terminal
# Create project directory
mkdir weather-mcp-server
cd weather-mcp-server

# Initialize npm project
npm init -y

# Install the MCP SDK and Zod for validation
npm install @modelcontextprotocol/sdk zod

# Install dev dependencies
npm install -D typescript @types/node

# Initialize TypeScript (optional but recommended)
npx tsc --init

Step 2: Project Structure

weather-mcp-server/
  package.json — project config, bin entry
  index.js — main server file (all the logic)
  .mcp.json — MCP configuration for Claude Code
  node_modules/ — dependencies

Step 3: Configure package.json

package.json
{
  "name": "weather-mcp-server",
  "version": "1.0.0",
  "description": "MCP server for weather data",
  "main": "index.js",
  "type": "module",
  "bin": {
    "weather-mcp": "./index.js"
  },
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.12.0",
    "zod": "^3.23.0"
  }
}

Why "type": "module"?

The MCP SDK uses ES modules (import/export) rather than CommonJS (require). Setting "type": "module" in package.json tells Node.js to treat .js files as ES modules. This is the modern standard and what the SDK expects.

Building Tools

Your First Tool — get_weather

A tool in MCP is a function that the AI can call. Let's build a get_weather tool that fetches current weather data from the OpenWeatherMap API.

The Complete Server

index.js
#!/usr/bin/env node

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

// ─── Create the MCP Server ─────────────────────────────────
const server = new McpServer({
  name: "weather-server",
  version: "1.0.0",
  description: "Get current weather data for any city"
});

// ─── Helper: Fetch weather from OpenWeatherMap ─────────────
const API_KEY = process.env.WEATHER_API_KEY || "your-api-key";

async function fetchWeather(city) {
  const url = `https://api.openweathermap.org/data/2.5/weather`
    + `?q=${encodeURIComponent(city)}`
    + `&appid=${API_KEY}`
    + `&units=metric`;

  const res = await fetch(url);
  if (!res.ok) {
    throw new Error(`Weather API error: ${res.status} ${res.statusText}`);
  }
  return res.json();
}

// ─── Tool: get_weather ──────────────────────────────────────
server.tool(
  "get_weather",
  "Get current weather for a city (temperature, conditions, humidity, wind)",
  {
    city: z.string().describe("City name (e.g., 'Paris', 'New York', 'Tokyo')")
  },
  async ({ city }) => {
    try {
      const data = await fetchWeather(city);

      const result = {
        city: data.name,
        country: data.sys.country,
        temperature: `${data.main.temp}°C`,
        feels_like: `${data.main.feels_like}°C`,
        condition: data.weather[0].description,
        humidity: `${data.main.humidity}%`,
        wind: `${data.wind.speed} m/s`,
        pressure: `${data.main.pressure} hPa`
      };

      return {
        content: [{
          type: "text",
          text: JSON.stringify(result, null, 2)
        }]
      };
    } catch (error) {
      return {
        content: [{
          type: "text",
          text: `Error fetching weather for ${city}: ${error.message}`
        }],
        isError: true
      };
    }
  }
);

// ─── Start the server ───────────────────────────────────────
const transport = new StdioServerTransport();
await server.connect(transport);

Let's break down what's happening:

1

Create Server

new McpServer() initializes the server with name, version, and description

2

Define Tool

server.tool() registers a tool with name, description, params, and handler

3

Handle Call

The async handler fetches data and returns MCP-formatted content

4

Connect

StdioServerTransport wires up stdin/stdout communication

The Return Format

Every tool must return an object with a content array. Each item has a type (usually "text") and the actual data. You can also return isError: true to signal failure. The AI model uses this to understand what happened and respond appropriately.

Input Validation

Zod Validation — Making Tools Robust

Zod is the recommended validation library for MCP tool parameters. It provides runtime type checking and automatic JSON Schema generation — the AI model sees a well-structured schema that helps it understand what parameters to provide.

Basic Types

Zod Schemas
import { z } from "zod";

// String with description (shown to the AI)
z.string().describe("Stock ticker symbol")

// Number with constraints
z.number().min(1).max(100).describe("Number of results to return")

// Enum — AI sees the valid options
z.enum(["celsius", "fahrenheit"]).describe("Temperature unit")

// Optional with default
z.string().optional().default("en").describe("Language code")

// Boolean
z.boolean().optional().default(false).describe("Include forecast")

// Array
z.array(z.string()).describe("List of city names")

Advanced Example: Multi-Parameter Tool

index.js — Enhanced Tool
server.tool(
  "get_forecast",
  "Get weather forecast for a city for the next N days",
  {
    city: z.string().describe("City name"),
    days: z.number()
      .min(1).max(7)
      .optional()
      .default(3)
      .describe("Number of forecast days (1-7, default 3)"),
    units: z.enum(["metric", "imperial"])
      .optional()
      .default("metric")
      .describe("Temperature units")
  },
  async ({ city, days, units }) => {
    const url = `https://api.openweathermap.org/data/2.5/forecast`
      + `?q=${encodeURIComponent(city)}&appid=${API_KEY}&units=${units}`
      + `&cnt=${days * 8}`; // API returns 3-hour intervals

    const res = await fetch(url);
    const data = await res.json();

    // Group by day and pick noon forecast
    const daily = data.list
      .filter(item => item.dt_txt.includes("12:00:00"))
      .slice(0, days)
      .map(item => ({
        date: item.dt_txt.split(" ")[0],
        temp: item.main.temp,
        condition: item.weather[0].description,
        humidity: item.main.humidity
      }));

    return {
      content: [{ type: "text", text: JSON.stringify({ city, forecast: daily }, null, 2) }]
    };
  }
);
Zod Type JSON Schema Output AI Sees
z.string(){"type":"string"}Expects a text value
z.number().min(1).max(10){"type":"number","minimum":1,"maximum":10}Number between 1 and 10
z.enum(["a","b"]){"type":"string","enum":["a","b"]}Must be "a" or "b"
z.boolean().optional(){"type":"boolean"} (not required)Optional true/false
z.array(z.string()){"type":"array","items":{"type":"string"}}List of strings

Why Descriptions Matter

The .describe() method is not just documentation — it's instructions for the AI. The model reads these descriptions to understand what each parameter expects. A good description like "Stock ticker symbol (e.g., AAPL, MSFT)" dramatically improves the AI's ability to use your tool correctly. Think of descriptions as your API documentation for an AI reader.

Exposing Data

Resources — Giving AI Read Access to Data

Resources are the second MCP primitive. Unlike tools (which perform actions), resources provide read-only data that the AI can access. They use URI-based addressing, similar to REST APIs.

Static Resource

index.js — Adding Resources
// ─── Resource: Supported cities list ────────────────────────
server.resource(
  "cities",
  "weather://cities",
  { description: "List of popular cities with weather stations", mimeType: "application/json" },
  async () => ({
    contents: [{
      uri: "weather://cities",
      mimeType: "application/json",
      text: JSON.stringify([
        { name: "London", country: "GB", lat: 51.51, lon: -0.13 },
        { name: "New York", country: "US", lat: 40.71, lon: -74.01 },
        { name: "Tokyo", country: "JP", lat: 35.69, lon: 139.69 },
        { name: "Paris", country: "FR", lat: 48.86, lon: 2.35 },
        { name: "Sydney", country: "AU", lat: -33.87, lon: 151.21 }
      ], null, 2)
    }]
  })
);

Dynamic Resource Template

index.js — Resource Template
// ─── Resource Template: Weather by city ─────────────────────
server.resource(
  "city-weather",
  "weather://city/{cityName}",
  { description: "Current weather for a specific city", mimeType: "application/json" },
  async (uri, { cityName }) => {
    const data = await fetchWeather(cityName);
    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify({
          city: data.name,
          temp: data.main.temp,
          condition: data.weather[0].description
        }, null, 2)
      }]
    };
  }
);

With this template, the AI (or host) can request weather://city/Paris, weather://city/Tokyo, etc. The {cityName} placeholder is extracted from the URI and passed to the handler.

Feature Tools Resources
Initiated byAI model autonomouslyApplication/user explicitly
Side effectsCan modify stateRead-only
AddressingBy name + parametersBy URI
Use caseActions (fetch, create, update)Context (config, schema, docs)
CachingTypically noCan be cached/subscribed
Testing

Testing with MCP Inspector

Before connecting to an AI host, you should test your server in isolation. The MCP Inspector is the official debugging tool — it connects to your server and lets you interactively browse tools, call them, and inspect responses.

Running the Inspector

Terminal
# Run MCP Inspector against your server
npx @modelcontextprotocol/inspector node index.js

# With environment variables
WEATHER_API_KEY=abc123 npx @modelcontextprotocol/inspector node index.js

The Inspector opens a web UI where you can:

Manual Testing with stdio

You can also test by piping JSON-RPC messages directly:

Terminal — Manual Test
# Send an initialize request
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' | \
  WEATHER_API_KEY=abc123 node index.js

# Expected: JSON response with server capabilities

Common Debugging Issues

Server exits immediately: Make sure you have await server.connect(transport) at the end. Without it, the process exits.

"Module not found" error: Check that "type": "module" is in package.json and imports use the full path (e.g., "@modelcontextprotocol/sdk/server/mcp.js").

Tool not showing up: Ensure you register tools before calling server.connect().

Going Live

Connecting to Claude Code

Now the exciting part — connecting your server to a real AI host. Claude Code reads its MCP configuration from a .mcp.json file in your project root.

Step 1: Create .mcp.json

.mcp.json
{
  "mcpServers": {
    "weather": {
      "command": "node",
      "args": ["./weather-mcp-server/index.js"],
      "env": {
        "WEATHER_API_KEY": "your-actual-api-key"
      }
    }
  }
}

Step 2: Verify Connection

Terminal
# Start Claude Code in your project directory
claude

# Claude will automatically detect .mcp.json and connect to your server
# You'll see: "Connected to MCP server: weather (1 tool, 2 resources)"

# Now just ask naturally:
> What's the weather in Paris right now?

# Claude will call your get_weather tool and format the response!

Step 3: Use It!

Once connected, Claude can use your tools seamlessly. Here's what a real interaction looks like:

Claude Code Session
Human: What's the weather like in Tokyo and London? Compare them.

Claude: Let me check the weather in both cities.

[Calling tool: get_weather with {"city": "Tokyo"}]
[Calling tool: get_weather with {"city": "London"}]

Here's the comparison:

| City   | Temp   | Condition       | Humidity | Wind    |
|--------|--------|-----------------|----------|---------|
| Tokyo  | 22°C   | scattered clouds| 65%      | 3.5 m/s |
| London | 14°C   | light rain      | 82%      | 5.1 m/s |

Tokyo is significantly warmer at 22°C compared to London's 14°C.
London is experiencing rain with higher humidity (82% vs 65%).

Configuration for Other AI Tools

The same server works with any MCP host. Here's how to configure it for different tools:

Host Config File Location
Claude Code.mcp.jsonProject root
Claude Desktopclaude_desktop_config.json~/Library/Application Support/Claude/
Cursor.cursor/mcp.jsonProject root
Windsurf.windsurf/mcp.jsonProject root
VS Code.vscode/mcp.jsonProject root

The config format is identical across all hosts — same server, same tools, just different file paths.

Complete Reference

Complete Server Code

Here's the complete, production-ready weather MCP server with both tools and resources:

index.js — Complete Server (70 lines)
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const API_KEY = process.env.WEATHER_API_KEY;
if (!API_KEY) { console.error("WEATHER_API_KEY required"); process.exit(1); }

const server = new McpServer({
  name: "weather-server",
  version: "1.0.0",
  description: "Weather data via OpenWeatherMap API"
});

async function callWeatherAPI(endpoint, params) {
  const qs = new URLSearchParams({ ...params, appid: API_KEY, units: "metric" });
  const res = await fetch(`https://api.openweathermap.org/data/2.5/${endpoint}?${qs}`);
  if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
  return res.json();
}

// Tool: Current weather
server.tool("get_weather", "Get current weather for a city", {
  city: z.string().describe("City name (e.g., Paris, Tokyo)")
}, async ({ city }) => {
  const d = await callWeatherAPI("weather", { q: city });
  return { content: [{ type:"text", text: JSON.stringify({
    city: d.name, country: d.sys.country,
    temp: `${d.main.temp}°C`, feels_like: `${d.main.feels_like}°C`,
    condition: d.weather[0].description,
    humidity: `${d.main.humidity}%`, wind: `${d.wind.speed} m/s`
  }, null, 2) }] };
});

// Tool: Forecast
server.tool("get_forecast", "Get N-day weather forecast", {
  city: z.string().describe("City name"),
  days: z.number().min(1).max(7).optional().default(3).describe("Forecast days (1-7)")
}, async ({ city, days }) => {
  const d = await callWeatherAPI("forecast", { q: city, cnt: days * 8 });
  const daily = d.list.filter(i => i.dt_txt.includes("12:00")).slice(0, days);
  return { content: [{ type:"text", text: JSON.stringify(daily.map(i => ({
    date: i.dt_txt.split(" ")[0], temp: i.main.temp,
    condition: i.weather[0].description
  })), null, 2) }] };
});

// Resource: City list
server.resource("cities", "weather://cities",
  { description: "Supported cities", mimeType: "application/json" },
  async () => ({ contents: [{ uri: "weather://cities", mimeType: "application/json",
    text: JSON.stringify(["London","New York","Tokyo","Paris","Sydney","Dubai","Singapore"])
  }] })
);

// Start
await server.connect(new StdioServerTransport());
Knowledge Check

Quick Quiz

What are the two imports needed for a basic stdio MCP server?
McpServer from @modelcontextprotocol/sdk/server/mcp.js (the server instance) and StdioServerTransport from @modelcontextprotocol/sdk/server/stdio.js (the communication layer).
Why do we use Zod instead of plain JSON Schema for tool parameters?
Zod provides runtime validation (catches bad input before your handler runs), TypeScript inference (your handler gets typed parameters), and automatic JSON Schema generation (the SDK converts Zod schemas to JSON Schema for the AI). Plus, .describe() adds human-readable descriptions that the AI uses to understand parameters.
What's the difference between a resource and a resource template?
A resource has a fixed URI (e.g., weather://cities) — always returns the same data. A resource template has a URI with placeholders (e.g., weather://city/{cityName}) — the placeholder is filled at request time, allowing dynamic data per entity.
What's the return format for MCP tools?
An object with a content array of content items. Each item has type (usually "text") and text (the data). Optionally include isError: true to signal failure. Example: { content: [{ type: "text", text: "result" }] }

Key Takeaways — Part 2

Part 3 of 5
Connecting Live Data — APIs, Scraping & Databases

MCP from A to Z — Supercharging AI with Live Data

Complete series in 5 parts — From protocol theory to production deployment

Back to Home
MCP from A to Z2/5