ChanlChanl
Learning AI

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

Build a working MCP server from scratch in TypeScript and Python. Hands-on tutorial covering tools, resources, transports, and testing.

Chanl TeamAI Agent Testing Platform
March 6, 2026
20 min read
Illustration of a diverse team collaborating around a whiteboard with code diagrams

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.

By the end of this tutorial, you'll have a working MCP server — written twice, once in TypeScript and once in Python — that exposes tools an AI agent can discover and call at runtime. You'll understand the protocol's architecture, know how the three primitives (tools, resources, prompts) work together, and have a server you can connect to Claude Desktop or test with the MCP Inspector.

No hand-waving. Real code, real output.

What MCP Actually Is (and Why You Should Care)

The 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 is simple: 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. If you wanted your database lookup to work with Claude and ChatGPT and your custom agent framework, you wrote three different integrations. MCP says: write one server, and any MCP-compatible client can use it.

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

The Three Primitives

MCP organizes everything into three primitives. Understanding these is the key to the whole protocol.

Tools are actions the agent can take. Think API calls, database queries, calculations, file operations — anything with side effects or computation. 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, and the one we'll focus on in this tutorial.

Resources are read-only data the agent can pull in for context. A file's contents, a database record, a configuration value. Resources are like GET endpoints — they provide information without changing anything. They're 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. Think of prompts as instruction manuals that ship alongside your tools.

Here's how they relate:

text
┌──────────────────────────────────────────┐
│            MCP Client (AI App)           │
│  Discovers tools, resources, prompts     │
│  via protocol handshake                  │
├──────────────────────────────────────────┤
│          JSON-RPC 2.0 Transport          │
│  (stdio | Streamable HTTP)               │
├──────────────────────────────────────────┤
│            MCP Server                    │
│  Exposes:                                │
│    - Tools (actions)                     │
│    - Resources (read-only data)          │
│    - Prompts (templates)                 │
└──────────────────────────────────────────┘

What We're Building

We'll build 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

We'll also expose one resource — a static list of supported cities. This is deliberately simple so the focus stays on the MCP plumbing, not the business logic. In production, you'd swap the mock data for real API calls.

We'll build it first in TypeScript using the official @modelcontextprotocol/sdk, then in Python using the official mcp package. Same server, same tools, two languages.

Part 1: TypeScript MCP Server

Project Setup

Create a new directory and initialize the project:

bash
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 your tsconfig.json with these settings:

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

And update your package.json to include the build script and type setting:

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"
  },
  "devDependencies": {
    "typescript": "^5.7.0",
    "@types/node": "^22.0.0"
  }
}

The Server Code

Here's the complete MCP server. We'll walk through it section by section after.

typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
 
// — Mock weather data ---
 
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 {
  // Normalize to Celsius first
  let celsius: number;
  switch (from.toLowerCase()) {
    case "fahrenheit":
      celsius = (value - 32) * (5 / 9);
      break;
    case "kelvin":
      celsius = value - 273.15;
      break;
    default:
      celsius = value;
  }
 
  // Convert from Celsius to target
  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 cities: ${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
          ),
        },
      ],
    };
  }
);
 
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, default 3)"),
  },
  async ({ city, days }) => {
    const data = weatherDatabase[city.toLowerCase()];
 
    if (!data) {
      return {
        content: [
          {
            type: "text",
            text: `City "${city}" not found. Available cities: ${Object.values(weatherDatabase)
              .map((w) => w.city)
              .join(", ")}`,
          },
        ],
      };
    }
 
    // Generate mock forecast with slight variations
    const forecast = Array.from({ length: days }, (_, i) => {
      const date = new Date();
      date.setDate(date.getDate() + i + 1);
      const tempVariation = Math.round((Math.random() - 0.5) * 6);
      return {
        date: date.toISOString().split("T")[0],
        temperature: `${data.temperature + tempVariation}°C`,
        condition: data.condition,
        humidity: `${data.humidity + Math.round((Math.random() - 0.5) * 10)}%`,
      };
    });
 
    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            { city: data.city, forecast },
            null,
            2
          ),
        },
      ],
    };
  }
);
 
server.tool(
  "convert-temperature",
  "Convert a temperature 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 }) => {
    const result = convertTemp(value, from, to);
    return {
      content: [
        {
          type: "text",
          text: `${value}° ${from} = ${result}° ${to}`,
        },
      ],
    };
  }
);
 
// — Register a resource ---
 
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);

Breaking It Down

Let's look at the key parts.

Server initialization. You create an McpServer with a name and version. These get sent to the client during the protocol handshake, so the client knows what it's talking to.

Tool registration. The server.tool() method takes four arguments: the tool name, a description (this is what the LLM reads), a Zod schema for the input parameters, and an async handler function. The handler receives the validated parameters and returns a content array. Each content item has a type (usually "text") and the actual data.

Notice the Zod schema does double duty — it validates incoming parameters and generates the JSON Schema that gets sent to the client during capability discovery. When Claude or another LLM sees these tools, it reads the descriptions and schemas to decide when and how to call them. That's why .describe() on each field matters: it's documentation for the AI, not just for humans.

Resource registration. server.resource() registers a read-only data source. The second argument is the URI, and the handler returns the resource contents. Resources are great for providing context that doesn't require computation — configuration files, reference data, documentation.

Transport. StdioServerTransport means our server communicates over standard input/output. This is the simplest transport and the one Claude Desktop uses. We'll cover other transports later.

Build and Test

bash
npm run build

If everything compiles cleanly, your server is ready. Don't try to run it directly with node build/index.js — it's expecting 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, but in Python using the official mcp package and its built-in FastMCP framework.

Project Setup

bash
mkdir mcp-weather-server-python && cd mcp-weather-server-python
python -m venv venv
source venv/bin/activate   # On Windows: venv\Scripts\activate
pip install mcp

If you prefer, here's the requirements.txt:

text
mcp>=1.6.0

The Server Code

python
from mcp.server.fastmcp import FastMCP
import json
import random
from datetime import datetime, timedelta
 
# — Create the MCP server ---
 
mcp = FastMCP("weather-service")
 
# — Mock weather data ---
 
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")
 
 
# — Temperature conversion helper ---
 
def convert_temp(value: float, from_unit: str, to_unit: str) -> float:
    """Convert temperature between Celsius, Fahrenheit, and Kelvin."""
    # Normalize to Celsius first
    if from_unit == "fahrenheit":
        celsius = (value - 32) * 5 / 9
    elif from_unit == "kelvin":
        celsius = value - 273.15
    else:
        celsius = value
 
    # Convert from Celsius to target
    if to_unit == "fahrenheit":
        return round(celsius * 9 / 5 + 32, 2)
    elif to_unit == "kelvin":
        return round(celsius + 273.15, 2)
    else:
        return round(celsius, 2)
 
 
# — Register tools ---
 
@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)
        temp_variation = round(random.uniform(-3, 3))
        humidity_variation = round(random.uniform(-5, 5))
        forecast.append(
            {
                "date": date.strftime("%Y-%m-%d"),
                "temperature": f"{data['temperature'] + temp_variation}°C",
                "condition": data["condition"],
                "humidity": f"{data['humidity'] + humidity_variation}%",
            }
        )
 
    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}"
 
 
# — Register a resource ---
 
@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)
 
 
# — Start the server ---
 
if __name__ == "__main__":
    mcp.run(transport="stdio")

Python vs. TypeScript: What's Different

The Python version is noticeably shorter, and that's by design. FastMCP (which is built into the official mcp package) uses Python's type hints and docstrings to auto-generate the tool schemas. Where TypeScript needs an explicit Zod schema with .describe() calls, Python infers parameter types from annotations and reads the Args: section of the docstring for descriptions.

The @mcp.tool() decorator does the heavy lifting. It inspects the function signature, builds the JSON Schema, and registers everything with the server. The @mcp.resource() decorator works the same way for resources — you just pass the URI.

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.

Part 3: Testing Your Server

You've got a compiled TypeScript server and a Python server. Now let's make sure they actually work.

Option 1: MCP Inspector

The MCP Inspector is a browser-based tool for testing MCP servers interactively. Think Postman, but for MCP. It's the fastest way to verify your tools work correctly.

bash
# For the TypeScript server
npx @modelcontextprotocol/inspector node build/index.js
 
# For the Python server
npx @modelcontextprotocol/inspector python server.py

This opens a UI at http://localhost:6274. From there you can:

  • See all registered tools, resources, and prompts
  • Call any tool with custom input parameters
  • Inspect the raw JSON-RPC messages going back and forth
  • Verify that schemas are being generated correctly

Try calling get-current-weather with {"city": "London"}. You should see the JSON response with temperature, condition, and humidity. Try a city that doesn't exist and verify you get the helpful error message.

For deeper debugging, enable verbose mode:

bash
DEBUG=true npx @modelcontextprotocol/inspector node build/index.js

This logs every JSON-RPC message, which is invaluable when you're debugging schema issues or unexpected behavior.

Option 2: Claude Desktop

To use your server with Claude, add it to Claude Desktop's configuration file.

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

For the TypeScript server:

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

For the Python server:

json
{
  "mcpServers": {
    "weather": {
      "command": "python",
      "args": ["/absolute/path/to/mcp-weather-server-python/server.py"]
    }
  }
}

Restart Claude Desktop after saving the config. You should see a hammer icon in the chat input area indicating MCP tools are available. Ask Claude "What's the weather in Tokyo?" and watch it discover and call your get-current-weather tool.

Option 3: Claude Code

If you're using Claude Code (Anthropic's CLI), you can add MCP servers to your project's .mcp.json:

json
{
  "mcpServers": {
    "weather": {
      "type": "stdio",
      "command": "node",
      "args": ["/absolute/path/to/mcp-weather-server/build/index.js"]
    }
  }
}

Claude Code picks this up automatically and makes the tools available in your coding session.

Part 4: Transport Layers Explained

So far we've used stdio, which is the simplest transport. But MCP supports multiple transports for different deployment scenarios. Understanding when to use each one matters.

stdio (Standard I/O)

The client spawns the server as a child process. Communication happens over stdin/stdout. This is what Claude Desktop and most local integrations use.

When to use it: Local tools, CLI integrations, development. The client and server run on the same machine.

Pros: Zero network configuration, process isolation, simple setup.

Cons: Can't work over a network. The client must be able to execute the server binary.

Streamable HTTP

Introduced in the March 2025 MCP specification update, Streamable HTTP is the modern transport for remote MCP servers. The server runs as an HTTP service, and clients communicate via POST requests. The server can optionally stream responses back using Server-Sent Events.

Here's how to add Streamable HTTP to the TypeScript server:

typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
 
// ... (same server setup and tool registration as before)
 
const app = express();
app.use(express.json());
 
// Handle MCP requests at /mcp endpoint
app.post("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined, // stateless mode
  });
  res.on("close", () => transport.close());
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});
 
// Optional: health check
app.get("/health", (_req, res) => {
  res.json({ status: "ok", server: "weather-service" });
});
 
app.listen(3050, () => {
  console.log("MCP HTTP server listening on port 3050");
});

And in Python:

python
from mcp.server.fastmcp import FastMCP
 
mcp = FastMCP("weather-service")
 
# ... (same tool and resource registration as before)
 
if __name__ == "__main__":
    # FastMCP supports transport switching via the run method
    mcp.run(transport="streamable-http", host="0.0.0.0", port=3050)

When to use it: Remote servers, cloud deployments, multi-tenant setups. Any time the client and server aren't on the same machine.

Pros: Works over the network, supports stateless and stateful modes, can handle multiple clients.

Cons: Requires HTTP infrastructure, more complex to secure.

SSE (Server-Sent Events) — Deprecated

The original HTTP transport in the November 2024 spec used SSE for server-to-client streaming with separate POST endpoints for client-to-server messages. The March 2025 spec update deprecated SSE in favor of Streamable HTTP, which is simpler and more resilient.

If you're building something new, use Streamable HTTP. If you need to support older clients, you can run both transports side by side.

Adding More Capabilities

Once you have the basic pattern down, extending your server is straightforward. Here are patterns you'll use in real projects.

Tools with Complex Input Schemas

Zod (TypeScript) and type hints (Python) handle nested objects, arrays, enums, and optional fields:

typescript
server.tool(
  "search-weather-history",
  "Search historical weather data with filters",
  {
    city: z.string(),
    dateRange: z.object({
      start: z.string().describe("ISO date string"),
      end: z.string().describe("ISO date string"),
    }),
    metrics: z
      .array(z.enum(["temperature", "humidity", "wind"]))
      .describe("Which metrics to include"),
    format: z
      .enum(["json", "csv"])
      .optional()
      .default("json")
      .describe("Output format"),
  },
  async ({ city, dateRange, metrics, format }) => {
    // Your implementation here
    return {
      content: [{ type: "text", text: "results..." }],
    };
  }
);

Error Handling

Return isError: true to signal tool failures without crashing:

typescript
server.tool(
  "fetch-live-weather",
  "Fetch real-time weather from external API",
  { city: z.string() },
  async ({ city }) => {
    try {
      const data = await fetchFromWeatherAPI(city);
      return {
        content: [{ type: "text", text: JSON.stringify(data) }],
      };
    } catch (error) {
      return {
        isError: true,
        content: [
          {
            type: "text",
            text: `Failed to fetch weather for ${city}: ${error.message}`,
          },
        ],
      };
    }
  }
);

Dynamic Resources with Templates

Resources can use URI templates to serve dynamic content:

typescript
server.resource(
  "city-weather",
  "weather://cities/{city}",
  { description: "Detailed weather data for a specific city" },
  async (uri, { city }) => ({
    contents: [
      {
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(weatherDatabase[city.toLowerCase()]),
      },
    ],
  })
);

Real-World Considerations

Building a tutorial server is one thing. Shipping one to production is another. Here's what changes.

Authentication

MCP doesn't prescribe a specific auth mechanism, but the TypeScript SDK includes OAuth helpers for Streamable HTTP servers. For stdio servers, auth typically happens at the process level — environment variables, config files, or the host application's credential store.

json
{
  "mcpServers": {
    "weather-api": {
      "command": "node",
      "args": ["build/index.js"],
      "env": {
        "WEATHER_API_KEY": "your-api-key-here"
      }
    }
  }
}

Logging

Write diagnostic output to stderr, not stdout. Stdout is reserved for JSON-RPC messages. This is a common gotcha — a stray console.log() in TypeScript or print() in Python will corrupt the protocol stream.

typescript
// GOOD - writes to stderr, won't interfere with MCP
console.error("Processing request for city:", city);
 
// BAD - writes to stdout, will break JSON-RPC
console.log("Processing request for city:", city);

Testing Tools Systematically

Once your server has more than a handful of tools, manual testing with the Inspector won't scale. You'll want automated tests that exercise each tool with valid inputs, invalid inputs, and edge cases. This is where scenario-based testing shines — define a set of interactions (agent asks for weather, gets response, asks follow-up) and verify the whole flow works end-to-end.

If you're running MCP servers in production, monitoring tool call patterns and failure rates becomes critical. A tool that silently returns bad data is worse than one that throws an error. Scorecards that track tool reliability alongside conversation quality give you the full picture.

Performance

For stdio servers, startup time matters. The client spawns your process on every new session (in most implementations). If your server takes 5 seconds to initialize because it's loading a large model or connecting to a database, that's 5 seconds of latency before the first tool call. Consider lazy initialization — connect to the database on first use, not at startup.

For HTTP servers, think about concurrency. A single MCP server instance might handle requests from multiple clients simultaneously. Make sure your tool handlers are stateless or properly synchronized.

What's Next

You now have the foundation for building MCP servers in both TypeScript and Python. The weather example is simple by design, but the patterns transfer directly to real-world servers:

  • Database MCP server: Replace the mock data with actual queries. Each tool becomes a parameterized query — search-customers, get-order-details, update-ticket-status.
  • API wrapper: Turn any REST API into an MCP server. The server handles auth, rate limiting, and response formatting. The AI just sees clean tools.
  • Internal tools: File operations, CI/CD triggers, deployment commands. MCP servers are a clean way to give agents controlled access to your infrastructure.

The official MCP specification is the authoritative reference. The TypeScript SDK's examples/ directory has runnable servers covering everything from simple calculators to full OAuth-protected services. The Python SDK repo includes similar examples.

MCP is still evolving. The spec committee (now under the Linux Foundation) is actively working on capability negotiation improvements, better streaming semantics, and standardized auth flows. But the core primitives — tools, resources, prompts — are stable and unlikely to change in breaking ways.

Build something. Ship it. See what your agents can do with real tools at their disposal.

Sources & References

    Chanl Team

    AI Agent Testing Platform

    Building the platform for AI agents at Chanl — tools, testing, and observability for customer experience.

    Get AI Agent Insights

    Subscribe to our newsletter for weekly tips and best practices.