Building MCP Servers

MCP Server Basics

5 min read

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

Process start

Imports run, handlers register. An import error kills the server here and the host just shows nothing at all

Transport attach

Client connects over stdio or Streamable HTTP. Wrong path or wrong interpreter stops you here

Request served

Each request arrives self-contained, is validated against its own _meta, and is dispatched. Repeat, independently, forever

Shutdown

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:

ComponentPurpose
nameUnique identifier (snake_case)
descriptionWhat the tool does (AI reads this!)
inputSchemaJSON 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

What gets ignored
name: search_kb
description: "Search KB"

Three things changed, and each one fixes a specific failure:

ChangeFailure it prevents
Spelled out knowledge_base instead of kbThe model guessing what the abbreviation covers
Named what's inside — policies, runbooks, post-mortemsThe tool getting skipped for questions it could have answered
Named what's not inside — no code, no ticketsThe 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. :::

Quiz

Module 2 Quiz: Building MCP Servers

Take Quiz
Was this lesson helpful?

Sign in to rate