Skip to main content
Glama
matjmiles

mcp-weather-tutorial

by matjmiles
README.md
# MCP Weather Tutorial

A working MCP server and two clients, built against [Open-Meteo](https://open-meteo.com)
(free, no API key, no signup).

## Heads up: most MCP tutorials online are out of date

They open with:

```python
from mcp.server.fastmcp import FastMCP   # ModuleNotFoundError on mcp 2.0
```

`FastMCP` was removed in `mcp` 2.0. It is now `MCPServer`, from `mcp.server`.
Response fields also moved to snake_case (`input_schema`, not `inputSchema`).
If a tutorial mentions `FastMCP`, it is describing 1.x.

Verified against `mcp` 2.0.0 and `claude-agent-sdk` 0.2.95.

## Files

| File | What it does | Needs |
| --- | --- | --- |
| `server.py` | MCP server exposing `geocode_city` and `get_forecast` | nothing |
| `client_basic.py` | Raw protocol client -- no LLM, nothing hidden | nothing |
| `client_agent.py` | Claude picks and chains the tools itself | Claude Code login |

## Run it

```sh
uv run client_basic.py                        # defaults to Denver
uv run client_basic.py Lisbon
uv run client_basic.py San Francisco          # spaces are fine, no quotes
uv run client_basic.py Portland --pick 1      # 2nd match, not the 1st
uv run client_basic.py Tokyo --days 7
uv run client_basic.py --lat 27.99 --lon 86.93   # skip geocoding entirely
uv run client_basic.py Lisbon --json          # also dump the raw payload

uv run client_agent.py                        # just ask, in English
uv run client_agent.py "should I bring an umbrella in Lisbon tomorrow?"
uv run client_agent.py "compare Tokyo and Seoul this weekend"
```

`--pick` exists because city names are ambiguous: "Denver" matches five places
and "Portland" two well-known ones. Open-Meteo ranks by population, so the top
hit is a guess. `client_basic.py` lists every match and marks the one it used;
`client_agent.py` lets Claude choose and say why.

`uv run server.py` on its own will look like it hangs. It hasn't -- it is
waiting for a client on stdin.

## Debugging

Press **F5** in VS Code and pick a configuration. Start with
**"1. Step into server tools (in-process)"**.

**The thing that catches people out:** normally `client_basic.py` launches
`server.py` as a *subprocess*, and a debugger does not follow across a process
boundary. Breakpoints in `server.py` are silently never hit, which looks like
the tool is not being called at all.

`--in-process` fixes this. `Client()` accepts an `MCPServer` object directly and
runs it in the same process, no pipes involved:

```sh
uv run client_basic.py Denver --in-process     # identical output, one process
```

Now a breakpoint inside `geocode_city` hits, and **Step Into** (F11) on
`call_tool(...)` walks from the client, through the protocol layer, into the
tool body.

Good breakpoints to start from:

| File | Line | What you see |
| --- | --- | --- |
| `client_basic.py` | the `list_tools()` call | the schemas generated from your type hints |
| `client_basic.py` | the first `call_tool(...)` | arguments going out as a plain dict |
| `server.py` | first line of `geocode_city` | your tool receiving them |
| `server.py` | the `return` in `get_forecast` | the value before MCP serializes it |

The configs set `"justMyCode": false`, so you can also step down into the `mcp`
library itself and watch the JSON-RPC request get built. Set it to `true` to
stay in your own files.

### Debugging the real subprocess

When you specifically want the true stdio path (config 3 debugs the client
only), have the server wait for a debugger. Add this at the top of `server.py`,
run the client normally, then launch config **4. Attach to server subprocess**:

```python
import debugpy
debugpy.listen(5678)
debugpy.wait_for_client()   # server pauses here until you attach
```

Remove it when you are done -- it makes the server hang for anything that is
not a debugger. For everyday work `--in-process` is easier.

## Why two tools instead of one

`get_forecast` takes coordinates, not city names, because Open-Meteo's forecast
endpoint does. Answering "weather in Denver" therefore takes two calls:

```text
geocode_city("Denver")  ->  lat 39.73915, lon -104.9847
get_forecast(39.73915, -104.9847)  ->  daily highs and lows
```

`client_basic.py` does that hand-off by hand so you can see it.
`client_agent.py` never mentions Denver or latitude -- Claude works it out:

```text
[tool] mcp__weather__geocode_city(name='Denver')
[tool] mcp__weather__get_forecast(latitude=39.73915, longitude=-104.9847, days=6)
```

That is the point of the whole exercise.

## Two things that will bite you

**Return annotations need a schema.** A tool annotated `-> dict` fails with
`InvalidSignature: return type <class 'dict'> is not serializable for structured
output`. Use a `TypedDict` (see `City`, `Forecast` in `server.py`).

**List returns get wrapped, object returns do not.** A tool returning a list
arrives as `{"result": [...]}`, because JSON Schema needs an object at the top
level. A tool returning a `TypedDict` arrives as-is:

```python
geo.structured_content["result"]   # list-returning tool
forecast.structured_content        # object-returning tool -- no "result" key
```

## Connecting it to Claude Code

```sh
claude mcp add weather -- uv run server.py
```

Then ask Claude Code about the weather directly. `claude mcp remove weather`
undoes it.

## License

MIT -- see [LICENSE](LICENSE).

## Design notes

See [docs/specs/2026-07-28-mcp-weather-tutorial-design.md](docs/specs/2026-07-28-mcp-weather-tutorial-design.md).

TDQS

A4.4/5.0

Scored across 2 tools

Disambiguation5/5

The two tools have completely distinct purposes: one converts city names to coordinates, the other retrieves forecasts from coordinates. There is no functional overlap.

Naming Consistency5/5

Both tools follow the same verb_noun snake_case pattern (geocode_city, get_forecast), making them predictable and easy to understand.

Tool Count4/5

With only 2 tools, the server feels minimal, but as a tutorial it is appropriately scoped for the basic workflow of converting a city name to coordinates then getting a forecast.

Completeness2/5

The server only provides geocoding and daily forecasts, omitting common weather features like current conditions, hourly forecasts, alerts, or historical data. This is notably incomplete for a general weather domain.

Maintenance

ActivityMaintained
ResponsivenessSyncing