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:
- get-current-weather — returns current conditions for a city
- get-forecast — returns a multi-day forecast
- 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
Update tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"]
}
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"
}
}
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),
}],
};
}
);
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}` }],
})
);
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);
Build it:
npm run build
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
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")
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
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
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"]
}
}
}
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"]
}
}
}
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.
Part of the Learning AI series — deep technical tutorials that build AI systems from first principles. Real code, no hand-waving.
Top comments (0)