Lesson 4 of 12Track 3

Building MCP servers

Your first MCP server

Exposing a tool via MCP.

Video lesson Interactive exercise ~10 min

Video coming soon

A server with one tool

The fastest way to understand MCP is to write a server. We'll build one that exposes a single tool, run it, and have a client call it. Everything else in the protocol (resources, prompts, lifecycle, multiple servers) is variations on this base case.

We'll use Python and the official mcp package, which provides both server and client SDKs. The shape of the code translates directly to the Node.js, Rust, and Go SDKs; only the syntax changes.

What we're building

A server that exposes one tool: weather(city). It returns a fake forecast. (You won't be calling a real weather API; the point is the server, not the data.)

The full file is around 30 lines:

# server.py
from mcp.server.fastmcp import FastMCP
 
 
mcp = FastMCP("weather-server")
 
 
@mcp.tool()
def weather(city: str) -> str:
    """Get a fake weather forecast for the given city."""
    forecasts = {
        "san francisco": "Foggy, 58F",
        "new york":      "Sunny, 72F",
        "tokyo":         "Rain, 65F",
    }
    return forecasts.get(city.lower(), f"No forecast available for {city}")
 
 
if __name__ == "__main__":
    mcp.run()

That's it. One decorator turns a Python function into an MCP tool. The tool's name is the function name. Its description comes from the docstring. Its input schema is derived from the type hints.

What FastMCP does for you

The FastMCP class wraps the lower-level MCP server SDK. It handles:

  • The MCP wire protocol (JSON-RPC over stdio by default).
  • Capability negotiation (declares "I support tools" on connect).
  • Tool listing (returns the registered tools when a client asks).
  • Tool dispatch (routes incoming tools/call to your Python function).
  • Error wrapping (Python exceptions become structured MCP errors).

You could write all of this manually using the lower-level SDK, but FastMCP is the right starting point: it fits 90% of servers and only gets in your way when you need very custom behavior.

Running it

By default FastMCP runs over stdio: it's a subprocess that reads/writes JSON-RPC on stdin/stdout. Run it directly:

$ python server.py

(It just sits there. Stdio servers are designed to be spawned by a host, not run interactively.)

To actually use it, configure a host (Claude Code, your own client) to spawn this script. In Claude Code's .mcp.json:

{
  "mcpServers": {
    "weather": {
      "command": "python",
      "args": ["/abs/path/to/server.py"]
    }
  }
}

After restarting Claude Code, you should see a weather tool available. Asking the agent "what's the weather in Tokyo" causes it to call weather(city="tokyo"), which dispatches into your Python function.

How a tool call looks on the wire

If you wanted to see the JSON traffic, you'd see something like this on the stdio pipe between host and server:

client -> server: {"jsonrpc":"2.0","id":1,"method":"initialize","params":...}
server -> client: {"id":1,"result":{"capabilities":{"tools":{}},"serverInfo":...}}
client -> server: {"jsonrpc":"2.0","id":2,"method":"tools/list"}
server -> client: {"id":2,"result":{"tools":[{"name":"weather","description":"...","inputSchema":{...}}]}}
client -> server: {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"weather","arguments":{"city":"Tokyo"}}}
server -> client: {"id":3,"result":{"content":[{"type":"text","text":"Rain, 65F"}]}}

FastMCP writes all of this for you. It's worth knowing the shape because when something goes wrong you'll often debug at the JSON level.

Tools that take and return structured data

The example took a string and returned a string. Real tools usually take and return structured data. FastMCP handles this through Python type hints:

from pydantic import BaseModel
 
 
class Forecast(BaseModel):
    city: str
    temp_f: int
    condition: str
 
 
@mcp.tool()
def weather_v2(city: str, units: str = "F") -> Forecast:
    """Get a structured forecast for the given city."""
    return Forecast(city=city, temp_f=72, condition="Sunny")

FastMCP reads the type hints and the Pydantic model; it generates the input schema from the function arguments and the output schema from the return type. The host receives a tool whose schema matches your Python types exactly.

Errors

Tool errors should be returned, not raised, when they're expected. For unexpected failures, raise; FastMCP will wrap the exception into a structured MCP error.

@mcp.tool()
def divide(a: float, b: float) -> float:
    """Divide a by b. Returns an error if b is zero."""
    if b == 0:
        # Returning a structured error result is friendlier to agents.
        return {"error": "division by zero"}
    return a / b

For agents, returning a structured error gives the model something it can reason about. A raised exception becomes an opaque MCP error message that's harder for the model to interpret.

What about resources and prompts?

FastMCP supports them via @mcp.resource() and @mcp.prompt() decorators. We'll cover those in the next lesson. The shape is the same: write a Python function, decorate it, the protocol details are handled for you.

A few things to know

Don't print to stdout

For stdio servers, stdout is the protocol channel. Anything you print there will be parsed by the client as a JSON-RPC message and probably crash the connection. Use print(..., file=sys.stderr) for logging, or use a logger configured to write to stderr.

Don't assume long uptimes

In stdio mode, a server's lifetime is the host's session. It might be killed and respawned at any time. Don't put expensive initialization in module-level code; do it lazily on first call, or accept that it runs every time the host starts.

Schema descriptions matter

The tool's name and description are the agent's only guide for when to use it. A poorly described tool gets called incorrectly or skipped. Track 1 Module 3 lesson 2 covered this; the same lessons apply to MCP tools. Treat the docstring like a prompt.

Run your server against a known-good client first

The fastest debugging cycle is to point Claude Code at your server and watch the host's MCP logs. You'll see initialize, tools/list, and tools/call messages flow through, and any error in your server will show up clearly. The MCP Inspector (a tool the spec authors maintain) is also useful for poking at servers without configuring a real agent.

Key takeaway

A minimum MCP server is around 30 lines: a FastMCP instance, a decorated function with type hints, and a mcp.run() call. The SDK handles wire protocol, capability negotiation, and tool dispatch. Stdout is reserved for the protocol; log to stderr. Schema descriptions are the agent's guide; write them like prompts. The next lesson adds the other two MCP primitives (resources, prompts) and shows when each is the right shape for what you're exposing.

>_first-mcp-server.py
Loading editor...
Output will appear here.

Done with this lesson?