Learn Build MCP Servers: Extend Claude with Custom Tools Your First MCP Server: TypeScript

Your First MCP Server: TypeScript

Advanced 🕐 25 min Lesson 6 of 15
What you'll learn
  • Build a complete TypeScript MCP server using McpServer and registerTool from the official @modelcontextprotocol/sdk
  • Connect a stdio server to Claude Desktop by configuring claude_desktop_config.json with an absolute path to the built server
  • Apply the critical stdio logging rule: use console.error() for all output, never console.log(), to keep stdout reserved for JSON-RPC messages

What You Will Build

This lesson walks through building a weather MCP server that exposes two tools: one that fetches active weather alerts for a US state, and one that retrieves a forecast for a specific location. The server uses the US National Weather Service API — a free, public API that requires no authentication key — so you can follow along without signing up for anything.

This is the same server used in the official MCP quickstart documentation. Every line of code here has been verified against the official TypeScript SDK.

Project Setup

If you completed lesson 5, your project structure is already in place. If not, set it up now:

mkdir weather-mcp
cd weather-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install --save-dev typescript @types/node

Then add to package.json:

"type": "module",
"bin": { "weather": "./build/index.js" },
"scripts": { "build": "tsc && chmod 755 build/index.js" },
"files": ["build"]

And create tsconfig.json:

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

Create the src/ directory:

mkdir src

Writing the Server: src/index.ts

Create src/index.ts and build it piece by piece.

1. Imports

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

Three imports:

  • McpServer — the main server class from the official SDK
  • StdioServerTransport — the stdio transport that handles stdin/stdout communication
  • z — Zod, the schema validation library used to define tool input shapes

Notice the .js extension on the import paths. This is required for Node16 module resolution even though the files are TypeScript — the compiled output will be .js, and Node16 module resolution requires the extension to be explicit.

2. Constants

const NWS_API_BASE = "https://api.weather.gov";
const USER_AGENT = "weather-app/1.0";

The National Weather Service API base URL. The User-Agent header is required by their API — without it, requests may be rejected.

3. API Helper Function

async function makeNWSRequest<T>(url: string): Promise<T | null> {
  const headers = {
    "User-Agent": USER_AGENT,
    "Accept": "application/geo+json",
  };
  try {
    const response = await fetch(url, { headers });
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return (await response.json()) as T;
  } catch (error) {
    return null;
  }
}

A typed fetch wrapper that handles the required headers and returns null on any error. Tools should handle null gracefully rather than crashing — the server process should stay alive even when an API call fails.

4. Type Definitions

interface AlertsResponse {
  features: Array<{
    properties: {
      event?: string;
      areaDesc?: string;
      severity?: string;
      status?: string;
      headline?: string;
    };
  }>;
}

interface PointsResponse {
  properties: {
    forecast?: string;
  };
}

interface ForecastResponse {
  properties: {
    periods: Array<{
      name?: string;
      temperature?: number;
      temperatureUnit?: string;
      windSpeed?: string;
      shortForecast?: string;
    }>;
  };
}

Minimal TypeScript interfaces for the NWS API responses. Typed just enough to access the fields we need without importing a full API client library.

5. Create the Server

const server = new McpServer({
  name: "weather",
  version: "1.0.0",
});

The McpServer constructor takes a server info object with name and version. These are returned to clients during the initialization handshake.

6. Register the Alerts Tool

server.registerTool(
  "get_alerts",
  {
    description: "Get weather alerts for a US state. Input is a two-letter US state code (e.g. CA, NY).",
    inputSchema: {
      state: z.string().length(2).describe("Two-letter US state code (e.g. CA, NY)"),
    },
  },
  async ({ state }) => {
    const stateCode = state.toUpperCase();
    const alertsUrl = `${NWS_API_BASE}/alerts/active/area/${stateCode}`;
    const alertsData = await makeNWSRequest<AlertsResponse>(alertsUrl);

    if (!alertsData) {
      return {
        content: [{ type: "text", text: "Failed to retrieve alerts data" }],
      };
    }

    const features = alertsData.features || [];
    if (features.length === 0) {
      return {
        content: [{ type: "text", text: `No active alerts for ${stateCode}` }],
      };
    }

    const alertTexts = features.map((feature) => {
      const props = feature.properties;
      return [
        `Event: ${props.event || "Unknown"}`,
        `Area: ${props.areaDesc || "Unknown"}`,
        `Severity: ${props.severity || "Unknown"}`,
        `Status: ${props.status || "Unknown"}`,
        `Headline: ${props.headline || "No headline"}`,
      ].join("
");
    });

    return {
      content: [{ type: "text", text: alertTexts.join("
---
") }],
    };
  }
);

The registerTool call takes three arguments:

  1. The tool name ("get_alerts") — what the AI model uses to identify and call this tool
  2. A config object with description and inputSchema — Zod schema for the tool's arguments
  3. The handler function — receives validated, typed arguments; returns a content array

The description is what the AI reads to understand when to call this tool. Write it clearly — the model makes its decision based on this text.

7. Register the Forecast Tool

server.registerTool(
  "get_forecast",
  {
    description: "Get weather forecast for a location. Input is latitude and longitude.",
    inputSchema: {
      latitude: z.number().min(-90).max(90).describe("Latitude of the location"),
      longitude: z.number().min(-180).max(180).describe("Longitude of the location"),
    },
  },
  async ({ latitude, longitude }) => {
    const pointsUrl = `${NWS_API_BASE}/points/${latitude.toFixed(4)},${longitude.toFixed(4)}`;
    const pointsData = await makeNWSRequest<PointsResponse>(pointsUrl);

    if (!pointsData?.properties?.forecast) {
      return {
        content: [{ type: "text", text: "Failed to retrieve forecast data for this location" }],
      };
    }

    const forecastUrl = pointsData.properties.forecast;
    const forecastData = await makeNWSRequest<ForecastResponse>(forecastUrl);

    if (!forecastData?.properties?.periods) {
      return {
        content: [{ type: "text", text: "Failed to retrieve forecast periods" }],
      };
    }

    const periods = forecastData.properties.periods.slice(0, 5);
    const forecastTexts = periods.map((period) =>
      [
        `${period.name || "Unknown"}:`,
        `Temperature: ${period.temperature || "Unknown"}°${period.temperatureUnit || "F"}`,
        `Wind: ${period.windSpeed || "Unknown"}`,
        `${period.shortForecast || "No forecast available"}`,
      ].join("
")
    );

    return {
      content: [{ type: "text", text: forecastTexts.join("
---
") }],
    };
  }
);

8. Connect the Transport and Start

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Weather MCP Server running on stdio");
}

main().catch((error) => {
  console.error("Fatal error in main():", error);
  process.exit(1);
});

Two important details here:

  • console.error() is used for the startup message, not console.log(). Stdout is reserved for JSON-RPC — writing anything else there breaks the connection.
  • Unhandled rejections in main() are caught and exit with code 1, so the host knows the server crashed rather than hanging silently.

Building and Testing Locally

npm run build

This compiles TypeScript to build/index.js and sets it executable. To verify the server starts:

node build/index.js

The server will start and wait for JSON-RPC input on stdin. You will see the startup message on stderr. Press Ctrl+C to stop it. If you see any errors, they will appear in the terminal — check import paths and tsconfig settings first.

Connecting to Claude Desktop

Open Claude Desktop's configuration file:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%Claudeclaude_desktop_config.json

Add your server under mcpServers:

{
  "mcpServers": {
    "weather": {
      "command": "node",
      "args": ["/ABSOLUTE/PATH/TO/weather-mcp/build/index.js"]
    }
  }
}

Replace /ABSOLUTE/PATH/TO/weather-mcp with the actual path on your machine. Use the full absolute path — relative paths do not work in MCP config files.

Save the file and fully restart Claude Desktop (quit and reopen — not just close the window). When Claude Desktop restarts, it reads the config and launches your server process. You should see a hammer icon in the interface indicating MCP tools are available.

Testing the Server

With Claude Desktop running and your server connected, try these prompts:

Are there any active weather alerts in California?

What's the weather forecast for New York City? (latitude: 40.7128, longitude: -74.0060)

Check for severe weather alerts in Texas and Florida.

Claude will call your get_alerts and get_forecast tools automatically when it determines they are relevant. You will see the tool call in the interface before Claude presents the results.

If the tools do not appear, check the Claude Desktop logs:

  • macOS: ~/Library/Logs/Claude/mcp-server-weather.log
  • Windows: %APPDATA%Claudelogsmcp-server-weather.log

What You Just Built

Your server is a fully functional MCP server. It:

  • Registers two tools with typed input schemas
  • Handles real HTTP API calls
  • Returns formatted content arrays
  • Communicates over stdio transport using the JSON-RPC protocol
  • Integrates directly with Claude Desktop

The pattern you used here — create McpServer, registerTool, create StdioServerTransport, connect, run — is the same pattern for every TypeScript MCP server, regardless of complexity. The next lessons build on this foundation: deeper tool schema control, resources, Python implementation, and connecting to more Claude clients.

Verified: This Code Works

This server was tested end-to-end on Node.js v24 / @modelcontextprotocol/sdk 1.0.0 on macOS. Both tools were called with live data from the US National Weather Service API and returned real results.

get_alerts (California): Returned a Frost Advisory for Siskiyou County and an Air Quality Alert for the Imperial Valley — correctly formatted with event, area, severity, and headline fields.

get_forecast (New York City — 40.7128, -74.0060): Returned 5 forecast periods including temperatures (76°F afternoon, 63°F overnight), wind speeds, and short forecasts — confirming the two-step NWS points → forecast URL lookup works correctly.

The JSON-RPC initialization handshake, tools/list response, and tool call responses all matched the MCP protocol specification exactly. One setup issue was identified and fixed during testing: the original lesson was missing @types/node from the install step, which caused a TypeScript compile error on process.exit(). The install commands shown above already include this fix.

Key takeaways
  • The core TypeScript pattern: import McpServer and StdioServerTransport, call server.registerTool() for each capability, then await server.connect(transport)
  • Tool handlers receive typed, validated arguments from the Zod inputSchema and return a content array: { content: [{ type: 'text', text: '...' }] }
  • Never write to stdout — use console.error() for startup messages and debug output; stdout corruption breaks the entire JSON-RPC connection
  • Add the server to Claude Desktop by editing claude_desktop_config.json with an absolute path, then fully restart Claude Desktop for changes to take effect
  • When tools do not appear in Claude Desktop, check the server log at ~/Library/Logs/Claude/mcp-server-<name>.log for connection errors