Skip to main content
Glama
README.md
# WeatherOps MCP

An MCP server and CLI for field-ops, event and construction go/no-go calls. Give it a site (address or lat/lng), a work window and a threshold profile; it pulls the Google Weather API hourly forecast (or the keyless US National Weather Service grid forecast) and returns every hour as **GO**, **MARGINAL** or **NO-GO**, with the exact rule that tripped and the value that tripped it.

> **Decision support only, not a safety authority.** Forecasts can be wrong, and the four bundled profiles are illustrative examples, not safety standards. The responsible person on site, equipment manufacturer limits and applicable regulations decide whether work goes ahead.

[![CI](https://github.com/SkilledDragonCorp/weatherops-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/SkilledDragonCorp/weatherops-mcp/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./LICENSE)

## The problem

Agents that plan crane lifts, concrete pours, roof work or outdoor events are asked "can we work tomorrow 07:00-17:00?" and answer from a weather summary. The useful answer is per hour, against the crew's own limits, with a reason a supervisor can check: "14:00 NO-GO, gusts 38 km/h break `gust < 35`". WeatherOps does the geocoding, paging, unit normalization and threshold evaluation in one read-only tool call.

## Quickstart

```bash
bun install --frozen-lockfile
bun run demo          # keyless: synthetic crane-lift window, prints the hour table below
bun run weatherops profiles
bun run weatherops go-no-go --lat 35.7796 --lng -78.6382 --profile crane-lift --start now --end +10h --source nws   # live, keyless (US)
cp .env.example .env  # add GOOGLE_MAPS_API_KEY for Google Weather + Geocoding
bun run weatherops go-no-go --address "..." --profile concrete-pour --start 2026-11-03T07:00:00-05:00 --end 2026-11-03T15:00:00-05:00 --fail-on no-go
```

Demo output (`reports/example-crane-lift.md`, synthetic data), first rows:

| Hour | Decision | Gust km/h | Temp C | Thunder % | Why |
| --- | --- | --- | --- | --- | --- |
| 10:00 | GO | 22 | 10 | 5 | |
| 11:00 | MARGINAL | 28 | 11 | 10 | thunderstorm_probability_pct 10% at 11:00 is within 10% of the 20% limit |
| 14:00 | NO-GO | 38 | 13 | 35 | wind_gust_kmh 38 km/h at 14:00 breaks gust: wind_gust_kmh < 35 margin 5 (+ thunder, storm-condition) |

### MCP server

```json
{
  "mcpServers": {
    "weatherops": {
      "command": "bun",
      "args": ["/abs/path/weatherops-mcp/src/cli.ts", "serve"],
      "env": { "GOOGLE_MAPS_API_KEY": "" }
    }
  }
}
```

Add `"--fixtures"` to `args` for the keyless synthetic demo. Five read-only tools:

| Tool | Input | Upstream calls |
| --- | --- | --- |
| `go_no_go` | `address` or `lat`+`lng`; `start`, `end` (RFC 3339 with offset, `now`, `+Nh`; window up to 72 h); `profile` and/or `rules[]`; `source` `auto` (default) / `google` / `nws`; `format` | 1 geocode (addresses only) + 1 per 24 forecast hours (Google), or 2 (NWS) |
| `outlook` | location, `days` 1-10, profile/rules | 1 `forecast/days` call; one decision per 12 h day part |
| `current_conditions` | location, optional profile/rules | 1 `currentConditions` call; horizon rules are listed as not checked |
| `geocode_site` | `address` | 1 Geocoding v4 call |
| `list_profiles` | none | none |

Results come back as compact JSON plus Markdown (`format: json|markdown|both`). Every result carries `disclaimer`, the profile's own `disclaimer`, `source.used` / `source.fallback`, and the attribution lines Google requires.

## Threshold profiles

A rule states what must hold for GO, e.g. `wind_gust_kmh < 35`:

- violated: **NO-GO** (the rule "tripped"; the result names the rule, the value and the hour it came from)
- holds, but within `margin` of the limit: **MARGINAL**
- the source has no value for the metric: `on_missing` (`marginal` by default, or `no-go` / `ignore`)
- `horizon_hours: N` (inline `over Nh`) checks this hour and the next N-1, using the worst value (or the total with `aggregate: sum`). Read each hour as "start the job at this hour".

An hour's decision is the worst of its rules; the window's decision is the worst hour. The summary lists GO blocks, the first NO-GO hour and per-rule counts.

Bundled **example** profiles in [`profiles/`](./profiles) (each carries an "EXAMPLE ONLY, not a safety standard" disclaimer):

| Profile | Rules |
| --- | --- |
| `crane-lift` | `wind_gust_kmh < 35 margin 5`; `thunderstorm_probability_pct <= 20 margin 10`; `condition not_in THUNDERSTORM, THUNDERSHOWER, ... HAIL_SHOWERS` (Google only); `temperature_c > 2 margin 2` |
| `concrete-pour` | `precip_mm <= 0 over 6h` (no precipitation in the 6 h after the start); `precip_probability_pct < 40 margin 15 over 6h`; `thunderstorm_probability_pct <= 20`; `temperature_c >= 5 margin 2 over 12h` |
| `outdoor-event` | `thunderstorm_probability_pct < 20 margin 10`; storm conditions; `wind_gust_kmh < 50 margin 10`; `precip_probability_pct < 60 margin 20`; `feels_like_c < 32 margin 3` |
| `roofing` | `wind_gust_kmh < 40 margin 8`; `precip_mm <= 0 over 2h`; `ice_thickness_mm <= 0`; `temperature_c > 4 margin 2`; `thunderstorm_probability_pct <= 20` |

Inline rules (CLI `--rule`, MCP `rules[]`) use `METRIC OP VALUE [margin N] [over Nh] [sum] [missing marginal|no-go|ignore] [id NAME]`; a rule with a profile rule's `id` replaces it. Metrics: `temperature_c`, `feels_like_c`, `wind_speed_kmh`, `wind_gust_kmh`, `precip_probability_pct`, `precip_mm`, `thunderstorm_probability_pct`, `cloud_cover_pct`, `relative_humidity_pct`, `uv_index`, `visibility_km`, `ice_thickness_mm` (numeric: `< <= > >=`), `condition`, `precip_type` (`in` / `not_in`). The CLI also takes `--profile-file my.yaml`; the MCP server only accepts bundled profile names.

## Sources (checked against the references on 2026-10-21)

| Source | Endpoint and parameters used | Fields read | Reference |
| --- | --- | --- | --- |
| Google Weather hourly | `GET https://weather.googleapis.com/v1/forecast/hours:lookup?location.latitude=&location.longitude=&unitsSystem=METRIC&hours=&pageSize=24&pageToken=` (`hours` 1-240, `pageSize` 1-24) | `forecastHours[].interval`, `temperature`, `feelsLikeTemperature`, `wind.speed/gust`, `precipitation.probability.{percent,type}`, `precipitation.qpf/snowQpf`, `thunderstormProbability`, `weatherCondition.type`, `cloudCover`, `relativeHumidity`, `uvIndex`, `visibility`, `iceThickness`, `isDaytime`; `timeZone.id`; `nextPageToken` | [forecast.hours.lookup](https://developers.google.com/maps/documentation/weather/reference/rest/v1/forecast.hours/lookup), [hourly guide](https://developers.google.com/maps/documentation/weather/hourly-forecast) |
| Google Weather daily | `GET .../v1/forecast/days:lookup?...&days=&pageSize=10` (`days` 1-10) | `forecastDays[].daytimeForecast` / `nighttimeForecast` (interval, wind, precipitation, thunderstormProbability, weatherCondition, iceThickness), `maxTemperature`, `minTemperature`, `displayDate` | [forecast.days.lookup](https://developers.google.com/maps/documentation/weather/reference/rest/v1/forecast.days/lookup) |
| Google Weather current | `GET .../v1/currentConditions:lookup?...` | `currentTime`, `timeZone`, same condition fields as an hour | [currentConditions.lookup](https://developers.google.com/maps/documentation/weather/reference/rest/v1/currentConditions/lookup) |
| Google Geocoding v4 | `GET https://geocode.googleapis.com/v4/geocode/address?addressQuery=` with `X-Goog-FieldMask: results.placeId,results.formattedAddress,results.location,results.granularity` | `results[0].location.{latitude,longitude}`, `formattedAddress`, `placeId`, `granularity` | [geocodeAddress](https://developers.google.com/maps/documentation/geocoding/reference/rest/v4/geocode.address/geocodeAddress), [GeocodeResult](https://developers.google.com/maps/documentation/geocoding/reference/rest/v4/GeocodeResult) |
| NWS (keyless, US) | `GET https://api.weather.gov/points/{lat},{lng}` then the `forecastGridData` URL `GET /gridpoints/{wfo}/{x},{y}` (raw grid) | layers `temperature`, `apparentTemperature`, `windSpeed`, `windGust`, `probabilityOfPrecipitation`, `probabilityOfThunder`, `quantitativePrecipitation`, `skyCover`, `relativeHumidity`, `visibility`, `iceAccumulation` (`uom` + `values[{validTime: "START/ISO8601-duration", value}]`), `timeZone` from points | [NWS API docs](https://www.weather.gov/documentation/services-web-api), [OpenAPI](https://api.weather.gov/openapi.json) |

The Google key is sent only in the `X-Goog-Api-Key` header. Values are normalized to metric from the response's own unit fields (`FAHRENHEIT`, `MILES_PER_HOUR`, `INCHES`, WMO unit codes). `source: auto` uses Google when a key is set; on a missing key or any Google API error (for example 403 `SERVICE_DISABLED`) it falls back to NWS and says so in `source.fallback`. Outside NWS coverage `/points` answers 404, reported as `NOT_COVERED`.

Why the raw NWS grid instead of `/gridpoints/{wfo}/{x},{y}/forecast/hourly`: a live hourly response (Raleigh, 2026-10-21) had `temperature`, a text `windSpeed` ("6 mph"), `probabilityOfPrecipitation`, `dewpoint` and `relativeHumidity` per period, but no gust, thunder probability or precipitation amount. The raw grid has all three.

## Measured

Produced by `bun run report` ([`scripts/report.ts`](./scripts/report.ts)): every row is a real call through the MCP server (in-memory client, real tools, service and HTTP client) with Google and NWS answered by the synthetic fixtures. No network, no keys. Source: [`reports/weatherops-mcp.md`](./reports/weatherops-mcp.md) and `.json`. Run on 2026-10-21 (darwin-x64, Bun 1.4.3). "Upstream calls" includes the geocode call and any failed Google call before a fallback.

| Scenario | Tool | Profile | Source used | Hours | GO | MARGINAL | NO-GO | First NO-GO (rule, value) | Longest GO block | Upstream calls |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| harbor-yard crane lift 07-17 EST (Google) | go_no_go | crane-lift | google | 10 | 4 | 3 | 3 | 14:00 EST `gust` wind_gust_kmh=38 | 07:00 EST to 11:00 (4 h) | 2 |
| harbor-yard crane lift 07-17 EST, forced NWS grid | go_no_go | crane-lift | nws | 10 | 4 | 3 | 3 | 14:00 EST `gust` wind_gust_kmh=38.9 | 07:00 EST to 11:00 (4 h) | 3 |
| harbor-yard roofing next day 07-17 EST (Google, 2 pages) | go_no_go | roofing | google | 10 | 5 | 1 | 4 | 07:00 EST `materials-cold` temperature_c=1 | 12:00 EST to 17:00 (5 h) | 3 |
| river-lot concrete pour starts 07-15 MST (Google 403 -> NWS) | go_no_go | concrete-pour | nws (fallback: SERVICE_DISABLED) | 8 | 3 | 2 | 3 | 12:00 MST `no-rain-6h` precip_mm=0.5 | 09:00 MST to 12:00 (3 h) | 3 |
| coast-plaza outdoor event 08-22 GMT (Google; outside NWS) | go_no_go | outdoor-event | google | 14 | 11 | 2 | 1 | 19:00 GMT `thunder` thunderstorm_probability_pct=25 | 08:00 GMT to 18:00 (10 h) | 1 |
| harbor-yard inline rules (gust < 25 margin 5; rain < 2 mm over 3 h sum) | go_no_go | inline | google | 10 | 3 | 1 | 6 | 11:00 EST `wind-gust-kmh-1` wind_gust_kmh=28 | 07:00 EST to 10:00 (3 h) | 2 |
| river-lot source=google only (403 SERVICE_DISABLED) | go_no_go | roofing | google | error: upstream_403 SERVICE_DISABLED | - | - | - | - | - | - |
| coast-plaza source=nws only (outside coverage) | go_no_go | outdoor-event | nws | error: upstream_404 NOT_COVERED | - | - | - | - | - | - |
| harbor-yard 10-day outlook (5 days x day/night) | outlook | crane-lift | google | 10 parts | 1 | 2 | 7 | - | - | 2 |
| harbor-yard current conditions vs crane-lift | current_conditions | crane-lift | google | now | 1 | 0 | 0 | - | - | 2 |

Google vs NWS on the same synthetic weather (harbor-yard crane-lift 07-17 EST): 10 of 10 hours get the same decision. The `storm-condition` rule tripped 0 NO-GO hours on NWS (no condition type in the grid, rule set to `on_missing: ignore`) vs 2 on Google.

Example outputs: [crane lift](./reports/example-crane-lift.md), [concrete pour with NWS fallback](./reports/example-concrete-pour-fallback.md), [outdoor event](./reports/example-outdoor-event.md), [10-day outlook](./reports/example-outlook.md).

### Live checks (`bun run test:live`, 2026-10-21, this machine)

| Check | Result |
| --- | --- |
| Google Weather `forecast/hours`, `forecast/days`, `currentConditions` (key from `dev-test-461117`) | HTTP 403 `PERMISSION_DENIED`, reason `SERVICE_DISABLED` (Weather API not enabled in that project); the three tests print a `skipped:` line |
| Geocoding v4 `geocode/address` (public landmark address) | HTTP 200, `granularity=ROOFTOP`, lat/lng inside the expected box; test passes |
| NWS `/points` -> `/gridpoints` -> `go_no_go` (Raleigh, NC, next 12 h, `crane-lift`) | 13 hours evaluated from grid `RAH/75,57`; test passes |
| NWS `/points` for London | 404 -> `NOT_COVERED`; test passes |
| `source: auto` with the key above | fell back to NWS with `fallback.reason = SERVICE_DISABLED`; test passes |

Without a key the Google and Geocoding tests print `skipped: no GOOGLE_MAPS_API_KEY`; with `WEATHEROPS_LIVE=0` (CI) every live test prints `skipped: WEATHEROPS_LIVE=0`.

## Limits

- **Not verified live: Google Weather responses.** The Weather API is not enabled in the only project available to this build, so the Google client has been exercised only against synthetic fixtures built from the published reference and example response. Field names and unit enums (`CELSIUS`/`FAHRENHEIT`, `KILOMETERS_PER_HOUR`/`MILES_PER_HOUR`, `MILLIMETERS`/`INCHES`, `KILOMETERS`/`MILES`) come from the reference pages linked above; how real responses fill optional fields (for example whether `wind.gust` or `qpf` can be absent) is not confirmed.
- The forecast does not say at what height wind and gusts apply. Crane and work-at-height limits normally refer to wind at height; treat forecast wind as a lower bound.
- NWS fallback: no condition type, precipitation type or UV; precipitation and ice amounts arrive as 6 h blocks and are spread evenly over each hour; values are resampled from the raw grid, not the NWS hourly text forecast. US only.
- The `outlook` tool is coarse: Google day parts report max wind/gust and average thunderstorm probability for 12 h, and only the day's max/min temperature, so a cold night also flags the daytime part for a lower temperature limit.
- `current_conditions` is Google only (NWS observations are not wired in).
- No caching; every call goes upstream. A per-session call budget (`WEATHEROPS_MAX_CALLS`, default 60) protects billed keys.
- Work windows are limited to 72 h and must end inside the 240 h hourly forecast (plus any rule horizon).
- The four profiles are examples written for this lab. They are not derived from any standard, manufacturer chart or regulation.

## Development

`bun run check` is the local gate (and the only CI step): frozen install, skills layout, strict YAML (workflow + profiles), Biome, `tsc --noEmit`, unit + keyless e2e tests, live tests (skip cleanly), the report, and `gitleaks dir`. Fixtures are regenerated with `bun run fixtures`.

## Not affiliated

Not affiliated with, endorsed by, or sponsored by Google LLC or the US National Weather Service. Google Maps and Google Maps Platform are trademarks of Google LLC. Google-sourced results print "Source: Includes weather data from Google" and "Includes data from Google Maps" as the [Weather API policies](https://developers.google.com/maps/documentation/weather/policies) require.

Built by [SkilledDragon LLC](https://skilleddragon.com). Questions: hello@skilleddragon.com. Apache-2.0, see [LICENSE](./LICENSE) and [NOTICE](./NOTICE).