@ryancardin/noaa-tides-currents-mcp-server 2.0.0 → 2.1.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 (68) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +18 -5
  3. package/dist/client/deadline.d.ts +6 -0
  4. package/dist/client/deadline.js +23 -0
  5. package/dist/client/http.d.ts +2 -0
  6. package/dist/client/http.js +75 -4
  7. package/dist/constants.d.ts +11 -0
  8. package/dist/constants.js +11 -0
  9. package/dist/format/respond.d.ts +37 -1
  10. package/dist/format/respond.js +45 -10
  11. package/dist/index.js +0 -0
  12. package/dist/reference/content.d.ts +1 -1
  13. package/dist/reference/content.js +24 -0
  14. package/dist/resources/index.js +8 -0
  15. package/dist/services/nws-api.d.ts +115 -0
  16. package/dist/services/nws-api.js +267 -0
  17. package/dist/tools/astronomy.js +6 -1
  18. package/dist/tools/currents.js +3 -1
  19. package/dist/tools/derived.js +6 -1
  20. package/dist/tools/index.js +12 -0
  21. package/dist/tools/marine-forecast.d.ts +7 -0
  22. package/dist/tools/marine-forecast.js +177 -0
  23. package/dist/tools/met.js +2 -1
  24. package/dist/tools/reference.js +4 -0
  25. package/dist/tools/station-metadata.js +4 -1
  26. package/dist/tools/stations.js +4 -1
  27. package/dist/tools/water.js +4 -1
  28. package/package.json +6 -6
  29. package/dist/interfaces/config.d.ts +0 -6
  30. package/dist/interfaces/config.js +0 -1
  31. package/dist/interfaces/noaa.d.ts +0 -334
  32. package/dist/interfaces/noaa.js +0 -98
  33. package/dist/interfaces/parameters.d.ts +0 -18
  34. package/dist/interfaces/parameters.js +0 -5
  35. package/dist/mcp-server.d.ts +0 -12
  36. package/dist/mcp-server.js +0 -103
  37. package/dist/moon-phase-service.d.ts +0 -122
  38. package/dist/moon-phase-service.js +0 -187
  39. package/dist/noaa-service.d.ts +0 -60
  40. package/dist/noaa-service.js +0 -159
  41. package/dist/schemas/dpapi.d.ts +0 -198
  42. package/dist/schemas/dpapi.js +0 -89
  43. package/dist/server/config.d.ts +0 -9
  44. package/dist/server/config.js +0 -40
  45. package/dist/server/mcp-server.d.ts +0 -12
  46. package/dist/server/mcp-server.js +0 -103
  47. package/dist/services/dpapi-service.d.ts +0 -72
  48. package/dist/services/dpapi-service.js +0 -164
  49. package/dist/services/noaa-parameters-service.d.ts +0 -76
  50. package/dist/services/noaa-parameters-service.js +0 -128
  51. package/dist/services/noaa-service.d.ts +0 -52
  52. package/dist/services/noaa-service.js +0 -151
  53. package/dist/sun-service.d.ts +0 -184
  54. package/dist/sun-service.js +0 -218
  55. package/dist/tools/derived-product-tools.d.ts +0 -6
  56. package/dist/tools/derived-product-tools.js +0 -168
  57. package/dist/tools/moon-tools.d.ts +0 -6
  58. package/dist/tools/moon-tools.js +0 -69
  59. package/dist/tools/parameter-tools.d.ts +0 -6
  60. package/dist/tools/parameter-tools.js +0 -77
  61. package/dist/tools/station-tools.d.ts +0 -6
  62. package/dist/tools/station-tools.js +0 -51
  63. package/dist/tools/sun-tools.d.ts +0 -6
  64. package/dist/tools/sun-tools.js +0 -109
  65. package/dist/tools/water-tools.d.ts +0 -6
  66. package/dist/tools/water-tools.js +0 -150
  67. package/dist/types.d.ts +0 -337
  68. package/dist/types.js +0 -98
@@ -0,0 +1,267 @@
1
+ /**
2
+ * NWS Weather API (api.weather.gov) — wind and marine FORECASTS.
3
+ *
4
+ * This is a different NOAA service from CO-OPS with different shapes:
5
+ * - /points/{lat},{lon} resolves a coordinate to a forecast office gridpoint
6
+ * (gridX/gridY assignments drift over re-gridding, so cache with a TTL and
7
+ * never hardcode them).
8
+ * - /gridpoints/{office}/{x},{y} returns NUMERIC time series (the friendlier
9
+ * /forecast/hourly endpoint returns display strings like "10 to 15 mph"
10
+ * and is deliberately not used). Each series value carries a validTime of
11
+ * the form "2026-07-05T18:00:00+00:00/PT3H" — an ISO instant plus an ISO
12
+ * duration the value holds for — which we expand into hourly samples.
13
+ * - Marine zone TEXT forecasts are NOT served by /zones/forecast/{id}/forecast
14
+ * (that 404s with "Marine Forecast Not Supported"); the working path is the
15
+ * Products API: latest CWF product for the issuing office, then extracting
16
+ * the segment for the zone from the multi-zone bulletin.
17
+ */
18
+ import { cache } from "../client/cache.js";
19
+ import { fetchNwsApi } from "../client/http.js";
20
+ import { NoaaApiError } from "../client/http.js";
21
+ import { CACHE_TTL } from "../constants.js";
22
+ function pointKey(latitude, longitude) {
23
+ return `${latitude.toFixed(4)},${longitude.toFixed(4)}`;
24
+ }
25
+ export async function resolvePoint(latitude, longitude) {
26
+ const key = pointKey(latitude, longitude);
27
+ return cache.getOrLoad(`nws:point:${key}`, CACHE_TTL.nwsPoint, async () => {
28
+ const data = await fetchNwsApi(`/points/${key}`);
29
+ const p = data.properties;
30
+ if (!p?.gridId || p.gridX === undefined || p.gridY === undefined) {
31
+ throw new NoaaApiError("NWS API error: the point resolved to no forecast gridpoint. NWS forecasts cover the US and its territories only.");
32
+ }
33
+ const loc = p.relativeLocation?.properties;
34
+ return {
35
+ gridId: p.gridId,
36
+ gridX: p.gridX,
37
+ gridY: p.gridY,
38
+ cwa: p.cwa ?? p.gridId,
39
+ timeZone: p.timeZone ?? "UTC",
40
+ pointType: p.type,
41
+ place: loc?.city && loc?.state ? `${loc.city}, ${loc.state}` : undefined,
42
+ };
43
+ });
44
+ }
45
+ const HOUR_MS = 3_600_000;
46
+ /** Parse an ISO 8601 duration like "PT3H", "P1D", "PT1H30M" to milliseconds. */
47
+ export function parseIsoDurationMs(duration) {
48
+ const match = duration.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
49
+ if (!match)
50
+ return HOUR_MS;
51
+ const [, days, hours, minutes, seconds] = match;
52
+ return ((Number(days ?? 0) * 24 + Number(hours ?? 0)) * HOUR_MS +
53
+ Number(minutes ?? 0) * 60_000 +
54
+ Number(seconds ?? 0) * 1_000);
55
+ }
56
+ /**
57
+ * Expand a gridpoint series (start-instant + hold-duration values) into a map
58
+ * of epoch-ms-per-hour → value.
59
+ */
60
+ export function expandGridSeries(series) {
61
+ const out = new Map();
62
+ for (const entry of series?.values ?? []) {
63
+ if (entry.value === null || entry.value === undefined)
64
+ continue;
65
+ const [startStr, durationStr] = entry.validTime.split("/");
66
+ const start = Date.parse(startStr);
67
+ if (Number.isNaN(start))
68
+ continue;
69
+ const startHour = Math.floor(start / HOUR_MS) * HOUR_MS;
70
+ const hours = Math.max(1, Math.round(parseIsoDurationMs(durationStr ?? "PT1H") / HOUR_MS));
71
+ for (let i = 0; i < hours; i++) {
72
+ const key = startHour + i * HOUR_MS;
73
+ if (!out.has(key))
74
+ out.set(key, entry.value);
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+ /** Convert a gridpoint speed value to knots based on its declared unit. */
80
+ export function speedToKnots(value, uom) {
81
+ if (uom?.includes("km_h"))
82
+ return value / 1.852;
83
+ if (uom?.includes("m_s"))
84
+ return value * 1.9438444924;
85
+ if (uom?.includes("kn"))
86
+ return value;
87
+ if (uom?.includes("mi_h"))
88
+ return value * 0.8689762419;
89
+ // NWS gridpoint wind defaults to km/h; assume that when the unit is missing.
90
+ return value / 1.852;
91
+ }
92
+ export function knotsToMs(knots) {
93
+ return knots * 0.5144444444;
94
+ }
95
+ /** Convert a gridpoint length value to meters based on its declared unit. */
96
+ export function lengthToMeters(value, uom) {
97
+ if (uom?.includes("ft"))
98
+ return value * 0.3048;
99
+ return value; // wmoUnit:m
100
+ }
101
+ export function metersToFeet(meters) {
102
+ return meters / 0.3048;
103
+ }
104
+ const COMPASS_POINTS = [
105
+ "N",
106
+ "NNE",
107
+ "NE",
108
+ "ENE",
109
+ "E",
110
+ "ESE",
111
+ "SE",
112
+ "SSE",
113
+ "S",
114
+ "SSW",
115
+ "SW",
116
+ "WSW",
117
+ "W",
118
+ "WNW",
119
+ "NW",
120
+ "NNW",
121
+ ];
122
+ export function toCompass(degrees) {
123
+ const normalized = ((degrees % 360) + 360) % 360;
124
+ return COMPASS_POINTS[Math.round(normalized / 22.5) % 16];
125
+ }
126
+ /**
127
+ * Hourly numeric wind forecast (plus wave height when the grid carries it)
128
+ * for a coordinate, starting at the current hour.
129
+ */
130
+ export async function getWindForecast(latitude, longitude, hours) {
131
+ const point = await resolvePoint(latitude, longitude);
132
+ const gridPath = `/gridpoints/${point.gridId}/${point.gridX},${point.gridY}`;
133
+ const grid = await cache.getOrLoad(`nws:grid:${gridPath}`, CACHE_TTL.nwsForecast, () => fetchNwsApi(gridPath));
134
+ const p = grid.properties ?? {};
135
+ const speeds = expandGridSeries(p.windSpeed);
136
+ const gusts = expandGridSeries(p.windGust);
137
+ const directions = expandGridSeries(p.windDirection);
138
+ const waves = expandGridSeries(p.waveHeight);
139
+ const startHour = Math.floor(Date.now() / HOUR_MS) * HOUR_MS;
140
+ const samples = [];
141
+ for (let i = 0; i < hours; i++) {
142
+ const t = startHour + i * HOUR_MS;
143
+ const speed = speeds.get(t);
144
+ const gust = gusts.get(t);
145
+ const direction = directions.get(t);
146
+ const wave = waves.get(t);
147
+ if (speed === undefined &&
148
+ gust === undefined &&
149
+ direction === undefined &&
150
+ wave === undefined) {
151
+ continue; // past the end of the forecast grid
152
+ }
153
+ samples.push({
154
+ time: new Date(t).toISOString(),
155
+ speed_knots: speed === undefined
156
+ ? null
157
+ : round1(speedToKnots(speed, p.windSpeed?.uom)),
158
+ gust_knots: gust === undefined ? null : round1(speedToKnots(gust, p.windGust?.uom)),
159
+ direction_deg: direction === undefined ? null : Math.round(direction),
160
+ compass: direction === undefined ? null : toCompass(direction),
161
+ wave_height_m: wave === undefined
162
+ ? null
163
+ : round1(lengthToMeters(wave, p.waveHeight?.uom)),
164
+ });
165
+ }
166
+ return { point, updated: p.updateTime, samples };
167
+ }
168
+ function round1(value) {
169
+ return Math.round(value * 10) / 10;
170
+ }
171
+ /** Resolve the NWS coastal marine zone covering a coordinate (e.g. GMZ350). */
172
+ export async function findCoastalZone(latitude, longitude) {
173
+ const key = pointKey(latitude, longitude);
174
+ return cache.getOrLoad(`nws:zone:${key}`, CACHE_TTL.nwsPoint, async () => {
175
+ const data = await fetchNwsApi("/zones", {
176
+ type: "coastal",
177
+ point: key,
178
+ include_geometry: false,
179
+ });
180
+ const zone = data.features?.[0]?.properties;
181
+ if (!zone?.id) {
182
+ throw new NoaaApiError("NWS API error: no coastal marine zone covers this point. Coastal Waters Forecasts exist only for US coastal waters (roughly out to 20-60 NM); for a land point, use nws_get_wind_forecast instead.");
183
+ }
184
+ return {
185
+ id: zone.id,
186
+ name: zone.name ?? zone.id,
187
+ cwa: zone.cwa?.[0] ?? "",
188
+ };
189
+ });
190
+ }
191
+ /**
192
+ * True when a CWF bulletin segment's UGC header covers the given zone.
193
+ * Headers look like "GMZ330-335-350-061015-" (list) or "GMZ350>355-061015-"
194
+ * (range), always ending in a 6-digit expiry.
195
+ */
196
+ export function segmentCoversZone(segment, zoneId) {
197
+ const prefix = zoneId.slice(0, 3);
198
+ const target = Number(zoneId.slice(3));
199
+ const ugcLines = segment.match(/^[A-Z]{2}Z[0-9>-]+/gm) ?? [];
200
+ for (const line of ugcLines) {
201
+ if (!line.startsWith(prefix))
202
+ continue;
203
+ const body = line.slice(3).replace(/-\d{6}-?$/, "");
204
+ for (const part of body.split("-")) {
205
+ if (!part)
206
+ continue;
207
+ if (part.includes(">")) {
208
+ const [lo, hi] = part.split(">").map(Number);
209
+ if (target >= lo && target <= hi)
210
+ return true;
211
+ }
212
+ else if (Number(part) === target) {
213
+ return true;
214
+ }
215
+ }
216
+ }
217
+ return false;
218
+ }
219
+ /**
220
+ * Extract the segment of a multi-zone CWF bulletin covering one zone.
221
+ * Bulletins are segmented by "$$" terminators; each segment opens with a UGC
222
+ * zone-list header. Returns undefined when no segment matches.
223
+ */
224
+ export function extractZoneSegment(productText, zoneId) {
225
+ const segments = productText.split(/^\s*\$\$\s*$/m);
226
+ const match = segments.find((s) => segmentCoversZone(s, zoneId));
227
+ return match?.trim();
228
+ }
229
+ /**
230
+ * Extract the office-wide synopsis block, when the bulletin carries one.
231
+ * Offices vary the header casing (".SYNOPSIS...", ".Synopsis For ...").
232
+ */
233
+ export function extractSynopsis(productText) {
234
+ const match = productText.match(/^\.synopsis[\s\S]*?(?=^\s*\$\$\s*$|^\.\w)/im);
235
+ return match?.[0]?.trim();
236
+ }
237
+ /**
238
+ * Latest Coastal Waters Forecast text for the marine zone covering a point.
239
+ * CWF products are keyed by issuing office (not zone) and cover every zone
240
+ * that office serves in one bulletin, so the zone's segment is extracted here.
241
+ */
242
+ export async function getMarineTextForecast(latitude, longitude) {
243
+ const zone = await findCoastalZone(latitude, longitude);
244
+ if (!zone.cwa) {
245
+ throw new NoaaApiError(`NWS API error: marine zone ${zone.id} has no issuing office on record; cannot locate its Coastal Waters Forecast.`);
246
+ }
247
+ const result = await cache.getOrLoad(`nws:cwf:${zone.cwa}`, CACHE_TTL.nwsForecast, async () => {
248
+ const list = await fetchNwsApi(`/products/types/CWF/locations/${zone.cwa}`);
249
+ const latest = list["@graph"]?.[0];
250
+ if (!latest?.id) {
251
+ throw new NoaaApiError(`NWS API error: no recent Coastal Waters Forecast found for office ${zone.cwa}.`);
252
+ }
253
+ const product = await fetchNwsApi(`/products/${latest.id}`);
254
+ return {
255
+ productId: latest.id,
256
+ issuanceTime: product.issuanceTime ?? latest.issuanceTime,
257
+ text: product.productText ?? "",
258
+ };
259
+ });
260
+ return {
261
+ zone,
262
+ productId: result.productId,
263
+ issuanceTime: result.issuanceTime,
264
+ segment: extractZoneSegment(result.text, zone.id),
265
+ synopsis: extractSynopsis(result.text),
266
+ };
267
+ }
@@ -9,7 +9,7 @@ import { SunService } from "../services/sun-service.js";
9
9
  import { MoonPhaseName } from "../types/moon.js";
10
10
  import { SunEventType } from "../types/sun.js";
11
11
  import { LatitudeSchema, LOCAL_COMPUTE_ANNOTATIONS, LongitudeSchema, ResponseFormatSchema, } from "../schemas/common.js";
12
- import { markdownTable, respond, respondError } from "../format/respond.js";
12
+ import { markdownTable, respond, respondError, RawToolOutputSchema, } from "../format/respond.js";
13
13
  const IsoDateSchema = z
14
14
  .string()
15
15
  .regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD format.")
@@ -34,6 +34,7 @@ Tide context: spring tides (largest range) occur just after new and full moons;
34
34
  response_format: ResponseFormatSchema,
35
35
  },
36
36
  annotations: LOCAL_COMPUTE_ANNOTATIONS,
37
+ outputSchema: RawToolOutputSchema,
37
38
  }, async (params) => {
38
39
  try {
39
40
  const phases = params.end_date
@@ -103,6 +104,7 @@ Use for: "when is the next full moon?", planning around spring tides (which foll
103
104
  response_format: ResponseFormatSchema,
104
105
  },
105
106
  annotations: LOCAL_COMPUTE_ANNOTATIONS,
107
+ outputSchema: RawToolOutputSchema,
106
108
  }, async (params) => {
107
109
  try {
108
110
  const occurrences = moonService.getNextMoonPhase({
@@ -136,6 +138,7 @@ Times are ISO UTC unless an IANA timezone is provided. At high latitudes some ev
136
138
  response_format: ResponseFormatSchema,
137
139
  },
138
140
  annotations: LOCAL_COMPUTE_ANNOTATIONS,
141
+ outputSchema: RawToolOutputSchema,
139
142
  }, async (params) => {
140
143
  try {
141
144
  const times = params.end_date
@@ -201,6 +204,7 @@ Use for: shadow/lighting analysis, solar exposure. Computed locally with suncalc
201
204
  response_format: ResponseFormatSchema,
202
205
  },
203
206
  annotations: LOCAL_COMPUTE_ANNOTATIONS,
207
+ outputSchema: RawToolOutputSchema,
204
208
  }, async (params) => {
205
209
  try {
206
210
  const position = sunService.getSunPosition({
@@ -245,6 +249,7 @@ Use for: "when is sunset today?", planning golden-hour photography or dawn fishi
245
249
  response_format: ResponseFormatSchema,
246
250
  },
247
251
  annotations: LOCAL_COMPUTE_ANNOTATIONS,
252
+ outputSchema: RawToolOutputSchema,
248
253
  }, async (params) => {
249
254
  try {
250
255
  const occurrences = sunService.getNextSunEvent({
@@ -4,7 +4,7 @@
4
4
  import { z } from "zod";
5
5
  import { getCurrents, getCurrentPredictions } from "../services/data-api.js";
6
6
  import { dateFields, READ_ONLY_ANNOTATIONS, ResponseFormatSchema, StationIdSchema, TimeZoneSchema, UnitsSchema, } from "../schemas/common.js";
7
- import { respond, respondError } from "../format/respond.js";
7
+ import { respond, respondError, RawToolOutputSchema, } from "../format/respond.js";
8
8
  import { seriesMarkdown, timeZoneLabel } from "../format/series.js";
9
9
  import { unitLabel } from "../format/units.js";
10
10
  const BinSchema = z
@@ -35,6 +35,7 @@ Returns per record: t (time), s (speed), d (direction, degrees true), b (bin num
35
35
  response_format: ResponseFormatSchema,
36
36
  },
37
37
  annotations: READ_ONLY_ANNOTATIONS,
38
+ outputSchema: RawToolOutputSchema,
38
39
  }, async (params) => {
39
40
  try {
40
41
  const response = await getCurrents(params);
@@ -89,6 +90,7 @@ vel_type="speed_dir" returns Speed/Direction pairs; "default" returns velocities
89
90
  response_format: ResponseFormatSchema,
90
91
  },
91
92
  annotations: READ_ONLY_ANNOTATIONS,
93
+ outputSchema: RawToolOutputSchema,
92
94
  }, async (params) => {
93
95
  try {
94
96
  const response = await getCurrentPredictions(params);
@@ -5,7 +5,7 @@
5
5
  import { z } from "zod";
6
6
  import { getExtremeWaterLevels, getHighTideFlooding, getSeaLevelRiseProjections, getSeaLevelTrends, getTopTenWaterLevels, } from "../services/dpapi.js";
7
7
  import { READ_ONLY_ANNOTATIONS, ResponseFormatSchema, StationIdSchema, UnitsSchema, } from "../schemas/common.js";
8
- import { markdownTable, respond, respondError } from "../format/respond.js";
8
+ import { markdownTable, respond, respondError, RawToolOutputSchema, } from "../format/respond.js";
9
9
  function firstArray(payload) {
10
10
  for (const [key, value] of Object.entries(payload)) {
11
11
  if (Array.isArray(value) &&
@@ -53,6 +53,7 @@ Relative sea level combines ocean rise AND local land movement (subsidence/uplif
53
53
  response_format: ResponseFormatSchema,
54
54
  },
55
55
  annotations: READ_ONLY_ANNOTATIONS,
56
+ outputSchema: RawToolOutputSchema,
56
57
  }, async (params) => {
57
58
  try {
58
59
  const payload = await getSeaLevelTrends({
@@ -98,6 +99,7 @@ Scenarios: low, intermediate-low, intermediate, intermediate-high, high, extreme
98
99
  response_format: ResponseFormatSchema,
99
100
  },
100
101
  annotations: READ_ONLY_ANNOTATIONS,
102
+ outputSchema: RawToolOutputSchema,
101
103
  }, async (params) => {
102
104
  try {
103
105
  const payload = await getSeaLevelRiseProjections({
@@ -134,6 +136,7 @@ Use for flood risk questions ("what water level has a 1% chance per year at X?")
134
136
  response_format: ResponseFormatSchema,
135
137
  },
136
138
  annotations: READ_ONLY_ANNOTATIONS,
139
+ outputSchema: RawToolOutputSchema,
137
140
  }, async (params) => {
138
141
  try {
139
142
  const payload = await getExtremeWaterLevels({
@@ -183,6 +186,7 @@ Heights are relative to the requested datum (MHHW is typical for flood compariso
183
186
  response_format: ResponseFormatSchema,
184
187
  },
185
188
  annotations: READ_ONLY_ANNOTATIONS,
189
+ outputSchema: RawToolOutputSchema,
186
190
  }, async (params) => {
187
191
  try {
188
192
  const payload = await getTopTenWaterLevels({
@@ -283,6 +287,7 @@ Use for: "how often does X flood?", trends in nuisance flooding, future flooding
283
287
  response_format: ResponseFormatSchema,
284
288
  },
285
289
  annotations: READ_ONLY_ANNOTATIONS,
290
+ outputSchema: RawToolOutputSchema,
286
291
  }, async (params) => {
287
292
  try {
288
293
  const { report, response_format, ...rest } = params;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Central registration for all tools.
3
3
  */
4
+ import { withProviderDeadline } from "../client/deadline.js";
4
5
  import { registerWaterTools } from "./water.js";
5
6
  import { registerCurrentTools } from "./currents.js";
6
7
  import { registerMetTools } from "./met.js";
@@ -8,8 +9,18 @@ import { registerStationTools } from "./stations.js";
8
9
  import { registerStationMetadataTools } from "./station-metadata.js";
9
10
  import { registerDerivedProductTools } from "./derived.js";
10
11
  import { registerAstronomyTools } from "./astronomy.js";
12
+ import { registerMarineForecastTools } from "./marine-forecast.js";
11
13
  import { registerReferenceTools } from "./reference.js";
12
14
  export function registerAllTools(server) {
15
+ // Keep a provider deadline across chained reads within one tool invocation.
16
+ // The facade preserves registration state on the real SDK server.
17
+ server = new Proxy(server, {
18
+ get(target, key, receiver) {
19
+ if (key !== "registerTool")
20
+ return Reflect.get(target, key, receiver);
21
+ return (name, config, callback) => target.registerTool(name, config, ((...args) => withProviderDeadline(() => callback(...args), 45_000)));
22
+ },
23
+ });
13
24
  registerWaterTools(server);
14
25
  registerCurrentTools(server);
15
26
  registerMetTools(server);
@@ -17,5 +28,6 @@ export function registerAllTools(server) {
17
28
  registerStationMetadataTools(server);
18
29
  registerDerivedProductTools(server);
19
30
  registerAstronomyTools(server);
31
+ registerMarineForecastTools(server);
20
32
  registerReferenceTools(server);
21
33
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Wind & marine FORECAST tools backed by the NWS Weather API
3
+ * (api.weather.gov) — a different NOAA service from CO-OPS, hence the
4
+ * nws_* prefix (mirroring how astro_* marks non-CO-OPS tools).
5
+ */
6
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ export declare function registerMarineForecastTools(server: McpServer): void;
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Wind & marine FORECAST tools backed by the NWS Weather API
3
+ * (api.weather.gov) — a different NOAA service from CO-OPS, hence the
4
+ * nws_* prefix (mirroring how astro_* marks non-CO-OPS tools).
5
+ */
6
+ import { z } from "zod";
7
+ import { getMarineTextForecast, getWindForecast, knotsToMs, metersToFeet, } from "../services/nws-api.js";
8
+ import { LatitudeSchema, LongitudeSchema, READ_ONLY_ANNOTATIONS, ResponseFormatSchema, UnitsSchema, } from "../schemas/common.js";
9
+ import { respond, respondError, RawToolOutputSchema, } from "../format/respond.js";
10
+ import { seriesMarkdown } from "../format/series.js";
11
+ /** "2026-07-06 09:00" in the gridpoint's IANA time zone. */
12
+ function formatLocal(isoUtc, timeZone) {
13
+ try {
14
+ return new Intl.DateTimeFormat("sv-SE", {
15
+ timeZone,
16
+ year: "numeric",
17
+ month: "2-digit",
18
+ day: "2-digit",
19
+ hour: "2-digit",
20
+ minute: "2-digit",
21
+ hour12: false,
22
+ }).format(new Date(isoUtc));
23
+ }
24
+ catch {
25
+ return isoUtc;
26
+ }
27
+ }
28
+ export function registerMarineForecastTools(server) {
29
+ server.registerTool("nws_get_wind_forecast", {
30
+ title: "Get Wind Forecast (NWS)",
31
+ description: `Get the NWS hourly wind FORECAST (speed, gust, direction — plus wave height where the grid carries it) for a latitude/longitude.
32
+
33
+ Data comes from the NWS forecast grid (api.weather.gov gridpoints — numeric NDFD values, not display strings). Horizon is up to ~7 days (156 hours), starting at the current hour. US and territories only. Wave height appears for marine/nearshore gridpoints; swell/period fields are typically only populated for open-ocean points.
34
+
35
+ Units: english = knots and feet (default), metric = m/s and meters. Direction is degrees true, the direction the wind blows FROM.
36
+
37
+ This is FORECAST data. For observed (measured) wind at a NOAA station right now, use noaa_get_meteorological_data (product "wind"). For the official marine text forecast (Coastal Waters Forecast narrative with small-craft advisories), use nws_get_marine_forecast.`,
38
+ inputSchema: {
39
+ latitude: LatitudeSchema,
40
+ longitude: LongitudeSchema,
41
+ hours: z
42
+ .number()
43
+ .int()
44
+ .min(1)
45
+ .max(156)
46
+ .default(24)
47
+ .describe("Forecast hours to return, starting at the current hour (max 156 ≈ 7 days; the grid may end sooner)."),
48
+ units: UnitsSchema,
49
+ response_format: ResponseFormatSchema,
50
+ },
51
+ annotations: READ_ONLY_ANNOTATIONS,
52
+ outputSchema: RawToolOutputSchema,
53
+ }, async (params) => {
54
+ try {
55
+ const forecast = await getWindForecast(params.latitude, params.longitude, params.hours);
56
+ const metric = params.units === "metric";
57
+ const speedLabel = metric ? "m/s" : "knots";
58
+ const waveLabel = metric ? "meters" : "feet";
59
+ const convertSpeed = (knots) => knots === null ? null : metric ? round1(knotsToMs(knots)) : knots;
60
+ const convertWave = (meters) => meters === null
61
+ ? null
62
+ : metric
63
+ ? meters
64
+ : round1(metersToFeet(meters));
65
+ const data = forecast.samples.map((s) => ({
66
+ time_utc: s.time,
67
+ time_local: formatLocal(s.time, forecast.point.timeZone),
68
+ speed: convertSpeed(s.speed_knots),
69
+ gust: convertSpeed(s.gust_knots),
70
+ direction_deg: s.direction_deg,
71
+ compass: s.compass,
72
+ wave_height: convertWave(s.wave_height_m),
73
+ }));
74
+ const hasWaves = data.some((d) => d.wave_height !== null);
75
+ const structured = {
76
+ latitude: params.latitude,
77
+ longitude: params.longitude,
78
+ place: forecast.point.place,
79
+ grid: {
80
+ office: forecast.point.gridId,
81
+ grid_x: forecast.point.gridX,
82
+ grid_y: forecast.point.gridY,
83
+ point_type: forecast.point.pointType,
84
+ },
85
+ time_zone: forecast.point.timeZone,
86
+ units: params.units,
87
+ speed_units_label: speedLabel,
88
+ wave_units_label: waveLabel,
89
+ forecast_updated: forecast.updated,
90
+ count: data.length,
91
+ data,
92
+ };
93
+ const headers = [
94
+ `Time (${forecast.point.timeZone})`,
95
+ `Speed (${speedLabel})`,
96
+ `Gust (${speedLabel})`,
97
+ "Direction (°T)",
98
+ "Compass",
99
+ ...(hasWaves ? [`Waves (${waveLabel})`] : []),
100
+ ];
101
+ const rows = data.map((d) => [
102
+ d.time_local,
103
+ d.speed,
104
+ d.gust,
105
+ d.direction_deg,
106
+ d.compass,
107
+ ...(hasWaves ? [d.wave_height] : []),
108
+ ]);
109
+ const markdown = seriesMarkdown({
110
+ title: "Wind Forecast (NWS)",
111
+ station: `${params.latitude.toFixed(4)},${params.longitude.toFixed(4)}`,
112
+ stationName: forecast.point.place,
113
+ unitsLabel: speedLabel,
114
+ timeZone: forecast.point.timeZone,
115
+ extra: [
116
+ `NWS grid ${forecast.point.gridId} ${forecast.point.gridX},${forecast.point.gridY}${forecast.updated ? ` · updated ${forecast.updated}` : ""}. Direction = where the wind blows FROM.`,
117
+ ],
118
+ }, headers, rows);
119
+ return respond(params.response_format, structured, markdown);
120
+ }
121
+ catch (error) {
122
+ return respondError(error);
123
+ }
124
+ });
125
+ server.registerTool("nws_get_marine_forecast", {
126
+ title: "Get Marine Text Forecast (NWS)",
127
+ description: `Get the official NWS Coastal Waters Forecast (CWF) narrative for the marine zone covering a latitude/longitude — day-part wind/seas text ("SE winds 10 to 15 kt... Bay waters choppy"), including Small Craft Advisories.
128
+
129
+ The point must lie in (or very near) US coastal waters; CWF zones extend roughly 20-60 NM offshore. Returns the zone's segment from the latest bulletin plus the office-wide synopsis.
130
+
131
+ For numeric hourly wind values use nws_get_wind_forecast; for observed wind at a NOAA station use noaa_get_meteorological_data (product "wind").`,
132
+ inputSchema: {
133
+ latitude: LatitudeSchema,
134
+ longitude: LongitudeSchema,
135
+ response_format: ResponseFormatSchema,
136
+ },
137
+ annotations: READ_ONLY_ANNOTATIONS,
138
+ outputSchema: RawToolOutputSchema,
139
+ }, async (params) => {
140
+ try {
141
+ const forecast = await getMarineTextForecast(params.latitude, params.longitude);
142
+ const structured = {
143
+ latitude: params.latitude,
144
+ longitude: params.longitude,
145
+ zone_id: forecast.zone.id,
146
+ zone_name: forecast.zone.name,
147
+ issuing_office: forecast.zone.cwa,
148
+ product_id: forecast.productId,
149
+ issuance_time: forecast.issuanceTime,
150
+ synopsis: forecast.synopsis ?? null,
151
+ forecast: forecast.segment ?? null,
152
+ };
153
+ const lines = [
154
+ `# Coastal Waters Forecast — ${forecast.zone.name}`,
155
+ "",
156
+ `**Zone**: ${forecast.zone.id} · **Office**: ${forecast.zone.cwa}${forecast.issuanceTime ? ` · **Issued**: ${forecast.issuanceTime}` : ""}`,
157
+ "",
158
+ ];
159
+ if (forecast.synopsis) {
160
+ lines.push("```", forecast.synopsis, "```", "");
161
+ }
162
+ if (forecast.segment) {
163
+ lines.push("```", forecast.segment, "```");
164
+ }
165
+ else {
166
+ lines.push(`_The latest ${forecast.zone.cwa} bulletin contains no segment for ${forecast.zone.id} — the zone may be covered under a combined header this parser missed, or the bulletin may be mid-update. Retry, or request response_format "json"._`);
167
+ }
168
+ return respond(params.response_format, structured, lines.join("\n"));
169
+ }
170
+ catch (error) {
171
+ return respondError(error);
172
+ }
173
+ });
174
+ }
175
+ function round1(value) {
176
+ return Math.round(value * 10) / 10;
177
+ }
package/dist/tools/met.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { z } from "zod";
5
5
  import { getMeteorologicalData, } from "../services/data-api.js";
6
6
  import { dateFields, READ_ONLY_ANNOTATIONS, ResponseFormatSchema, StationIdSchema, TimeZoneSchema, UnitsSchema, } from "../schemas/common.js";
7
- import { respond, respondError } from "../format/respond.js";
7
+ import { respond, respondError, RawToolOutputSchema, } from "../format/respond.js";
8
8
  import { seriesMarkdown, timeZoneLabel } from "../format/series.js";
9
9
  import { FLAG_LEGENDS, unitLabel } from "../format/units.js";
10
10
  const MET_KIND = {
@@ -53,6 +53,7 @@ Not every station has every sensor — check with noaa_get_station_info (expand
53
53
  response_format: ResponseFormatSchema,
54
54
  },
55
55
  annotations: READ_ONLY_ANNOTATIONS,
56
+ outputSchema: RawToolOutputSchema,
56
57
  }, async (params) => {
57
58
  try {
58
59
  const response = await getMeteorologicalData(params);
@@ -20,9 +20,13 @@ Consult this before constructing unusual requests — especially "datums" (which
20
20
  .describe("Reference topic to retrieve."),
21
21
  },
22
22
  annotations: LOCAL_COMPUTE_ANNOTATIONS,
23
+ outputSchema: z
24
+ .object({ topic: z.enum(REFERENCE_TOPICS), guide: z.string() })
25
+ .strict(),
23
26
  }, async ({ topic }) => {
24
27
  try {
25
28
  return {
29
+ structuredContent: { topic, guide: REFERENCE_CONTENT[topic] },
26
30
  content: [{ type: "text", text: REFERENCE_CONTENT[topic] }],
27
31
  };
28
32
  }
@@ -5,7 +5,7 @@
5
5
  import { z } from "zod";
6
6
  import { extractList, getStationResource } from "../services/metadata-api.js";
7
7
  import { READ_ONLY_ANNOTATIONS, ResponseFormatSchema, StationIdSchema, UnitsSchema, } from "../schemas/common.js";
8
- import { markdownTable, respond, respondError } from "../format/respond.js";
8
+ import { markdownTable, respond, respondError, RawToolOutputSchema, } from "../format/respond.js";
9
9
  import { unitLabel } from "../format/units.js";
10
10
  export function registerStationMetadataTools(server) {
11
11
  server.registerTool("noaa_get_station_datums", {
@@ -25,6 +25,7 @@ All values share one reference zero (the station datum), so datum-to-datum conve
25
25
  response_format: ResponseFormatSchema,
26
26
  },
27
27
  annotations: READ_ONLY_ANNOTATIONS,
28
+ outputSchema: RawToolOutputSchema,
28
29
  }, async (params) => {
29
30
  try {
30
31
  const resource = params.epoch === "superseded" ? "supersededdatums" : "datums";
@@ -84,6 +85,7 @@ Use for: building custom tide computations, checking a station's dominant consti
84
85
  response_format: ResponseFormatSchema,
85
86
  },
86
87
  annotations: READ_ONLY_ANNOTATIONS,
88
+ outputSchema: RawToolOutputSchema,
87
89
  }, async (params) => {
88
90
  try {
89
91
  const payload = await getStationResource(params.station, "harcon", {
@@ -172,6 +174,7 @@ Reference (R) stations return empty/null offsets — they don't need any.`,
172
174
  response_format: ResponseFormatSchema,
173
175
  },
174
176
  annotations: READ_ONLY_ANNOTATIONS,
177
+ outputSchema: RawToolOutputSchema,
175
178
  }, async (params) => {
176
179
  try {
177
180
  const resource = params.kind === "tide"