TUTORIAL • 18 MIN READ

MCP TypeScript SDK: Complete Developer Guide

Build, test, and deploy MCP servers with the official TypeScript SDK. Covers the McpServer class, tools, resources, prompts, transport options, error handling, and a full end-to-end weather API example.

Updated recently

TL;DR

  • Install with npm install @modelcontextprotocol/sdk — current stable is 1.x
  • McpServer is the high-level class; use it over the low-level Server class
  • Define tools with server.tool(), resources with server.resource(), prompts with server.prompt()
  • Use StdioServerTransport for Claude Desktop; SSEServerTransport for web/remote clients
  • Test locally with MCP Inspector: npx @modelcontextprotocol/inspector
  • Always handle errors with isError: true — never throw unhandled exceptions

Installation and Project Setup

The TypeScript SDK requires Node.js 18 or later. Start with a clean TypeScript project:

Terminal

mkdir weather-mcp-server && cd weather-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

# Create tsconfig.json
npx tsc --init --module nodenext --moduleResolution nodenext   --target es2022 --strict --outDir dist

Update package.json to set the module type and add build scripts:

package.json (relevant fields)

{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "dev": "tsx watch src/server.ts",
    "start": "node dist/server.js",
    "inspect": "npx @modelcontextprotocol/inspector tsx src/server.ts"
  }
}

The McpServer Class

McpServer is the recommended entry point. It handles protocol negotiation, capability advertisement, and request routing automatically. You declare your tools, resources, and prompts declaratively, and the SDK wires up the JSON-RPC 2.0 handlers.

src/server.ts — minimal skeleton

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

const server = new McpServer({
  name: "weather-server",     // Shown in Claude Desktop UI
  version: "1.0.0",
});

// Tools, resources, and prompts go here (see below)

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

Defining Tools

Tools are functions that the AI model can call. They receive typed arguments and return content (text, images, or embedded resources). Use Zod schemas for type-safe argument validation — the SDK uses them both for TypeScript types and to generate the JSON Schema that is sent to the model.

Tool definition with Zod validation

import { z } from "zod";

server.tool(
  "get_current_weather",
  "Get the current weather for a city. Returns temperature, conditions, and humidity.",
  {
    city: z.string().describe("City name, e.g. 'San Francisco'"),
    units: z
      .enum(["celsius", "fahrenheit"])
      .default("celsius")
      .describe("Temperature units"),
  },
  async ({ city, units }) => {
    try {
      const apiUrl =
        `https://api.openweathermap.org/data/2.5/weather?` +
        `q=${encodeURIComponent(city)}&units=${units === "celsius" ? "metric" : "imperial"}&` +
        `appid=${process.env.OPENWEATHER_API_KEY}`;

      const res = await fetch(apiUrl);
      if (!res.ok) throw new Error(`API error: ${res.status}`);

      const data = (await res.json()) as {
        main: { temp: number; humidity: number };
        weather: { description: string }[];
        name: string;
      };

      const tempUnit = units === "celsius" ? "°C" : "°F";
      const summary =
        `Weather in ${data.name}: ${data.main.temp}${tempUnit}, ` +
        `${data.weather[0].description}, humidity ${data.main.humidity}%`;

      return { content: [{ type: "text", text: summary }] };
    } catch (err) {
      return {
        content: [{ type: "text", text: `Error: ${(err as Error).message}` }],
        isError: true,
      };
    }
  }
);

Defining Resources

Resources are read-only data sources that the model can access without executing any side-effecting logic. Use them for static reference data, file contents, or database records that the model needs to reason about.

Resource definition

server.resource(
  "weather_units_reference",
  "weather://units/reference",
  { mimeType: "text/plain" },
  async () => ({
    contents: [
      {
        uri: "weather://units/reference",
        mimeType: "text/plain",
        text:
          "Celsius (metric): used in most countries.\n" +
          "Fahrenheit (imperial): used in US, Belize, Cayman Islands.\n" +
          "Kelvin: scientific use only; 0K = -273.15°C.",
      },
    ],
  })
);

Defining Prompts

Prompts are reusable message templates that users can invoke by name in Claude Desktop (via the / slash command menu). They are useful for common queries that require specific formatting or context.

Prompt definition

server.prompt(
  "weather_report",
  "Generate a friendly weather report for a city",
  { city: z.string().describe("City to get weather for") },
  ({ city }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text:
            `Please get the current weather for ${city} and write a friendly, ` +
            `conversational weather report that a local TV presenter might read. ` +
            `Include temperature, conditions, and any notable weather advisory.`,
        },
      },
    ],
  })
);

Transport Types: stdio vs SSE

TransportUse CaseProsCons
stdioClaude Desktop, local toolsZero network config, secure by defaultOne client at a time, local only
SSERemote clients, multi-user, web appsMultiple clients, supports streaming, HTTP-compatibleRequires TLS + auth setup, more complex

SSE transport setup (for remote/web clients)

import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";

const app = express();
const transports = new Map<string, SSEServerTransport>();

app.get("/sse", async (req, res) => {
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");

  const transport = new SSEServerTransport("/messages", res);
  const sessionId = crypto.randomUUID();
  transports.set(sessionId, transport);

  res.on("close", () => transports.delete(sessionId));

  await server.connect(transport);
});

app.post("/messages", express.json(), async (req, res) => {
  const sessionId = req.query.sessionId as string;
  const transport = transports.get(sessionId);
  if (!transport) {
    res.status(404).json({ error: "Session not found" });
    return;
  }
  await transport.handlePostMessage(req, res);
});

app.listen(3000, () => console.log("MCP server on :3000"));

Error Handling

MCP tools should never throw unhandled exceptions. The protocol expects all errors to be returned as content with isError: true. This lets the model understand what went wrong and decide how to recover — rather than crashing the entire session.

Error handling pattern

server.tool("risky_tool", { input: z.string() }, async ({ input }) => {
  try {
    const result = await doSomethingRisky(input);
    return { content: [{ type: "text", text: result }] };
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);

    // Log server-side for debugging
    console.error("[risky_tool] Error:", message);

    // Return structured error to the model
    return {
      content: [
        {
          type: "text",
          text: `Tool failed: ${message}. Please try a different approach.`,
        },
      ],
      isError: true,
    };
  }
});

Testing with MCP Inspector

MCP Inspector is the official debugging tool. It launches your server and provides a web UI where you can call tools, inspect responses, and verify your JSON Schema definitions — all without needing a full Claude Desktop session.

Launch MCP Inspector

# Inspect your server directly (requires tsx for .ts files)
npx @modelcontextprotocol/inspector tsx src/server.ts

# Or for compiled output
npx @modelcontextprotocol/inspector node dist/server.js

# Opens browser at http://localhost:5173
# You'll see all tools, resources, and prompts listed
# Click any tool to test it with custom arguments

Complete Weather Server: Full Code

Here is the complete, production-ready weather server combining all the concepts above. It includes four tools, one resource, one prompt, graceful shutdown, and environment variable validation.

src/server.ts — complete implementation

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

// Validate environment on startup
const API_KEY = process.env.OPENWEATHER_API_KEY;
if (!API_KEY) {
  console.error("OPENWEATHER_API_KEY environment variable is required");
  process.exit(1);
}

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

// Tool 1: Current weather
server.tool(
  "get_current_weather",
  "Get current weather for a city. Returns temp, conditions, humidity.",
  {
    city: z.string().describe("City name"),
    units: z.enum(["celsius", "fahrenheit"]).default("celsius"),
  },
  async ({ city, units }) => {
    try {
      const metric = units === "celsius" ? "metric" : "imperial";
      const res = await fetch(
        `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&units=${metric}&appid=${API_KEY}`
      );
      if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
      const d = (await res.json()) as {
        main: { temp: number; feels_like: number; humidity: number };
        weather: { description: string; main: string }[];
        wind: { speed: number };
        name: string;
      };
      const u = units === "celsius" ? "°C" : "°F";
      const ws = units === "celsius" ? "m/s" : "mph";
      return {
        content: [
          {
            type: "text",
            text:
              `${d.name}: ${d.main.temp}${u} (feels like ${d.main.feels_like}${u})\n` +
              `Conditions: ${d.weather[0].description}\n` +
              `Humidity: ${d.main.humidity}% | Wind: ${d.wind.speed} ${ws}`,
          },
        ],
      };
    } catch (err) {
      return {
        content: [{ type: "text", text: `Error: ${(err as Error).message}` }],
        isError: true,
      };
    }
  }
);

// Tool 2: 5-day forecast
server.tool(
  "get_forecast",
  "Get a 5-day weather forecast for a city.",
  { city: z.string(), units: z.enum(["celsius", "fahrenheit"]).default("celsius") },
  async ({ city, units }) => {
    try {
      const metric = units === "celsius" ? "metric" : "imperial";
      const res = await fetch(
        `https://api.openweathermap.org/data/2.5/forecast?q=${encodeURIComponent(city)}&units=${metric}&appid=${API_KEY}`
      );
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const d = (await res.json()) as {
        list: { dt_txt: string; main: { temp_max: number; temp_min: number }; weather: { description: string }[] }[];
        city: { name: string };
      };
      const u = units === "celsius" ? "°C" : "°F";
      // One entry per day (take noon reading)
      const daily = d.list
        .filter((e) => e.dt_txt.includes("12:00:00"))
        .slice(0, 5)
        .map(
          (e) =>
            `${e.dt_txt.split(" ")[0]}: ${e.main.temp_max}${u}/${e.main.temp_min}${u} — ${e.weather[0].description}`
        )
        .join("\n");
      return { content: [{ type: "text", text: `5-day forecast for ${d.city.name}:\n${daily}` }] };
    } catch (err) {
      return {
        content: [{ type: "text", text: `Error: ${(err as Error).message}` }],
        isError: true,
      };
    }
  }
);

// Resource: Units reference
server.resource(
  "units_reference",
  "weather://units",
  { mimeType: "text/plain" },
  async () => ({
    contents: [
      {
        uri: "weather://units",
        mimeType: "text/plain",
        text: "celsius (metric) | fahrenheit (imperial)",
      },
    ],
  })
);

// Prompt: Weather briefing
server.prompt(
  "morning_briefing",
  "Get a morning weather briefing for a city",
  { city: z.string() },
  ({ city }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `Get the current weather and 5-day forecast for ${city}. Write a concise morning briefing.`,
        },
      },
    ],
  })
);

// Graceful shutdown
process.on("SIGINT", async () => {
  await server.close();
  process.exit(0);
});

// Start
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Weather MCP server running");

Connecting to Claude Desktop

claude_desktop_config.json

{
  "mcpServers": {
    "weather": {
      "command": "node",
      "args": ["/absolute/path/to/weather-mcp-server/dist/server.js"],
      "env": {
        "OPENWEATHER_API_KEY": "your_api_key_here"
      }
    }
  }
}

Build first with npm run build, restart Claude Desktop, and your weather tools appear automatically. Ask Claude: "What's the weather in Tokyo right now?" and it will call your server.

Next Steps

Have Questions?

Join the MCP community on GitHub or Discord for help and discussion.