Building MCP Servers
MCP Server Basics
Architecture out of the way, here is the part you actually write.
Server Lifecycle
Because the protocol is stateless, a server's lifecycle is mostly process lifecycle — not protocol lifecycle. Knowing which stage you're stuck in is most of debugging, and the first two look identical from the outside.
Server lifecycle — where things break
Imports run, handlers register. An import error kills the server here and the host just shows nothing at all
Client connects over stdio or Streamable HTTP. Wrong path or wrong interpreter stops you here
Each request arrives self-contained, is validated against its own _meta, and is dispatched. Repeat, independently, forever
Release file handles and connections. A client may drop the transport at any point without warning
Notice what is not a stage: there is no capability-exchange step. Capabilities arrive with each request rather than being agreed once up front, which is why the connection carries no memory and your handler must be able to serve a cold request at any moment.
Creating a Server Instance
from mcp.server import MCPServer
# Create server with a unique name
mcp = MCPServer(name="my-awesome-server")
The server name appears in logs and helps identify your server when multiple MCP servers are configured.
MCPServer is the high-level API, and it is what the SDK's own quickstart uses. There is
also a low-level Server underneath it, which you reach for when you need to shape raw
protocol responses yourself — the error-handling lesson uses it for
exactly that. Everywhere else in this course, MCPServer is the right default.
Registering Tools
Tools are the primary way AI interacts with your server. Each tool needs:
| Component | Where it comes from |
|---|---|
| name | The function name, unless you override it |
| description | The docstring — this is the part the model reads |
| inputSchema | Generated from your type hints and defaults |
from typing import Literal
@mcp.tool()
def get_weather(city: str, units: Literal["celsius", "fahrenheit"] = "celsius") -> str:
"""Get current weather for a city, e.g. 'London' or 'Tokyo'."""
return fetch_weather(city, units)
The Literal is doing real work: it becomes an enum in the generated schema, so the
model is constrained to the two values your code actually handles rather than being
trusted to guess them. Anything you can express in a type hint, you get to stop
validating by hand.
Handling Tool Calls
The function is the handler. The arguments arrive as ordinary parameters, already validated against the schema your type hints produced:
@mcp.tool()
async def get_weather(city: str, units: Literal["celsius", "fahrenheit"] = "celsius") -> str:
"""Get current weather for a city, e.g. 'London' or 'Tokyo'."""
weather = await fetch_weather(city, units)
return f"Weather in {city}: {weather}"
There is no dispatch table and no if name == ... ladder, because the SDK routes the call
to the function you decorated. Handlers can be def or async def; use async def the
moment you touch the network, which for most real tools is immediately.
Tool Descriptions Matter
The description is not documentation. It is the only thing the model reads when deciding whether this tool is the right one for what the user just asked — and it is reading a list of candidates, cold, before it has any idea what the conversation will be about.
The same tool, described two ways
Three things changed, and each one fixes a specific failure:
| Change | Failure it prevents |
|---|---|
Spelled out knowledge_base instead of kb | The model guessing what the abbreviation covers |
| Named what's inside — policies, runbooks, post-mortems | The tool getting skipped for questions it could have answered |
| Named what's not inside — no code, no tickets | The tool getting called for questions it will fail at |
That last line is the one people leave out. Telling the model where a tool stops is worth as much as telling it where the tool starts.
Next, we'll add resources to expose data that the AI can read. :::
Sign in to rate