DEV Community

Cover image for MCP Explained: Build Your First MCP Server in TypeScript and Python
Dean Grover
Dean Grover

Posted on • Originally published at chanl.ai

MCP Explained: Build Your First MCP Server in TypeScript and Python

Originally published on chanl.ai


You've probably heard the pitch: MCP is "USB-C for AI." One protocol, universal connectivity, everything just works. That's the marketing version. Let's build the real thing.

MCP (Model Context Protocol) is an open standard that defines how AI applications talk to external tools and data sources. Anthropic released it in November 2024, and within a year every major AI platform — OpenAI, Google, Microsoft — adopted it. In December 2025 it moved to the Linux Foundation under neutral governance. It's not a vendor's API anymore. It's a standard.

The problem it solves: before MCP, every AI framework had its own way of connecting to tools. OpenAI's function calling, LangChain's tool abstraction, Anthropic's tool use spec — they all solved the same problem differently. Want your database lookup to work with Claude and ChatGPT and your custom agent? Three integrations. MCP says: write one server, any MCP-compatible client can use it.

In this tutorial, you'll build a working MCP server — written in both TypeScript and Python — that exposes tools an AI agent can discover and call at runtime. Real code, real output, no hand-waving.

The Three Primitives

MCP organizes capabilities into three types:

Tools are actions the agent can take — API calls, database queries, calculations. Each tool has a name, a description (which the LLM reads to decide when to use it), and an input schema. Tools are the most commonly used primitive.

Resources are read-only data the agent can pull in for context. A file's contents, a database record, a config value. Think GET endpoints — they provide information without changing anything. Identified by URIs like file:///logs/app.log or db://users/123.

Prompts are reusable templates that encode best practices for working with a specific service. If your MCP server wraps a complex API, you might include a prompt that teaches the agent how to query it effectively.

The architecture follows a client-server model over JSON-RPC 2.0. An MCP server exposes capabilities. An MCP client (Claude Desktop, VS Code, your custom agent) discovers and calls them. The connection is persistent and bidirectional — a big upgrade from stateless function calling where you declare everything upfront in the prompt.

What We're Building

A weather service MCP server with three tools:

  1. get-current-weather — returns current conditions for a city
  2. get-forecast — returns a multi-day forecast
  3. convert-temperature — converts between Celsius, Fahrenheit, and Kelvin

Plus one resource — a list of supported cities. Deliberately simple so the focus stays on the MCP plumbing, not business logic. In production you'd swap mock data for real API calls.

We'll build it first in TypeScript, then in Python. Same server, same tools, two languages.

Part 1: TypeScript MCP Server

Project Setup

mkdir mcp-weather-server && cd mcp-weather-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init
Enter fullscreen mode Exit fullscreen mode

Update tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true
  },
  "include": ["src/**/*"]
}
Enter fullscreen mode Exit fullscreen mode

And package.json:

{
  "name": "mcp-weather-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node build/index.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.12.0",
    "zod": "^3.24.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

The Server Code

Here's the complete MCP server. The key thing to understand: server.tool() takes a name, description, Zod schema for input validation, and an async handler. The Zod schema does double duty — it validates incoming parameters and generates the JSON Schema sent to the client during capability discovery. That's why .describe() on each field matters: it's documentation for the AI, not just for humans.

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

// Mock weather data (swap with real API in production)
interface WeatherData {
  city: string;
  temperature: number;
  unit: string;
  condition: string;
  humidity: number;
  windSpeed: number;
}

const weatherDatabase: Record<string, WeatherData> = {
  "new york": {
    city: "New York", temperature: 5, unit: "celsius",
    condition: "Partly Cloudy", humidity: 62, windSpeed: 18,
  },
  london: {
    city: "London", temperature: 8, unit: "celsius",
    condition: "Overcast", humidity: 78, windSpeed: 12,
  },
  tokyo: {
    city: "Tokyo", temperature: 14, unit: "celsius",
    condition: "Clear", humidity: 45, windSpeed: 8,
  },
  sydney: {
    city: "Sydney", temperature: 26, unit: "celsius",
    condition: "Sunny", humidity: 55, windSpeed: 15,
  },
  paris: {
    city: "Paris", temperature: 7, unit: "celsius",
    condition: "Light Rain", humidity: 82, windSpeed: 10,
  },
};

// Temperature conversion helper
function convertTemp(value: number, from: string, to: string): number {
  let celsius: number;
  switch (from.toLowerCase()) {
    case "fahrenheit": celsius = (value - 32) * (5 / 9); break;
    case "kelvin": celsius = value - 273.15; break;
    default: celsius = value;
  }
  switch (to.toLowerCase()) {
    case "fahrenheit": return Math.round((celsius * (9 / 5) + 32) * 100) / 100;
    case "kelvin": return Math.round((celsius + 273.15) * 100) / 100;
    default: return Math.round(celsius * 100) / 100;
  }
}

// Create the MCP server
const server = new McpServer({
  name: "weather-service",
  version: "1.0.0",
});

// Register tools
server.tool(
  "get-current-weather",
  "Get the current weather conditions for a city",
  {
    city: z.string().describe("City name (e.g., 'London', 'New York')"),
  },
  async ({ city }) => {
    const data = weatherDatabase[city.toLowerCase()];
    if (!data) {
      return {
        content: [{
          type: "text",
          text: `City "${city}" not found. Available: ${Object.values(weatherDatabase).map(w => w.city).join(", ")}`,
        }],
      };
    }
    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          city: data.city,
          temperature: `${data.temperature}°C`,
          condition: data.condition,
          humidity: `${data.humidity}%`,
          windSpeed: `${data.windSpeed} km/h`,
        }, null, 2),
      }],
    };
  }
);
Enter fullscreen mode Exit fullscreen mode

The forecast and temperature conversion tools follow the same pattern:

server.tool(
  "get-forecast",
  "Get a multi-day weather forecast for a city",
  {
    city: z.string().describe("City name"),
    days: z.number().min(1).max(7).default(3).describe("Number of days (1-7)"),
  },
  async ({ city, days }) => {
    const data = weatherDatabase[city.toLowerCase()];
    if (!data) {
      return {
        content: [{
          type: "text",
          text: `City "${city}" not found.`,
        }],
      };
    }
    const forecast = Array.from({ length: days }, (_, i) => {
      const date = new Date();
      date.setDate(date.getDate() + i + 1);
      return {
        date: date.toISOString().split("T")[0],
        temperature: `${data.temperature + Math.round((Math.random() - 0.5) * 6)}°C`,
        condition: data.condition,
      };
    });
    return {
      content: [{
        type: "text",
        text: JSON.stringify({ city: data.city, forecast }, null, 2),
      }],
    };
  }
);

server.tool(
  "convert-temperature",
  "Convert between Celsius, Fahrenheit, and Kelvin",
  {
    value: z.number().describe("Temperature value to convert"),
    from: z.enum(["celsius", "fahrenheit", "kelvin"]).describe("Source unit"),
    to: z.enum(["celsius", "fahrenheit", "kelvin"]).describe("Target unit"),
  },
  async ({ value, from, to }) => ({
    content: [{ type: "text", text: `${value}° ${from} = ${convertTemp(value, from, to)}° ${to}` }],
  })
);
Enter fullscreen mode Exit fullscreen mode

Resources are read-only data exposed via URI:

server.resource(
  "supported-cities",
  "weather://cities",
  { description: "\"List of cities with available weather data\", mimeType: \"application/json\" },"
  async () => ({
    contents: [{
      uri: "weather://cities",
      mimeType: "application/json",
      text: JSON.stringify(
        Object.values(weatherDatabase).map(w => ({
          name: w.city, currentCondition: w.condition,
        })), null, 2
      ),
    }],
  })
);

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

main().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Build it:

npm run build
Enter fullscreen mode Exit fullscreen mode

Don't try node build/index.js directly — it expects JSON-RPC messages on stdin, not terminal input. We'll test it properly in a moment.

Part 2: Python MCP Server

Same server, same tools, Python. The official mcp package includes FastMCP, which uses decorators and type hints to auto-generate tool schemas. Where TypeScript needs explicit Zod schemas with .describe(), Python infers everything from annotations and docstrings.

Setup

mkdir mcp-weather-server-python && cd mcp-weather-server-python
python -m venv venv
source venv/bin/activate
pip install mcp
Enter fullscreen mode Exit fullscreen mode

The Server Code

from mcp.server.fastmcp import FastMCP
import json
import random
from datetime import datetime, timedelta

mcp = FastMCP("weather-service")

WEATHER_DATABASE: dict[str, dict] = {
    "new york": {
        "city": "New York", "temperature": 5, "unit": "celsius",
        "condition": "Partly Cloudy", "humidity": 62, "wind_speed": 18,
    },
    "london": {
        "city": "London", "temperature": 8, "unit": "celsius",
        "condition": "Overcast", "humidity": 78, "wind_speed": 12,
    },
    "tokyo": {
        "city": "Tokyo", "temperature": 14, "unit": "celsius",
        "condition": "Clear", "humidity": 45, "wind_speed": 8,
    },
    "sydney": {
        "city": "Sydney", "temperature": 26, "unit": "celsius",
        "condition": "Sunny", "humidity": 55, "wind_speed": 15,
    },
    "paris": {
        "city": "Paris", "temperature": 7, "unit": "celsius",
        "condition": "Light Rain", "humidity": 82, "wind_speed": 10,
    },
}

VALID_UNITS = ("celsius", "fahrenheit", "kelvin")

def convert_temp(value: float, from_unit: str, to_unit: str) -> float:
    if from_unit == "fahrenheit":
        celsius = (value - 32) * 5 / 9
    elif from_unit == "kelvin":
        celsius = value - 273.15
    else:
        celsius = value

    if to_unit == "fahrenheit":
        return round(celsius * 9 / 5 + 32, 2)
    elif to_unit == "kelvin":
        return round(celsius + 273.15, 2)
    return round(celsius, 2)


@mcp.tool()
def get_current_weather(city: str) -> str:
    """Get the current weather conditions for a city.

    Args:
        city: City name (e.g., 'London', 'New York')
    """
    data = WEATHER_DATABASE.get(city.lower())
    if not data:
        available = ", ".join(d["city"] for d in WEATHER_DATABASE.values())
        return f'City "{city}" not found. Available cities: {available}'

    return json.dumps({
        "city": data["city"],
        "temperature": f"{data['temperature']}°C",
        "condition": data["condition"],
        "humidity": f"{data['humidity']}%",
        "windSpeed": f"{data['wind_speed']} km/h",
    }, indent=2)


@mcp.tool()
def get_forecast(city: str, days: int = 3) -> str:
    """Get a multi-day weather forecast for a city.

    Args:
        city: City name
        days: Number of forecast days (1-7, default 3)
    """
    days = max(1, min(7, days))
    data = WEATHER_DATABASE.get(city.lower())
    if not data:
        available = ", ".join(d["city"] for d in WEATHER_DATABASE.values())
        return f'City "{city}" not found. Available cities: {available}'

    forecast = []
    for i in range(1, days + 1):
        date = datetime.now() + timedelta(days=i)
        forecast.append({
            "date": date.strftime("%Y-%m-%d"),
            "temperature": f"{data['temperature'] + round(random.uniform(-3, 3))}°C",
            "condition": data["condition"],
        })

    return json.dumps({"city": data["city"], "forecast": forecast}, indent=2)


@mcp.tool()
def convert_temperature(value: float, from_unit: str, to_unit: str) -> str:
    """Convert a temperature between Celsius, Fahrenheit, and Kelvin.

    Args:
        value: Temperature value to convert
        from_unit: Source unit (celsius, fahrenheit, or kelvin)
        to_unit: Target unit (celsius, fahrenheit, or kelvin)
    """
    if from_unit not in VALID_UNITS or to_unit not in VALID_UNITS:
        return f"Invalid unit. Use one of: {', '.join(VALID_UNITS)}"
    result = convert_temp(value, from_unit, to_unit)
    return f"{value}° {from_unit} = {result}° {to_unit}"


@mcp.resource("weather://cities")
def list_supported_cities() -> str:
    """List of cities with available weather data."""
    cities = [
        {"name": d["city"], "currentCondition": d["condition"]}
        for d in WEATHER_DATABASE.values()
    ]
    return json.dumps(cities, indent=2)


if __name__ == "__main__":
    mcp.run(transport="stdio")
Enter fullscreen mode Exit fullscreen mode

The @mcp.tool() decorator inspects the function signature, builds the JSON Schema from type hints, reads the Args: section of the docstring for descriptions, and registers everything. Both versions produce identical MCP-compatible servers — a client can't tell which language the server is written in. That's the whole point of a protocol.

Testing Your Server

MCP Inspector

The MCP Inspector is a browser-based tool for testing MCP servers interactively. Think Postman, but for MCP.

# TypeScript server
npx @modelcontextprotocol/inspector node build/index.js

# Python server
npx @modelcontextprotocol/inspector python server.py
Enter fullscreen mode Exit fullscreen mode

Opens at http://localhost:6274. You can see all registered tools, call them with custom parameters, and inspect the raw JSON-RPC messages. Try calling get-current-weather with {"city": "London"} — you should see temperature, condition, and humidity. Try a city that doesn't exist and verify you get the helpful error.

For deeper debugging:

DEBUG=true npx @modelcontextprotocol/inspector node build/index.js
Enter fullscreen mode Exit fullscreen mode

This logs every JSON-RPC message — invaluable when debugging schema issues.

Claude Desktop

Add your server to Claude Desktop's config file.

macOS: ~/Library/Application Support/Claude/claude_desktop_config.json

{
  "mcpServers": {
    "weather": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-weather-server/build/index.js"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Restart Claude Desktop. Ask it "What's the weather in Tokyo?" and watch it discover and call your tool. The first time you see an AI find your server's tools through the protocol handshake and call them without any hardcoded configuration — that's the moment MCP clicks.

Claude Code

Add to your project's .mcp.json:

{
  "mcpServers": {
    "weather": {
      "command": "node",
      "args": ["./build/index.js"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Transport: stdio vs Streamable HTTP

Everything above uses stdio — the server communicates over standard input/output, and the client spawns it as a child process. This is the simplest transport and what Claude Desktop uses.

For remote deployment, MCP supports Streamable HTTP. Instead of the client spawning the server as a child process, the server runs as a standalone HTTP service and the client connects to it over the network. This is what you'd use for a shared MCP server that multiple clients connect to.

SSE (Server-Sent Events) transport exists but is deprecated in favor of Streamable HTTP.

What's in the Full Article

The complete tutorial covers more ground:

  • Resource registration deep dive — how to expose read-only data alongside tools
  • Transport comparison — stdio vs Streamable HTTP vs deprecated SSE, with examples
  • Claude Desktop configuration — step-by-step setup for macOS and Windows
  • Debugging tips — common failure modes and how to fix them
  • Production considerations — error handling patterns, auth, and deployment

Every section includes runnable code and real output.

Read the full tutorial →


Part of the Learning AI series — deep technical tutorials that build AI systems from first principles. Real code, no hand-waving.

Top comments (0)