baseline-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of baseline-mcp might be problematic. Click here for more details.
- baseline_mcp-0.1.0/.env.example +2 -0
- baseline_mcp-0.1.0/.gitignore +6 -0
- baseline_mcp-0.1.0/LICENSE +21 -0
- baseline_mcp-0.1.0/METHODOLOGY.md +64 -0
- baseline_mcp-0.1.0/PKG-INFO +81 -0
- baseline_mcp-0.1.0/README.md +62 -0
- baseline_mcp-0.1.0/pyproject.toml +33 -0
- baseline_mcp-0.1.0/src/baseline_mcp/__init__.py +0 -0
- baseline_mcp-0.1.0/src/baseline_mcp/server.py +402 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chad McNutt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Methodology
|
|
2
|
+
|
|
3
|
+
Every number Baseline returns — a percentile rank, a "wetter than normal" label, a water year total — comes from a specific, fixed process described here. Nothing in Baseline's output is generated or estimated by a language model. This document exists so anyone using Baseline, or building on top of it, can check that claim rather than take it on faith.
|
|
4
|
+
|
|
5
|
+
## The data: ERA5-Land reanalysis
|
|
6
|
+
|
|
7
|
+
Baseline's historical numbers come from **ERA5-Land**, a reanalysis dataset produced by the European Centre for Medium-Range Weather Forecasts (ECMWF). Reanalysis is not a network of weather stations — it's a physically consistent, gridded reconstruction of the atmosphere built by combining decades of observations (stations, satellites, weather balloons, ships, aircraft) with a fixed numerical weather model, run once, over the whole historical period.
|
|
8
|
+
|
|
9
|
+
That matters for a specific reason: station records are uneven. Stations open, close, move, get new instruments, or simply don't exist in a lot of the world's more sparsely monitored places. Comparing "this week vs. 1995" at a single station can mean comparing against a different instrument, a different location, or a gap in the record. A reanalysis grid doesn't have that problem — every grid cell has a complete, consistently-produced record for the full period, computed the same way in 2026 as it was for 1996. That consistency, more than raw accuracy at any single point, is why Baseline uses it as its historical baseline.
|
|
10
|
+
|
|
11
|
+
**Coverage:** 1991–2025, 0.1° resolution (roughly 11 km at the equator), land areas only. ERA5-Land doesn't produce values for ocean grid cells, so locations resolve to the nearest valid land point — this occasionally matters for small islands or immediate coastlines.
|
|
12
|
+
|
|
13
|
+
## Climatology normals vs. historical ranking — two different periods, on purpose
|
|
14
|
+
|
|
15
|
+
Baseline uses two different windows of the ERA5-Land record, for two different jobs:
|
|
16
|
+
|
|
17
|
+
- **"Normal" (the expected value for a given place and date)** is computed over **1991–2020** — the 30-year period the World Meteorological Organization (WMO) designates as the current standard climatological normal. This is the same convention national weather services use, so a Baseline "normal" means the same thing a meteorologist means by it.
|
|
18
|
+
- **Historical ranking** ("3rd driest since 1991," "wetter than 91% of years") is computed over the **full 1991–2025 record** — all years currently available, not just the 30-year normals window. A 35-year ranking window gives a more meaningful answer to "how unusual is this" than a 30-year one would, and it lets the most recent years be ranked against history at all, which a fixed 1991–2020 window couldn't do.
|
|
19
|
+
|
|
20
|
+
In short: Baseline tells you what's *normal* using the WMO standard, and tells you how *unusual* something is using the fullest record available. Both numbers are labeled with their source period in Baseline's response provenance.
|
|
21
|
+
|
|
22
|
+
## How rank and percentile are computed
|
|
23
|
+
|
|
24
|
+
For a given location, date, and variable (precipitation or temperature), Baseline pulls the matching value for every year in the ranking window and compares the current value against that full set:
|
|
25
|
+
|
|
26
|
+
- **Rank** ("3rd driest," "7th wettest") counts how many years in the record had a more extreme value, plus one. If two other years were wetter than this one, this one ranks 3rd wettest.
|
|
27
|
+
- **Percentile** ("wetter than 83% of years") is the share of years in the record at or below the current value. It answers "where does this year fall in the distribution," independent of how many years are in the record.
|
|
28
|
+
|
|
29
|
+
Both numbers describe the same underlying comparison from two different angles — rank is easier to say in a sentence, percentile is easier to compare across locations with different record lengths.
|
|
30
|
+
|
|
31
|
+
## Water year vs. calendar year
|
|
32
|
+
|
|
33
|
+
Baseline frames cumulative precipitation context two ways, depending on the user's location:
|
|
34
|
+
|
|
35
|
+
- **Water year** (Oct 1 – Sep 30), used for North American users. This is the standard US hydrological accounting year — it starts in the fall so a full winter snowpack season falls inside a single year, rather than being split across two calendar years.
|
|
36
|
+
- **Calendar year** (Jan 1 – Dec 31), used everywhere else.
|
|
37
|
+
|
|
38
|
+
This is a real limitation worth being upfront about: the Oct 1 water year start is a US-specific convention, not a global hydrological standard — other countries define their own water years differently, or don't use the concept at all. Baseline currently applies the US convention to North American locations and calendar year everywhere else; the rankings themselves are valid globally, but the Oct 1 start date for North America is a convention choice, not a universal one.
|
|
39
|
+
|
|
40
|
+
## Forecast data
|
|
41
|
+
|
|
42
|
+
Baseline's forward-looking numbers (the next 10 days) come from a separate source — **Open-Meteo** — and are never blended with, or used to adjust, the historical ranking. Forecast and historical context are always computed and reported independently.
|
|
43
|
+
|
|
44
|
+
## Known limitations
|
|
45
|
+
|
|
46
|
+
- **Land-only, 0.1° grid.** No ocean cells; locations near coastlines or on small islands resolve to the nearest valid land grid point, which may be a few kilometers away.
|
|
47
|
+
- **Archive lag.** The most recent 1–2 months of ERA5-Land data can arrive with precipitation before temperature is finalized. When that gap occurs, Baseline falls back to Open-Meteo's historical archive for temperature and flags the response accordingly — it does not leave the gap unfilled or guess.
|
|
48
|
+
- **Reanalysis vs. a specific station.** ERA5-Land is a model-assimilated reconstruction, not a direct instrument reading. It's built to be highly accurate and, critically, *consistent* across the full record — but a nearby station could show a somewhat different number for any single day.
|
|
49
|
+
- **Oct 1 water year is a US convention**, applied to North American locations by default (see above).
|
|
50
|
+
|
|
51
|
+
## Provenance line
|
|
52
|
+
|
|
53
|
+
Every Baseline response ends with a line like:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
Source: Baseline v0.1.0 | ERA5-Land reanalysis 1991-2025 (35-yr daily climatology,
|
|
57
|
+
WMO 1991-2020 normals), 0.1-degree resolution, land-only | Forecast: Open-Meteo
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Read left to right: the Baseline version that produced this answer (methodology changes bump the version), the historical dataset and the two windows described above, the spatial resolution, and the separate forecast source. If you're relaying a Baseline answer to someone else, this line is the citation.
|
|
61
|
+
|
|
62
|
+
## About this document
|
|
63
|
+
|
|
64
|
+
Baseline is built by someone with a background in operational climate services, including work with NOAA. That background is why the reanalysis-vs-station distinction and the "normal" vs. "ranking" period split above are treated as first-class product decisions rather than implementation details — they're the same distinctions a working climate scientist has to get right. Baseline's audience isn't limited to any one field; this methodology holds the same whether the question comes from a ski resort operator, an insurance analyst, a journalist, or a rancher.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: baseline-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server exposing Baseline's statistically rigorous weather and climate context as agent tools.
|
|
5
|
+
Author-email: Chad McNutt <chadmcnutt@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: agent-tools,climate,era5,mcp,weather
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: httpx>=0.27
|
|
16
|
+
Requires-Dist: mcp[cli]>=1.28.1
|
|
17
|
+
Requires-Dist: python-dotenv>=1.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# baseline-mcp
|
|
21
|
+
|
|
22
|
+
MCP server exposing Baseline as agent tools — statistically rigorous weather and climate context, not just current conditions. Thin translation layer only: no climate logic lives here, every tool call is an HTTP request to the Baseline API. See `baseline_mcp_server_plan.md` in the Baseline project for the full design, and [`METHODOLOGY.md`](./METHODOLOGY.md) for how the underlying data and rankings are computed.
|
|
23
|
+
|
|
24
|
+
## Tools
|
|
25
|
+
|
|
26
|
+
- `get_climate_context` — natural-language weather and climate questions, full context back (forecast + 35-year historical percentile ranking).
|
|
27
|
+
- `get_context_for_coordinates` — same, for an exact lat/lon rather than a place name.
|
|
28
|
+
- `get_water_year_status` — precipitation/temperature status since the start of the water year (Oct 1 US / Jan 1 elsewhere), ranked against 35 years.
|
|
29
|
+
- `compare_to_normal` — how unusual current or forecast conditions are at one location.
|
|
30
|
+
- `compare_locations` — rank precipitation or temperature across 2-10 locations (or a curated category like `colorado_ski_resorts`) in a single call.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
Requires a Baseline API key. **Self-serve signup isn't available yet** — during this early period, contact Chad McNutt (chadmcnutt@gmail.com) for a key.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install baseline-mcp
|
|
38
|
+
# or: uvx baseline-mcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then add it to your MCP client's config, with your API key:
|
|
42
|
+
|
|
43
|
+
**Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"mcpServers": {
|
|
47
|
+
"baseline": {
|
|
48
|
+
"command": "uvx",
|
|
49
|
+
"args": ["baseline-mcp"],
|
|
50
|
+
"env": {
|
|
51
|
+
"BASELINE_API_URL": "https://api.baseline.example",
|
|
52
|
+
"BASELINE_API_KEY": "your-key-here"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Claude Code**: `claude mcp add baseline --env BASELINE_API_URL=https://api.baseline.example --env BASELINE_API_KEY=your-key-here -- uvx baseline-mcp`
|
|
60
|
+
|
|
61
|
+
**Cursor** (`.cursor/mcp.json` or global MCP settings): same shape as the Claude Desktop config above, under whatever key Cursor's MCP settings use for server name.
|
|
62
|
+
|
|
63
|
+
## Local development
|
|
64
|
+
|
|
65
|
+
The venv lives outside this directory (`~/.venvs/baseline-mcp`) rather than in `.venv/` here, because this project sits under iCloud-synced `~/Documents` — iCloud evicts/re-materializes files inside large venvs unpredictably, which causes intermittent `ModuleNotFoundError`s. Keep it that way.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python3 -m venv ~/.venvs/baseline-mcp
|
|
69
|
+
~/.venvs/baseline-mcp/bin/pip install -e .
|
|
70
|
+
cp .env.example .env # fill in BASELINE_API_URL and a free_api-tier BASELINE_API_KEY
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Run against a local Baseline instance (`python3 app.py` in `../baseline`), then:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
~/.venvs/baseline-mcp/bin/mcp dev src/baseline_mcp/server.py
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Status
|
|
80
|
+
|
|
81
|
+
All 5 tools built and tested against a live local Baseline instance, including tool-selection validation in Claude Desktop. `METHODOLOGY.md` (trust collateral) complete. See `baseline_mcp_server_plan.md` in the Baseline project for full task history. **Not yet published to PyPI or any MCP registry** — packaging is staged but the publish step is intentionally on hold.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# baseline-mcp
|
|
2
|
+
|
|
3
|
+
MCP server exposing Baseline as agent tools — statistically rigorous weather and climate context, not just current conditions. Thin translation layer only: no climate logic lives here, every tool call is an HTTP request to the Baseline API. See `baseline_mcp_server_plan.md` in the Baseline project for the full design, and [`METHODOLOGY.md`](./METHODOLOGY.md) for how the underlying data and rankings are computed.
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
- `get_climate_context` — natural-language weather and climate questions, full context back (forecast + 35-year historical percentile ranking).
|
|
8
|
+
- `get_context_for_coordinates` — same, for an exact lat/lon rather than a place name.
|
|
9
|
+
- `get_water_year_status` — precipitation/temperature status since the start of the water year (Oct 1 US / Jan 1 elsewhere), ranked against 35 years.
|
|
10
|
+
- `compare_to_normal` — how unusual current or forecast conditions are at one location.
|
|
11
|
+
- `compare_locations` — rank precipitation or temperature across 2-10 locations (or a curated category like `colorado_ski_resorts`) in a single call.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
Requires a Baseline API key. **Self-serve signup isn't available yet** — during this early period, contact Chad McNutt (chadmcnutt@gmail.com) for a key.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install baseline-mcp
|
|
19
|
+
# or: uvx baseline-mcp
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Then add it to your MCP client's config, with your API key:
|
|
23
|
+
|
|
24
|
+
**Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"baseline": {
|
|
29
|
+
"command": "uvx",
|
|
30
|
+
"args": ["baseline-mcp"],
|
|
31
|
+
"env": {
|
|
32
|
+
"BASELINE_API_URL": "https://api.baseline.example",
|
|
33
|
+
"BASELINE_API_KEY": "your-key-here"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Claude Code**: `claude mcp add baseline --env BASELINE_API_URL=https://api.baseline.example --env BASELINE_API_KEY=your-key-here -- uvx baseline-mcp`
|
|
41
|
+
|
|
42
|
+
**Cursor** (`.cursor/mcp.json` or global MCP settings): same shape as the Claude Desktop config above, under whatever key Cursor's MCP settings use for server name.
|
|
43
|
+
|
|
44
|
+
## Local development
|
|
45
|
+
|
|
46
|
+
The venv lives outside this directory (`~/.venvs/baseline-mcp`) rather than in `.venv/` here, because this project sits under iCloud-synced `~/Documents` — iCloud evicts/re-materializes files inside large venvs unpredictably, which causes intermittent `ModuleNotFoundError`s. Keep it that way.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
python3 -m venv ~/.venvs/baseline-mcp
|
|
50
|
+
~/.venvs/baseline-mcp/bin/pip install -e .
|
|
51
|
+
cp .env.example .env # fill in BASELINE_API_URL and a free_api-tier BASELINE_API_KEY
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Run against a local Baseline instance (`python3 app.py` in `../baseline`), then:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
~/.venvs/baseline-mcp/bin/mcp dev src/baseline_mcp/server.py
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Status
|
|
61
|
+
|
|
62
|
+
All 5 tools built and tested against a live local Baseline instance, including tool-selection validation in Claude Desktop. `METHODOLOGY.md` (trust collateral) complete. See `baseline_mcp_server_plan.md` in the Baseline project for full task history. **Not yet published to PyPI or any MCP registry** — packaging is staged but the publish step is intentionally on hold.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "baseline-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server exposing Baseline's statistically rigorous weather and climate context as agent tools."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Chad McNutt", email = "chadmcnutt@gmail.com" },
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
keywords = ["mcp", "weather", "climate", "era5", "agent-tools"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Scientific/Engineering :: Atmospheric Science",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"mcp[cli]>=1.28.1",
|
|
21
|
+
"httpx>=0.27",
|
|
22
|
+
"python-dotenv>=1.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
baseline-mcp = "baseline_mcp.server:main"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/baseline_mcp"]
|
|
File without changes
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Baseline MCP server.
|
|
2
|
+
|
|
3
|
+
Thin translation layer between the MCP protocol and the Baseline HTTP API.
|
|
4
|
+
Contains zero Baseline logic — every tool is a POST to the Baseline API and
|
|
5
|
+
a reformat of the JSON response into agent-readable text. If a feature needs
|
|
6
|
+
new climate logic, it belongs in Baseline, not here.
|
|
7
|
+
|
|
8
|
+
See baseline_mcp_server_plan.md (companion doc, in the Baseline project dir)
|
|
9
|
+
for the full design rationale.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from dotenv import load_dotenv
|
|
18
|
+
from mcp.server.fastmcp import FastMCP
|
|
19
|
+
|
|
20
|
+
load_dotenv()
|
|
21
|
+
|
|
22
|
+
BASELINE_API_URL = os.environ.get("BASELINE_API_URL", "http://127.0.0.1:5050").rstrip("/")
|
|
23
|
+
BASELINE_API_KEY = os.environ.get("BASELINE_API_KEY", "")
|
|
24
|
+
|
|
25
|
+
# Documented cold start is 8-12s on the production Droplet (local tile cache).
|
|
26
|
+
# 60s gives real margin above that without leaving a genuinely-hung API pending forever.
|
|
27
|
+
REQUEST_TIMEOUT_SECONDS = 60.0
|
|
28
|
+
|
|
29
|
+
# /api/compare processes up to 10 locations at 2 concurrent workers server-side, so its
|
|
30
|
+
# cold-start cost is not the single-location figure above — measured 2:57 for a cold
|
|
31
|
+
# 10-location call in dev. This is a real latency problem (see baseline_mcp_server_plan.md
|
|
32
|
+
# follow-up), not something to treat as settled; the longer timeout here just keeps the
|
|
33
|
+
# tool from failing outright on large categories until that gets fixed.
|
|
34
|
+
COMPARE_TIMEOUT_SECONDS = 240.0
|
|
35
|
+
|
|
36
|
+
_PROVENANCE_LINE = (
|
|
37
|
+
"Source: Baseline | ERA5-Land reanalysis 1991-2025 (35-yr daily climatology, "
|
|
38
|
+
"WMO 1991-2020 normals), 0.1-degree resolution, land-only | Forecast: Open-Meteo"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
mcp = FastMCP("Baseline")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _headers() -> dict:
|
|
45
|
+
headers = {"Content-Type": "application/json"}
|
|
46
|
+
if BASELINE_API_KEY:
|
|
47
|
+
headers["X-Api-Key"] = BASELINE_API_KEY
|
|
48
|
+
return headers
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _post(path: str, payload: dict, timeout: float = REQUEST_TIMEOUT_SECONDS) -> dict:
|
|
52
|
+
"""POST to a Baseline API path. Raises RuntimeError with an actionable
|
|
53
|
+
message on any failure — callers should catch this and hand it back to the
|
|
54
|
+
agent as the tool result, not let it surface as a stack trace."""
|
|
55
|
+
url = f"{BASELINE_API_URL}{path}"
|
|
56
|
+
try:
|
|
57
|
+
response = httpx.post(url, json=payload, headers=_headers(), timeout=timeout)
|
|
58
|
+
except httpx.ConnectError as error:
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
f"Could not reach the Baseline API at {url}. Is the server running? ({error})"
|
|
61
|
+
)
|
|
62
|
+
except httpx.TimeoutException:
|
|
63
|
+
raise RuntimeError(f"Baseline API at {url} timed out after {timeout:.0f}s.")
|
|
64
|
+
|
|
65
|
+
if response.status_code == 401:
|
|
66
|
+
raise RuntimeError(
|
|
67
|
+
"Baseline API rejected the request (401 Unauthorized). "
|
|
68
|
+
"Check that BASELINE_API_KEY is set and valid."
|
|
69
|
+
)
|
|
70
|
+
if response.status_code == 429:
|
|
71
|
+
body = response.json() if response.headers.get("content-type", "").startswith("application/json") else {}
|
|
72
|
+
raise RuntimeError(
|
|
73
|
+
f"Baseline API rate limit exceeded (daily_limit={body.get('daily_limit')}, "
|
|
74
|
+
f"resets at {body.get('reset_at')})."
|
|
75
|
+
)
|
|
76
|
+
if response.status_code >= 400:
|
|
77
|
+
try:
|
|
78
|
+
detail = response.json().get("error", response.text)
|
|
79
|
+
except Exception:
|
|
80
|
+
detail = response.text
|
|
81
|
+
raise RuntimeError(f"Baseline API returned {response.status_code}: {detail}")
|
|
82
|
+
|
|
83
|
+
return response.json()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _post_context(payload: dict) -> dict:
|
|
87
|
+
return _post("/api/context", payload)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _format_clarification(data: dict) -> str:
|
|
91
|
+
question = data.get("question", "The location is ambiguous.")
|
|
92
|
+
candidates = data.get("candidates") or []
|
|
93
|
+
lines = [question, ""]
|
|
94
|
+
for c in candidates:
|
|
95
|
+
label = c.get("label") or c.get("name") or "Unknown"
|
|
96
|
+
lat, lon = c.get("lat"), c.get("lon")
|
|
97
|
+
if lat is not None and lon is not None:
|
|
98
|
+
lines.append(f"- {label} ({lat}, {lon})")
|
|
99
|
+
else:
|
|
100
|
+
lines.append(f"- {label}")
|
|
101
|
+
lines.append("")
|
|
102
|
+
lines.append(
|
|
103
|
+
"Call this tool again with a more specific location string, or with "
|
|
104
|
+
"exact coordinates, to resolve the ambiguity."
|
|
105
|
+
)
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _format_context_result(data: dict) -> str:
|
|
110
|
+
lines = []
|
|
111
|
+
|
|
112
|
+
location_name = data.get("location_name")
|
|
113
|
+
if location_name:
|
|
114
|
+
lines.append(f"Location: {location_name}")
|
|
115
|
+
|
|
116
|
+
short_answer = data.get("short_answer") or {}
|
|
117
|
+
if short_answer.get("show"):
|
|
118
|
+
title = short_answer.get("title") or "Short Answer"
|
|
119
|
+
answer = short_answer.get("answer") or ""
|
|
120
|
+
lines.append(f"\n{title}: {answer}")
|
|
121
|
+
|
|
122
|
+
summary = data.get("summary")
|
|
123
|
+
if summary:
|
|
124
|
+
lines.append(f"\nOverview: {summary}")
|
|
125
|
+
|
|
126
|
+
water_year_context = data.get("water_year_context")
|
|
127
|
+
if water_year_context:
|
|
128
|
+
lines.append(f"\n{water_year_context}")
|
|
129
|
+
|
|
130
|
+
metrics = data.get("metrics") or []
|
|
131
|
+
if metrics:
|
|
132
|
+
lines.append("\nKey signals:")
|
|
133
|
+
for metric in metrics[:6]:
|
|
134
|
+
label = metric.get("label") if isinstance(metric, dict) else None
|
|
135
|
+
value = metric.get("value") if isinstance(metric, dict) else None
|
|
136
|
+
if label is not None:
|
|
137
|
+
lines.append(f"- {label}: {value}")
|
|
138
|
+
|
|
139
|
+
lines.append(f"\n{_PROVENANCE_LINE}")
|
|
140
|
+
|
|
141
|
+
lines.append("\n```json")
|
|
142
|
+
lines.append(json.dumps(data, indent=2, default=str))
|
|
143
|
+
lines.append("```")
|
|
144
|
+
|
|
145
|
+
return "\n".join(lines)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@mcp.tool()
|
|
149
|
+
def get_climate_context(query: str) -> str:
|
|
150
|
+
"""Get statistically rigorous weather and climate context for any location
|
|
151
|
+
on Earth (land only). Answers natural-language questions with 10-day
|
|
152
|
+
forecast data and historical percentile rankings against a 35-year ERA5
|
|
153
|
+
daily climatology (1991-2025, WMO 1991-2020 normals). Use this when you
|
|
154
|
+
need to know not just what conditions are or will be, but how unusual
|
|
155
|
+
they are relative to history.
|
|
156
|
+
|
|
157
|
+
query MUST be phrased as a question in one of these forms (the location
|
|
158
|
+
goes where LOCATION is shown; the underlying parser matches these
|
|
159
|
+
patterns specifically and will fail on other phrasings, e.g. "weather
|
|
160
|
+
context for LOCATION" does not work):
|
|
161
|
+
- "Will LOCATION be warmer/wetter than normal this week?"
|
|
162
|
+
- "Has LOCATION been dry this water year?" / "this year?"
|
|
163
|
+
- "How cold/warm/wet was last winter/spring/summer/fall in LOCATION?"
|
|
164
|
+
- "What is the wettest/driest month in LOCATION?"
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
data = _post_context({"query": query})
|
|
168
|
+
except RuntimeError as error:
|
|
169
|
+
return str(error)
|
|
170
|
+
|
|
171
|
+
if data.get("status") == "clarification_needed":
|
|
172
|
+
return _format_clarification(data)
|
|
173
|
+
|
|
174
|
+
return _format_context_result(data)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@mcp.tool()
|
|
178
|
+
def get_context_for_coordinates(latitude: float, longitude: float, label: str = "") -> str:
|
|
179
|
+
"""Get 10-day forecast and 35-year historical climate context for exact
|
|
180
|
+
coordinates. Use when you have a specific latitude/longitude (a
|
|
181
|
+
property, field, trailhead, or site) rather than a place name — this
|
|
182
|
+
skips geocoding entirely. Land locations only.
|
|
183
|
+
"""
|
|
184
|
+
location_explicit = {"lat": latitude, "lon": longitude}
|
|
185
|
+
if label:
|
|
186
|
+
location_explicit["label"] = label
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
data = _post_context({"location_explicit": location_explicit})
|
|
190
|
+
except RuntimeError as error:
|
|
191
|
+
return str(error)
|
|
192
|
+
|
|
193
|
+
if data.get("status") == "clarification_needed":
|
|
194
|
+
return _format_clarification(data)
|
|
195
|
+
|
|
196
|
+
return _format_context_result(data)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
_COORD_RE = re.compile(r"^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _parse_coords(text: str):
|
|
203
|
+
match = _COORD_RE.match(text)
|
|
204
|
+
if not match:
|
|
205
|
+
return None
|
|
206
|
+
lat, lon = float(match.group(1)), float(match.group(2))
|
|
207
|
+
if -90 <= lat <= 90 and -180 <= lon <= 180:
|
|
208
|
+
return lat, lon
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@mcp.tool()
|
|
213
|
+
def get_water_year_status(location: str) -> str:
|
|
214
|
+
"""Get water year precipitation and temperature status for a location:
|
|
215
|
+
totals since the start of the water/calendar year (Oct 1 for North
|
|
216
|
+
America, Jan 1 elsewhere), percentile rank against the same period
|
|
217
|
+
across 35 historical years, and whether conditions are notably wet,
|
|
218
|
+
dry, warm, or cold. Built for drought monitoring, water resource,
|
|
219
|
+
agricultural, and fire-planning contexts. location can be a place name
|
|
220
|
+
("Casper WY") or "lat,lon" coordinates.
|
|
221
|
+
"""
|
|
222
|
+
coords = _parse_coords(location)
|
|
223
|
+
if coords:
|
|
224
|
+
lat, lon = coords
|
|
225
|
+
payload = {
|
|
226
|
+
"location_explicit": {"lat": lat, "lon": lon, "label": location},
|
|
227
|
+
"query": "Has this location been dry this water year?",
|
|
228
|
+
}
|
|
229
|
+
else:
|
|
230
|
+
payload = {"query": f"Has {location} been dry this water year?"}
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
data = _post_context(payload)
|
|
234
|
+
except RuntimeError as error:
|
|
235
|
+
return str(error)
|
|
236
|
+
|
|
237
|
+
if data.get("status") == "clarification_needed":
|
|
238
|
+
return _format_clarification(data)
|
|
239
|
+
|
|
240
|
+
return _format_context_result(data)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@mcp.tool()
|
|
244
|
+
def compare_to_normal(location: str, variable: str, time_window: str = "") -> str:
|
|
245
|
+
"""Compare current or forecast conditions at a location to 35-year
|
|
246
|
+
historical normals. Returns percentile rankings, not vague comparisons.
|
|
247
|
+
Use for questions like "is this week unusually warm" or "will it be
|
|
248
|
+
wetter than normal this month". variable must be "temperature" or
|
|
249
|
+
"precipitation". time_window is optional free text (e.g. "this week",
|
|
250
|
+
"this month") — defaults to "this week".
|
|
251
|
+
"""
|
|
252
|
+
variable = variable.strip().lower()
|
|
253
|
+
if variable not in ("temperature", "precipitation"):
|
|
254
|
+
return f'variable must be "temperature" or "precipitation" (got {variable!r}).'
|
|
255
|
+
|
|
256
|
+
adjective = "warmer" if variable == "temperature" else "wetter"
|
|
257
|
+
window = time_window.strip() or "this week"
|
|
258
|
+
query = f"Will {location} be {adjective} than normal {window}?"
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
data = _post_context({"query": query})
|
|
262
|
+
except RuntimeError as error:
|
|
263
|
+
return str(error)
|
|
264
|
+
|
|
265
|
+
if data.get("status") == "clarification_needed":
|
|
266
|
+
return _format_clarification(data)
|
|
267
|
+
|
|
268
|
+
return _format_context_result(data)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _resolve_location(text: str) -> dict:
|
|
272
|
+
"""Resolve a place name or 'lat,lon' string to {lat, lon, label} via
|
|
273
|
+
Baseline's /api/resolve. Raises RuntimeError on failure or ambiguity."""
|
|
274
|
+
coords = _parse_coords(text)
|
|
275
|
+
if coords:
|
|
276
|
+
lat, lon = coords
|
|
277
|
+
return {"lat": lat, "lon": lon, "label": text}
|
|
278
|
+
|
|
279
|
+
data = _post("/api/resolve", {"location": text})
|
|
280
|
+
|
|
281
|
+
if data.get("status") == "ambiguous":
|
|
282
|
+
candidates = ", ".join(
|
|
283
|
+
c.get("label") or c.get("name", "?") for c in data.get("candidates", [])
|
|
284
|
+
)
|
|
285
|
+
raise RuntimeError(
|
|
286
|
+
f"{text!r} is ambiguous. Candidates: {candidates}. "
|
|
287
|
+
"Use a more specific name or exact coordinates."
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return {"lat": data["lat"], "lon": data["lon"], "label": data.get("name", text)}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _format_compare_result(data: dict) -> str:
|
|
294
|
+
comparison = data.get("comparison", {})
|
|
295
|
+
lines = [
|
|
296
|
+
f"Compared {comparison.get('n_locations')} locations — "
|
|
297
|
+
f"{comparison.get('variable_label')}, {comparison.get('period_label')} "
|
|
298
|
+
f"(vs. {comparison.get('baseline_years')} baseline)",
|
|
299
|
+
"",
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
for entry in comparison.get("ranked", []):
|
|
303
|
+
label = entry.get("label", "Unknown")
|
|
304
|
+
if entry.get("status") not in ("ok", "partial"):
|
|
305
|
+
lines.append(f"- {label}: no data ({entry.get('reason', 'unknown error')})")
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
rank = entry.get("rank")
|
|
309
|
+
value = entry.get("value_display")
|
|
310
|
+
rank_label = entry.get("rank_label", "")
|
|
311
|
+
if entry.get("percent_of_normal") is not None:
|
|
312
|
+
lines.append(
|
|
313
|
+
f"{rank}. {label}: {value} ({entry['percent_of_normal']}% of normal) — {rank_label}"
|
|
314
|
+
)
|
|
315
|
+
else:
|
|
316
|
+
lines.append(
|
|
317
|
+
f"{rank}. {label}: {value} ({entry.get('departure_display')} vs. normal) — {rank_label}"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
lines.append(f"\n{_PROVENANCE_LINE}")
|
|
321
|
+
|
|
322
|
+
lines.append("\n```json")
|
|
323
|
+
lines.append(json.dumps(data, indent=2, default=str))
|
|
324
|
+
lines.append("```")
|
|
325
|
+
|
|
326
|
+
return "\n".join(lines)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@mcp.tool()
|
|
330
|
+
def compare_locations(
|
|
331
|
+
locations: list[str] | None = None,
|
|
332
|
+
category: str = "",
|
|
333
|
+
variable: str = "precipitation",
|
|
334
|
+
period: str = "water_year",
|
|
335
|
+
season: str = "",
|
|
336
|
+
year: int = 0,
|
|
337
|
+
month: int = 0,
|
|
338
|
+
) -> str:
|
|
339
|
+
"""Compare precipitation or temperature across 2-10 locations in a single
|
|
340
|
+
ranked comparison, computed directly by Baseline. Use this instead of
|
|
341
|
+
calling get_climate_context or get_water_year_status once per location
|
|
342
|
+
and comparing the answers yourself — the ranking and percent-of-normal
|
|
343
|
+
figures in the result come from Baseline, not from your own arithmetic
|
|
344
|
+
over several separate answers.
|
|
345
|
+
|
|
346
|
+
Provide EITHER `locations` (a list of 2-10 place names and/or "lat,lon"
|
|
347
|
+
strings) OR `category` (a curated group name) — not both.
|
|
348
|
+
|
|
349
|
+
Valid category values: colorado_ski_resorts, wyoming_ski_resorts,
|
|
350
|
+
utah_ski_resorts, wyoming_watersheds, colorado_river_basin_states,
|
|
351
|
+
great_plains_ag, major_us_cities, major_european_cities,
|
|
352
|
+
us_national_parks.
|
|
353
|
+
|
|
354
|
+
variable: "precipitation" (default) or "temperature".
|
|
355
|
+
period: "water_year" (default, Oct 1 / Jan 1 to date), "season" (also
|
|
356
|
+
set season="winter"/"spring"/"summer"/"fall" and optionally year), or
|
|
357
|
+
"month" (also set month=1-12 and optionally year). Custom date ranges
|
|
358
|
+
are not supported by this tool — use "water_year", "season", or "month".
|
|
359
|
+
"""
|
|
360
|
+
if locations and category:
|
|
361
|
+
return "Provide either locations or category, not both."
|
|
362
|
+
if not locations and not category:
|
|
363
|
+
return "Provide either locations (2-10 places) or category (a curated group name)."
|
|
364
|
+
|
|
365
|
+
payload: dict = {"variable": variable, "period": period}
|
|
366
|
+
if season:
|
|
367
|
+
payload["season"] = season
|
|
368
|
+
if year:
|
|
369
|
+
payload["year"] = year
|
|
370
|
+
if month:
|
|
371
|
+
payload["month"] = month
|
|
372
|
+
|
|
373
|
+
if category:
|
|
374
|
+
payload["category"] = category
|
|
375
|
+
else:
|
|
376
|
+
if len(locations) < 2 or len(locations) > 10:
|
|
377
|
+
return f"locations must have between 2 and 10 entries (got {len(locations)})."
|
|
378
|
+
resolved = []
|
|
379
|
+
for text in locations:
|
|
380
|
+
try:
|
|
381
|
+
resolved.append(_resolve_location(text))
|
|
382
|
+
except RuntimeError as error:
|
|
383
|
+
return str(error)
|
|
384
|
+
payload["locations"] = resolved
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
data = _post("/api/compare", payload, timeout=COMPARE_TIMEOUT_SECONDS)
|
|
388
|
+
except RuntimeError as error:
|
|
389
|
+
return str(error)
|
|
390
|
+
|
|
391
|
+
if data.get("status") == "error":
|
|
392
|
+
return data.get("error", "Unknown error from Baseline API.")
|
|
393
|
+
|
|
394
|
+
return _format_compare_result(data)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def main():
|
|
398
|
+
mcp.run()
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
if __name__ == "__main__":
|
|
402
|
+
main()
|