I've now built a handful of these, and the gap between the docs and a server that actually shows up in Claude Desktop is wider than anyone admits. This is the version I wish I'd had on day one. We'll build a weather server because it's the "hello world" that still teaches the real shape — a tool, a transport, and the client wiring that trips everyone up.


What MCP actually is (the one-paragraph version)

MCP is a standard protocol that lets an AI app (the host — Claude Desktop, Cursor, VS Code, or Claude Code on the CLI) talk to your code (the server) over a defined interface. Your server exposes tools (functions the model can call), and the host decides when to call them. That's the whole mental model. It's JSON-RPC under the hood, but you'll never touch that layer — the SDK handles it.

HOST Claude Desktop or Claude Code YOUR MCP SERVER weather tool: get_forecast() EXTERNAL API wttr.in (any API you want) stdio JSON-RPC HTTPS result flows back to the model  —  "Tokyo: +76°F"
The host calls your tool over stdio; your server does the real work and hands the result back.

The one distinction worth holding onto: transport. A stdio server runs locally and talks over standard in/out — the host launches it as a subprocess. A Streamable HTTP server runs remotely over the network. We're building stdio today because it's the fastest path to something working, and it's what the overwhelming majority of personal MCP servers actually use. (Remote + OAuth is a different, much longer post — and honestly where most of the production pain lives.)


Step 1: Set up the project

We'll use uv because it's fast and handles the virtualenv for you. (Plain pip install mcp works too.)

bash
$ uv init weather-mcp
$ cd weather-mcp
$ uv add "mcp[cli]" httpx

The [cli] extra is the line that matters — it pulls in the MCP Inspector and the mcp command we'll use to test in Step 4. Skip it and you'll be wondering why mcp dev doesn't exist. (Cost me ten minutes the first time.) We add httpx now too, since our tool will make an HTTP call.


Step 2: Write the server

Create server.py:

server.py
from mcp.server.fastmcp import FastMCP

# The name here is what shows up in the client's tool list
mcp = FastMCP("weather")

if __name__ == "__main__":
    mcp.run()

That's a complete, valid MCP server. It does nothing useful yet — it has no tools — but it will start, handshake with a client, and report zero tools. mcp.run() with no arguments defaults to stdio transport, which is exactly what we want.


Step 3: Add a tool

A tool is just a function with the @mcp.tool() decorator. The decorator reads your type hints and docstring to build the schema the model sees — so the docstring isn't a nicety, it's the model's entire understanding of what your tool does. Write it for Claude, not for yourself.

server.py
import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather")

@mcp.tool()
async def get_forecast(city: str) -> str:
    """Get the current weather forecast for a city.

    Args:
        city: The city name, e.g. "Chicago" or "Tokyo"
    """
    async with httpx.AsyncClient() as client:
        # wttr.in is a free, no-key weather API — great for demos
        resp = await client.get(f"https://wttr.in/{city}?format=3")
        return resp.text

if __name__ == "__main__":
    mcp.run()

What just happened: Claude now sees a tool named get_forecast that takes a city string. When a user asks "what's the weather in Chicago," the model will choose to call it, pass "Chicago", and get your function's return value back. You didn't write any prompt logic — the docstring and type hint did all the wiring.


Step 4: Test it before you touch Claude

Do not skip this. The number one way to waste an afternoon is to wire an untested server into Claude Desktop, get silence, and not know whether the bug is your code or the config. Test the server in isolation first:

bash
$ uv run mcp dev server.py
Starting MCP Inspector...
🌐 Open http://localhost:6274 in your browser

This launches the MCP Inspector — a local web UI. Open it, click Connect, then Tools → List Tools. You should see get_forecast. Run it with a city argument and confirm you get a forecast string back.

Verified

Running exactly this against the current SDK, the tool registers as get_forecast(city) and a call with city="Tokyo" returns Tokyo: 🌤️ +76°F. If the tool shows up and returns weather here, your server is correct — anything that breaks after this point is configuration, not code, and that's a much smaller haystack.


Step 5: Connect it to Claude Desktop

This is the step the tutorials gloss over and the one that actually trips people. Open your Claude Desktop config:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json

Add your server under mcpServers:

claude_desktop_config.json
{
  "mcpServers": {
    "weather": {
      "command": "uv",
      "args": ["--directory", "/absolute/path/to/weather-mcp", "run", "server.py"]
    }
  }
}

The scar here: the path must be absolute, and uv must be on the PATH that Claude Desktop sees — which is not your shell's PATH. If Claude launches from the GUI, it may not find uv at all. If the server won't connect, replace "uv" with the absolute path to the binary (which uv will tell you). This one failure mode accounts for half the "my MCP server won't show up" threads on the forums.

Fully quit and reopen Claude Desktop (not just close the window — quit it). You should see a tools icon in the input box. Ask it: "What's the weather in Tokyo?" It'll ask permission to run get_forecast, then answer with live data.

A note for terminal users

We built this against Claude Desktop because it's the most common starting point, but the server code (Steps 1–4) is identical no matter what calls it — the protocol doesn't care about the client. If you live in the terminal with Claude Code, the wiring is genuinely better: instead of hand-editing JSON and quitting the app, you run one command — claude mcp add weather -- uv --directory /path run server.py — and the PATH gotcha above disappears, because the CLI inherits your shell's environment where uv already lives. The tradeoff cuts the other way too: the CLI already ships with file access, bash, git, and web tools, so a lot of what people reach for MCP to do, Claude Code can already do natively — which means your bar for "is this server worth building" is higher there. Desktop is the blanker slate, so it's the better teaching canvas; the CLI is the better workshop once you know the shape. Same server, different ergonomics — pick the host that matches how you work.


The honest tradeoffs

MCP isn't free magic, and this audience knows it. Three things worth saying out loud:

  • Token overhead is real. Every tool's schema and description is loaded into context. A server with thirty verbose tools eats your context window before the conversation starts. Keep tool counts lean and descriptions tight.
  • stdio means local-only. This server runs on your machine, as you. The moment you want it shared or remote, you're into Streamable HTTP and auth — a genuinely harder problem (the spec has moved three times in 18 months).
  • "Why not just a script?" Fair question. The answer is when the model should decide whether to call it. If you always want the weather, call the API directly. MCP earns its keep when you want Claude to choose.

What's next

You have a working server. The interesting directions from here:

  • Add more tools — anything with an API is a candidate. A tool that queries your database is where this gets genuinely useful.
  • Go remote — wrap it in Streamable HTTP and deploy it so other people (or other machines) can use it. This is where OAuth and the production hard parts enter.
  • Add Resources and Prompts — tools are one of three things a server can expose. Resources give the model readable data; prompts give users reusable templates.

The mental shift that matters: you're no longer just using Claude, you're extending it. Every API you have access to is now a tool you can hand the model. The weather server is a toy — but the shape is identical for the one that matters to you. Notice, too, that Steps 1–4 never mentioned which client you use. Write the server once; every MCP host can call it.