@dangahagan/weather-mcp 1.22.0 → 1.23.0

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.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm version](https://badge.fury.io/js/@dangahagan%2Fweather-mcp.svg)](https://www.npmjs.com/package/@dangahagan/weather-mcp)
4
4
  [![MCP Registry](https://img.shields.io/badge/MCP-Registry-blue)](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.dgahagan/weather-mcp)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
- [![Tests](https://img.shields.io/badge/tests-2%2C204%20passing-brightgreen)](./docs/testing/TEST_SUITE_README.md)
6
+ [![Tests](https://img.shields.io/badge/tests-2%2C274%20passing-brightgreen)](./docs/testing/TEST_SUITE_README.md)
7
7
  [![Node](https://img.shields.io/badge/node-%3E%3D18-339933?logo=node.js&logoColor=white)](https://nodejs.org)
8
8
 
9
9
  **Give your AI assistant real weather data — 17 tools, zero API keys, zero signup, zero cost.**
@@ -50,7 +50,7 @@ Choose this one if you want:
50
50
 
51
51
  - **Genuinely free** — every data source is a free public API. No trial that expires, no credit card, no rate-limited "free tier" bait.
52
52
  - **No API keys** — install to first forecast in under a minute. Nothing to configure, nothing to leak into a repo. ([Three optional keys](#optional-api-keys) add extras if you want them; the default configuration needs none.)
53
- - **Fully open source** — MIT licensed, readable TypeScript, 2,204 tests. Audit it, fork it, fix it.
53
+ - **Fully open source** — MIT licensed, readable TypeScript, 2,274 tests. Audit it, fork it, fix it.
54
54
  - **Privacy-respecting** — your queries go directly from your machine to public weather APIs. No middleman server, no telemetry.
55
55
  - **Breadth** — 17 tools covering weather, safety hazards (lightning, floods, wildfires), marine conditions, air quality, and historical data back to 1940. Most weather MCPs stop at forecasts.
56
56
 
@@ -256,6 +256,7 @@ Supported on `get_forecast`, `get_current_conditions`, and `get_historical_weath
256
256
  | `NCEI_API_TOKEN` | — | Optional [free NCEI token](https://www.ncdc.noaa.gov/cdo-web/token) for official NOAA climate normals (US); falls back to Open-Meteo automatically. See [Optional API keys](#optional-api-keys) |
257
257
  | `FIRMS_MAP_KEY` | — | Optional [free FIRMS key](https://firms.modaps.eosdis.nasa.gov/api/map_key/) for targeted wildfire queries and up to 5 days of detection history. See [Optional API keys](#optional-api-keys) |
258
258
  | `GOOGLE_POLLEN_API_KEY` | — | Optional key for pollen outside Europe (incl. the US). **Requires a Google Cloud billing account** — free tier is 5,000 lookups/month. See [Optional API keys](#optional-api-keys) and [the setup guide](./docs/GOOGLE_POLLEN_KEY_SETUP.md) |
259
+ | `GOOGLE_WEATHER_API_KEY` | — | Optional key for official weather alerts beyond the US, Canada, and Europe (~45+ more territories). **Requires a Google Cloud billing account.** See [Optional API keys](#optional-api-keys) and [the setup guide](./docs/GOOGLE_WEATHER_KEY_SETUP.md) |
259
260
 
260
261
  For caching architecture details, see [.github/CACHING.md](./.github/CACHING.md).
261
262
 
@@ -266,7 +267,7 @@ zero signup, and zero cost — that is the configuration this project is built
266
267
  around and the one most people should use. If that's you, you can skip this
267
268
  section entirely.
268
269
 
269
- Three optional keys each unlock one extra. Without them the corresponding tool
270
+ Four optional keys each unlock one extra. Without them the corresponding tool
270
271
  still works; it just returns the keyless answer.
271
272
 
272
273
  | Variable | What it costs | What it adds | Without it |
@@ -274,11 +275,15 @@ still works; it just returns the keyless answer.
274
275
  | [`NCEI_API_TOKEN`](https://www.ncdc.noaa.gov/cdo-web/token) | Free registration (email) | Official NOAA station climate normals for US locations | Normals are computed from the Open-Meteo reanalysis archive — global, and the path virtually every user is already on |
275
276
  | [`FIRMS_MAP_KEY`](https://firms.modaps.eosdis.nasa.gov/api/map_key/) | Free registration (email) | Targeted wildfire bbox queries with 1–5 days of detection history | Keyless 24-hour regional detection files — `get_wildfire_info` still works globally |
276
277
  | `GOOGLE_POLLEN_API_KEY` | **Free tier, but requires a Google Cloud billing account (credit card on file)** — 5,000 lookups/month free, ~$10/1,000 after. [Setup guide](./docs/GOOGLE_POLLEN_KEY_SETUP.md) | Grass/tree/weed Universal Pollen Index outside Europe, including the US (65+ countries) | European pollen via CAMS still works keyless; elsewhere no pollen section renders |
278
+ | `GOOGLE_WEATHER_API_KEY` | **Free tier, but requires a Google Cloud billing account (credit card on file)** — the Weather API bills under a Maps Platform Essentials SKU; check Google's current allowance. [Setup guide](./docs/GOOGLE_WEATHER_KEY_SETUP.md) | Official weather alerts for ~45+ more territories — Australia, Japan, Brazil, Mexico and others ([Google's coverage list](https://developers.google.com/maps/documentation/weather/coverage) is authoritative) | US, Canadian, and European alerts still work keyless; elsewhere `get_alerts` returns today's not-covered message |
277
279
 
278
- The NCEI and FIRMS keys are true free registrations. **The Google Pollen key is
279
- not** — it has a free usage tier, but Google requires a billing account with a
280
- payment method to issue it at all. That's why it isn't described as simply
281
- "free" anywhere in these docs, and why it stays strictly optional.
280
+ The NCEI and FIRMS keys are true free registrations. **The two Google keys are
281
+ not** — each has a free usage tier, but Google requires a billing account with
282
+ a payment method to issue them at all. That's why they aren't described as
283
+ simply "free" anywhere in these docs, and why they stay strictly optional. They
284
+ are separate variables on purpose: the recommended console restriction ties a
285
+ key to one specific API, so a Pollen-restricted key cannot serve alerts and
286
+ vice versa. If you prefer one unrestricted key, put the same string in both.
282
287
 
283
288
  ### Standing key policy
284
289
 
@@ -302,13 +307,14 @@ Being honest about what free public data can and can't do:
302
307
  | Historical weather (1940+) | ✅ (>7 days old) | Station-level detail for last 7 days |
303
308
  | Air quality, marine, radar, lightning | ✅ | — |
304
309
  | Current conditions | ✅ (model data, or real station observations via `source="metar"`) | Station observations via NOAA (richer detail) |
305
- | Weather alerts | ✅ US, Canada, and 38 European countries | NWS zone-level precision; Europe is matched at country level |
310
+ | Weather alerts | ✅ US, Canada, and 38 European countries keyless; ~45+ more territories with an optional key | NWS zone-level precision; Europe is matched at country level |
306
311
  | River conditions | ✅ (GloFAS modeled discharge) | Gauge observations + official flood stages via NWPS |
307
312
  | Wildfires | ✅ (FIRMS satellite detections) | Named incidents with acreage + containment via NIFC |
308
313
  | Fire weather | ✅ (computed Fosberg index + dryness context) | NOAA-published Haines, grassland, red-flag indices |
309
314
  | Pollen | 🇪🇺 Europe keyless (CAMS, grains/m³); elsewhere needs an optional key | Universal Pollen Index with `GOOGLE_POLLEN_API_KEY` |
310
315
 
311
316
  - European alerts are matched at **country level** — the keyless MeteoAlarm feeds carry no region polygons, so warnings for a large country may not affect the requested point; the output says so. Canadian alerts use a real bbox query with polygon-backed features.
317
+ - Outside the US, Canada, and Europe, alerts need an optional [`GOOGLE_WEATHER_API_KEY`](#optional-api-keys); without one `get_alerts` says plainly that the region isn't covered rather than guessing. With a key, Google aggregates official national feeds for roughly 45 more territories — Australia, Japan, Brazil and Mexico among them, with [Google's coverage page](https://developers.google.com/maps/documentation/weather/coverage) as the authoritative list. Matching is by **provider polygon**, so coverage alignment may not be exact and an empty answer means "no alerts found", not a guarantee of coverage; the output says so both ways. Alert text appears in the publisher's source language. **The US, Canada, and Europe never contact Google** — those authorities stay first choice, key or no key — and a key failure surfaces loudly rather than degrading to a possibly-false all-clear.
312
318
  - International current conditions default to **model-interpolated** values at the exact coordinates. `source="metar"` returns a **real instrument reading** instead — but from the nearest airport, which may be tens of km away and up to an hour old. The output always names the station, its distance and bearing, and the observation age, so the tradeoff is visible rather than assumed. METAR coverage follows airports, so remote land and open ocean have real gaps.
313
319
  - Historical data older than 7 days comes from reanalysis models (9–25km grid), not direct station observations, and trails real time by ~5 days.
314
320
  - Non-US wildfire results are **satellite heat detections, not managed incidents** — no fire names, sizes, or containment percentages exist in the data, detections can include industrial heat sources or agricultural burns, and a clear result is not an all-clear (cloud cover hides fires; small or new fires evade detection). The output frames all of this explicitly. Keyless data covers the last 24 hours; an optional free [`FIRMS_MAP_KEY`](https://firms.modaps.eosdis.nasa.gov/api/map_key/) unlocks targeted queries and up to 5 days of detection history (`day_range`).
@@ -324,7 +330,7 @@ Being honest about what free public data can and can't do:
324
330
  ```bash
325
331
  npm run build # Compile TypeScript
326
332
  npm run dev # Run in development mode
327
- npm test # Run all 2,204 tests
333
+ npm test # Run all 2,274 tests
328
334
  npm run test:coverage # Coverage report
329
335
  npm run audit # Dependency vulnerability scan
330
336
  ```
@@ -77,4 +77,32 @@ export declare const GOOGLE_POLLEN_API_KEY: string | undefined;
77
77
  * Check if Google Pollen API key is available (key configured)
78
78
  */
79
79
  export declare function isGooglePollenKeyAvailable(): boolean;
80
+ /**
81
+ * Google Weather API key
82
+ *
83
+ * OPTIONAL: Create a key in the Google Cloud console
84
+ * (https://console.cloud.google.com/). Unlike the NCEI and FIRMS keys above,
85
+ * this one is **not a free registration**: it has a free usage tier, but
86
+ * **requires a Google Cloud billing account** (credit card on file).
87
+ * The **Weather API** must be enabled on the project.
88
+ *
89
+ * Benefits of providing a key:
90
+ * - Official weather alerts for ~45+ additional territories (Australia, Japan,
91
+ * Brazil, Mexico, and others) via the Google Weather API
92
+ *
93
+ * If not provided:
94
+ * - US, Canadian, and European alerts continue to work keyless
95
+ * - Alerts outside these regions are unavailable
96
+ * - No setup required
97
+ *
98
+ * Rate limits with key:
99
+ * - Per Google's Weather API free tier
100
+ *
101
+ * Setup guide: docs/GOOGLE_WEATHER_KEY_SETUP.md
102
+ */
103
+ export declare const GOOGLE_WEATHER_API_KEY: string | undefined;
104
+ /**
105
+ * Check if Google Weather API key is available (key configured)
106
+ */
107
+ export declare function isGoogleWeatherKeyAvailable(): boolean;
80
108
  //# sourceMappingURL=api.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/config/api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,cAAc,oBAA6B,CAAC;AAEzD;;GAEG;AACH,wBAAgB,eAAe,IAAI,OAAO,CAEzC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,aAAa,oBAA4B,CAAC;AAEvD;;GAEG;AACH,wBAAgB,mBAAmB,IAAI,OAAO,CAE7C;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,qBAAqB,oBAAoC,CAAC;AAEvE;;GAEG;AACH,wBAAgB,0BAA0B,IAAI,OAAO,CAEpD"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/config/api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,cAAc,oBAA6B,CAAC;AAEzD;;GAEG;AACH,wBAAgB,eAAe,IAAI,OAAO,CAEzC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,aAAa,oBAA4B,CAAC;AAEvD;;GAEG;AACH,wBAAgB,mBAAmB,IAAI,OAAO,CAE7C;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,qBAAqB,oBAAoC,CAAC;AAEvE;;GAEG;AACH,wBAAgB,0BAA0B,IAAI,OAAO,CAEpD;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,sBAAsB,oBAAqC,CAAC;AAEzE;;GAEG;AACH,wBAAgB,2BAA2B,IAAI,OAAO,CAErD"}
@@ -83,4 +83,34 @@ export const GOOGLE_POLLEN_API_KEY = process.env.GOOGLE_POLLEN_API_KEY;
83
83
  export function isGooglePollenKeyAvailable() {
84
84
  return !!GOOGLE_POLLEN_API_KEY && GOOGLE_POLLEN_API_KEY.trim().length > 0;
85
85
  }
86
+ /**
87
+ * Google Weather API key
88
+ *
89
+ * OPTIONAL: Create a key in the Google Cloud console
90
+ * (https://console.cloud.google.com/). Unlike the NCEI and FIRMS keys above,
91
+ * this one is **not a free registration**: it has a free usage tier, but
92
+ * **requires a Google Cloud billing account** (credit card on file).
93
+ * The **Weather API** must be enabled on the project.
94
+ *
95
+ * Benefits of providing a key:
96
+ * - Official weather alerts for ~45+ additional territories (Australia, Japan,
97
+ * Brazil, Mexico, and others) via the Google Weather API
98
+ *
99
+ * If not provided:
100
+ * - US, Canadian, and European alerts continue to work keyless
101
+ * - Alerts outside these regions are unavailable
102
+ * - No setup required
103
+ *
104
+ * Rate limits with key:
105
+ * - Per Google's Weather API free tier
106
+ *
107
+ * Setup guide: docs/GOOGLE_WEATHER_KEY_SETUP.md
108
+ */
109
+ export const GOOGLE_WEATHER_API_KEY = process.env.GOOGLE_WEATHER_API_KEY;
110
+ /**
111
+ * Check if Google Weather API key is available (key configured)
112
+ */
113
+ export function isGoogleWeatherKeyAvailable() {
114
+ return !!GOOGLE_WEATHER_API_KEY && GOOGLE_WEATHER_API_KEY.trim().length > 0;
115
+ }
86
116
  //# sourceMappingURL=api.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/config/api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC;AAEzD;;GAEG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,CAAC,CAAC,cAAc,IAAI,cAAc,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC;AAEvD;;GAEG;AACH,MAAM,UAAU,mBAAmB;IACjC,OAAO,CAAC,CAAC,aAAa,IAAI,aAAa,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC5D,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC;AAEvE;;GAEG;AACH,MAAM,UAAU,0BAA0B;IACxC,OAAO,CAAC,CAAC,qBAAqB,IAAI,qBAAqB,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC5E,CAAC"}
1
+ {"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/config/api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC;AAEzD;;GAEG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,CAAC,CAAC,cAAc,IAAI,cAAc,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC;AAEvD;;GAEG;AACH,MAAM,UAAU,mBAAmB;IACjC,OAAO,CAAC,CAAC,aAAa,IAAI,aAAa,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC5D,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC;AAEvE;;GAEG;AACH,MAAM,UAAU,0BAA0B;IACxC,OAAO,CAAC,CAAC,qBAAqB,IAAI,qBAAqB,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC5E,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,OAAO,CAAC,GAAG,CAAC,sBAAsB,CAAC;AAEzE;;GAEG;AACH,MAAM,UAAU,2BAA2B;IACzC,OAAO,CAAC,CAAC,sBAAsB,IAAI,sBAAsB,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC9E,CAAC"}
@@ -5,7 +5,14 @@
5
5
  * US → NOAA (the original path, byte-identical output),
6
6
  * Canada → MSC GeoMet (Environment and Climate Change Canada),
7
7
  * MeteoAlarm member countries → the country's MeteoAlarm feed,
8
- * elsewhere → a clean not-covered message.
8
+ * elsewhere → the optional keyed Google Weather API fallback when a
9
+ * `GOOGLE_WEATHER_API_KEY` is configured, else a clean not-covered
10
+ * message (see docs/global-alerts-fallback-plan.md D1).
11
+ *
12
+ * The branch order is the invariant: a US, Canadian, or MeteoAlarm-country
13
+ * request never contacts Google, key or no key — those are jurisdictional
14
+ * authorities and stay first-choice. Without a key the elsewhere branch is
15
+ * byte-identical to before the fallback existed.
9
16
  *
10
17
  * Country resolution order: a `country_code` already carried by the resolved
11
18
  * location (saved location or geocoded city_name), else a cached
@@ -21,12 +28,13 @@ import { GeocodingService } from '../services/geocoding.js';
21
28
  import { MeteoAlarmService } from '../services/meteoalarm.js';
22
29
  import { GeoMetService } from '../services/geomet.js';
23
30
  import { NominatimService } from '../services/nominatim.js';
31
+ import { GoogleWeatherService } from '../services/googleWeather.js';
24
32
  type HandlerResult = {
25
33
  content: Array<{
26
34
  type: string;
27
35
  text: string;
28
36
  }>;
29
37
  };
30
- export declare function handleGetAlerts(args: unknown, noaaService: NOAAService, locationStore: LocationStore, geocodingService: GeocodingService, meteoAlarmService?: MeteoAlarmService, geoMetService?: GeoMetService, nominatimService?: NominatimService): Promise<HandlerResult>;
38
+ export declare function handleGetAlerts(args: unknown, noaaService: NOAAService, locationStore: LocationStore, geocodingService: GeocodingService, meteoAlarmService?: MeteoAlarmService, geoMetService?: GeoMetService, nominatimService?: NominatimService, googleWeatherService?: GoogleWeatherService): Promise<HandlerResult>;
31
39
  export {};
32
40
  //# sourceMappingURL=alertsHandler.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"alertsHandler.d.ts","sourceRoot":"","sources":["../../src/handlers/alertsHandler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EACL,iBAAiB,EAGlB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAuB5D,KAAK,aAAa,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC;AAMxE,wBAAsB,eAAe,CACnC,IAAI,EAAE,OAAO,EACb,WAAW,EAAE,WAAW,EACxB,aAAa,EAAE,aAAa,EAC5B,gBAAgB,EAAE,gBAAgB,EAClC,iBAAiB,CAAC,EAAE,iBAAiB,EACrC,aAAa,CAAC,EAAE,aAAa,EAC7B,gBAAgB,CAAC,EAAE,gBAAgB,GAClC,OAAO,CAAC,aAAa,CAAC,CAsDxB"}
1
+ {"version":3,"file":"alertsHandler.d.ts","sourceRoot":"","sources":["../../src/handlers/alertsHandler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EACL,iBAAiB,EAGlB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAC;AAuBpE,KAAK,aAAa,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC;AAMxE,wBAAsB,eAAe,CACnC,IAAI,EAAE,OAAO,EACb,WAAW,EAAE,WAAW,EACxB,aAAa,EAAE,aAAa,EAC5B,gBAAgB,EAAE,gBAAgB,EAClC,iBAAiB,CAAC,EAAE,iBAAiB,EACrC,aAAa,CAAC,EAAE,aAAa,EAC7B,gBAAgB,CAAC,EAAE,gBAAgB,EACnC,oBAAoB,CAAC,EAAE,oBAAoB,GAC1C,OAAO,CAAC,aAAa,CAAC,CAsExB"}
@@ -5,7 +5,14 @@
5
5
  * US → NOAA (the original path, byte-identical output),
6
6
  * Canada → MSC GeoMet (Environment and Climate Change Canada),
7
7
  * MeteoAlarm member countries → the country's MeteoAlarm feed,
8
- * elsewhere → a clean not-covered message.
8
+ * elsewhere → the optional keyed Google Weather API fallback when a
9
+ * `GOOGLE_WEATHER_API_KEY` is configured, else a clean not-covered
10
+ * message (see docs/global-alerts-fallback-plan.md D1).
11
+ *
12
+ * The branch order is the invariant: a US, Canadian, or MeteoAlarm-country
13
+ * request never contacts Google, key or no key — those are jurisdictional
14
+ * authorities and stay first-choice. Without a key the elsewhere branch is
15
+ * byte-identical to before the fallback existed.
9
16
  *
10
17
  * Country resolution order: a `country_code` already carried by the resolved
11
18
  * location (saved location or geocoded city_name), else a cached
@@ -24,7 +31,7 @@ import { logger } from '../utils/logger.js';
24
31
  /** Display caps for the international renderers (country feeds routinely carry 50+ warnings). */
25
32
  const STANDARD_DISPLAY_CAP = 10;
26
33
  const FULL_DISPLAY_CAP = 25;
27
- export async function handleGetAlerts(args, noaaService, locationStore, geocodingService, meteoAlarmService, geoMetService, nominatimService) {
34
+ export async function handleGetAlerts(args, noaaService, locationStore, geocodingService, meteoAlarmService, geoMetService, nominatimService, googleWeatherService) {
28
35
  // Resolve location from coordinates, a saved location name, or a geocoded city name
29
36
  const resolved = await resolveLocationAsync(args, locationStore, geocodingService);
30
37
  const { latitude, longitude } = resolved;
@@ -67,6 +74,14 @@ export async function handleGetAlerts(args, noaaService, locationStore, geocodin
67
74
  if (!countryCode && isInUS(latitude, longitude)) {
68
75
  return handleNoaaAlerts(resolved, noaaService, active_only, detail);
69
76
  }
77
+ // 4. Elsewhere (D1): the optional keyed Google Weather API fallback. No
78
+ // client-side country allowlist — Google answers the coverage question
79
+ // per request, and a hardcoded list would drift and mis-gate border
80
+ // regions. Reaching this line already proves the point is not US,
81
+ // Canadian, or MeteoAlarm-covered.
82
+ if (googleWeatherService && googleWeatherService.isKeyAvailable()) {
83
+ return handleGoogleAlerts(resolved, googleWeatherService, countryCode, active_only, detail, reverseLookupFailed);
84
+ }
70
85
  return notCoveredResult(resolved, countryCode, reverseLookupFailed);
71
86
  }
72
87
  /**
@@ -220,8 +235,12 @@ function capSeverityEmoji(severity) {
220
235
  function capitalize(value) {
221
236
  return value.charAt(0).toUpperCase() + value.slice(1);
222
237
  }
223
- /** "…and N more warnings, mostly Minor" remainder line for capped lists. */
224
- function remainderNote(remainder) {
238
+ /**
239
+ * "…and N more warnings, mostly Minor" remainder line for capped lists.
240
+ * Typed on the only field it reads so every CAP-shaped renderer can share it;
241
+ * `noun` defaults to the MeteoAlarm wording, keeping that output unchanged.
242
+ */
243
+ function remainderNote(remainder, noun = 'warning') {
225
244
  const counts = new Map();
226
245
  for (const warning of remainder) {
227
246
  const severity = warning.severity ?? 'Unknown';
@@ -236,7 +255,7 @@ function remainderNote(remainder) {
236
255
  }
237
256
  }
238
257
  const plural = remainder.length > 1 ? 's' : '';
239
- return `*…and ${remainder.length} more warning${plural}, mostly ${top}. Use detail="full" to see more.*\n\n`;
258
+ return `*…and ${remainder.length} more ${noun}${plural}, mostly ${top}. Use detail="full" to see more.*\n\n`;
240
259
  }
241
260
  /**
242
261
  * MeteoAlarm (Europe) renderer — CAP-shaped, so visually close to the NOAA
@@ -459,6 +478,296 @@ async function handleGeoMetAlerts(resolved, geoMetService, active_only, detail)
459
478
  content: [{ type: 'text', text: output }]
460
479
  }, resolved);
461
480
  }
481
+ /**
482
+ * Coverage caveat shown on **both** empty and non-empty Google results (D5/D6).
483
+ * One constant serves both so the two paths cannot drift apart, and it does
484
+ * double duty: it explains why an alert may not match the exact point, and why
485
+ * an empty answer is "none found" rather than a guarantee of coverage.
486
+ */
487
+ const GOOGLE_COVERAGE_CAVEAT = `*Coverage note: Google aggregates official national alert feeds across ~45+ territories and ` +
488
+ `matches them by provider polygon, so country and region coverage alignment may not be exact — ` +
489
+ `warnings may not correspond to your exact point, and an empty result means no alerts were ` +
490
+ `found rather than a guarantee that this location is covered.*\n\n`;
491
+ /**
492
+ * Coverage caveat for the uncovered-region answer (HTTP 404). The shared
493
+ * caveat above is written for a location Google *does* serve, and its closing
494
+ * clause ("an empty result means no alerts were found") would be actively
495
+ * misleading here — nothing was searched at all.
496
+ */
497
+ const GOOGLE_UNCOVERED_CAVEAT = `*Coverage note: Google aggregates official national alert feeds across ~45+ territories, ` +
498
+ `and this location falls outside that aggregation. Alerts for this area may still be ` +
499
+ `published directly by the responsible national weather service.*\n\n`;
500
+ /**
501
+ * Google Weather API footer. The final sentence is the **exact mandatory
502
+ * attribution string** required by the Weather API policies — do not reword it
503
+ * (layer 1 of the two-layer attribution; layer 2 is the per-alert `dataSource`
504
+ * line rendered above).
505
+ */
506
+ const GOOGLE_FOOTER = `---\n` +
507
+ `*Data source: official national weather services, aggregated by the Google Weather API. ` +
508
+ `Alert text is shown in its source language, as issued. ` +
509
+ `Source: Includes weather data from Google*\n`;
510
+ /**
511
+ * `alertTitle` is documented as an object but its exact shape is a live
512
+ * to-verify (design upstream (g)), so both a plain string and a `{ text }`
513
+ * object are accepted. Anything else falls through to the caller's fallback.
514
+ */
515
+ function googleAlertTitle(alert) {
516
+ const title = alert.alertTitle;
517
+ if (typeof title === 'string' && title.trim().length > 0) {
518
+ return title;
519
+ }
520
+ if (title && typeof title === 'object' && typeof title.text === 'string' && title.text.trim().length > 0) {
521
+ return title.text;
522
+ }
523
+ return humanizeEventType(alert.eventType) ?? 'Weather alert';
524
+ }
525
+ /**
526
+ * Google publishes CAP-family enums in SCREAMING_CASE (`"MINOR"`,
527
+ * `"EXPECTED"`, `"POSSIBLE"`) — **live-verified 2026-08-18**, and contrary to
528
+ * the design's assumption that "Google's severity enum is exactly NOAA's".
529
+ * It is the same vocabulary in different casing, so it is normalized to the
530
+ * project's title-case form once, here, before it reaches `capSeverityRank`
531
+ * or `capSeverityEmoji` — which would otherwise rank every alert `Unknown`
532
+ * and mark every one ⚪.
533
+ */
534
+ function capEnumValue(value) {
535
+ if (!value || value.trim().length === 0) {
536
+ return undefined;
537
+ }
538
+ const cleaned = value.trim().toLowerCase().replace(/_/g, ' ');
539
+ return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
540
+ }
541
+ /** `FLASH_FLOOD` → `Flash Flood`. Returns undefined for an absent/blank enum value. */
542
+ function humanizeEventType(eventType) {
543
+ if (!eventType || eventType.trim().length === 0) {
544
+ return undefined;
545
+ }
546
+ return eventType
547
+ .toLowerCase()
548
+ .split(/[_\s]+/)
549
+ .filter(part => part.length > 0)
550
+ .map(capitalize)
551
+ .join(' ');
552
+ }
553
+ /**
554
+ * Parse Google's `timezoneOffset` into minutes east of UTC.
555
+ *
556
+ * **Live-verified 2026-08-18:** it is a protobuf *Duration in seconds*
557
+ * (`"28800s"` for UTC+08:00), not the `±HH:MM` the design plan assumed. The
558
+ * `±HH:MM` form is still accepted defensively, since misreading an offset
559
+ * silently shifts a safety-critical expiry time rather than failing loudly.
560
+ */
561
+ function parseTimezoneOffsetMinutes(timezoneOffset) {
562
+ if (!timezoneOffset) {
563
+ return undefined;
564
+ }
565
+ const raw = timezoneOffset.trim();
566
+ const secondsMatch = /^(-?\d+)s$/.exec(raw);
567
+ if (secondsMatch) {
568
+ return Number(secondsMatch[1]) / 60;
569
+ }
570
+ const hhmmMatch = /^([+-])(\d{2}):?(\d{2})$/.exec(raw);
571
+ if (hhmmMatch) {
572
+ const [, sign, hours, minutes] = hhmmMatch;
573
+ return (sign === '-' ? -1 : 1) * (Number(hours) * 60 + Number(minutes));
574
+ }
575
+ return undefined;
576
+ }
577
+ /**
578
+ * Render a UTC instant in the alert's own timezone offset — the "times as
579
+ * issued" doctrine applied to a source that publishes the instant and the
580
+ * offset in separate fields. Falls back to `formatPublishedTime` (which
581
+ * renders the instant as published) when the offset is absent or unparseable.
582
+ * Deliberately does all arithmetic in UTC so the rendered string never depends
583
+ * on the server's own timezone.
584
+ */
585
+ function formatGoogleAlertTime(iso, timezoneOffset) {
586
+ if (!iso) {
587
+ return undefined;
588
+ }
589
+ const offsetMinutes = parseTimezoneOffsetMinutes(timezoneOffset);
590
+ const instant = Date.parse(iso);
591
+ if (offsetMinutes === undefined || Number.isNaN(instant)) {
592
+ return formatPublishedTime(iso);
593
+ }
594
+ const shifted = new Date(instant + offsetMinutes * 60_000);
595
+ const pad = (value) => String(value).padStart(2, '0');
596
+ const sign = offsetMinutes < 0 ? '-' : '+';
597
+ const absolute = Math.abs(offsetMinutes);
598
+ const label = `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`;
599
+ const date = `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`;
600
+ const time = `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}`;
601
+ return `${date} ${time} (${label})`;
602
+ }
603
+ /**
604
+ * Google Weather API renderer (D5) — the fourth CAP-shaped renderer, kept
605
+ * deliberately close to the MeteoAlarm one and reusing the same severity rank,
606
+ * emoji, display caps, and remainder note (Google's severity enum is exactly
607
+ * NOAA's).
608
+ *
609
+ * **Failure posture is contract, not garnish (D6):** alerts *are* this tool's
610
+ * whole answer, so nothing here catches a service error. A rejected key, a
611
+ * timeout, a 429, or a network failure propagates with the service's fixed
612
+ * sanitized message, exactly as a GeoMet or MeteoAlarm failure surfaces today.
613
+ * A silent "✅ no alerts" produced by a failed fetch would be a dangerous lie
614
+ * on safety data.
615
+ */
616
+ async function handleGoogleAlerts(resolved, googleWeatherService, countryCode, active_only, detail, reverseLookupFailed) {
617
+ const { alerts, covered } = await googleWeatherService.getPublicAlerts(resolved.latitude, resolved.longitude);
618
+ const regionName = countryCode ? regionDisplayName(countryCode) : null;
619
+ let output = regionName ? `# Weather Alerts — ${regionName}\n\n` : `# Weather Alerts\n\n`;
620
+ output += `**Location:** ${resolved.latitude.toFixed(4)}, ${resolved.longitude.toFixed(4)}\n\n`;
621
+ if (reverseLookupFailed) {
622
+ output += `*Note: the country lookup service was unavailable, so routing fell back to coordinate checks.*\n\n`;
623
+ }
624
+ if (!active_only) {
625
+ output += `*Note: historical alerts are not available for this region — showing current alerts only.*\n\n`;
626
+ }
627
+ if (alerts.length === 0 && !covered) {
628
+ // Google answered "I do not cover this place" (HTTP 404). Nothing was
629
+ // checked, so there is nothing to be reassured by: no ✅, and the text
630
+ // says out loud that this is not an all-clear. The premise behind the
631
+ // original single message — that Google returns the same shape for
632
+ // "quiet" and "uncovered" — was falsified by live testing.
633
+ output += `ℹ️ **No alert coverage for this location.**\n\n`;
634
+ output += `The Google Weather API does not cover this area, so **this is not an all-clear** — `;
635
+ output += `no check for active weather could be made here. A national or local weather service `;
636
+ output += `may still be issuing warnings.\n\n`;
637
+ }
638
+ else if (alerts.length === 0) {
639
+ // Honest empty (D6): a covered region with nothing active. The message
640
+ // still credits the source rather than claiming a bare all-clear — the
641
+ // FIRMS empty-result framing.
642
+ output += `✅ **No active weather alerts found for this location via the Google Weather API.**\n\n`;
643
+ }
644
+ else {
645
+ output += `⚠️ **${alerts.length} active alert${alerts.length > 1 ? 's' : ''} found**\n\n`;
646
+ // Sort by CAP severity (the NOAA Extreme→Unknown order), then by expiry.
647
+ const sorted = [...alerts].sort((a, b) => {
648
+ const bySeverity = capSeverityRank(capEnumValue(a.severity)) - capSeverityRank(capEnumValue(b.severity));
649
+ if (bySeverity !== 0) {
650
+ return bySeverity;
651
+ }
652
+ const aExpires = a.expirationTime ? new Date(a.expirationTime).getTime() : Infinity;
653
+ const bExpires = b.expirationTime ? new Date(b.expirationTime).getTime() : Infinity;
654
+ return aExpires - bExpires;
655
+ });
656
+ if (detail === 'summary') {
657
+ const bySeverity = new Map();
658
+ const byEvent = new Map();
659
+ for (const alert of sorted) {
660
+ const severity = capEnumValue(alert.severity) ?? 'Unknown';
661
+ bySeverity.set(severity, (bySeverity.get(severity) ?? 0) + 1);
662
+ const event = humanizeEventType(alert.eventType);
663
+ if (event) {
664
+ byEvent.set(event, (byEvent.get(event) ?? 0) + 1);
665
+ }
666
+ }
667
+ output += `**By severity:** ${[...bySeverity.entries()]
668
+ .map(([severity, count]) => `${severity}: ${count}`)
669
+ .join(' | ')}\n`;
670
+ if (byEvent.size > 0) {
671
+ output += `**By type:** ${[...byEvent.entries()]
672
+ .map(([event, count]) => `${event}: ${count}`)
673
+ .join(' | ')}\n`;
674
+ }
675
+ output += `\n*Counts only at detail="summary". Use detail="standard" or detail="full" for the alerts themselves.*\n\n`;
676
+ }
677
+ else {
678
+ const cap = detail === 'full' ? FULL_DISPLAY_CAP : STANDARD_DISPLAY_CAP;
679
+ const shown = sorted.slice(0, cap);
680
+ const remainder = sorted.slice(cap);
681
+ for (const alert of shown) {
682
+ // The event-type suffix renders only when it says something the title
683
+ // does not already say — publishers routinely title an alert
684
+ // "Severe Thunderstorm Warning", and "(Severe Thunderstorm)" beside it
685
+ // is noise. (Found by reading real rendered output, not a failing test.)
686
+ const title = googleAlertTitle(alert);
687
+ const event = humanizeEventType(alert.eventType);
688
+ const eventSuffix = event && !title.toLowerCase().includes(event.toLowerCase()) ? ` (${event})` : '';
689
+ const severity = capEnumValue(alert.severity);
690
+ const urgency = capEnumValue(alert.urgency);
691
+ const certainty = capEnumValue(alert.certainty);
692
+ output += `${capSeverityEmoji(severity)} **${title}**${eventSuffix}\n`;
693
+ output += `---\n`;
694
+ // Live sampling found publishers that omit all three CAP fields; a line
695
+ // reading "Unknown | Unknown | Unknown" is noise, so it renders only
696
+ // when at least one is actually supplied.
697
+ if (severity || urgency || certainty) {
698
+ output += `**Severity:** ${severity ?? 'Unknown'} | **Urgency:** ${urgency ?? 'Unknown'} | **Certainty:** ${certainty ?? 'Unknown'}\n`;
699
+ }
700
+ if (alert.areaName) {
701
+ output += `**Area:** ${alert.areaName}\n`;
702
+ }
703
+ // Times render in the alert's own offset; a null expirationTime
704
+ // (documented as possible) simply omits its line.
705
+ const effective = formatGoogleAlertTime(alert.startTime, alert.timezoneOffset);
706
+ const expires = formatGoogleAlertTime(alert.expirationTime, alert.timezoneOffset);
707
+ if (effective) {
708
+ output += `**Effective:** ${effective}\n`;
709
+ }
710
+ if (expires) {
711
+ output += `**Expires:** ${expires}\n`;
712
+ }
713
+ // Source text renders verbatim, in the publisher's language: the full
714
+ // description at full only, instructions at standard+full, safety
715
+ // recommendations at full — the NOAA detail contract.
716
+ if (detail === 'full' && alert.description) {
717
+ output += `\n**Description:**\n${alert.description}\n`;
718
+ }
719
+ if (alert.instruction && alert.instruction.length > 0) {
720
+ output += `\n**Instructions:**\n${alert.instruction.map(line => `- ${line}`).join('\n')}\n`;
721
+ }
722
+ // Live-verified as objects (`{ directive, subtext }`), not strings.
723
+ // The subtext is the publisher's own expansion of the directive, so it
724
+ // renders alongside rather than being dropped.
725
+ if (detail === 'full' && alert.safetyRecommendations && alert.safetyRecommendations.length > 0) {
726
+ const recommendations = alert.safetyRecommendations
727
+ .map(item => {
728
+ const directive = item?.directive?.trim();
729
+ const subtext = item?.subtext?.trim();
730
+ if (!directive && !subtext) {
731
+ return undefined;
732
+ }
733
+ if (directive && subtext) {
734
+ return `- ${directive} ${subtext}`;
735
+ }
736
+ return `- ${directive ?? subtext}`;
737
+ })
738
+ .filter((line) => line !== undefined);
739
+ if (recommendations.length > 0) {
740
+ output += `\n**Safety recommendations:**\n${recommendations.join('\n')}\n`;
741
+ }
742
+ }
743
+ // Layer 2 of the mandatory attribution (upstream (d)): the original
744
+ // publisher of this alert, with its authority URI.
745
+ // Live-verified shape: `{ publisher, name, authorityUri }` — there is no
746
+ // `fullName`. `name` is the human-readable short form ("PAGASA");
747
+ // `publisher` is the enum form, used only as a fallback.
748
+ const publisher = alert.dataSource?.name ?? alert.dataSource?.publisher;
749
+ if (publisher) {
750
+ const uri = alert.dataSource?.authorityUri ? ` (${alert.dataSource.authorityUri})` : '';
751
+ output += `\n**Source:** ${publisher}${uri}\n`;
752
+ }
753
+ output += `\n`;
754
+ }
755
+ if (remainder.length > 0) {
756
+ // Normalized here too, so the remainder line reads "mostly Minor"
757
+ // rather than echoing Google's raw "MINOR".
758
+ output += remainderNote(remainder.map(alert => ({ severity: capEnumValue(alert.severity) })), 'alert');
759
+ }
760
+ if (detail !== 'full') {
761
+ output += `*Showing standard detail. Use detail="full" for complete alert descriptions.*\n\n`;
762
+ }
763
+ }
764
+ }
765
+ output += covered ? GOOGLE_COVERAGE_CAVEAT : GOOGLE_UNCOVERED_CAVEAT;
766
+ output += GOOGLE_FOOTER;
767
+ return prependLocationLine({
768
+ content: [{ type: 'text', text: output }]
769
+ }, resolved);
770
+ }
462
771
  /**
463
772
  * The clean not-covered message (D1): names the region, states the current
464
773
  * coverage, and never surfaces a wrong-country upstream error. The one-line