weather-learning-server
README.md
# Weather MCP Learning
A progressive learning project that shows the path from a plain LLM application to weather capabilities exposed through the Model Context Protocol (MCP).
## Learning progression
1. **Plain LLM application** — chat with a local open-source model via Ollama
2. **Traditional weather API app** — Open-Meteo client (Stage 2A) + direct LLM orchestration (Stage 2B)
3. **Weather MCP server** — expose weather as MCP tools over stdio (Stage 3)
4. **MCP client / agent** — explicit tool client (Stage 4A) + model-selected tools (Stage 4B)
This repository currently implements **stages 1 through 4B**.
## Requirements
- Python 3.12 or later
- [Ollama](https://ollama.com) (or any OpenAI-compatible local server)
- A local open-source model with tool calling (default: `qwen2.5:7b`)
No OpenAI or Gemini account is required.
## Setup
### 1. Install and start Ollama
Install from [https://ollama.com](https://ollama.com), then pull a model:
```bash
ollama pull qwen2.5:7b
```
Or use any Responses-API tool-capable model you already have (`ollama list`), then set `LLM_MODEL` in `.env` to that name.
Confirm Ollama is running (usually automatic on macOS after install):
```bash
ollama list
```
### 2. Create a virtual environment
```bash
python3 -m venv .venv
source .venv/bin/activate
```
On Windows:
```bash
python -m venv .venv
.venv\Scripts\activate
```
### 3. Install dependencies
```bash
pip install -e ".[dev]"
```
### 4. Configure environment variables
```bash
cp .env.example .env
```
Defaults in `.env` target local Ollama:
```env
LLM_BASE_URL=http://localhost:11434/v1
LLM_API_KEY=ollama
LLM_MODEL=qwen2.5:7b
```
- `LLM_BASE_URL` — OpenAI-compatible API URL (Ollama’s default is shown above; used by Chat Completions and Responses)
- `LLM_API_KEY` — required by the client library; Ollama ignores it (any non-empty value works)
- `LLM_MODEL` — local model name from `ollama list` (Stage 4B tool-calling works well with `qwen2.5:7b`)
Other options: LM Studio, vLLM, or any server that speaks the OpenAI chat API — just change `LLM_BASE_URL` and `LLM_MODEL`.
## Stage 2A: Open-Meteo weather client
`app/weather_client.py` talks to Open-Meteo in two steps (no LLM, no MCP):
1. **Geocoding** — `GET https://geocoding-api.open-meteo.com/v1/search` turns a city name (plus optional state/region and country) into latitude, longitude, canonical name, admin region, country, and timezone.
2. **Forecast** — `GET https://api.open-meteo.com/v1/forecast` uses those coordinates to fetch **current** weather (temperature, humidity, wind, WMO weather code).
Callers receive typed models (`Location`, `CurrentWeather`, `WeatherResult`), not raw provider JSON. WMO weather-code → text translation lives in one place (`WMO_WEATHER_CODES` / `weather_condition_from_code`).
Example (async):
```python
from app.weather_client import get_current_weather
result = await get_current_weather("Berlin")
print(result.location.name, result.current.temperature, result.current.condition)
```
## Stage 2B: Direct weather + LLM application
`app/direct_weather_app.py` is a **traditional** LLM app: **your code** decides when to call the weather API, then passes that result to the LLM for a friendly summary.
```text
User
→ direct_weather_app
→ Open-Meteo (application-controlled)
→ LLM (summarize only the supplied payload)
→ Response
```
### How to run it
With the virtual environment activated, Ollama running, and network access for Open-Meteo:
```bash
python -m app.direct_weather_app "San Francisco"
```
Optional disambiguation:
```bash
python -m app.direct_weather_app "Springfield" --state Illinois --country US
```
Or the console script:
```bash
direct-weather "San Francisco"
```
On stderr you will see the orchestration steps:
1. Application received city
2. Application called weather provider
3. Application received structured weather
4. Application sent weather context to the LLM
Stdout shows the structured weather block, then the LLM summary.
### How it differs from the plain LLM application
| | Stage 1 `plain_llm_app` | Stage 2B `direct_weather_app` |
|---|---|---|
| Weather data | None — model has no live weather | Fetched from Open-Meteo first |
| Who calls weather? | Nobody | **Application code** (explicit) |
| LLM role | Answer a free-form prompt | Summarize an authoritative payload |
| MCP / tools | No | No |
Important learning point: the LLM does **not** discover or call weather tools. The application orchestrates Open-Meteo, then asks the LLM to phrase the result. The prompt tells the model the payload is authoritative and not to invent missing facts.
## Stage 3: Weather MCP server
`app/mcp_server.py` exposes the existing `weather_client` as an MCP **tool**. The server provides capabilities only — it does not talk to an LLM or manage a conversation.
### Official SDK version and API used
Inspected in this project environment:
| Item | Value |
|---|---|
| Package | official `mcp` on PyPI (`modelcontextprotocol/python-sdk`) |
| Installed version | **2.0.0** |
| Server class | `MCPServer` from `mcp.server` |
| Not used | third-party `fastmcp` package; older v1 `FastMCP` import path |
```python
from mcp.server import MCPServer
mcp = MCPServer("weather-learning-server")
```
### Server responsibilities
- Advertise tools to MCP clients (tool discovery)
- Accept a `get_current_weather` tool call
- Delegate to `app.weather_client` (no duplicated Open-Meteo code)
- Return a structured weather payload (or a safe tool error)
- Speak MCP over **stdio** for this local learning POC
### Exposed tool contract: `get_current_weather`
**Arguments**
| Name | Type | Required | Description |
|---|---|---|---|
| `city` | string | yes | City or place name |
| `state_or_region` | string | no | State / admin region for disambiguation |
| `country` | string | no | Country name or ISO-3166-1 alpha-2 code |
**Structured result fields**
`resolved_location`, `region`, `country`, `latitude`, `longitude`, `temperature`, `apparent_temperature` (when available), `condition`, `wind_speed`, `observation_time`, `timezone`, `units`
### How to start the server
```bash
python -m app.mcp_server
```
Or:
```bash
weather-mcp-server
```
With stdio, the process waits for an MCP host on stdin/stdout. Running it alone in a terminal looks “hung” — that is expected.
### How stdio transport works (conceptually)
```text
MCP host / Inspector
├── spawns: python -m app.mcp_server
├── writes JSON-RPC MCP messages → server stdin
└── reads JSON-RPC MCP messages ← server stdout
```
- No port and no HTTP for this POC
- stdout is the protocol wire (do not `print()` normal app output there)
- Logs belong on stderr
### Testing independently with the official MCP Inspector
Verified against:
- official `mcp` **2.0.0** (`MCPServer`)
- official Inspector package `@modelcontextprotocol/inspector`
- Node.js **22.19+** (required by current Inspector docs)
- network access to Open-Meteo
#### Prerequisites
```bash
cd weather-mcp-learning
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]" # includes mcp[cli]
```
Confirm Node/npx:
```bash
node --version # need 22.19.0 or newer
npx --version
```
If your system `node`/`npx` is broken or too old, use a current Node via nvm (or equivalent), then ensure that `npx` is first on your `PATH`.
#### Option A — Web UI via `mcp dev` (official SDK helper)
From the project root with the venv active (also needs [`uv`](https://github.com/astral-sh/uv) because `mcp dev` launches the server through `uv run`):
```bash
mcp dev app/mcp_server.py --with-editable .
```
Expected:
1. Terminal prints something like `MCP Inspector Web is up and running at: http://localhost:6274?MCP_INSPECTOR_API_TOKEN=...`
2. Browser opens the Inspector
3. Inspector starts/connects to the local **stdio** server (`weather-learning-server`)
4. Session initializes (server name/instructions appear)
5. Open **Tools** → list shows `get_current_weather`
6. Select the tool → UI shows the docstring/description and input fields from the schema (`city` required; `state_or_region` / `country` optional)
7. Set `city` = `San Francisco` → **Run Tool**
8. Result pane shows structured content such as `resolved_location`, `region`, `temperature`, `condition`, `units`, etc.
`--with-editable .` installs this project into the temporary env `mcp dev` builds so `import app...` works.
#### Option B — Web UI via Inspector + project config
`mcp-inspector.json` in the repo root points Inspector at the local stdio server:
```bash
npx -y @modelcontextprotocol/inspector --config ./mcp-inspector.json --server weather-learning-server
```
Open the printed `http://localhost:6274?...` URL, confirm the session is connected, then use the **Tools** tab as in Option A.
#### Option C — Scriptable CLI checks (no browser)
These are useful to prove the same protocol steps from a terminal. Run from the project root with the venv active and a working Node 22.19+ `npx` on `PATH`:
```bash
# 1–2. Start/connect over stdio + initialize session
npx -y @modelcontextprotocol/inspector --cli \
--config ./mcp-inspector.json \
--server weather-learning-server \
--method initialize \
--format json
```
Expected JSON includes `"name": "weather-learning-server"` under `result.serverInfo`.
```bash
# 3–4. List tools; confirm description + input schema
npx -y @modelcontextprotocol/inspector --cli \
--config ./mcp-inspector.json \
--server weather-learning-server \
--method tools/list \
--format json
```
Expected: one tool named `get_current_weather`, with `inputSchema.required` containing `city`, and a live/current-weather description.
```bash
# 5–6. Invoke with city = San Francisco; display structured result
npx -y @modelcontextprotocol/inspector --cli \
--config ./mcp-inspector.json \
--server weather-learning-server \
--method tools/call \
--tool-name get_current_weather \
--tool-arg 'city=San Francisco' \
--format json
```
Expected: `"isError": false` and `structuredContent` with fields like:
```json
{
"resolved_location": "San Francisco",
"region": "California",
"country": "United States",
"latitude": 37.77493,
"longitude": -122.41942,
"temperature": 13.8,
"apparent_temperature": 12.1,
"condition": "Fog",
"wind_speed": 19.1,
"observation_time": "2026-08-12T22:45",
"timezone": "America/Los_Angeles",
"units": {
"temperature": "°C",
"wind_speed": "km/h",
"apparent_temperature": "°C"
}
}
```
Numeric weather values change over time; the field names and `"isError": false` are what matter.
Official Inspector docs: [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) · SDK run docs: [Running your server](https://py.sdk.modelcontextprotocol.io/run/)
## Stage 4A: Basic MCP client (explicit tool call)
`app/basic_mcp_client.py` is a **non-LLM** MCP client. It launches the local weather MCP server over **stdio**, discovers tools, then **explicitly** calls `get_current_weather`.
```text
basic_mcp_client
→ list_tools
→ get_current_weather (hardcoded by this app — not chosen by an LLM)
→ MCP server (app.mcp_server via stdio)
→ Open-Meteo
```
Important: this client still invokes the weather tool **explicitly**. The LLM has **not** yet chosen the tool. That comes in a later stage.
### How to run it
With the virtual environment activated (no need to start the MCP server yourself — this client spawns it):
```bash
python -m app.basic_mcp_client "San Francisco"
```
Optional filters:
```bash
python -m app.basic_mcp_client "Springfield" --state Illinois --country US
```
Or:
```bash
basic-mcp-client "San Francisco"
```
You should see:
1. Connection / protocol info for `weather-learning-server`
2. Each discovered tool’s name, description, and input schema
3. An explicit call to `get_current_weather`
4. The structured MCP tool result JSON
Leaving the process cleans up the MCP session and child server process.
## Stage 4B: OpenAI Responses agent (model-selected MCP tools)
`app/mcp_agent.py` connects to the weather MCP server, **discovers** tools at runtime, gives those definitions to the model via the official **OpenAI Responses API**, executes any model-requested tool calls through MCP, returns tool results to the model, and prints the final answer.
```text
user question
→ mcp_agent
→ MCP list_tools (discovery)
→ OpenAI Responses API (question + tool schemas)
→ model may request tool(s)
→ MCP tools/call (only discovered names)
→ Responses function_call_output
→ final natural-language answer
```
There is **no** `if "weather" in question`, no city regex, and no hardcoded `get_current_weather` call. The **model** chooses whether to use a tool.
### Agent loop (detail)
1. **Start MCP session** — spawn `python -m app.mcp_server` over stdio; initialize client
2. **Tool discovery** — `list_tools`; log each tool name/description
3. **Schema translation** — MCP tools → Responses `type: "function"` tools
4. **Model turn** — `client.responses.create(..., tools=..., tool_choice="auto")`
5. **Inspect output** — if `function_call` items exist:
- validate tool name against the discovered set
- parse/validate JSON arguments
- call MCP; preserve structured results
- submit `function_call_output` with `previous_response_id`
6. **Repeat** until the model returns a final text message (or hit max iterations)
7. **Print final answer** and close the MCP session/child process
### How to run it
```bash
ollama pull qwen2.5:7b # once, if needed
source .venv/bin/activate
python -m app.mcp_agent "What is the current weather in San Francisco?"
python -m app.mcp_agent "Explain what dependency injection is."
```
Expected:
- Weather question → logs show `model_requested_tools` / `tool_call` for `get_current_weather`, then a weather answer
- Dependency-injection question → logs show a final response **without** tool calls
Watch stderr for `[mcp-agent]` lines: discovery, model output types, tool name/arguments/duration/outcome. API keys are never logged.
## Running the plain application
With the virtual environment activated and Ollama running:
```bash
python -m app.plain_llm_app
```
Or with a custom prompt:
```bash
python -m app.plain_llm_app "What is the Model Context Protocol in one sentence?"
```
You can also use the installed console script:
```bash
plain-llm "Hello!"
```
## Running tests
```bash
pytest
```
## Project layout
```text
weather-mcp-learning/
README.md
.env.example
.gitignore
pyproject.toml
mcp-inspector.json
app/
__init__.py
config.py
llm_client.py
plain_llm_app.py
weather_client.py
direct_weather_app.py
mcp_server.py
basic_mcp_client.py
mcp_agent.py
tests/
```
## Notes
- The official `openai` Python package is used as an OpenAI-**compatible** client (Chat Completions earlier; **Responses API** in Stage 4B). Requests go to your configured `LLM_BASE_URL` (Ollama by default).
- Weather lookups use Open-Meteo over `httpx` (`app/weather_client.py`).
- Stage 2B (`direct_weather_app.py`) explicitly orchestrates weather → LLM; no MCP and no tool calling.
- Stage 3 uses the official `mcp` **2.0.0** SDK (`MCPServer` from `mcp.server`) over stdio. Do not use the third-party `fastmcp` package.
- Stage 4A (`basic_mcp_client.py`) still calls the weather tool explicitly (no LLM tool choice).
- Stage 4B (`mcp_agent.py`) lets the model select tools after MCP discovery via the Responses API.
TDQS
A3.9/5.0
Scored across 1 tool
Disambiguation5/5
With only one tool, there is no risk of confusion between tools. The tool's purpose is clearly defined and distinct by default.
Naming Consistency3/5
The single tool uses a clear verb_noun pattern (get_current_weather), but with only one tool, consistency cannot be meaningfully evaluated across a set.
Tool Count2/5
A server named 'weather-learning-server' with only one weather tool feels incomplete for learning purposes. A 'learning' server typically benefits from multiple tools (e.g., forecast, history, alerts) to cover educational use cases.
Completeness2/5
The server only provides current weather data, missing obvious complementary tools like forecasts, historical data, or weather alerts. This severely limits its usefulness for weather-related learning or applications.
Maintenance
ActivitySlowing
ResponsivenessNo issues