Build an MCP server in Python: a step-by-step tutorial (mcp 2.3.0)
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. Inmcp2.x the server class isMCPServer, imported frommcp.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:
- Validation sits in the type hints.
Field(gt=0, le=1_000_000_000)throws out negative and silly amounts before your code even runs. - Money uses
Decimal. Floating point and rupees don't mix well. Ask anyone who has reconciled GST. - No network, no files. The safest tool is one that can't do much damage.
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
- A few more tools, each with limits on its inputs
- A read-only resource, like a catalogue of document IDs
- Authentication before you expose anything over HTTP (our MCP security checklist has the basics)
- A look at the real messages going back and forth: what changed in 2026-07-28
Mistakes we see a lot
- Copying
FastMCPfrom a 2024 tutorial. - Printing debug text to stdout on a stdio server.
- Taking a free-form string where a
Literalor a bounded number would do. - Hand-writing requests and forgetting the required
_metakeys. - 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.