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 Server
from mcp.types import Tool, TextContent
# Create server with a unique name
server = Server(name="my-awesome-server")
The server name appears in logs and helps identify your server when multiple MCP servers are configured.
Registering Tools
Tools are the primary way AI interacts with your server. Each tool needs:
| Component | Purpose |
|---|---|
| name | Unique identifier (snake_case) |
| description | What the tool does (AI reads this!) |
| inputSchema | JSON Schema for parameters |
@server.list_tools()
async def list_tools():
return [
Tool(
name="get_weather",
description="Get current weather for a city",
inputSchema={
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name (e.g., 'London', 'Tokyo')"
},
"units": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"default": "celsius"
}
},
"required": ["city"]
}
)
]
Handling Tool Calls
When the AI invokes a tool, your handler receives the name and arguments:
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "get_weather":
city = arguments["city"]
units = arguments.get("units", "celsius")
# Your logic here (e.g., call weather API)
weather = fetch_weather(city, units)
return [TextContent(
type="text",
text=f"Weather in {city}: {weather}"
)]
raise ValueError(f"Unknown tool: {name}")
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 reads it once, at capability exchange, 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