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.
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.
node --version to check)# 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
{
"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"
}
}
"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.
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.
#!/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:
new McpServer() initializes the server with name, version, and description
server.tool() registers a tool with name, description, params, and handler
The async handler fetches data and returns MCP-formatted content
StdioServerTransport wires up stdin/stdout communication
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.
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.
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")
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 |
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.
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.
// ─── 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) }] }) );
// ─── 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 by | AI model autonomously | Application/user explicitly |
| Side effects | Can modify state | Read-only |
| Addressing | By name + parameters | By URI |
| Use case | Actions (fetch, create, update) | Context (config, schema, docs) |
| Caching | Typically no | Can be cached/subscribed |
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.
# 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:
You can also test by piping JSON-RPC messages directly:
# 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
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().
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.
{
"mcpServers": {
"weather": {
"command": "node",
"args": ["./weather-mcp-server/index.js"],
"env": {
"WEATHER_API_KEY": "your-actual-api-key"
}
}
}
}
# 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!
Once connected, Claude can use your tools seamlessly. Here's what a real interaction looks like:
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%).
The same server works with any MCP host. Here's how to configure it for different tools:
| Host | Config File | Location |
|---|---|---|
| Claude Code | .mcp.json | Project root |
| Claude Desktop | claude_desktop_config.json | ~/Library/Application Support/Claude/ |
| Cursor | .cursor/mcp.json | Project root |
| Windsurf | .windsurf/mcp.json | Project root |
| VS Code | .vscode/mcp.json | Project root |
The config format is identical across all hosts — same server, same tools, just different file paths.
Here's the complete, production-ready weather MCP server with both tools and resources:
#!/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());
McpServer from @modelcontextprotocol/sdk/server/mcp.js (the server instance) and StdioServerTransport from @modelcontextprotocol/sdk/server/stdio.js (the communication layer)..describe() adds human-readable descriptions that the AI uses to understand parameters.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.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" }] }McpServer, StdioServerTransport, and tool/resource definitions.server.tool(name, description, zodSchema, handler) registers a tool the AI can call..mcp.json configures Claude Code; similar files exist for Cursor, Windsurf, VS Code.MCP from A to Z — Supercharging AI with Live Data
Complete series in 5 parts — From protocol theory to production deployment
Back to Home