New in 2026: Master Python for AI, Data Science

ProgrammingPython

Build Your First MCP Server with Python SDK — Fundamentals

Build Your First MCP Server with Python SDK

Your AI agent can browse the web, run code, and query databases — but only because someone built a custom integration for each one. Every AI stack ends up as a tangled web of one-off adapters, each talking to a different tool in a different way. There is a better way. The Model Context Protocol (MCP) replaces all of that glue code with a single, open standard that any AI client can use to talk to any server, securely and without custom code.

In this tutorial, you will learn to:

  • Understand what MCP is and why it matters for AI-native applications
  • Install the official Python SDK and create your first MCP server
  • Define tools, resources, and prompts using the SDK’s built-in patterns
  • Run your server over stdio transport and test it with an MCP client
  • Package your server for use by AI agents and applications

What Is the Model Context Protocol?

The Model Context Protocol (MCP) is an open specification developed by Anthropic that defines how Large Language Model (LLM) applications communicate with external tools, data sources, and services. Think of it as USB for AI applications — a universal interface layer that replaces the need to write bespoke integrations every time you want an AI agent to interact with a new tool.

Before MCP, connecting an AI agent to your internal database, your file system, or your company’s API meant writing custom code on both sides. MCP formalises this into three primitives:

  • Tools — Functions an LLM can call. Any operation with defined inputs and outputs qualifies: calling an API, querying a database, running a calculation.
  • Resources — Structured data an LLM can read. Unlike tools, resources are not executed — they are retrieved. Database schemas, file contents, configuration objects.
  • Prompts — Reusable prompt templates parameterised by the client. Useful for standardising how an LLM approaches a specific task type.

MCP servers expose these primitives over a transport layer. The most common transport is stdio, where the client launches the server as a subprocess and communicates over standard input and output using JSON-RPC messages. Later articles in this series cover HTTP-based transports for production deployments.

Prerequisites

This tutorial assumes you have Python 3.10 or later installed. No prior experience with MCP is required. If you want to connect MCP to an existing FastAPI application, see the second article in this series.

Installing the MCP Python SDK

The official Python SDK is maintained by the Model Context Protocol GitHub organisation. Install it with pip or uv:

# With pip
pip install mcp

# With uv (recommended — 10-100x faster than pip)
uv pip install mcp

The SDK provides the Server class, decorator-based tool and resource registration, and built-in stdio transport handling. There are no third-party dependencies beyond the standard library and Pydantic.

Your First MCP Server

Create a file named weather_server.py. This server exposes a single tool: get_forecast, which accepts a city name and returns a mock weather forecast. In a real application this would call an external API — you will see that pattern in the second article of this series.

import mcp.server
import mcp.server.stdio
import mcp.types as types
from pydantic import AnyUrl

# Initialise the server with a name
server = mcp.server.Server("weather-server")


@server.list_tools()
async def list_tools() -> list[types.Tool]:
    """Advertise available tools to the MCP client."""
    return [
        types.Tool(
            name="get_forecast",
            description="Get a 3-day weather forecast for a given city.",
            inputSchema=types.ToolInputSchema(
                type="object",
                properties={
                    "city": {
                        "type": "string",
                        "description": "The name of the city (e.g. Bangalore, London)",
                    },
                    "country": {
                        "type": "string",
                        "description": "The country code (e.g. IN, GB). Optional.",
                    },
                },
                required=["city"],
            ),
        )
    ]


@server.call_tool()
async def call_tool(
    name: str, arguments: dict
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
    """Handle a tool call request from the MCP client."""
    if name == "get_forecast":
        city = arguments["city"]
        country = arguments.get("country", "")

        # WARNING: This is mock data. Replace with a real weather API call
        # (e.g. OpenWeatherMap, Tomorrow.io) before any production use.
        forecast = {
            "city": city,
            "country": country or "Unknown",
            "days": [
                {"day": "Monday", "condition": "Sunny", "high": 28, "low": 18},
                {"day": "Tuesday", "condition": "Partly Cloudy", "high": 26, "low": 17},
                {"day": "Wednesday", "condition": "Rainy", "high": 22, "low": 15},
            ],
        }
        return [types.TextContent(type="text", text=str(forecast))]

    raise ValueError(f"Unknown tool: {name}")


async def main():
    """Launch the server over stdio transport."""
    async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
        await server.run(
            read_stream,
            write_stream,
            server.create_initialization_options(),
        )


if __name__ == "__main__":
    import asyncio
    asyncio.run(main())

Understanding the Server Lifecycle

The server above follows a specific initialization sequence defined by the MCP specification:

  1. The client sends an initialize request with protocol version and capability information.
  2. The server responds with its own protocol version and capabilities — which tools, resources, and prompts it exposes.
  3. Both sides send a notifications/initialized notification to signal handshake completion.
  4. The client sends tools/list to discover available tools, then tools/call to invoke them.

The SDK’s server.run() method handles all of this automatically. Your application code only needs to implement list_tools() and call_tool().

Running and Testing Your Server

Run the server directly with Python. Because stdio transport uses standard input and output, the server produces no console output by default — all communication happens over the JSON-RPC channel.

python weather_server.py

To test it, you need an MCP client. The official SDK includes a CLI client that works with any stdio server. Install the MCP CLI tools:

uv pip install mcp[cli]

Run the interactive client, pointing it at your server script:

mcp dev python weather_server.py

The mcp dev command starts an interactive session where you can list tools and call them by name. For automated testing, write a script that launches the server as a subprocess and sends JSON-RPC messages over stdin.

Adding Resources and Prompts

Tools are the most common primitive, but resources and prompts round out the server’s capability surface. A resource is read-only data that the LLM can retrieve — for example, the server’s documentation or a reference dataset.

@server.list_resources()
async def list_resources() -> list[types.Resource]:
    """Advertise available resources."""
    return [
        types.Resource(
            uri=AnyUrl("weather://supported-cities"),
            name="Supported Cities",
            description="List of cities for which forecasts are available.",
            mimeType="text/plain",
        )
    ]


@server.read_resource()
async def read_resource(uri: AnyUrl) -> str:
    """Return resource content for the given URI."""
    if str(uri) == "weather://supported-cities":
        return "Bangalore, London, New York, Tokyo, Sydney"
    raise ValueError(f"Unknown resource: {uri}")

A prompt is a parameterised template the client can request. Useful for standardising complex multi-step workflows:

@server.list_prompts()
async def list_prompts() -> list[types.Prompt]:
    return [
        types.Prompt(
            name="weather_report",
            description="Generate a travel weather report for a city.",
            arguments=[
                types.PromptArgument(
                    name="city",
                    description="City to generate the report for",
                    required=True,
                )
            ],
        )
    ]


@server.get_prompt()
async def get_prompt(
    name: str, arguments: dict
) -> types.GetPromptResult:
    if name == "weather_report":
        city = arguments["city"]
        return types.GetPromptResult(
            messages=[
                types.PromptMessage(
                    role="user",
                    content=types.TextContent(
                        type="text",
                        text=f"Generate a concise 3-day travel weather report for {city}. "
                             "Include packing suggestions based on the forecast conditions.",
                    ),
                )
            ]
        )
    raise ValueError(f"Unknown prompt: {name}")

Packaging Your Server for Distribution

To make your server discoverable by MCP clients, create a pyproject.toml with an MCP entry point. The official MCP SDK uses a pyproject.toml-based registration system.

[project]
name = "weather-mcp-server"
version = "0.1.0"
description = "MCP server providing weather forecasts"
requires-python = ">=3.10"
dependencies = ["mcp>=1.0.0", "pydantic>=2.0"]

[project.scripts]
weather-mcp = "weather_server:main"

# Claude Desktop reads [tool.mcp-server] for auto-discovery.
# Other MCP clients may require manual configuration.
[tool.mcp-server]
command = "python"
args = ["-m", "weather_server"]

Clients that support the MCP registry (such as the Claude Desktop app) can install your server with a single command once it is published to PyPI:

pip install weather-mcp-server

Common Mistakes and Gotchas

  • Forgetting to mark the main coroutine with async def — The MCP SDK is fully async. Every handler function must be a coroutine, and the entry point must use asyncio.run() or an async context manager. Mixing sync and async code silently swallows errors.
  • Returning raw Python objects instead of types.TextContent — The call_tool handler must return a list of MCP content objects. Returning a bare string or dict raises a protocol error. Wrap everything in types.TextContent(type="text", text=...).
  • No input validation on tool arguments — The inputSchema you define in list_tools() is declarative only. Always validate arguments inside call_tool() — a malicious or misconfigured client can send arbitrary data.
  • Running blocking I/O inside async handlers — Calling a synchronous HTTP library or database driver inside an async handler blocks the entire event loop. Use asyncio.to_thread() for blocking calls, or switch to an async HTTP client like httpx.

Summary and Next Steps

You now have a working MCP server that exposes a tool, a resource, and a prompt — all running over stdio transport. The server is minimal but follows the correct MCP patterns: proper initialization handshake, typed tool schemas, and clean separation between tool discovery and tool execution.

Two natural next steps follow from here. The second article in this series shows how to integrate MCP with an existing FastAPI application — either by mounting an MCP server inside FastAPI for HTTP transport, or by wrapping FastAPI routes as MCP tools using the fastapi_mcp library. The third article covers production hardening: authentication, testing strategies, Streamable HTTP transport, and Docker deployment.

Related posts
Python

Pydantic Agent Basics: A Complete 2026 Tutorial

ProgrammingPython

Production-Ready MCP Servers — Security, Testing & Deployment

ProgrammingPython

Connect FastAPI to MCP — Two Integration Patterns

Python

ValueError: Length of values does not match length of index — Python Error Causes and How to Fix It

Leave a Reply