@cyanheads/noaa-spaceweather-mcp-server 0.1.13 → 0.1.14

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 (38) hide show
  1. package/AGENTS.md +6 -4
  2. package/CLAUDE.md +6 -4
  3. package/README.md +9 -7
  4. package/changelog/0.1.x/0.1.14.md +32 -0
  5. package/dist/mcp-server/tools/definitions/get-alerts.tool.d.ts +19 -1
  6. package/dist/mcp-server/tools/definitions/get-alerts.tool.d.ts.map +1 -1
  7. package/dist/mcp-server/tools/definitions/get-alerts.tool.js +281 -41
  8. package/dist/mcp-server/tools/definitions/get-alerts.tool.js.map +1 -1
  9. package/dist/mcp-server/tools/definitions/get-aurora-forecast.tool.d.ts +9 -1
  10. package/dist/mcp-server/tools/definitions/get-aurora-forecast.tool.d.ts.map +1 -1
  11. package/dist/mcp-server/tools/definitions/get-aurora-forecast.tool.js +91 -52
  12. package/dist/mcp-server/tools/definitions/get-aurora-forecast.tool.js.map +1 -1
  13. package/dist/mcp-server/tools/definitions/get-conditions.tool.d.ts +7 -1
  14. package/dist/mcp-server/tools/definitions/get-conditions.tool.d.ts.map +1 -1
  15. package/dist/mcp-server/tools/definitions/get-conditions.tool.js +8 -1
  16. package/dist/mcp-server/tools/definitions/get-conditions.tool.js.map +1 -1
  17. package/dist/mcp-server/tools/definitions/get-kp-index.tool.d.ts +7 -1
  18. package/dist/mcp-server/tools/definitions/get-kp-index.tool.d.ts.map +1 -1
  19. package/dist/mcp-server/tools/definitions/get-kp-index.tool.js +21 -6
  20. package/dist/mcp-server/tools/definitions/get-kp-index.tool.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/get-solar-activity.tool.d.ts +7 -1
  22. package/dist/mcp-server/tools/definitions/get-solar-activity.tool.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/get-solar-activity.tool.js +23 -6
  24. package/dist/mcp-server/tools/definitions/get-solar-activity.tool.js.map +1 -1
  25. package/dist/mcp-server/tools/definitions/get-solar-wind.tool.d.ts +7 -1
  26. package/dist/mcp-server/tools/definitions/get-solar-wind.tool.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/definitions/get-solar-wind.tool.js +8 -1
  28. package/dist/mcp-server/tools/definitions/get-solar-wind.tool.js.map +1 -1
  29. package/dist/mcp-server/tools/definitions/index.d.ts +56 -6
  30. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  31. package/dist/services/space-weather/space-weather-service.d.ts +42 -1
  32. package/dist/services/space-weather/space-weather-service.d.ts.map +1 -1
  33. package/dist/services/space-weather/space-weather-service.js +284 -40
  34. package/dist/services/space-weather/space-weather-service.js.map +1 -1
  35. package/dist/services/space-weather/types.d.ts +58 -12
  36. package/dist/services/space-weather/types.d.ts.map +1 -1
  37. package/package.json +3 -3
  38. package/server.json +3 -3
package/AGENTS.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** noaa-spaceweather-mcp-server
4
- **Version:** 0.1.13
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.2`
4
+ **Version:** 0.1.14
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.3`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0 (protocol revisions 2026-07-28 and 2025-*)
8
- **Zod:** ^4.6.4
8
+ **Zod:** ^4.6.5
9
9
 
10
10
  > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
11
11
 
@@ -217,7 +217,7 @@ Available skills:
217
217
  | `code-simplifier` | Post-session cleanup against `git diff` — modernize syntax, consolidate duplication, align with the codebase |
218
218
  | `polish-docs-meta` | Finalize docs, README, metadata, and agent protocol for shipping |
219
219
  | `git-wrapup` | Land working-tree changes as a commit stack — version bump, changelog, verify, commit by concern, release commit on top. No tag, no push to main; opens the release PR when the project declares release PR mode |
220
- | `release-pr-review` | Review pass on an open release PR — simplifier + correctness review, fixup commits autosquashed into the stack, PR body kept in sync. Release PR mode only |
220
+ | `release-pr-review` | Review pass on an open release PR — simplifier + correctness review, fixes as ordinary commits on top of the stack, PR body kept in sync. Release PR mode only |
221
221
  | `release-and-publish` | Fast-forward merge (release PR mode) + tag + push + npm + MCP Registry + GH Release + Docker. Picks up from `git-wrapup` |
222
222
  | `maintenance` | Investigate changelogs, adopt upstream changes, sync skills to agent dirs |
223
223
  | `orchestrations` | Chain task skills into a gated multi-phase pipeline — build-out, QA-fix, update-ship — when you can spawn sub-agents |
@@ -268,6 +268,8 @@ When you complete a skill's checklist, check the boxes and add a completion time
268
268
  | `bun run changelog:check` | Verify `CHANGELOG.md` is in sync (used by devcheck) |
269
269
  | `bun run bundle` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install |
270
270
 
271
+ **CI is one file.** `.github/workflows/codeql.yml` (scaffolded) is the only GitHub Actions workflow: CodeQL is GitHub-owned end to end, and the file runs only while the repo's CodeQL *default setup* is turned off. Verification — `devcheck`, tests, the release gates — runs locally; don't add a workflow that re-runs it.
272
+
271
273
  ---
272
274
 
273
275
  ## Bundling
package/CLAUDE.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** noaa-spaceweather-mcp-server
4
- **Version:** 0.1.13
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.2`
4
+ **Version:** 0.1.14
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.3`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0 (protocol revisions 2026-07-28 and 2025-*)
8
- **Zod:** ^4.6.4
8
+ **Zod:** ^4.6.5
9
9
 
10
10
  > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
11
11
 
@@ -217,7 +217,7 @@ Available skills:
217
217
  | `code-simplifier` | Post-session cleanup against `git diff` — modernize syntax, consolidate duplication, align with the codebase |
218
218
  | `polish-docs-meta` | Finalize docs, README, metadata, and agent protocol for shipping |
219
219
  | `git-wrapup` | Land working-tree changes as a commit stack — version bump, changelog, verify, commit by concern, release commit on top. No tag, no push to main; opens the release PR when the project declares release PR mode |
220
- | `release-pr-review` | Review pass on an open release PR — simplifier + correctness review, fixup commits autosquashed into the stack, PR body kept in sync. Release PR mode only |
220
+ | `release-pr-review` | Review pass on an open release PR — simplifier + correctness review, fixes as ordinary commits on top of the stack, PR body kept in sync. Release PR mode only |
221
221
  | `release-and-publish` | Fast-forward merge (release PR mode) + tag + push + npm + MCP Registry + GH Release + Docker. Picks up from `git-wrapup` |
222
222
  | `maintenance` | Investigate changelogs, adopt upstream changes, sync skills to agent dirs |
223
223
  | `orchestrations` | Chain task skills into a gated multi-phase pipeline — build-out, QA-fix, update-ship — when you can spawn sub-agents |
@@ -268,6 +268,8 @@ When you complete a skill's checklist, check the boxes and add a completion time
268
268
  | `bun run changelog:check` | Verify `CHANGELOG.md` is in sync (used by devcheck) |
269
269
  | `bun run bundle` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install |
270
270
 
271
+ **CI is one file.** `.github/workflows/codeql.yml` (scaffolded) is the only GitHub Actions workflow: CodeQL is GitHub-owned end to end, and the file runs only while the repo's CodeQL *default setup* is turned off. Verification — `devcheck`, tests, the release gates — runs locally; don't add a workflow that re-runs it.
272
+
271
273
  ---
272
274
 
273
275
  ## Bundling
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  <div align="center">
9
9
 
10
- [![Version](https://img.shields.io/badge/Version-0.1.13-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/noaa-spaceweather-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.0.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/noaa-spaceweather-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/noaa-spaceweather-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0-blueviolet.svg?style=flat-square)](https://bun.sh/)
10
+ [![Version](https://img.shields.io/badge/Version-0.1.14-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/noaa-spaceweather-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.0.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/noaa-spaceweather-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/noaa-spaceweather-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
12
12
  </div>
13
13
 
@@ -57,6 +57,7 @@ Space weather from NOAA's Space Weather Prediction Center (SWPC) — geomagnetic
57
57
 
58
58
  - `window_days` (1–7, default 1) bounds the observed series; the forecast series is always SWPC's full 3-day forecast
59
59
  - Each observed/forecast record carries Kp, G-scale equivalent, G-scale label, and aurora-latitude guidance
60
+ - G levels follow SWPC's minus-third band floors — G1 starts at Kp 4.67 (5−), G4 runs through 8.67 (9−), and only Kp 9 is G5
60
61
  - Forecast excludes the feed's embedded historical "observed" entries — only forward-looking `estimated`/`predicted` rows
61
62
  - `observedCount` reports how many observed readings matched the window
62
63
 
@@ -65,7 +66,7 @@ Space weather from NOAA's Space Weather Prediction Center (SWPC) — geomagnetic
65
66
  ### `noaa_spaceweather_get_aurora_forecast` <sub>tool</sub>
66
67
 
67
68
  - Without coordinates: global metadata only — grid point count, global peak probability, peak region
68
- - With `latitude`/`longitude` (WGS84, required together): nearest 1°-grid lookup, minimum Kp needed at that latitude, and a plain-language go/no-go verdict
69
+ - With `latitude`/`longitude` (WGS84, required together): nearest 1°-grid lookup, the centered-dipole geomagnetic latitude those coordinates convert to, the minimum Kp and G level needed at that geomagnetic latitude, and a plain-language go/no-go verdict
69
70
  - `invalid_coordinates` error when only one of the pair is supplied
70
71
  - OVATION model updates every ~5 minutes; forecast horizon is ~30–60 minutes ahead
71
72
 
@@ -92,10 +93,11 @@ Space weather from NOAA's Space Weather Prediction Center (SWPC) — geomagnetic
92
93
 
93
94
  ### `noaa_spaceweather_get_alerts` <sub>tool</sub>
94
95
 
95
- - `active_only` (default true) — in-force Warnings/Watches/Alerts only; cancellations and Summaries excluded
96
- - `max_age_hours` (1–720, default 48) bounds how far back to look; the SWPC feed itself has no expiry
97
- - Each record carries product type, NOAA scale + level (0 means "no scale stated," not zero severity), parsed validity window, and full message text
98
- - `cancelled` flags a record that cancels a prior product rather than being active
96
+ - `active_only` (default true) — in-force Warnings/Watches/Alerts only. A product stays in force until the feed says otherwise, so this also drops any product a later cancellation names by serial, and all but the newest Watch carrying `THIS SUPERSEDES ANY/ALL PRIOR WATCHES IN EFFECT` — alongside cancellations, Summaries, and products whose validity end has passed
97
+ - `max_age_hours` (1–720, default 48) bounds how far back to look for candidates; the SWPC feed itself has no expiry. Under `active_only=true` it does not cut off a product whose validity end is still ahead, so a multi-day Watch survives until the last day it forecasts a storm for ends; under `active_only=false` it is a literal age cutoff
98
+ - Each record carries product type, NOAA scale + level (0 means "no scale stated," not zero severity), serial number, parsed validity window, and full message text
99
+ - `cancelled` flags a record that cancels a prior product rather than being active; the product it cancels is a separate record, excluded by the serial link rather than by this flag
100
+ - Under `active_only=true` the response echoes the applied window and counts what it excluded, by reason — so an empty result reads as "quiet" or "everything was filtered" without a second call
99
101
 
100
102
  ---
101
103
 
@@ -116,7 +118,7 @@ Agent-friendly output:
116
118
  - Observed timestamps on every response so agents can reason about data freshness
117
119
  - Plain-language summaries and verdicts alongside raw values — agents can display or reason without re-interpreting indices
118
120
  - Bz component surfaced as a first-class field in solar wind output (southward Bz = primary storm driver)
119
- - Typed error contracts with recovery hints: `feed_unavailable` → "Retry in 30–60 s"
121
+ - Typed error contracts with recovery hints, split on whether retrying can help: a transient feed failure is `feed_unavailable` → "Retry in 30–60 s"; a feed path SWPC no longer serves is `feed_moved` → "Retrying will not help", raised on the first attempt
120
122
 
121
123
  ---
122
124
 
@@ -0,0 +1,32 @@
1
+ ---
2
+ summary: "Kp→G-scale thresholds follow SWPC thirds, aurora forecast converts to geomagnetic latitude, get_alerts correctly excludes cancelled/superseded Watches, feed failures carry a typed reason, DST-safe forecast dates, NaN token repair"
3
+ breaking: false
4
+ security: false
5
+ ---
6
+
7
+ # 0.1.14 — 2026-09-17
8
+
9
+ ## Added
10
+
11
+ - **`get_alerts` `serialNumber` field** on every record, plus an `activeOnly` echo of the request scope — resolves the `Cancel Serial Number:` / `Continuation of Serial Number:` chains in the message body. ([#29](https://github.com/cyanheads/noaa-spaceweather-mcp-server/issues/29))
12
+ - **`get_alerts` exclusion disclosure** — under `active_only=true`, enrichment fields `appliedWindowHours`, `appliedCutoff`, and per-reason `exclusions` counts report what was filtered, so an empty result reads as "quiet" or "everything was filtered" without a second call. ([#29](https://github.com/cyanheads/noaa-spaceweather-mcp-server/issues/29))
13
+ - **`get_aurora_forecast` `localLookup.geomagneticLatitude` and `minGScale`** — the centered-dipole geomagnetic latitude the requested coordinates convert to, and the NOAA G level backing `minKpRequired`. ([#28](https://github.com/cyanheads/noaa-spaceweather-mcp-server/issues/28))
14
+ - **`feed_moved` error reason**, declared on all six tools — a permanent 4xx (or the scales feed missing its `"0"` period) now fails in one attempt with a "retrying will not help" recovery hint, instead of retrying or surfacing an unclassified error. ([#30](https://github.com/cyanheads/noaa-spaceweather-mcp-server/issues/30))
15
+
16
+ ## Changed
17
+
18
+ - **`get_solar_activity` per-region flare-probability fields** (`cFlareProbability`, `mFlareProbability`, `xFlareProbability`, `protonProbability`) now state they cover the UTC day after `observedDate`, matching what SWPC's feed reports. ([#22](https://github.com/cyanheads/noaa-spaceweather-mcp-server/issues/22))
19
+
20
+ ## Fixed
21
+
22
+ - **`kpToGScale()` follows SWPC's minus-third band floors** (G1 at Kp 4.67, G2 5.67, G3 6.67, G4 7.67, G5 9.00) instead of whole-number cutoffs — `get_kp_index` and `get_conditions` no longer disagree with SWPC's own `noaa_scale`. ([#27](https://github.com/cyanheads/noaa-spaceweather-mcp-server/issues/27))
23
+ - **`get_aurora_forecast` converts geographic coordinates to geomagnetic latitude** before the Kp lookup and wraps longitude at the antimeridian in the nearest-grid-point search — Denver and San Francisco no longer read "aurora not visible" during a storm. Shares one latitude-band table with `gScaleToAuroraLatitude()`. ([#28](https://github.com/cyanheads/noaa-spaceweather-mcp-server/issues/28))
24
+ - **`get_alerts` `active_only=true`** now drops a product named by a later cancellation's `Cancel Serial Number:` and all but the newest Watch carrying the supersede line, and keeps a Watch active past `max_age_hours` while a day it forecasts a storm for is still running. The empty-state text no longer claims "no active alerts" for an unfiltered request. ([#29](https://github.com/cyanheads/noaa-spaceweather-mcp-server/issues/29))
25
+ - **Feed failures carry a declared reason and recovery hint on the wire** — every SWPC feed error now reaches the client as `feed_unavailable` or `feed_moved` with `data.reason`, `data.recovery.hint`, and a `Recovery:` line in `content[]`. ([#30](https://github.com/cyanheads/noaa-spaceweather-mcp-server/issues/30))
26
+ - **`get_solar_activity` forecast dates use UTC epoch arithmetic** instead of local-calendar `setDate`/`getDate` — a 3-day window crossing a DST transition in the process timezone no longer duplicates a forecast day or drifts off midnight UTC. ([#22](https://github.com/cyanheads/noaa-spaceweather-mcp-server/issues/22))
27
+ - **A bare `NaN`/`Infinity` token in any SWPC feed body is repaired to `null`** before the retry loop, instead of failing the parse and burning all four retry attempts on bytes that will never parse differently. ([#25](https://github.com/cyanheads/noaa-spaceweather-mcp-server/issues/25))
28
+
29
+ ## Dependencies
30
+
31
+ - `@cyanheads/mcp-ts-core` `^0.13.2` → `^0.13.3`
32
+ - `zod` `^4.6.4` → `^4.6.5`
@@ -21,6 +21,7 @@ export declare const getAlerts: import("@cyanheads/mcp-ts-core").ToolDefinition<
21
21
  level: z.ZodNumber;
22
22
  noaaScale: z.ZodNullable<z.ZodString>;
23
23
  cancelled: z.ZodBoolean;
24
+ serialNumber: z.ZodNullable<z.ZodString>;
24
25
  phenomenon: z.ZodString;
25
26
  issueDatetime: z.ZodString;
26
27
  validFrom: z.ZodNullable<z.ZodString>;
@@ -28,14 +29,31 @@ export declare const getAlerts: import("@cyanheads/mcp-ts-core").ToolDefinition<
28
29
  message: z.ZodString;
29
30
  }, z.core.$strip>>;
30
31
  totalCount: z.ZodNumber;
32
+ activeOnly: z.ZodBoolean;
31
33
  fetchedAt: z.ZodString;
32
34
  }, z.core.$strip>, readonly [{
33
35
  readonly reason: "feed_unavailable";
34
36
  readonly code: JsonRpcErrorCode.ServiceUnavailable;
35
- readonly when: "SWPC endpoint returns non-OK status or times out after retries.";
37
+ readonly when: "SWPC feed returns 5xx or 429, times out, or answers with a body that is not parseable JSON. Retried before failing.";
36
38
  readonly retryable: true;
37
39
  readonly recovery: "Retry in 30–60 seconds; SWPC feeds occasionally lag during high-activity events.";
40
+ }, {
41
+ readonly reason: "feed_moved";
42
+ readonly code: JsonRpcErrorCode.ServiceUnavailable;
43
+ readonly when: "SWPC feed path returns a permanent 4xx (404, 410, 401, 403). Fails in one attempt.";
44
+ readonly retryable: false;
45
+ readonly recovery: "Retrying will not help — the SWPC feed path no longer resolves or no longer has the expected shape; the feed URL needs updating against SWPC current inventory.";
38
46
  }], {
39
47
  readonly notice: z.ZodOptional<z.ZodString>;
48
+ readonly appliedWindowHours: z.ZodOptional<z.ZodNumber>;
49
+ readonly appliedCutoff: z.ZodOptional<z.ZodString>;
50
+ readonly exclusions: z.ZodOptional<z.ZodObject<{
51
+ agedOut: z.ZodNumber;
52
+ productType: z.ZodNumber;
53
+ cancellationRecord: z.ZodNumber;
54
+ cancelledBySerial: z.ZodNumber;
55
+ superseded: z.ZodNumber;
56
+ validityElapsed: z.ZodNumber;
57
+ }, z.core.$strip>>;
40
58
  }>;
41
59
  //# sourceMappingURL=get-alerts.tool.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"get-alerts.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/get-alerts.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAwDjE,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA4IpB,CAAC"}
1
+ {"version":3,"file":"get-alerts.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/get-alerts.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AA+NjE,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAqPpB,CAAC"}
@@ -5,6 +5,154 @@
5
5
  import { tool, z } from '@cyanheads/mcp-ts-core';
6
6
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
7
7
  import { getSpaceWeatherService } from '../../../services/space-weather/space-weather-service.js';
8
+ /**
9
+ * Why a record was left out of an `active_only=true` result, in the precedence order a
10
+ * record is attributed to — each record is counted under the first reason that fires, so
11
+ * the counts partition the feed rather than overlapping.
12
+ *
13
+ * The window leads because it decides which records were candidates at all, and product
14
+ * type follows because a Summary is never in force whatever its body says. Then the
15
+ * reasons that name a *replacement* — this record is itself a cancellation, a later
16
+ * record cancelled it, a later record superseded it — and only last the generic
17
+ * "its stated end passed".
18
+ *
19
+ * That ordering is deliberate: a superseded Watch has usually outlived its own forecast
20
+ * days too, so ranking elapsed validity higher would report the whole Watch chain as
21
+ * merely stale and never mention that a newer forecast replaced it. The specific reason
22
+ * is the one a caller can act on — it points at a successor record.
23
+ */
24
+ const EXCLUSION_REASONS = [
25
+ 'agedOut',
26
+ 'productType',
27
+ 'cancellationRecord',
28
+ 'cancelledBySerial',
29
+ 'superseded',
30
+ 'validityElapsed',
31
+ ];
32
+ /** Product types that can be in force; everything else is informational or unparsed. */
33
+ const IN_FORCE_TYPES = new Set([
34
+ 'Warning',
35
+ 'Watch',
36
+ 'Alert',
37
+ ]);
38
+ /**
39
+ * The record's stated end as an epoch, or null when nothing usable is stated. A validity
40
+ * line SWPC wrote as prose survives parsing as raw text (see `parseValidity`), and an end
41
+ * that cannot be read is not evidence the product has finished — those return null and
42
+ * the record is treated as in force.
43
+ */
44
+ function endMs(alert) {
45
+ if (alert.validTo === null)
46
+ return null;
47
+ const ms = Date.parse(alert.validTo);
48
+ return Number.isNaN(ms) ? null : ms;
49
+ }
50
+ /**
51
+ * Resolve each cancellation to the single record it names, as indices into `all`.
52
+ *
53
+ * Serials are per-message-code counters, so the match is scoped to the cancelling
54
+ * record's own code — the same serial under another code is a different product. SWPC
55
+ * reuses a serial within a code on a corrected reissue, so "Original Issue Time:" picks
56
+ * between them when it names one of the candidates; when it names none (a formatting
57
+ * drift upstream), the nearest preceding record under that code and serial is taken
58
+ * rather than missing the cancellation entirely.
59
+ *
60
+ * Resolving one target per cancellation is what keeps a cancellation from clearing its
61
+ * whole message code: a single code cycles CONTINUED → CANCEL → CONTINUED within minutes,
62
+ * and the records either side of the cancellation are still in force.
63
+ */
64
+ function resolveCancelledBySerial(all) {
65
+ const cancelled = new Set();
66
+ for (const cancellation of all) {
67
+ if (!cancellation.cancelled || !cancellation.cancelsSerialNumber)
68
+ continue;
69
+ const cancelIssuedMs = Date.parse(cancellation.issueDatetime);
70
+ const candidates = all
71
+ .map((alert, index) => ({ alert, index }))
72
+ .filter(({ alert }) => alert !== cancellation &&
73
+ alert.messageCode === cancellation.messageCode &&
74
+ alert.serialNumber === cancellation.cancelsSerialNumber &&
75
+ Date.parse(alert.issueDatetime) < cancelIssuedMs)
76
+ .sort((a, b) => Date.parse(a.alert.issueDatetime) - Date.parse(b.alert.issueDatetime));
77
+ if (candidates.length === 0)
78
+ continue;
79
+ const named = cancellation.cancelsOriginalIssueDatetime;
80
+ // The two times come from different feed fields — the body's minute-precision
81
+ // "Issue Time:" versus the record's sub-second `issue_datetime` — so they agree only
82
+ // to the minute.
83
+ const exact = named
84
+ ? candidates.filter(({ alert }) => sameMinute(alert.issueDatetime, named))
85
+ : [];
86
+ const target = (exact.length > 0 ? exact : candidates).at(-1);
87
+ if (target)
88
+ cancelled.add(target.index);
89
+ }
90
+ return cancelled;
91
+ }
92
+ /** True when two ISO 8601 instants fall in the same UTC minute. */
93
+ function sameMinute(a, b) {
94
+ const aMs = Date.parse(a);
95
+ const bMs = Date.parse(b);
96
+ if (Number.isNaN(aMs) || Number.isNaN(bMs))
97
+ return false;
98
+ return Math.floor(aMs / 60_000) === Math.floor(bMs / 60_000);
99
+ }
100
+ /**
101
+ * Indices of every record a later supersede-carrying record has replaced — all of them
102
+ * but the newest by issue time.
103
+ *
104
+ * The rule keys on the line rather than the message code because the line says any and
105
+ * all: the live Watches are sequential revisions of one three-day forecast, issued under
106
+ * whichever `WATA*` code matches the level they predict, so scoping per code returns two
107
+ * conflicting outlooks for the same day. The newest is chosen across the whole feed, not
108
+ * just the records the window admitted — a superseding record outside the caller's
109
+ * lookback still happened.
110
+ *
111
+ * An unreadable issue time sorts oldest rather than unbeatable: every `>` comparison
112
+ * against `NaN` is false, so seeding it at 0 is what stops one undated record from
113
+ * standing as the newest carrier and superseding every genuine Watch behind it.
114
+ */
115
+ function resolveSuperseded(all) {
116
+ const issuedMs = ({ alert }) => Date.parse(alert.issueDatetime) || 0;
117
+ const carriers = all
118
+ .map((alert, index) => ({ alert, index }))
119
+ .filter(({ alert }) => alert.supersedes);
120
+ if (carriers.length <= 1)
121
+ return new Set();
122
+ const newest = carriers.reduce((best, current) => issuedMs(current) > issuedMs(best) ? current : best);
123
+ return new Set(carriers.filter(({ index }) => index !== newest.index).map(({ index }) => index));
124
+ }
125
+ /**
126
+ * The first reason a record is not in force, or null when it is. A product is in force
127
+ * until something in the feed says otherwise — a stated end that has passed, a
128
+ * cancellation naming it, or a later product that supersedes it.
129
+ */
130
+ function exclusionReason(alert, index, ctx) {
131
+ const end = endMs(alert);
132
+ const issuedMs = Date.parse(alert.issueDatetime);
133
+ // `max_age_hours` bounds how far back to look for candidates; it is not itself a
134
+ // statement about whether a product is in force. A product whose end is still ahead
135
+ // stays in scope however old its issue time — without that, the default window drops a
136
+ // Watch hours before the storm day it forecasts finishes. An issue time that cannot be
137
+ // read counts as outside the window: nothing places it inside one.
138
+ const outsideWindow = Number.isNaN(issuedMs) || issuedMs < ctx.cutoffMs;
139
+ const endAhead = end !== null && end >= ctx.nowMs;
140
+ if (outsideWindow && !endAhead)
141
+ return 'agedOut';
142
+ if (!IN_FORCE_TYPES.has(alert.productType))
143
+ return 'productType';
144
+ // A cancellation carries the cancelled product's own type and no validity window, so
145
+ // neither check above excludes it.
146
+ if (alert.cancelled)
147
+ return 'cancellationRecord';
148
+ if (ctx.cancelledBySerial.has(index))
149
+ return 'cancelledBySerial';
150
+ if (ctx.superseded.has(index))
151
+ return 'superseded';
152
+ if (end !== null && end < ctx.nowMs)
153
+ return 'validityElapsed';
154
+ return null;
155
+ }
8
156
  const AlertSchema = z
9
157
  .object({
10
158
  productId: z
@@ -25,7 +173,11 @@ const AlertSchema = z
25
173
  .describe('NOAA scale stated in the message body, e.g. "G1", "R2", "S1"; null when the product states none.'),
26
174
  cancelled: z
27
175
  .boolean()
28
- .describe('True when this record cancels a previously issued product ("CANCEL WARNING:"/"CANCEL ALERT:" headline) rather than being in force. Always false when active_only=true, which excludes cancellations; set active_only=false to see them.'),
176
+ .describe('True when this record cancels a previously issued product ("CANCEL WARNING:"/"CANCEL WATCH:"/"CANCEL ALERT:" headline) rather than being in force. Always false when active_only=true, which excludes both the cancellation record and the product it names — a cancelled product carries cancelled: false itself, so this flag does not identify one. Set active_only=false to see cancellations and what they cancelled.'),
177
+ serialNumber: z
178
+ .string()
179
+ .nullable()
180
+ .describe('The record\'s SWPC "Serial Number:" value, e.g. "1125"; null when the body carries no such line. A per-message-code counter, not a globally unique ID — it repeats across codes and within one code on a corrected reissue, so quote it together with messageCode. It is the key the "Cancel Serial Number:", "Extension to Serial Number:", and "Continuation of Serial Number:" lines in message point at, which is what makes those chains navigable.'),
29
181
  phenomenon: z
30
182
  .string()
31
183
  .describe('Short phenomenon name derived from the body\'s NOAA scale letter, e.g. "Geomagnetic", "Radio Blackout", "Solar Radiation".'),
@@ -37,43 +189,55 @@ const AlertSchema = z
37
189
  validTo: z
38
190
  .string()
39
191
  .nullable()
40
- .describe('Validity-window end as ISO 8601 UTC, parsed from the message body ("Valid To", "Now Valid Until", or "End Time"); null when the product carries no end line.'),
192
+ .describe('Validity-window end as ISO 8601 UTC. Read from the message body\'s "Valid To", "Now Valid Until", or "End Time" label when the product states one. A Watch states none, so its end is derived from the "Highest Storm Level Predicted by Day:" list instead: the instant the last listed UTC day forecasting a storm ends (a trailing "None" day is a forecast of quiet, not coverage), so a Watch listing Sep 17 as its last storm day ends at 2026-09-18T00:00:00Z. Null when nothing in the body states or implies an end — a point-in-time Alert, or a Watch forecasting no storm on any listed day.'),
41
193
  message: z.string().describe('Full plain-text message body.'),
42
194
  })
43
195
  .describe('One SWPC alert, watch, warning, or summary.');
44
196
  export const getAlerts = tool('noaa_spaceweather_get_alerts', {
45
197
  title: 'Get Space Weather Alerts',
46
198
  description: 'Active SWPC alerts, watches, and warnings — parsed into structured records with product type, ' +
47
- 'NOAA scale and level, issue time, validity window, and plain text. Covers geomagnetic storms, ' +
48
- 'radio blackouts, and radiation storms. With active_only=false, also returns informational ' +
49
- 'summaries, expired notices, and cancellations. max_age_hours controls how far back to look ' +
50
- '(default 48 h); the SWPC feed keeps all historical records and has no built-in expiry.',
199
+ 'NOAA scale and level, issue time, serial number, validity window, and plain text. Covers ' +
200
+ 'geomagnetic storms, radio blackouts, and radiation storms. With active_only=false, also ' +
201
+ 'returns informational summaries, expired notices, and cancellations. max_age_hours controls ' +
202
+ 'how far back to look for candidates (default 48 h) — under active_only=true it does not cut ' +
203
+ 'off a product whose validity end is still in the future, and under active_only=false it is a ' +
204
+ 'literal age cutoff. The SWPC feed keeps all historical records and has no built-in expiry.',
51
205
  annotations: { readOnlyHint: true, openWorldHint: true, idempotentHint: true },
52
206
  input: z.object({
53
207
  active_only: z
54
208
  .boolean()
55
209
  .default(true)
56
- .describe('When true (default), return only in-force Warnings, Watches, and Alerts; exclude Summaries, Other, expired products, and cancellation notices. Set false to return all products, including cancellations (flagged by the cancelled field).'),
210
+ .describe('When true (default), return only in-force Warnings, Watches, and Alerts. Excluded: Summaries and unrecognized products; products whose validity end has passed; cancellation notices; any product a later cancellation names by serial; and all but the newest record carrying the "THIS SUPERSEDES ANY/ALL PRIOR WATCHES IN EFFECT" line. Counts per reason ride in the exclusions enrichment field. Set false to return every product in the window, cancellations included (flagged by the cancelled field).'),
57
211
  max_age_hours: z
58
212
  .number()
59
213
  .min(1)
60
214
  .max(720)
61
215
  .default(48)
62
- .describe('Maximum age of alerts to return, in hours (default 48). The SWPC feed retains all historical records — this window prevents returning weeks of historical notices as "active."'),
216
+ .describe('How far back to look for products, in hours (default 48). The SWPC feed retains all historical records, so this bounds the candidate set rather than declaring what is current. Under active_only=true a product whose validity end is still in the future is returned even when its issue time falls outside this window — a multi-day Watch would otherwise disappear while a day it forecasts a storm for is still running. Under active_only=false it is literal and cuts at exactly the requested age.'),
63
217
  }),
64
218
  output: z.object({
65
219
  alerts: z.array(AlertSchema).describe('Matching SWPC alert/watch/warning records.'),
66
220
  totalCount: z.number().describe('Count of records in the alerts array.'),
221
+ activeOnly: z
222
+ .boolean()
223
+ .describe('Echo of the active_only input: true when the records are the in-force set, false when they are every product in the window. Distinguishes an empty in-force result from an empty feed window.'),
67
224
  fetchedAt: z.string().describe('ISO 8601 timestamp of when this data was fetched.'),
68
225
  }),
69
226
  errors: [
70
227
  {
71
228
  reason: 'feed_unavailable',
72
229
  code: JsonRpcErrorCode.ServiceUnavailable,
73
- when: 'SWPC endpoint returns non-OK status or times out after retries.',
230
+ when: 'SWPC feed returns 5xx or 429, times out, or answers with a body that is not parseable JSON. Retried before failing.',
74
231
  retryable: true,
75
232
  recovery: 'Retry in 30–60 seconds; SWPC feeds occasionally lag during high-activity events.',
76
233
  },
234
+ {
235
+ reason: 'feed_moved',
236
+ code: JsonRpcErrorCode.ServiceUnavailable,
237
+ when: 'SWPC feed path returns a permanent 4xx (404, 410, 401, 403). Fails in one attempt.',
238
+ retryable: false,
239
+ recovery: 'Retrying will not help — the SWPC feed path no longer resolves or no longer has the expected shape; the feed URL needs updating against SWPC current inventory.',
240
+ },
77
241
  ],
78
242
  async handler(input, ctx) {
79
243
  ctx.log.info('Fetching SWPC alerts', {
@@ -82,38 +246,58 @@ export const getAlerts = tool('noaa_spaceweather_get_alerts', {
82
246
  });
83
247
  const svc = getSpaceWeatherService();
84
248
  const all = await svc.getAlerts(ctx);
85
- // Apply recency window first — the feed keeps all historical records with no
86
- // expiry; without this, active_only=true returns weeks of historical notices.
87
- // Compare as Date objects (epoch) — string comparison would silently fail when
88
- // issueDatetime and the ISO cutoff don't share the exact same format.
249
+ // Compare as epochs — string comparison would silently fail when issueDatetime and
250
+ // the ISO cutoff don't share the exact same format.
89
251
  const nowMs = Date.now();
90
252
  const cutoffMs = nowMs - input.max_age_hours * 60 * 60 * 1000;
91
- const recents = all.filter((a) => new Date(a.issueDatetime).getTime() >= cutoffMs);
92
- // active_only admits only in-force Warnings/Watches/Alerts. A product whose
93
- // parsed validTo has already elapsed is dropped; Watch/Alert notices carry no
94
- // validTo (point-in-time), and a validTo we cannot parse is treated as in-force
95
- // — both fall back to the recency window above, so an "active" query never
96
- // silently hides a warning whose end time is missing or unreadable.
97
- const filtered = input.active_only
98
- ? recents.filter((a) => {
99
- if (a.productType !== 'Warning' &&
100
- a.productType !== 'Watch' &&
101
- a.productType !== 'Alert') {
102
- return false;
103
- }
104
- // A cancellation carries the cancelled product's own type and no validity
105
- // window, so neither check above excludes it — it must be dropped explicitly.
106
- if (a.cancelled)
107
- return false;
108
- if (a.validTo === null)
109
- return true;
110
- const validToMs = new Date(a.validTo).getTime();
111
- return Number.isNaN(validToMs) || validToMs >= nowMs;
112
- })
113
- : recents;
253
+ let filtered;
254
+ let excluded;
255
+ if (input.active_only) {
256
+ // Both cross-record rules read the whole feed, not just the records the window
257
+ // admitted: a cancellation or a superseding Watch issued outside the caller's
258
+ // lookback still happened.
259
+ const filterCtx = {
260
+ nowMs,
261
+ cutoffMs,
262
+ cancelledBySerial: resolveCancelledBySerial(all),
263
+ superseded: resolveSuperseded(all),
264
+ };
265
+ const counts = Object.fromEntries(EXCLUSION_REASONS.map((r) => [r, 0]));
266
+ filtered = [];
267
+ all.forEach((alert, index) => {
268
+ const reason = exclusionReason(alert, index, filterCtx);
269
+ if (reason)
270
+ counts[reason] += 1;
271
+ else
272
+ filtered.push(alert);
273
+ });
274
+ // Zeros across the board would tell a caller nothing it can act on; the window
275
+ // echo below already says filtering was applied.
276
+ if (filtered.length < all.length)
277
+ excluded = counts;
278
+ ctx.enrich({
279
+ appliedWindowHours: input.max_age_hours,
280
+ appliedCutoff: new Date(cutoffMs).toISOString(),
281
+ });
282
+ // Written separately rather than spread in conditionally: enrich accumulates, and
283
+ // an absent field must stay absent rather than arrive as an explicit undefined.
284
+ if (excluded)
285
+ ctx.enrich({ exclusions: excluded });
286
+ }
287
+ else {
288
+ // A literal history window — nothing is filtered by reason, so no counts.
289
+ filtered = all.filter((a) => Date.parse(a.issueDatetime) >= cutoffMs);
290
+ }
114
291
  if (filtered.length === 0) {
292
+ // ctx.enrich.notice is last-wins, so the whole empty-state message is composed
293
+ // once here rather than appended to across branches.
294
+ const excludedCount = excluded
295
+ ? Object.values(excluded).reduce((sum, n) => sum + n, 0)
296
+ : undefined;
115
297
  ctx.enrich.notice(input.active_only
116
- ? 'No active alerts, watches, or warnings. Set active_only=false to include summaries.'
298
+ ? excludedCount
299
+ ? `No active alerts, watches, or warnings — all ${excludedCount} products in the feed were excluded; see exclusions for the breakdown, or set active_only=false to see them.`
300
+ : 'No active alerts, watches, or warnings. Set active_only=false to include summaries.'
117
301
  : 'No space weather products issued in the requested window.');
118
302
  }
119
303
  return {
@@ -124,6 +308,7 @@ export const getAlerts = tool('noaa_spaceweather_get_alerts', {
124
308
  level: a.level,
125
309
  noaaScale: a.noaaScale,
126
310
  cancelled: a.cancelled,
311
+ serialNumber: a.serialNumber,
127
312
  phenomenon: a.phenomenon,
128
313
  issueDatetime: a.issueDatetime,
129
314
  validFrom: a.validFrom,
@@ -131,18 +316,69 @@ export const getAlerts = tool('noaa_spaceweather_get_alerts', {
131
316
  message: a.message,
132
317
  })),
133
318
  totalCount: filtered.length,
319
+ activeOnly: input.active_only,
134
320
  fetchedAt: new Date().toISOString(),
135
321
  };
136
322
  },
137
323
  enrichment: {
138
- notice: z.string().optional().describe('Status notice when no alerts are active.'),
324
+ notice: z.string().optional().describe('Status notice when no products were returned.'),
325
+ appliedWindowHours: z
326
+ .number()
327
+ .optional()
328
+ .describe('The max_age_hours window as applied, echoed so a caller can see what bounded the candidate set. Present only under active_only=true, where a future validity end can keep an older product in scope.'),
329
+ appliedCutoff: z
330
+ .string()
331
+ .optional()
332
+ .describe('ISO 8601 UTC instant the applied window starts at: a product issued before this is outside it. Present only under active_only=true.'),
333
+ exclusions: z
334
+ .object({
335
+ agedOut: z
336
+ .number()
337
+ .describe('Issued outside the applied window with no validity end still ahead.'),
338
+ productType: z
339
+ .number()
340
+ .describe('Summaries and unrecognized products, which are never in force.'),
341
+ cancellationRecord: z.number().describe('Cancellation notices themselves.'),
342
+ cancelledBySerial: z
343
+ .number()
344
+ .describe('Named by a later cancellation\'s "Cancel Serial Number:" under the same code.'),
345
+ superseded: z
346
+ .number()
347
+ .describe('Carries the supersede line but is not the newest record that does.'),
348
+ validityElapsed: z
349
+ .number()
350
+ .describe('Validity end already passed, with nothing newer cancelling or superseding it.'),
351
+ })
352
+ .optional()
353
+ .describe('How many products active_only=true left out, by reason. Reasons overlap, so each excluded record is counted under the first that applies, in this field order — these counts plus totalCount equal the number of records the feed carried. Emitted only when something was excluded, and never under active_only=false. Use it to tell "space weather is quiet" from "everything was filtered" without a second active_only=false call.'),
354
+ },
355
+ enrichmentTrailer: {
356
+ // A structured field would otherwise collapse to a JSON blob in the content[]
357
+ // trailer. Reasons that did not fire are dropped and the keys are left as the schema
358
+ // spells them, so a reader can map a count straight back to structuredContent.
359
+ exclusions: {
360
+ render: (value) => {
361
+ const counts = value ?? {};
362
+ const fired = Object.entries(counts)
363
+ .filter(([, count]) => count > 0)
364
+ .map(([reason, count]) => `${count} ${reason}`);
365
+ return `**Excluded:** ${fired.length > 0 ? fired.join(', ') : 'none'}`;
366
+ },
367
+ },
139
368
  },
140
369
  format: (result) => {
141
370
  const lines = [];
142
371
  lines.push(`## SWPC Space Weather Alerts — ${result.fetchedAt}`);
143
- lines.push(`**Total:** ${result.totalCount}`);
372
+ // The scope belongs beside the count: a total of 0 means something different for an
373
+ // in-force query than for an unfiltered one.
374
+ const scope = result.activeOnly
375
+ ? 'active products only'
376
+ : 'all products in the requested window';
377
+ lines.push(`**Total:** ${result.totalCount} · **Scope:** ${scope}`);
144
378
  if (result.alerts.length === 0) {
145
- lines.push('\n_No active alerts._');
379
+ lines.push(result.activeOnly
380
+ ? '\n_No active alerts._'
381
+ : '\n_No space weather products issued in the requested window._');
146
382
  }
147
383
  else {
148
384
  for (const alert of result.alerts) {
@@ -154,7 +390,11 @@ export const getAlerts = tool('noaa_spaceweather_get_alerts', {
154
390
  // Spell out a scale-less product rather than leaving a bare "Level: 0", which
155
391
  // reads as "calm" when it actually means the product states no NOAA scale.
156
392
  const scale = alert.noaaScale ? ` (${alert.noaaScale})` : ' (no NOAA scale)';
157
- lines.push(`**Issued:** ${alert.issueDatetime} | **Level:** ${alert.level}${scale}`);
393
+ // The serial is what makes the "Cancel Serial Number:" and "Continuation of
394
+ // Serial Number:" chains in the body navigable, so it rides here for clients
395
+ // that render only content[].
396
+ const serial = alert.serialNumber ?? 'not stated';
397
+ lines.push(`**Issued:** ${alert.issueDatetime} | **Level:** ${alert.level}${scale} | **Serial:** ${serial}`);
158
398
  if (alert.validFrom)
159
399
  lines.push(`**Valid From:** ${alert.validFrom}`);
160
400
  if (alert.validTo)