MCP·Jan 9, 2026·5 minmcp llm engineering

Building an MCP Server from Scratch

The fastest way to understand a protocol is to make something speak it. So let's build a small MCP server, run it, and watch the messages go by. No framework lecture first — we'll explain pieces as they come up.

The goal: a server that exposes one tool (look up the weather for a city) and one resource (a notes file the model can read). Local, stdio, Python. Maybe forty lines.

Setup

You need the official Python SDK. As of early 2026 it ships with FastMCP bundled in, which is the ergonomic decorator-based layer most people actually write against — the lower-level Server class is still there if you want to hand-assemble request handlers, but you rarely do.

pip install "mcp[cli]"

The [cli] extra pulls in the dev tooling, including the Inspector launcher we'll use to poke at the server.

The server

Here's the whole thing.

# weather_server.py
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather-demo")

# fake data so the example runs offline
_FORECASTS = {
    "lisbon": "18°C, clear",
    "reykjavik": "3°C, sleet",
    "nairobi": "26°C, humid",
}

@mcp.tool()
def get_forecast(city: str) -> str:
    """Return today's forecast for a city."""
    key = city.strip().lower()
    if key not in _FORECASTS:
        raise ValueError(f"No forecast for {city!r}")
    return _FORECASTS[key]

@mcp.resource("notes://travel")
def travel_notes() -> str:
    """A static note the client can read as context."""
    return "Pack layers for Reykjavik. Lisbon is mild. Nairobi: mosquito spray."

if __name__ == "__main__":
    mcp.run()   # defaults to stdio transport

Read it top to bottom and the protocol practically explains itself.

FastMCP("weather-demo") creates the server and gives it a name the client will see at connect time. The @mcp.tool() decorator does the heavy lifting: it inspects the function's type hints and docstring and generates the JSON Schema that the client advertises to the model. city: str becomes a required string parameter; the docstring becomes the tool's description. You write a normal Python function; FastMCP turns it into a protocol-compliant tool definition. That's the entire trick, and it's why people reach for it over writing the schemas by hand.

@mcp.resource("notes://travel") registers a resource at a URI. Tools do; resources are read. Notice the function takes no arguments and returns content — the client fetches it by URI when it wants that context, rather than the model deciding to "call" it. (Resources can be parameterized too — notes://{topic} with a topic argument — but we're keeping it flat.)

mcp.run() with no arguments starts the stdio transport: the server reads JSON-RPC requests from stdin and writes responses to stdout. That's deliberately the dumbest possible transport, which makes it perfect for local tools launched as subprocesses.

What actually happens on connect

When a client attaches, there's a short, fixed conversation before any of your code runs. It's worth seeing once, because every weird "my tool isn't showing up" bug lives somewhere in here.

Sequence of MCP messages: initialize, list tools and resources, then call a tool
The fixed opening conversation: initialize and discovery happen before any tool runs.

The initialize exchange is the version negotiation: the client says which protocol revision it speaks and what it supports (sampling, roots, and so on), the server replies in kind. Then the client sends an initialized notification — "we're good, go" — and only then does discovery happen. tools/list and resources/list are how the model finds out what exists. The actual work is the last line: tools/call.

If you ever wire up a server and the model swears it has no tools, nine times out of ten the initialize handshake failed and discovery never ran. Check that first.

Running and inspecting it

Don't connect it to a real model yet. Use the Inspector — a small debugging UI shipped with the SDK that acts as a client so you can call tools by hand:

mcp dev weather_server.py

This launches your server, attaches the Inspector, and opens a browser panel where you can see the advertised tools and resources, fill in arguments, and watch the raw JSON-RPC. One word of caution that became very real in 2025: the Inspector binds a local proxy, and older versions had a serious authentication gap (CVE-2025-49596). Keep your SDK current and don't run the Inspector on an untrusted network. We'll get into why in the security post.

Three things people get wrong

Docstrings are not optional. The model picks tools based on their descriptions. A tool named get_forecast with no docstring is a coin flip; "Return today's forecast for a city." is a signpost. Write them for the model, not for yourself.

Raise real errors. When get_forecast hits an unknown city, it raises ValueError. FastMCP turns that into a proper error result the model can read and react to — maybe by asking the user to clarify. Swallowing the error and returning "unknown" just teaches the model that your tool is unreliable.

Keep tools narrow. It's tempting to write one do_weather tool with a mode parameter that does five things. Don't. The model reasons about tools one schema at a time; five small, well-named tools beat one Swiss Army knife it has to figure out. The protocol won't stop you from building the knife — that's on you.

Where to go from here

This server is real. Point a host like Claude Desktop or an IDE at it (next post) and the model will call get_forecast for you. From here the natural extensions are: more tools, parameterized resources, prompts, and eventually moving off stdio onto Streamable HTTP so the server can live somewhere other than your laptop.

But notice how little ceremony it took. The hard part of MCP was never writing a server — FastMCP makes that almost too easy. The hard part is deciding what to expose and how narrowly, because the protocol will happily standardize a bad decision and hand it to a model that trusts you completely. Spend your effort there.

Leave a Reply

Your email address will not be published.