@dangahagan/weather-mcp 1.18.0 β†’ 1.19.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.
Files changed (56) hide show
  1. package/README.md +21 -9
  2. package/dist/config/cache.d.ts +3 -0
  3. package/dist/config/cache.d.ts.map +1 -1
  4. package/dist/config/cache.js +18 -0
  5. package/dist/config/cache.js.map +1 -1
  6. package/dist/handlers/alertsHandler.d.ts +22 -3
  7. package/dist/handlers/alertsHandler.d.ts.map +1 -1
  8. package/dist/handlers/alertsHandler.js +375 -2
  9. package/dist/handlers/alertsHandler.js.map +1 -1
  10. package/dist/handlers/weatherImageryHandler.d.ts +7 -5
  11. package/dist/handlers/weatherImageryHandler.d.ts.map +1 -1
  12. package/dist/handlers/weatherImageryHandler.js +187 -9
  13. package/dist/handlers/weatherImageryHandler.js.map +1 -1
  14. package/dist/handlers/weatherSummaryHandler.d.ts +4 -1
  15. package/dist/handlers/weatherSummaryHandler.d.ts.map +1 -1
  16. package/dist/handlers/weatherSummaryHandler.js +6 -14
  17. package/dist/handlers/weatherSummaryHandler.js.map +1 -1
  18. package/dist/index.js +24 -5
  19. package/dist/index.js.map +1 -1
  20. package/dist/services/basemap.d.ts +100 -0
  21. package/dist/services/basemap.d.ts.map +1 -0
  22. package/dist/services/basemap.js +171 -0
  23. package/dist/services/basemap.js.map +1 -0
  24. package/dist/services/geomet.d.ts +66 -0
  25. package/dist/services/geomet.d.ts.map +1 -0
  26. package/dist/services/geomet.js +194 -0
  27. package/dist/services/geomet.js.map +1 -0
  28. package/dist/services/meteoalarm.d.ts +125 -0
  29. package/dist/services/meteoalarm.d.ts.map +1 -0
  30. package/dist/services/meteoalarm.js +348 -0
  31. package/dist/services/meteoalarm.js.map +1 -0
  32. package/dist/services/nominatim.d.ts +16 -0
  33. package/dist/services/nominatim.d.ts.map +1 -1
  34. package/dist/services/nominatim.js +44 -1
  35. package/dist/services/nominatim.js.map +1 -1
  36. package/dist/types/geomet.d.ts +77 -0
  37. package/dist/types/geomet.d.ts.map +1 -0
  38. package/dist/types/geomet.js +20 -0
  39. package/dist/types/geomet.js.map +1 -0
  40. package/dist/types/imagery.d.ts +23 -0
  41. package/dist/types/imagery.d.ts.map +1 -1
  42. package/dist/types/meteoalarm.d.ts +110 -0
  43. package/dist/types/meteoalarm.d.ts.map +1 -0
  44. package/dist/types/meteoalarm.js +15 -0
  45. package/dist/types/meteoalarm.js.map +1 -0
  46. package/dist/types/nominatim.d.ts +18 -0
  47. package/dist/types/nominatim.d.ts.map +1 -1
  48. package/dist/utils/composite.d.ts +181 -0
  49. package/dist/utils/composite.d.ts.map +1 -0
  50. package/dist/utils/composite.js +329 -0
  51. package/dist/utils/composite.js.map +1 -0
  52. package/dist/utils/locationResolver.d.ts +14 -1
  53. package/dist/utils/locationResolver.d.ts.map +1 -1
  54. package/dist/utils/locationResolver.js +16 -6
  55. package/dist/utils/locationResolver.js.map +1 -1
  56. package/package.json +5 -2
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-1%2C675%20passing-brightgreen)](./docs/testing/TEST_SUITE_README.md)
6
+ [![Tests](https://img.shields.io/badge/tests-1%2C812%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.**
@@ -35,8 +35,11 @@ Then just ask:
35
35
  > *"Are there wildfires within 50 miles of my cabin?"*
36
36
  > *"Will there be a full moon this weekend?"*
37
37
  > *"Is this a record high for today?"*
38
+ > *"How bad is the pollen in Berlin today?"*
38
39
  > *"What was the weather in Paris on June 6, 1944?"*
39
40
 
41
+ πŸ‘‰ **[See it in action](./examples/)** β€” real prompts like these with the answers they produced, and the raw server output behind them.
42
+
40
43
  πŸ“¦ Listed in the [Official MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.dgahagan/weather-mcp) as `io.github.dgahagan/weather-mcp`.
41
44
 
42
45
  ## Why this server?
@@ -47,7 +50,7 @@ Choose this one if you want:
47
50
 
48
51
  - **Genuinely free** β€” every data source is a free public API. No trial that expires, no credit card, no rate-limited "free tier" bait.
49
52
  - **No API keys** β€” install to first forecast in under a minute. Nothing to configure, nothing to leak into a repo.
50
- - **Fully open source** β€” MIT licensed, readable TypeScript, 1,675 tests. Audit it, fork it, fix it.
53
+ - **Fully open source** β€” MIT licensed, readable TypeScript, 1,812 tests. Audit it, fork it, fix it.
51
54
  - **Privacy-respecting** β€” your queries go directly from your machine to public weather APIs. No middleman server, no telemetry.
52
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.
53
56
 
@@ -60,17 +63,17 @@ All 17 tools, documented in detail in **[docs/TOOLS.md](./docs/TOOLS.md)**:
60
63
  | Tool | What it does | Coverage |
61
64
  |------|-------------|----------|
62
65
  | `get_forecast` | Daily/hourly forecasts up to 16 days by coordinates, saved location, or city name; sunrise/sunset, UV, precipitation probability, optional climate-normals comparison, optional moon phase & twilight almanac | 🌍 Global |
63
- | `get_current_conditions` | Current weather: temperature, wind, humidity, pressure; NOAA station observations in the US (plus heat index/wind chill, snow depth, optional fire-weather indices), Open-Meteo model data elsewhere, or real airport station observations worldwide with `source="metar"` | 🌍 Global |
64
- | `get_alerts` | Active watches, warnings, and advisories sorted by severity | πŸ‡ΊπŸ‡Έ US |
66
+ | `get_current_conditions` | Current weather: temperature, wind, humidity, pressure; NOAA station observations in the US (plus heat index/wind chill, snow depth, optional fire-weather indices), Open-Meteo model data elsewhere, or real airport station observations worldwide with `source="metar"`. Always states the observation's age, and automatically falls back to a fresher nearby station when the nearest one has gone dark | 🌍 Global |
67
+ | `get_alerts` | Active watches, warnings, and advisories sorted by severity; official national warnings for the US (NOAA), Canada (ECCC), and 38 European countries (MeteoAlarm) | πŸ‡ΊπŸ‡Έ πŸ‡¨πŸ‡¦ πŸ‡ͺπŸ‡Ί |
65
68
  | `get_historical_weather` | Hourly/daily observations from 1940 to present | 🌍 Global |
66
69
  | `get_weather_summary` | One-call overview combining current conditions, forecast, and alerts (optionally air quality and lightning) | 🌍 Global |
67
70
  | `search_location` | Geocode place names to coordinates ("Paris" β†’ 48.85, 2.35) | 🌍 Global |
68
71
  | `get_air_quality` | AQI (US/European scales), pollutants, UV index, health guidance; current pollen levels for European locations; optional day-grouped forecast up to 7 days with per-day peak AQI and UV | 🌍 Global |
69
72
  | `get_marine_conditions` | Wave height, swell, ocean currents, Douglas Sea Scale β€” includes Great Lakes and major US bays; forecast up to 16 days | 🌍 Global |
70
- | `get_weather_imagery` | Precipitation radar (static or 2-hour animated loops) + GOES satellite imagery | 🌍 Global |
73
+ | `get_weather_imagery` | Precipitation radar (static or 2-hour animated loops) + GOES satellite imagery; `composite: true` returns a finished radar map over a base map as an image | 🌍 Global |
71
74
  | `get_lightning_activity` | Real-time strike detection with 4-level proximity safety assessment | 🌍 Global |
72
75
  | `get_river_conditions` | US: NWPS gauge levels, flood stages, streamflow, rise/fall trends, forecast series. Elsewhere: GloFAS modeled discharge snapped to the nearest river channel, with ensemble forecast | 🌍 Global |
73
- | `get_wildfire_info` | Active fires, containment, size, proximity-based safety guidance | πŸ‡ΊπŸ‡Έ US |
76
+ | `get_wildfire_info` | Active fires, containment, size, safety guidance from the nearest *uncontained* fire (fully contained fires are listed but don't drive danger levels) | πŸ‡ΊπŸ‡Έ US |
74
77
  | `check_service_status` | Health checks for all upstream APIs plus cache statistics | β€” |
75
78
  | `save_location` | Save places as aliases ("home", "cabin") with optional activity tags | β€” |
76
79
  | `list_saved_locations` | List all saved locations | β€” |
@@ -86,6 +89,7 @@ All 17 tools, documented in detail in **[docs/TOOLS.md](./docs/TOOLS.md)**:
86
89
  ## Feature highlights
87
90
 
88
91
  - **Smart source selection** β€” US queries use NOAA (detailed, includes forecaster narratives); everywhere else uses Open-Meteo. You never pick; it just works.
92
+ - **International weather alerts** β€” `get_alerts` routes by country: NOAA in the US, Environment and Climate Change Canada alerts in Canada, and the official national warnings of 38 European countries via EUMETNET MeteoAlarm β€” shown unmodified, with the issuing service credited. Border cities like Toronto get the right country's alerts, not the nearest bounding box's.
89
93
  - **Real observations worldwide** β€” ask for what a station is *actually reporting* and `get_current_conditions` will read the nearest airport's METAR (`source="metar"`): a genuine instrument reading anywhere on earth, with the station, its distance, and the observation age always stated. Outside the US this is the difference between a measurement and a model estimate.
90
94
  - **Saved locations** β€” save "home", "work", or "cabin" once, then ask *"what's the weather at home?"* Locations persist in `~/.weather-mcp/locations.json` and can be tagged with activities ("boating", "skiing") so the AI highlights what matters to you.
91
95
  - **Climate context** β€” optional 30-year climate normals show how today compares: *"10Β°F warmer than normal for this date."* For US locations the normals also carry the record high/low for the date and the year it was set: *"is this a record high?"*
@@ -107,13 +111,19 @@ All free, all public, no authentication required:
107
111
  | [Open-Meteo](https://open-meteo.com/) | Global forecasts, historical data (1940+), air quality, marine, geocoding, climate normals | Global |
108
112
  | [NOAA NWPS](https://water.noaa.gov/) | River levels, streamflow, flood stages | US |
109
113
  | [RCC ACIS](https://www.rcc-acis.org/) | Daily record high/low temperatures | US |
114
+ | [EUMETNET MeteoAlarm](https://meteoalarm.org/) | Official national weather warnings (38 European countries) | Europe |
115
+ | [MSC GeoMet](https://api.weather.gc.ca/) | Environment and Climate Change Canada weather alerts | Canada |
110
116
  | [NIFC WFIGS](https://data-nifc.opendata.arcgis.com/) | Active wildfire perimeters and incidents | US |
111
117
  | [RainViewer](https://www.rainviewer.com/api.html) | Precipitation radar imagery | Global |
112
- | [NASA GIBS](https://www.earthdata.nasa.gov/engage/open-data-services-software/earthdata-developer-portal/gibs-api) | GOES GeoColor satellite imagery | Western Hemisphere |
118
+ | [NASA GIBS](https://www.earthdata.nasa.gov/engage/open-data-services-software/earthdata-developer-portal/gibs-api) | GOES GeoColor satellite imagery; base map for composited radar | Western Hemisphere (satellite), Global (base map) |
113
119
  | [Blitzortung.org](https://www.blitzortung.org/) | Community lightning detection network | Global |
114
120
 
115
121
  Open-Meteo allows 10,000 requests/day for non-commercial use; caching keeps typical AI usage far below that. Please respect the upstream providers' fair-use terms.
116
122
 
123
+ Composited radar maps (`get_weather_imagery` with `composite: true`) carry the acknowledgment NASA requests for GIBS imagery, alongside the RainViewer radar credit:
124
+
125
+ > Imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS).
126
+
117
127
  ## Installation
118
128
 
119
129
  **Recommended β€” npx (always latest, nothing to manage):**
@@ -254,10 +264,11 @@ Being honest about what free public data can and can't do:
254
264
  | Historical weather (1940+) | βœ… (>7 days old) | Station-level detail for last 7 days |
255
265
  | Air quality, marine, radar, lightning | βœ… | β€” |
256
266
  | Current conditions | βœ… (model data, or real station observations via `source="metar"`) | Station observations via NOAA (richer detail) |
257
- | Weather alerts | ❌ | βœ… |
267
+ | Weather alerts | βœ… US, Canada, and 38 European countries | NWS zone-level precision; Europe is matched at country level |
258
268
  | River conditions | βœ… (GloFAS modeled discharge) | Gauge observations + official flood stages via NWPS |
259
269
  | Wildfires | ❌ | βœ… |
260
270
 
271
+ - 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.
261
272
  - 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.
262
273
  - Historical data older than 7 days comes from reanalysis models (9–25km grid), not direct station observations, and trails real time by ~5 days.
263
274
  - Non-US river discharge is **modeled**, not observed, and has no official flood-stage thresholds β€” levels are shown relative to recent history and the forecast ensemble. The ~5km model grid means the reported channel may be a few km from the requested point; the output says so when it is.
@@ -270,7 +281,7 @@ Being honest about what free public data can and can't do:
270
281
  ```bash
271
282
  npm run build # Compile TypeScript
272
283
  npm run dev # Run in development mode
273
- npm test # Run all 1,675 tests
284
+ npm test # Run all 1,812 tests
274
285
  npm run test:coverage # Coverage report
275
286
  npm run audit # Dependency vulnerability scan
276
287
  ```
@@ -294,6 +305,7 @@ To report a vulnerability, see [SECURITY.md](./SECURITY.md).
294
305
 
295
306
  ## Documentation
296
307
 
308
+ - **[Examples](./examples/)** β€” realistic sessions in cities around the world: prompt β†’ assistant answer β†’ verbatim server output
297
309
  - **[Tool Reference](./docs/TOOLS.md)** β€” all 17 tools: parameters, examples, sample output
298
310
  - **[Client Setup](./docs/CLIENT_SETUP.md)** β€” step-by-step for 8 MCP clients
299
311
  - **[Error Handling](./docs/ERROR_HANDLING.md)** β€” how failures are reported
@@ -25,6 +25,9 @@ export declare const CacheConfig: {
25
25
  readonly records: number;
26
26
  readonly recordsStation: number;
27
27
  readonly metarObservations: number;
28
+ readonly basemapTiles: number;
29
+ readonly compositeImage: number;
30
+ readonly reverseCountry: number;
28
31
  };
29
32
  };
30
33
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/config/cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAkDH,eAAO,MAAM,WAAW;aAEtB,OAAO;aAIP,OAAO;aAIP,YAAY;aAGZ,GAAG;iBAGD,eAAe;iBAIf,SAAS;iBAIT,QAAQ;iBAIR,QAAQ;iBAIR,iBAAiB;iBAIjB,MAAM;iBAIN,gBAAgB;iBAIhB,cAAc;iBAId,aAAa;iBAMb,cAAc;iBAMd,OAAO;iBAIP,cAAc;iBAMd,iBAAiB;;CAEX,CAAC;AAEX;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAYrE"}
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/config/cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAkDH,eAAO,MAAM,WAAW;aAEtB,OAAO;aAIP,OAAO;aAIP,YAAY;aAGZ,GAAG;iBAGD,eAAe;iBAIf,SAAS;iBAIT,QAAQ;iBAIR,QAAQ;iBAIR,iBAAiB;iBAIjB,MAAM;iBAIN,gBAAgB;iBAIhB,cAAc;iBAId,aAAa;iBAMb,cAAc;iBAMd,OAAO;iBAIP,cAAc;iBAMd,iBAAiB;iBAQjB,YAAY;iBAQZ,cAAc;iBAKd,cAAc;;CAER,CAAC;AAEX;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAYrE"}
@@ -106,6 +106,24 @@ export const CacheConfig = {
106
106
  // burst of calls into one fetch without ever serving a response across a
107
107
  // reporting cycle.
108
108
  metarObservations: 10 * MINUTE,
109
+ // NASA GIBS basemap tiles (OSM_Land_Water_Map, Reference_Features_15m),
110
+ // cached individually by raw tile buffer. These are near-static
111
+ // reference layers β€” land/water boundaries and coastline/border outlines
112
+ // don't move on any timescale that matters to a weather query β€” so cache
113
+ // aggressively; 24h balances that staticness against not holding stale
114
+ // tiles indefinitely if a layer is ever revised upstream.
115
+ basemapTiles: 24 * HOUR,
116
+ // Composited radar-on-basemap output, keyed on frame path + tile +
117
+ // marker pixel. Radar frames are immutable once published under their
118
+ // timestamp, so this isn't about staleness β€” it's about not re-fetching
119
+ // and re-compositing on every call within a feed cycle; RainViewer's
120
+ // nowcast cadence is ~10 minutes, so a repeat call inside that window is
121
+ // still looking at the same latest frame.
122
+ compositeImage: 10 * MINUTE,
123
+ // Nominatim reverse geocoding β€” country resolution only (zoom=3). Keyed
124
+ // on coordinates rounded to ~1.1km; countries don't move, so once
125
+ // resolved (including a "no country" open-ocean result) it's permanent.
126
+ reverseCountry: Infinity,
109
127
  },
110
128
  };
111
129
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"cache.js","sourceRoot":"","sources":["../../src/config/cache.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"cache.js","sourceRoot":"","sources":["../../src/config/cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,iCAAiC;AACjC,MAAM,MAAM,GAAG,EAAE,GAAG,IAAI,CAAC;AACzB,MAAM,IAAI,GAAG,EAAE,GAAG,MAAM,CAAC;AACzB,MAAM,GAAG,GAAG,EAAE,GAAG,IAAI,CAAC;AAEtB;;;;;GAKG;AACH,SAAS,aAAa,CAAC,GAAW,EAAE,YAAqB;IACvD,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,YAAY,CAAC;IAC7C,OAAO,KAAK,KAAK,OAAO,IAAI,KAAK,KAAK,GAAG,CAAC;AAC5C,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,YAAY,CAAC,GAAW,EAAE,YAAoB,EAAE,GAAY,EAAE,GAAY;IACjF,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,YAAY,CAAC;IAE7C,MAAM,MAAM,GAAG,QAAQ,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACnC,IAAI,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC;QAClB,OAAO,CAAC,IAAI,CAAC,WAAW,GAAG,MAAM,KAAK,qBAAqB,YAAY,EAAE,CAAC,CAAC;QAC3E,OAAO,YAAY,CAAC;IACtB,CAAC;IAED,IAAI,GAAG,KAAK,SAAS,IAAI,MAAM,GAAG,GAAG,EAAE,CAAC;QACtC,OAAO,CAAC,IAAI,CAAC,GAAG,GAAG,aAAa,MAAM,oBAAoB,GAAG,EAAE,CAAC,CAAC;QACjE,OAAO,GAAG,CAAC;IACb,CAAC;IAED,IAAI,GAAG,KAAK,SAAS,IAAI,MAAM,GAAG,GAAG,EAAE,CAAC;QACtC,OAAO,CAAC,IAAI,CAAC,GAAG,GAAG,cAAc,MAAM,oBAAoB,GAAG,EAAE,CAAC,CAAC;QAClE,OAAO,GAAG,CAAC;IACb,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,kCAAkC;IAClC,OAAO,EAAE,aAAa,CAAC,eAAe,EAAE,IAAI,CAAC;IAE7C,yDAAyD;IACzD,sCAAsC;IACtC,OAAO,EAAE,YAAY,CAAC,gBAAgB,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC;IAEzD,4BAA4B;IAC5B,oFAAoF;IACpF,YAAY,EAAE,YAAY,CAAC,gBAAgB,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,CAAC;IAEjE,sCAAsC;IACtC,GAAG,EAAE;QACH,oDAAoD;QACpD,wCAAwC;QACxC,eAAe,EAAE,QAAQ;QAEzB,6CAA6C;QAC7C,+CAA+C;QAC/C,SAAS,EAAE,QAAQ;QAEnB,wBAAwB;QACxB,yBAAyB;QACzB,QAAQ,EAAE,EAAE,GAAG,IAAI;QAEnB,kBAAkB;QAClB,8CAA8C;QAC9C,QAAQ,EAAE,CAAC,GAAG,IAAI;QAElB,6BAA6B;QAC7B,oDAAoD;QACpD,iBAAiB,EAAE,EAAE,GAAG,MAAM;QAE9B,iBAAiB;QACjB,sDAAsD;QACtD,MAAM,EAAE,CAAC,GAAG,MAAM;QAElB,wCAAwC;QACxC,6CAA6C;QAC7C,gBAAgB,EAAE,CAAC,GAAG,IAAI;QAE1B,kDAAkD;QAClD,6DAA6D;QAC7D,cAAc,EAAE,QAAQ;QAExB,8BAA8B;QAC9B,+BAA+B;QAC/B,aAAa,EAAE,CAAC,GAAG,MAAM;QAEzB,qDAAqD;QACrD,uEAAuE;QACvE,oEAAoE;QACpE,qEAAqE;QACrE,cAAc,EAAE,CAAC,GAAG,IAAI;QAExB,0EAA0E;QAC1E,wEAAwE;QACxE,qEAAqE;QACrE,yCAAyC;QACzC,OAAO,EAAE,CAAC,GAAG,GAAG;QAEhB,uEAAuE;QACvE,qEAAqE;QACrE,cAAc,EAAE,EAAE,GAAG,GAAG;QAExB,yEAAyE;QACzE,sEAAsE;QACtE,yEAAyE;QACzE,mBAAmB;QACnB,iBAAiB,EAAE,EAAE,GAAG,MAAM;QAE9B,wEAAwE;QACxE,gEAAgE;QAChE,yEAAyE;QACzE,yEAAyE;QACzE,uEAAuE;QACvE,0DAA0D;QAC1D,YAAY,EAAE,EAAE,GAAG,IAAI;QAEvB,mEAAmE;QACnE,sEAAsE;QACtE,wEAAwE;QACxE,qEAAqE;QACrE,yEAAyE;QACzE,0CAA0C;QAC1C,cAAc,EAAE,EAAE,GAAG,MAAM;QAE3B,wEAAwE;QACxE,kEAAkE;QAClE,wEAAwE;QACxE,cAAc,EAAE,QAAQ;KACzB;CACO,CAAC;AAEX;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,SAAwB;IAC3D,MAAM,KAAK,GAAG,OAAO,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAC9E,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC;IACvB,MAAM,QAAQ,GAAG,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,KAAK,CAAC,OAAO,EAAE,CAAC,GAAG,GAAG,CAAC;IAEzD,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;QACjB,gEAAgE;QAChE,OAAO,WAAW,CAAC,GAAG,CAAC,cAAc,CAAC;IACxC,CAAC;SAAM,CAAC;QACN,mCAAmC;QACnC,OAAO,WAAW,CAAC,GAAG,CAAC,gBAAgB,CAAC;IAC1C,CAAC;AACH,CAAC"}
@@ -1,13 +1,32 @@
1
1
  /**
2
- * Handler for get_alerts tool
2
+ * Handler for get_alerts tool.
3
+ *
4
+ * Routed by country (see docs/plans/international-alerts-plan.md D1):
5
+ * US β†’ NOAA (the original path, byte-identical output),
6
+ * Canada β†’ MSC GeoMet (Environment and Climate Change Canada),
7
+ * MeteoAlarm member countries β†’ the country's MeteoAlarm feed,
8
+ * elsewhere β†’ a clean not-covered message.
9
+ *
10
+ * Country resolution order: a `country_code` already carried by the resolved
11
+ * location (saved location or geocoded city_name), else a cached
12
+ * country-level Nominatim reverse lookup, else the `isInUS` bounding-box
13
+ * fallback (which preserves NOAA marine alerts for US coastal waters, where
14
+ * reverse geocoding returns no country). The reverse answer wins over
15
+ * `isInUS` β€” the CONUS box deliberately overruns into Canada (Toronto,
16
+ * Vancouver), and alert authority is jurisdictional.
3
17
  */
4
18
  import { NOAAService } from '../services/noaa.js';
5
19
  import { LocationStore } from '../services/locationStore.js';
6
20
  import { GeocodingService } from '../services/geocoding.js';
7
- export declare function handleGetAlerts(args: unknown, noaaService: NOAAService, locationStore: LocationStore, geocodingService: GeocodingService): Promise<{
21
+ import { MeteoAlarmService } from '../services/meteoalarm.js';
22
+ import { GeoMetService } from '../services/geomet.js';
23
+ import { NominatimService } from '../services/nominatim.js';
24
+ type HandlerResult = {
8
25
  content: Array<{
9
26
  type: string;
10
27
  text: string;
11
28
  }>;
12
- }>;
29
+ };
30
+ export declare function handleGetAlerts(args: unknown, noaaService: NOAAService, locationStore: LocationStore, geocodingService: GeocodingService, meteoAlarmService?: MeteoAlarmService, geoMetService?: GeoMetService, nominatimService?: NominatimService): Promise<HandlerResult>;
31
+ export {};
13
32
  //# sourceMappingURL=alertsHandler.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"alertsHandler.d.ts","sourceRoot":"","sources":["../../src/handlers/alertsHandler.ts"],"names":[],"mappings":"AAAA;;GAEG;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,wBAAsB,eAAe,CACnC,IAAI,EAAE,OAAO,EACb,WAAW,EAAE,WAAW,EACxB,aAAa,EAAE,aAAa,EAC5B,gBAAgB,EAAE,gBAAgB,GACjC,OAAO,CAAC;IAAE,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC,CA8H7D"}
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,16 +1,81 @@
1
1
  /**
2
- * Handler for get_alerts tool
2
+ * Handler for get_alerts tool.
3
+ *
4
+ * Routed by country (see docs/plans/international-alerts-plan.md D1):
5
+ * US β†’ NOAA (the original path, byte-identical output),
6
+ * Canada β†’ MSC GeoMet (Environment and Climate Change Canada),
7
+ * MeteoAlarm member countries β†’ the country's MeteoAlarm feed,
8
+ * elsewhere β†’ a clean not-covered message.
9
+ *
10
+ * Country resolution order: a `country_code` already carried by the resolved
11
+ * location (saved location or geocoded city_name), else a cached
12
+ * country-level Nominatim reverse lookup, else the `isInUS` bounding-box
13
+ * fallback (which preserves NOAA marine alerts for US coastal waters, where
14
+ * reverse geocoding returns no country). The reverse answer wins over
15
+ * `isInUS` β€” the CONUS box deliberately overruns into Canada (Toronto,
16
+ * Vancouver), and alert authority is jurisdictional.
3
17
  */
18
+ import { COUNTRY_FEEDS, isMeteoAlarmCountry } from '../services/meteoalarm.js';
4
19
  import { resolveLocationAsync, prependLocationLine } from '../utils/locationResolver.js';
5
20
  import { validateOptionalBoolean, validateDetail } from '../utils/validation.js';
6
21
  import { formatInTimezone, guessTimezoneFromCoords } from '../utils/timezone.js';
7
- export async function handleGetAlerts(args, noaaService, locationStore, geocodingService) {
22
+ import { isInUS } from '../utils/geography.js';
23
+ import { logger } from '../utils/logger.js';
24
+ /** Display caps for the international renderers (country feeds routinely carry 50+ warnings). */
25
+ const STANDARD_DISPLAY_CAP = 10;
26
+ const FULL_DISPLAY_CAP = 25;
27
+ export async function handleGetAlerts(args, noaaService, locationStore, geocodingService, meteoAlarmService, geoMetService, nominatimService) {
8
28
  // Resolve location from coordinates, a saved location name, or a geocoded city name
9
29
  const resolved = await resolveLocationAsync(args, locationStore, geocodingService);
10
30
  const { latitude, longitude } = resolved;
11
31
  const active_only = validateOptionalBoolean(args?.active_only, 'active_only', true);
12
32
  // Output verbosity: 'full' includes the complete NWS description text.
13
33
  const detail = validateDetail(args?.detail);
34
+ // --- Country routing (D1) ---
35
+ // 1. A country the resolution path already knows (saved location /
36
+ // geocoded city). Sources vary in casing; normalize to lowercase once.
37
+ let countryCode = resolved.country_code
38
+ ? resolved.country_code.toLowerCase()
39
+ : null;
40
+ // 2. Coordinates only: cached country-level reverse lookup. A missing
41
+ // service (test harnesses) skips this silently; only a *failed* lookup
42
+ // earns the one-line fallback note.
43
+ let reverseLookupFailed = false;
44
+ if (!countryCode && nominatimService) {
45
+ try {
46
+ countryCode = await nominatimService.reverseCountry(latitude, longitude);
47
+ }
48
+ catch (error) {
49
+ reverseLookupFailed = true;
50
+ logger.warn('Reverse country lookup failed; falling back to coordinate routing', {
51
+ error: error instanceof Error ? error.message : 'unknown'
52
+ });
53
+ }
54
+ }
55
+ // 3. Route. The reverse answer wins over isInUS; "no country" (open
56
+ // water, absent service, or a failed lookup) falls back to the
57
+ // bounding boxes so US coastal waters keep their NOAA marine alerts.
58
+ if (countryCode === 'us') {
59
+ return handleNoaaAlerts(resolved, noaaService, active_only, detail);
60
+ }
61
+ if (countryCode === 'ca' && geoMetService) {
62
+ return handleGeoMetAlerts(resolved, geoMetService, active_only, detail);
63
+ }
64
+ if (countryCode && isMeteoAlarmCountry(countryCode) && meteoAlarmService) {
65
+ return handleMeteoAlarmAlerts(resolved, meteoAlarmService, countryCode, active_only, detail);
66
+ }
67
+ if (!countryCode && isInUS(latitude, longitude)) {
68
+ return handleNoaaAlerts(resolved, noaaService, active_only, detail);
69
+ }
70
+ return notCoveredResult(resolved, countryCode, reverseLookupFailed);
71
+ }
72
+ /**
73
+ * The original US path β€” prologue (station timezone side-call) and renderer
74
+ * preserved verbatim so US output stays byte-identical
75
+ * (locked by tests/unit/alerts-detail.test.ts passing unedited).
76
+ */
77
+ async function handleNoaaAlerts(resolved, noaaService, active_only, detail) {
78
+ const { latitude, longitude } = resolved;
14
79
  // Get timezone for proper time formatting
15
80
  let timezone = guessTimezoneFromCoords(latitude, longitude); // fallback
16
81
  try {
@@ -107,4 +172,312 @@ export async function handleGetAlerts(args, noaaService, locationStore, geocodin
107
172
  ]
108
173
  }, resolved);
109
174
  }
175
+ /**
176
+ * Render a source-published ISO 8601 timestamp in the offset it was
177
+ * published with (`2026-08-13T09:21:00+02:00` β†’ `2026-08-13 09:21 (+02:00)`)
178
+ * β€” the licence terms favour times as issued, and it skips the NOAA
179
+ * station-timezone side-call entirely. Unparseable strings render raw.
180
+ */
181
+ function formatPublishedTime(iso) {
182
+ if (!iso) {
183
+ return undefined;
184
+ }
185
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/.exec(iso);
186
+ if (!match) {
187
+ return iso;
188
+ }
189
+ const [, date, time, offset] = match;
190
+ const zone = !offset || offset === 'Z' ? 'UTC' : offset;
191
+ return `${date} ${time} (${zone})`;
192
+ }
193
+ /** Human-readable region name from an ISO code ("au" β†’ "Australia"), falling back to the uppercased code. */
194
+ function regionDisplayName(countryCode) {
195
+ try {
196
+ const name = new Intl.DisplayNames(['en'], { type: 'region' }).of(countryCode.toUpperCase());
197
+ return name ?? countryCode.toUpperCase();
198
+ }
199
+ catch {
200
+ return countryCode.toUpperCase();
201
+ }
202
+ }
203
+ /** Shared severity rank/emoji conventions (the NOAA ordering, reused where a source supplies CAP severity). */
204
+ const CAP_SEVERITY_ORDER = {
205
+ 'Extreme': 0,
206
+ 'Severe': 1,
207
+ 'Moderate': 2,
208
+ 'Minor': 3,
209
+ 'Unknown': 4
210
+ };
211
+ function capSeverityRank(severity) {
212
+ return CAP_SEVERITY_ORDER[severity ?? 'Unknown'] ?? 4;
213
+ }
214
+ function capSeverityEmoji(severity) {
215
+ return severity === 'Extreme' ? 'πŸ”΄' :
216
+ severity === 'Severe' ? '🟠' :
217
+ severity === 'Moderate' ? '🟑' :
218
+ severity === 'Minor' ? 'πŸ”΅' : 'βšͺ';
219
+ }
220
+ function capitalize(value) {
221
+ return value.charAt(0).toUpperCase() + value.slice(1);
222
+ }
223
+ /** "…and N more warnings, mostly Minor" remainder line for capped lists. */
224
+ function remainderNote(remainder) {
225
+ const counts = new Map();
226
+ for (const warning of remainder) {
227
+ const severity = warning.severity ?? 'Unknown';
228
+ counts.set(severity, (counts.get(severity) ?? 0) + 1);
229
+ }
230
+ let top = 'Unknown';
231
+ let topCount = -1;
232
+ for (const [severity, count] of counts) {
233
+ if (count > topCount) {
234
+ top = severity;
235
+ topCount = count;
236
+ }
237
+ }
238
+ const plural = remainder.length > 1 ? 's' : '';
239
+ return `*…and ${remainder.length} more warning${plural}, mostly ${top}. Use detail="full" to see more.*\n\n`;
240
+ }
241
+ /**
242
+ * MeteoAlarm (Europe) renderer β€” CAP-shaped, so visually close to the NOAA
243
+ * blocks. Licence terms honoured here: CAP text verbatim, the issue time
244
+ * always shown, EUMETNET – MeteoAlarm + national service attribution.
245
+ */
246
+ async function handleMeteoAlarmAlerts(resolved, meteoAlarmService, countryCode, active_only, detail) {
247
+ const feed = COUNTRY_FEEDS[countryCode];
248
+ const countryName = feed?.name ?? regionDisplayName(countryCode);
249
+ const warnings = await meteoAlarmService.getWarnings(countryCode);
250
+ let output = `# Weather Alerts β€” ${countryName}\n\n`;
251
+ output += `**Location:** ${resolved.latitude.toFixed(4)}, ${resolved.longitude.toFixed(4)}\n`;
252
+ output += `**Coverage note:** European alerts are matched at country level β€” regional filtering is not yet available. Warnings below may not affect ${resolved.location_name ?? 'your exact location'}.\n\n`;
253
+ if (!active_only) {
254
+ output += `*Note: historical alerts are not available for this region β€” showing current warnings only.*\n\n`;
255
+ }
256
+ if (warnings.length === 0) {
257
+ output += `βœ… **No active weather alerts for ${countryName}.**\n\n`;
258
+ }
259
+ else {
260
+ output += `⚠️ **${warnings.length} active warning${warnings.length > 1 ? 's' : ''} for ${countryName}**\n\n`;
261
+ // Sort by CAP severity (the NOAA Extreme→Unknown order), then by expiry.
262
+ const sorted = [...warnings].sort((a, b) => {
263
+ const bySeverity = capSeverityRank(a.severity) - capSeverityRank(b.severity);
264
+ if (bySeverity !== 0) {
265
+ return bySeverity;
266
+ }
267
+ const aExpires = a.expires ? new Date(a.expires).getTime() : Infinity;
268
+ const bExpires = b.expires ? new Date(b.expires).getTime() : Infinity;
269
+ return aExpires - bExpires;
270
+ });
271
+ if (detail === 'summary') {
272
+ // Counts by severity and colour only.
273
+ const bySeverity = new Map();
274
+ const byColour = new Map();
275
+ for (const warning of sorted) {
276
+ const severity = warning.severity ?? 'Unknown';
277
+ bySeverity.set(severity, (bySeverity.get(severity) ?? 0) + 1);
278
+ if (warning.colour) {
279
+ byColour.set(warning.colour, (byColour.get(warning.colour) ?? 0) + 1);
280
+ }
281
+ }
282
+ output += `**By severity:** ${[...bySeverity.entries()]
283
+ .map(([severity, count]) => `${severity}: ${count}`)
284
+ .join(' | ')}\n`;
285
+ if (byColour.size > 0) {
286
+ output += `**By colour:** ${[...byColour.entries()]
287
+ .map(([colour, count]) => `${capitalize(colour)}: ${count}`)
288
+ .join(' | ')}\n`;
289
+ }
290
+ output += `\n*Counts only at detail="summary". Use detail="standard" or detail="full" for the warnings themselves.*\n\n`;
291
+ }
292
+ else {
293
+ const cap = detail === 'full' ? FULL_DISPLAY_CAP : STANDARD_DISPLAY_CAP;
294
+ const shown = sorted.slice(0, cap);
295
+ const remainder = sorted.slice(cap);
296
+ for (const warning of shown) {
297
+ const colourSuffix = warning.colour
298
+ ? ` β€” ${capitalize(warning.colour)}${warning.severity ? ` (${warning.severity})` : ''}`
299
+ : '';
300
+ output += `${capSeverityEmoji(warning.severity)} **${warning.event ?? 'Weather warning'}**${colourSuffix}\n`;
301
+ output += `---\n`;
302
+ if (warning.headline) {
303
+ output += `**Headline:** ${warning.headline}\n`;
304
+ }
305
+ output += `**Severity:** ${warning.severity ?? 'Unknown'} | **Urgency:** ${warning.urgency ?? 'Unknown'} | **Certainty:** ${warning.certainty ?? 'Unknown'}\n`;
306
+ if (warning.areaDesc.length > 0) {
307
+ output += `**Area:** ${warning.areaDesc.join('; ')}\n`;
308
+ }
309
+ // The issue time is always shown β€” a MeteoAlarm licence term.
310
+ output += `**Issued:** ${formatPublishedTime(warning.sent) ?? 'not stated'}\n`;
311
+ if (warning.onset) {
312
+ output += `**Onset:** ${formatPublishedTime(warning.onset)}\n`;
313
+ }
314
+ if (warning.expires) {
315
+ output += `**Expires:** ${formatPublishedTime(warning.expires)}\n`;
316
+ }
317
+ // CAP text renders verbatim (licence term): description at full only,
318
+ // instruction at standard+full β€” the NOAA detail contract.
319
+ if (detail === 'full' && warning.description) {
320
+ output += `\n**Description:**\n${warning.description}\n`;
321
+ }
322
+ if (warning.instruction) {
323
+ output += `\n**Instructions:**\n${warning.instruction}\n`;
324
+ }
325
+ if (warning.senderName) {
326
+ output += `\n**Sender:** ${warning.senderName}\n`;
327
+ }
328
+ output += `\n`;
329
+ }
330
+ if (remainder.length > 0) {
331
+ output += remainderNote(remainder);
332
+ }
333
+ if (detail !== 'full') {
334
+ output += `*Showing standard detail. Use detail="full" for complete warning descriptions.*\n\n`;
335
+ }
336
+ }
337
+ }
338
+ const serviceSuffix = feed?.service ? ` (national warnings: ${feed.service})` : '';
339
+ output += `---\n`;
340
+ output += `*Data source: EUMETNET – MeteoAlarm${serviceSuffix}. Alerts shown unmodified as issued; times as published.*\n`;
341
+ return prependLocationLine({
342
+ content: [{ type: 'text', text: output }]
343
+ }, resolved);
344
+ }
345
+ /** GeoMet alert_type display rank: warning > watch > advisory > statement. */
346
+ const GEOMET_TYPE_ORDER = {
347
+ 'warning': 0,
348
+ 'watch': 1,
349
+ 'advisory': 2,
350
+ 'statement': 3
351
+ };
352
+ function geoMetTypeRank(alertType) {
353
+ return GEOMET_TYPE_ORDER[(alertType ?? '').toLowerCase()] ?? 4;
354
+ }
355
+ function geoMetTypeEmoji(alertType) {
356
+ switch ((alertType ?? '').toLowerCase()) {
357
+ case 'warning':
358
+ return 'πŸ”Ά';
359
+ case 'watch':
360
+ return '🟑';
361
+ case 'advisory':
362
+ return 'πŸ”΅';
363
+ default:
364
+ return 'βšͺ';
365
+ }
366
+ }
367
+ /**
368
+ * MSC GeoMet (Canada) renderer β€” not CAP-shaped; renders what ECCC provides
369
+ * and invents nothing (no severity/urgency/certainty lines). `alert_text_en`
370
+ * renders verbatim β€” an ECCC licence term.
371
+ */
372
+ async function handleGeoMetAlerts(resolved, geoMetService, active_only, detail) {
373
+ const alerts = await geoMetService.getAlerts(resolved.latitude, resolved.longitude);
374
+ let output = `# Weather Alerts β€” Canada\n\n`;
375
+ output += `**Location:** ${resolved.latitude.toFixed(4)}, ${resolved.longitude.toFixed(4)}\n\n`;
376
+ if (!active_only) {
377
+ output += `*Note: historical alerts are not available for this region β€” showing current alerts only.*\n\n`;
378
+ }
379
+ if (alerts.length === 0) {
380
+ output += `βœ… **No active weather alerts for this area.**\n\n`;
381
+ }
382
+ else {
383
+ output += `⚠️ **${alerts.length} active alert${alerts.length > 1 ? 's' : ''} found**\n\n`;
384
+ // Sort by alert_type rank (warning > watch > advisory > statement), then recency.
385
+ const sorted = [...alerts].sort((a, b) => {
386
+ const byType = geoMetTypeRank(a.properties.alert_type) - geoMetTypeRank(b.properties.alert_type);
387
+ if (byType !== 0) {
388
+ return byType;
389
+ }
390
+ const aPublished = a.properties.publication_datetime
391
+ ? new Date(a.properties.publication_datetime).getTime()
392
+ : 0;
393
+ const bPublished = b.properties.publication_datetime
394
+ ? new Date(b.properties.publication_datetime).getTime()
395
+ : 0;
396
+ return bPublished - aPublished;
397
+ });
398
+ if (detail === 'summary') {
399
+ const byType = new Map();
400
+ for (const alert of sorted) {
401
+ const alertType = (alert.properties.alert_type ?? 'other').toLowerCase();
402
+ byType.set(alertType, (byType.get(alertType) ?? 0) + 1);
403
+ }
404
+ output += `**By type:** ${[...byType.entries()]
405
+ .map(([alertType, count]) => `${capitalize(alertType)}: ${count}`)
406
+ .join(' | ')}\n`;
407
+ output += `\n*Counts only at detail="summary". Use detail="standard" or detail="full" for the alerts themselves.*\n\n`;
408
+ }
409
+ else {
410
+ const cap = detail === 'full' ? FULL_DISPLAY_CAP : STANDARD_DISPLAY_CAP;
411
+ const shown = sorted.slice(0, cap);
412
+ const remainder = sorted.length - shown.length;
413
+ for (const alert of shown) {
414
+ const props = alert.properties;
415
+ const name = props.alert_name_en ?? props.alert_short_name_en ?? 'Weather alert';
416
+ const typeSuffix = props.alert_type ? ` (${props.alert_type})` : '';
417
+ output += `${geoMetTypeEmoji(props.alert_type)} **${name}**${typeSuffix}\n`;
418
+ output += `---\n`;
419
+ if (props.feature_name_en) {
420
+ output += `**Area:** ${props.feature_name_en}${props.province ? `, ${props.province}` : ''}\n`;
421
+ }
422
+ const riskParts = [];
423
+ if (props.risk_colour_en) {
424
+ riskParts.push(`**Risk:** ${props.risk_colour_en}`);
425
+ }
426
+ if (props.confidence_en) {
427
+ riskParts.push(`**Confidence:** ${props.confidence_en}`);
428
+ }
429
+ if (riskParts.length > 0) {
430
+ output += `${riskParts.join(' | ')}\n`;
431
+ }
432
+ const timeParts = [];
433
+ const issued = formatPublishedTime(props.publication_datetime);
434
+ const ends = formatPublishedTime(props.event_end_datetime);
435
+ if (issued) {
436
+ timeParts.push(`**Issued:** ${issued}`);
437
+ }
438
+ if (ends) {
439
+ timeParts.push(`**Ends:** ${ends}`);
440
+ }
441
+ if (timeParts.length > 0) {
442
+ output += `${timeParts.join(' | ')}\n`;
443
+ }
444
+ // The alert body renders verbatim at standard and full (ECCC terms:
445
+ // content unaltered).
446
+ if (props.alert_text_en) {
447
+ output += `\n${props.alert_text_en}\n`;
448
+ }
449
+ output += `\n`;
450
+ }
451
+ if (remainder > 0) {
452
+ output += `*…and ${remainder} more alert${remainder > 1 ? 's' : ''}. Use detail="full" to see more.*\n\n`;
453
+ }
454
+ }
455
+ }
456
+ output += `---\n`;
457
+ output += `*Data source: Environment and Climate Change Canada (MSC GeoMet). Alert content shown unaltered.*\n`;
458
+ return prependLocationLine({
459
+ content: [{ type: 'text', text: output }]
460
+ }, resolved);
461
+ }
462
+ /**
463
+ * The clean not-covered message (D1): names the region, states the current
464
+ * coverage, and never surfaces a wrong-country upstream error. The one-line
465
+ * note appears only when a real reverse lookup *failed* β€” an absent lookup
466
+ * service falls back silently.
467
+ */
468
+ function notCoveredResult(resolved, countryCode, reverseLookupFailed) {
469
+ const region = countryCode ? regionDisplayName(countryCode) : 'this location';
470
+ let output = `# Weather Alerts\n\n`;
471
+ output += `**Location:** ${resolved.latitude.toFixed(4)}, ${resolved.longitude.toFixed(4)}\n\n`;
472
+ output += `Weather alerts are not yet available for ${region}.\n\n`;
473
+ output += `Current alert coverage: the United States (NOAA National Weather Service), `;
474
+ output += `Canada (Environment and Climate Change Canada), and the European MeteoAlarm `;
475
+ output += `member countries (matched at country level).\n`;
476
+ if (reverseLookupFailed) {
477
+ output += `\n*Note: the country lookup service was unavailable, so routing fell back to coordinate checks.*\n`;
478
+ }
479
+ return prependLocationLine({
480
+ content: [{ type: 'text', text: output }]
481
+ }, resolved);
482
+ }
110
483
  //# sourceMappingURL=alertsHandler.js.map