prerequisite
Python essentials for MCP
Just enough Python to read and run every sample in this cluster: functions, type hints, decorators, async and await, and virtual environments.
Before this
Nothing beyond first-year college math. This is a starting page.
Why you need this
Every code sample in this cluster is Python using the official MCP SDK, and that SDK leans on four features beginners often skip: type hints, docstrings, decorators, and async. The SDK reads your type hints to tell the model (the language model inside the host) what arguments a tool takes, and it uses a decorator to register each tool. If those features feel like magic, the server code will too. This page shows each one with plain Python and no MCP code. Every snippet ran on Python 3.14; anything from 3.12 on behaves the same.
The idea
Functions, arguments, and type hints
A function is a named block of code that takes arguments and returns a value. Arguments can be passed by position or by name (a keyword argument), and can have defaults. A type hint is a note after a name saying what type it should be, such as value: float; -> float after the parentheses hints the return type. Literal["km", "mi"] means "exactly one of these strings". A docstring is a string written first inside the function; Python stores it as fn.__doc__.
# hints.py
import inspect
from typing import Literal, get_type_hints
def convert(value: float, unit: Literal["km", "mi"] = "km") -> float:
"""Convert a distance in meters to kilometers or miles."""
return value / 1000 if unit == "km" else value / 1609.344
print(convert(5000))
print(convert(5000, unit="mi"))
print(get_type_hints(convert))
print(inspect.signature(convert).parameters["unit"].default)
print(convert.__doc__)
print(convert("5000"))
python hints.py prints:
5.0
3.1068559611866697
{'value': <class 'float'>, 'unit': typing.Literal['km', 'mi'], 'return': <class 'float'>}
km
Convert a distance in meters to kilometers or miles.
Traceback (most recent call last):
...
TypeError: unsupported operand type(s) for /: 'str' and 'int'
Two lessons. First, a program can read the hints, the defaults, and the docstring at run time. That is exactly what the MCP SDK does: from this one function it can build a JSON Schema saying value is a number, unit must be "km" or "mi" with default "km", plus a description taken from the docstring. Second, Python itself does not enforce hints. Passing the string "5000" ran anyway and crashed inside. The SDK adds that check for you by validating arguments against the schema before your function runs.
Decorators
A decorator is a function that takes another function and returns a function. Writing @tool on the line above def add means "define add, then replace it with tool(add)". A decorator does not have to change the function at all. It can simply register it somewhere and hand it back. That is the job @server.tool() does in every MCP sample.
async and await
Some work is mostly waiting: for a network reply, a database, a person. An async def function is a coroutine: calling it does not run it, it creates an object that can run. Inside, await means "pause here until this finishes, and let other work run meanwhile". An event loop is the scheduler that runs coroutines and switches between them while they wait. asyncio.run(main()) starts one.
# async_demo.py
import asyncio
import time
async def fetch(name: str, seconds: float) -> str:
await asyncio.sleep(seconds) # stands in for waiting on a network reply
return f"{name} done"
async def main():
start = time.perf_counter()
one = await fetch("a", 1.0)
two = await fetch("b", 1.0)
print(one, two, f"one after the other: {time.perf_counter() - start:.1f} s")
start = time.perf_counter()
both = await asyncio.gather(fetch("a", 1.0), fetch("b", 1.0))
print(both, f"together: {time.perf_counter() - start:.1f} s")
asyncio.run(main())
print(fetch("c", 0))
a done b done one after the other: 2.0 s
['a done', 'b done'] together: 1.0 s
<coroutine object fetch at 0x0000019993A28E10>
Python also printed RuntimeWarning: coroutine 'fetch' was never awaited for the last line: calling an async function without await gives you a coroutine object, not a result. The MCP SDK's client is async, so every sample wraps its work in async def main() and ends with asyncio.run(main()). A tool function may be plain or async; the SDK handles both.
Data shapes: dataclass and TypedDict
A @dataclass turns a class with typed fields into a simple record. A TypedDict describes a plain dictionary with known keys. The SDK can read either (and Pydantic models, which samples here use) to build the schema of a tool's output.
# shapes.py
from dataclasses import dataclass
from typing import TypedDict
@dataclass
class Reading:
city: str
celsius: float
class ReadingDict(TypedDict):
city: str
celsius: float
r = Reading("Denver", 9.0)
print(r, r.celsius)
d: ReadingDict = {"city": "Denver", "celsius": 9.0}
print(d, d["celsius"], type(d).__name__)
Reading(city='Denver', celsius=9.0) 9.0
{'city': 'Denver', 'celsius': 9.0} 9.0 dict
At run time the TypedDict value is an ordinary dict; the type exists for readers and tools.
Virtual environments
A virtual environment is a folder holding its own copy of the Python interpreter's package list, so one project's libraries do not collide with another's. Create one, then install into it with pip (Python's package installer). On Windows:
python -m venv .venv
.venv\Scripts\python -m pip install "mcp==2.2.0"
.venv\Scripts\python -m pip show mcp
On macOS and Linux the interpreter is at .venv/bin/python instead. The last command printed, in part:
Name: mcp
Version: 2.2.0
Summary: Model Context Protocol SDK
...
Pinning ==2.2.0 means the code you test is the code you get next month.
Worked example
A registry decorator, then calling a function by its name. This is the core of what an MCP server does: register tools at startup, then look one up by the name in an incoming request.
# registry.py
REGISTRY = {}
def tool(fn):
"""Record fn in REGISTRY under its own name, then hand it back unchanged."""
REGISTRY[fn.__name__] = fn
return fn
@tool
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
@tool
def shout(text: str) -> str:
"""Upper-case some text."""
return text.upper()
print(list(REGISTRY))
print({name: fn.__doc__ for name, fn in REGISTRY.items()})
def call(name, arguments):
if name not in REGISTRY:
return f"unknown tool: {name}"
return REGISTRY[name](**arguments)
print(call("add", {"a": 2, "b": 3}))
print(call("shout", {"text": "hi"}))
print(call("divide", {"a": 1, "b": 0}))
python registry.py prints:
['add', 'shout']
{'add': 'Add two integers.', 'shout': 'Upper-case some text.'}
5
HI
unknown tool: divide
Step by step:
| Moment | What happens | REGISTRY afterwards |
|---|---|---|
def add runs |
Python builds the function, then calls tool(add) |
{'add': add} |
def shout runs |
Same, for shout |
{'add': add, 'shout': shout} |
call("add", {"a": 2, "b": 3}) |
Looks up "add", then add(**{"a": 2, "b": 3}), which is add(a=2, b=3) |
unchanged |
call("divide", ...) |
Name not found, returns a message instead of crashing | unchanged |
**arguments unpacks a dictionary into keyword arguments. That matters because a tool call arrives as JSON with a name and an object of arguments, and in Python that object becomes a dictionary. Replace this page's tool with the SDK's @server.tool(), which also records the hints and docstring, and the dictionary lookup with the SDK's request handling, and you have the outline of an MCP server.
In a server's life
- Build and connect (stage 3). Building a server in Python uses every feature here: decorators to register, hints and docstrings to build schemas,
asyncfor the client. - Design the surface (stage 2). The hints you write become the schema the model reads, so Designing tools is partly about writing good hints.
Common mistakes
- Missing hints. In a check with
def scale(value, factor: float), the SDK described the unhintedvalueas{"title": "value", "type": "string"}, so the model is told to send text where you meant a number, andvalue * factorthen fails. Hint every argument. @server.toolwithout parentheses. The SDK's decorator is a function that returns a decorator, so it is written@server.tool(). Leaving off()raisedTypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool.- Calling an async function without
await. You get a coroutine object and a "never awaited" warning instead of a result. - Installing into the wrong Python.
pip install mcpsucceeds, thenimport mcpfails, becausepipbelonged to a different interpreter. Usepython -m pipwith the samepythonyou run. - Mutable defaults.
def f(items=[])shares one list across every call, so values leak from one call into the next. UseNoneand create the list inside.
Cost
None of these features costs noticeable run time in a server. Reading hints and building schemas happens once, at startup. The async version of a program is no faster at computing anything; it only stops waiting from blocking other work, as the demo's 2.0 versus 1.0 seconds shows. A virtual environment costs disk space for its own copy of every installed package (pip show mcp listed 14 packages the SDK requires directly, each with requirements of its own) and one install per project. That is a small price for never wondering which version of a library a project is using.
Going further
- The Python tutorial's chapters on functions and classes.
- The
typingmodule:Annotated,Optional, and unions, which appear in more advanced tool signatures. - Pydantic models, which the SDK uses to validate arguments and results.
asynciotasks and timeouts, for running several tool calls at once with a limit.