Limited-period launch offer: 80% OFF · MCP Builder’s Handbook at ₹98 instead of the regular ₹499 · See details
Roori eBooks
Tutorial

Build an MCP server in Python: a step-by-step tutorial (mcp 2.3.0)

Published 2026-10-20 · Roori eBooks

Let's build a small MCP server in Python, then test it. Nothing fancy, but safe and real.

Everything here was actually run on 19 October 2026: Python 3.12.7, uv 0.12.3 and the official mcp package 2.3.0, on Windows. If something in a tutorial wasn't run, you should be suspicious of it. We try not to do that to you.

Heads up: older tutorials import FastMCP. In mcp 2.x the server class is MCPServer, imported from mcp.server. The code below is what actually ran.

1. Set up the project

uv init --name mcp-india-utils --no-readme --python 3.12
uv add "mcp==2.3.0"
uv add --dev pytest pytest-asyncio

We pin the version on purpose. This SDK ships often, and releases do change behaviour.

2. Write the server

Make a file called minimal_server.py. It has one tool, and that tool is read-only. It checks its inputs, touches no network and opens no files.

"""Minimal MCP server for the blog tutorial: one validated, read-only tool."""

from decimal import ROUND_HALF_UP, Decimal
from typing import Annotated

from mcp.server import MCPServer
from pydantic import Field

mcp = MCPServer("rupee-tools")


@mcp.tool()
def inr_to_foreign(
    amount_inr: Annotated[float, Field(gt=0, le=1_000_000_000, description="Amount in INR")],
    rate: Annotated[float, Field(gt=0, description="Units of foreign currency per 1 INR")],
) -> float:
    """Convert INR to a foreign currency using a rate you supply (no live rates, no network)."""
    value = Decimal(str(amount_inr)) * Decimal(str(rate))
    return float(value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))


if __name__ == "__main__":
    mcp.run()  # stdio by default; never print() to stdout in a stdio server

A few choices worth explaining:

3. Test it (no subprocess needed)

The SDK has an in-memory Client(mcp) that plugs straight into your server object. No port, no second process.

"""Test for minimal_server.py using the SDK's in-memory client."""

import sys
from pathlib import Path

import pytest
from mcp import Client

sys.path.insert(0, str(Path(__file__).parent))
from minimal_server import mcp  # noqa: E402


@pytest.fixture
def anyio_backend() -> str:
    return "asyncio"


@pytest.mark.anyio
async def test_converts() -> None:
    async with Client(mcp) as client:
        result = await client.call_tool("inr_to_foreign", {"amount_inr": 1000, "rate": 0.012})
        assert result.is_error is False
        assert result.structured_content == {"result": 12.0}


@pytest.mark.anyio
async def test_rejects_negative_amount() -> None:
    async with Client(mcp) as client:
        result = await client.call_tool("inr_to_foreign", {"amount_inr": -5, "rate": 1})
        assert result.is_error is True

Run uv run pytest -v. This is what we got:

============================= test session starts =============================
platform win32 -- Python 3.12.7, pytest-9.1.1, pluggy-1.7.0 -- <repo>\python-track\.venv\Scripts\python.exe
cachedir: .pytest_cache
rootdir: <repo>\python-track
configfile: pyproject.toml
plugins: anyio-4.15.1, asyncio-1.4.0
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 2 items

examples/test_minimal_server.py::test_converts PASSED                    [ 50%]
examples/test_minimal_server.py::test_rejects_negative_amount PASSED     [100%]

============================== 2 passed in 2.94s ==============================

4. Run it

uv run python minimal_server.py

It talks over stdio, which is how a local host starts a server. One rule you'll thank us for later: never print() in a stdio server. Stdout is the protocol channel, and stray text breaks it. Log to stderr.

5. What to build next

Mistakes we see a lot

  1. Copying FastMCP from a 2024 tutorial.
  2. Printing debug text to stdout on a stdio server.
  3. Taking a free-form string where a Literal or a bounded number would do.
  4. Hand-writing requests and forgetting the required _meta keys.
  5. Using floats for money.

The MCP Builder's Handbook takes this much further: a full tested server with three tools and a resource, a stdio client, 9 passing tests and a captured protocol exchange.

Frequently asked questions

Which Python package do I use for MCP?

The official one, called mcp on PyPI. We ran this tutorial with mcp 2.3.0. In 2.x the server class is MCPServer, imported from mcp.server. Older tutorials use FastMCP, which is a different import.

Which Python version do I need?

PyPI says requires-python >=3.10 for mcp 2.3.0, while the SDK README says Python 3.11+. We used Python 3.12.7. If you're on 3.10, test it yourself first.

Why shouldn't I print() in a stdio MCP server?

On stdio, the standard output stream is the protocol channel. Anything else you print to stdout breaks the JSON-RPC stream. Log to stderr instead.

₹98 ₹499 80% OFF
Buy the MCP e-book