@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
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Ryan Cardin
3
+ Copyright (c) 2025 Cardin LLC
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -9,6 +9,8 @@
9
9
 
10
10
  **A Model Context Protocol server for NOAA CO-OPS Tides and Currents data**
11
11
 
12
+ Built by [Cardin Labs](https://cardinlabs.com) · Hosted at [Perigee](https://perigee-two.vercel.app)
13
+
12
14
  Water levels · tide predictions · currents · marine weather · station metadata ·
13
15
  tidal datums · harmonic constituents · sea level trends & projections ·
14
16
  high tide flooding · sun & moon calculations
@@ -56,7 +58,7 @@ No API key is required — NOAA's CO-OPS APIs are open.
56
58
 
57
59
  ---
58
60
 
59
- ## Tools (23)
61
+ ## Tools (25)
60
62
 
61
63
  ### Observations & Predictions (Data API)
62
64
 
@@ -100,18 +102,25 @@ No API key is required — NOAA's CO-OPS APIs are open.
100
102
  | `astro_get_sun_position` | Azimuth/altitude (+ approximate declination/RA) |
101
103
  | `astro_get_next_sun_event` | Next occurrence(s) of any sun event |
102
104
 
105
+ ### Wind & Marine Forecasts (NWS Weather API)
106
+
107
+ | Tool | What it does |
108
+ |---|---|
109
+ | `nws_get_wind_forecast` | Hourly numeric wind forecast (speed/gust/direction, wave height where gridded) for any US lat/lon, up to ~7 days |
110
+ | `nws_get_marine_forecast` | Official Coastal Waters Forecast narrative for the marine zone covering a lat/lon, incl. Small Craft Advisories |
111
+
103
112
  ### Reference
104
113
 
105
114
  | Tool | What it does |
106
115
  |---|---|
107
- | `noaa_get_reference_guide` | Curated NOAA reference: products, datums, units, time zones, intervals, station types, data limits, quality flags, date formats |
116
+ | `noaa_get_reference_guide` | Curated NOAA reference: products, datums, units, time zones, intervals, station types, data limits, quality flags, date formats, marine forecasts |
108
117
 
109
118
  Every tool supports `response_format: "markdown"` (readable tables with units spelled out — the default) or `"json"` (complete structured payload), and attaches structured content for MCP clients that consume it.
110
119
 
111
120
  ## Resources
112
121
 
113
122
  - `noaa://guide/getting-started` — workflow recipes and common pitfalls
114
- - `noaa://reference/{topic}` — the nine reference topics above as pinnable resources
123
+ - `noaa://reference/{topic}` — the ten reference topics above as pinnable resources
115
124
 
116
125
  ## Prompts
117
126
 
@@ -180,7 +189,7 @@ src/
180
189
  ├── format/ # unit labeling, flag legends, markdown/json response shaping
181
190
  ├── schemas/ # shared Zod field schemas with nuance-carrying descriptions
182
191
  ├── services/ # Data API, Metadata API, DPAPI, moon & sun services
183
- ├── tools/ # 23 tool registrations grouped by domain
192
+ ├── tools/ # 25 tool registrations grouped by domain
184
193
  ├── resources/ # noaa:// reference resources
185
194
  ├── prompts/ # workflow prompt templates
186
195
  └── reference/ # curated NOAA reference content
@@ -194,6 +203,10 @@ Data sources:
194
203
 
195
204
  ## License
196
205
 
197
- MIT © Ryan Cardin
206
+ MIT © [Cardin LLC](https://cardinlabs.com) (Cardin Labs)
198
207
 
199
208
  NOAA data is provided by the NOAA Center for Operational Oceanographic Products and Services (CO-OPS). This project is not affiliated with or endorsed by NOAA.
209
+
210
+ ## 2.1.0 tool delivery contract
211
+
212
+ Every raw tool advertises an output schema. Successful provider payloads retain their fields and add `_response: { contractVersion: "2026-09-29.1", status: "complete" }`. Reference guides have a separate typed topic/guide result. Both text and structured payloads are bounded: an oversized result returns an explicit `response_too_large` error with narrowing guidance instead of truncated text alongside an unbounded structured response. Error metadata states whether retry may help. Chained provider reads share a 45-second cancellation deadline and each request has an 18-second total retry budget. No fishing, navigation or catch probability is inferred from raw data.
@@ -0,0 +1,6 @@
1
+ export declare function providerDeadline(): {
2
+ signal: AbortSignal;
3
+ endsAt: number;
4
+ } | undefined;
5
+ /** Propagates cancellation through nested provider reads and retry backoff. */
6
+ export declare function withProviderDeadline<T>(load: () => Promise<T>, milliseconds: number): Promise<T>;
@@ -0,0 +1,23 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ const deadlines = new AsyncLocalStorage();
3
+ export function providerDeadline() {
4
+ return deadlines.getStore();
5
+ }
6
+ /** Propagates cancellation through nested provider reads and retry backoff. */
7
+ export async function withProviderDeadline(load, milliseconds) {
8
+ const controller = new AbortController();
9
+ const parent = providerDeadline();
10
+ const budget = Math.max(1, Math.min(milliseconds, parent ? parent.endsAt - Date.now() : milliseconds));
11
+ const abort = () => controller.abort();
12
+ parent?.signal.addEventListener("abort", abort, { once: true });
13
+ if (parent?.signal.aborted)
14
+ controller.abort();
15
+ const timer = setTimeout(abort, budget);
16
+ try {
17
+ return await deadlines.run({ signal: controller.signal, endsAt: Date.now() + budget }, load);
18
+ }
19
+ finally {
20
+ clearTimeout(timer);
21
+ parent?.signal.removeEventListener("abort", abort);
22
+ }
23
+ }
@@ -24,5 +24,7 @@ export declare function cleanParams(params: Record<string, string | number | boo
24
24
  export declare function fetchDataApi<T = Record<string, unknown>>(params: Record<string, string | number | boolean | undefined | null>): Promise<T>;
25
25
  /** Metadata API GET: path like "/stations.json" or "/stations/8454000/datums.json". */
26
26
  export declare function fetchMetadataApi<T = Record<string, unknown>>(path: string, params?: Record<string, string | number | boolean | undefined | null>): Promise<T>;
27
+ /** NWS Weather API GET: path like "/points/28.77,-95.62" or "/products/{id}". */
28
+ export declare function fetchNwsApi<T = Record<string, unknown>>(path: string, params?: Record<string, string | number | boolean | undefined | null>): Promise<T>;
27
29
  /** Derived Product API GET: path like "/htf/htf_annual.json". */
28
30
  export declare function fetchDpapi<T = Record<string, unknown>>(path: string, params?: Record<string, string | number | boolean | undefined | null>): Promise<T>;
@@ -9,8 +9,9 @@
9
9
  * Metadata API returns bare 404s with no body — they are handled
10
10
  * differently on purpose)
11
11
  */
12
+ import { providerDeadline } from "./deadline.js";
12
13
  import axios from "axios";
13
- import { APPLICATION_NAME, DATA_API_BASE_URL, DPAPI_BASE_URL, MAX_RETRIES, METADATA_API_BASE_URL, REQUEST_TIMEOUT_MS, } from "../constants.js";
14
+ import { APPLICATION_NAME, DATA_API_BASE_URL, DPAPI_BASE_URL, MAX_RETRIES, METADATA_API_BASE_URL, NWS_API_BASE_URL, NWS_USER_AGENT, REQUEST_TIMEOUT_MS, } from "../constants.js";
14
15
  /** Error thrown for any NOAA API failure, with a message safe to show the agent. */
15
16
  export class NoaaApiError extends Error {
16
17
  status;
@@ -27,6 +28,15 @@ const http = axios.create({
27
28
  "User-Agent": `${APPLICATION_NAME}/2.0`,
28
29
  },
29
30
  });
31
+ // Separate instance for the NWS Weather API: NWS mandates a descriptive
32
+ // User-Agent with contact info, and serves GeoJSON rather than plain JSON.
33
+ const nwsHttp = axios.create({
34
+ timeout: REQUEST_TIMEOUT_MS,
35
+ headers: {
36
+ Accept: "application/geo+json, application/ld+json, application/json",
37
+ "User-Agent": NWS_USER_AGENT,
38
+ },
39
+ });
30
40
  function sleep(ms) {
31
41
  return new Promise((resolve) => setTimeout(resolve, ms));
32
42
  }
@@ -48,16 +58,29 @@ export function cleanParams(params) {
48
58
  }
49
59
  return out;
50
60
  }
51
- async function getWithRetry(url, params) {
61
+ async function getWithRetry(url, params, instance = http) {
62
+ const startedAt = Date.now();
63
+ const budgetMs = 18_000;
52
64
  let lastError;
53
65
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
54
66
  try {
55
- const response = await http.get(url, { params });
67
+ const deadline = providerDeadline();
68
+ const remainingMs = Math.min(budgetMs - (Date.now() - startedAt), deadline ? deadline.endsAt - Date.now() : Infinity);
69
+ if (remainingMs <= 0 || deadline?.signal.aborted)
70
+ throw new NoaaApiError("Provider request deadline reached.", 504);
71
+ const response = await instance.get(url, {
72
+ params,
73
+ timeout: Math.min(REQUEST_TIMEOUT_MS, remainingMs),
74
+ signal: deadline?.signal,
75
+ });
56
76
  return response.data;
57
77
  }
58
78
  catch (error) {
59
79
  lastError = error;
60
- if (attempt < MAX_RETRIES && isRetryable(error)) {
80
+ if (attempt < MAX_RETRIES &&
81
+ !providerDeadline()?.signal.aborted &&
82
+ isRetryable(error) &&
83
+ 500 * Math.pow(3, attempt) < budgetMs - (Date.now() - startedAt)) {
61
84
  await sleep(500 * Math.pow(3, attempt)); // 500ms, 1.5s
62
85
  continue;
63
86
  }
@@ -160,6 +183,54 @@ export async function fetchMetadataApi(path, params = {}) {
160
183
  throw toNoaaError(error, "Metadata API");
161
184
  }
162
185
  }
186
+ /**
187
+ * Shape of NWS API error bodies: RFC 7807 problem+json —
188
+ * {"type": "...", "title": "...", "status": 404, "detail": "..."}.
189
+ */
190
+ function extractNwsProblem(data) {
191
+ if (data && typeof data === "object") {
192
+ const body = data;
193
+ const parts = [body.title, body.detail].filter((s) => typeof s === "string" && s.length > 0);
194
+ if (parts.length > 0)
195
+ return parts.join(": ");
196
+ }
197
+ return undefined;
198
+ }
199
+ function toNwsError(error) {
200
+ if (axios.isAxiosError(error)) {
201
+ const axErr = error;
202
+ if (axErr.response) {
203
+ const status = axErr.response.status;
204
+ const problem = extractNwsProblem(axErr.response.data);
205
+ if (problem) {
206
+ return new NoaaApiError(`NWS API error: ${problem}`, status);
207
+ }
208
+ if (status === 404) {
209
+ return new NoaaApiError("NWS API error: not found (404). NWS forecasts cover the US and its territories only — the point may be outside coverage, or too far offshore for a gridpoint.", status);
210
+ }
211
+ if (status === 429) {
212
+ return new NoaaApiError("NWS API error: rate limited (429). Wait a few seconds and retry.", status);
213
+ }
214
+ return new NoaaApiError(`NWS API error: HTTP ${status}.`, status);
215
+ }
216
+ if (axErr.code === "ECONNABORTED") {
217
+ return new NoaaApiError(`NWS API error: request timed out after ${REQUEST_TIMEOUT_MS / 1000}s. Retry.`);
218
+ }
219
+ return new NoaaApiError(`NWS API error: network failure (${axErr.code ?? "unknown"}).`);
220
+ }
221
+ return new NoaaApiError(`NWS API error: ${error instanceof Error ? error.message : String(error)}`);
222
+ }
223
+ /** NWS Weather API GET: path like "/points/28.77,-95.62" or "/products/{id}". */
224
+ export async function fetchNwsApi(path, params = {}) {
225
+ try {
226
+ return await getWithRetry(`${NWS_API_BASE_URL}${path}`, cleanParams(params), nwsHttp);
227
+ }
228
+ catch (error) {
229
+ if (error instanceof NoaaApiError)
230
+ throw error;
231
+ throw toNwsError(error);
232
+ }
233
+ }
163
234
  /** Derived Product API GET: path like "/htf/htf_annual.json". */
164
235
  export async function fetchDpapi(path, params = {}) {
165
236
  try {
@@ -7,6 +7,13 @@ export declare const DATA_API_BASE_URL = "https://api.tidesandcurrents.noaa.gov/
7
7
  export declare const METADATA_API_BASE_URL = "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi";
8
8
  /** NOAA CO-OPS Derived Product API (sea level trends, high tide flooding...). */
9
9
  export declare const DPAPI_BASE_URL = "https://api.tidesandcurrents.noaa.gov/dpapi/prod";
10
+ /** NWS Weather API (point/gridpoint forecasts, marine zone text products). */
11
+ export declare const NWS_API_BASE_URL = "https://api.weather.gov";
12
+ /**
13
+ * NWS requires a descriptive User-Agent identifying the application and a
14
+ * contact point (their abuse-mitigation mechanism; the API has no keys).
15
+ */
16
+ export declare const NWS_USER_AGENT = "noaa-tides-currents-mcp-server (perigeetides.com, ryandcardin@gmail.com)";
10
17
  /**
11
18
  * Sent as the `application` parameter on every Data API call so NOAA can
12
19
  * attribute traffic in their logs (not an API key; the API is open).
@@ -25,4 +32,8 @@ export declare const CACHE_TTL: {
25
32
  readonly stationList: number;
26
33
  /** Individual station metadata (datums, sensors, harcon...). */
27
34
  readonly stationResource: number;
35
+ /** Lat/lon → NWS gridpoint resolution (grid assignments drift rarely, but do drift). */
36
+ readonly nwsPoint: number;
37
+ /** NWS forecasts and marine text products update roughly hourly. */
38
+ readonly nwsForecast: number;
28
39
  };
package/dist/constants.js CHANGED
@@ -7,6 +7,13 @@ export const DATA_API_BASE_URL = "https://api.tidesandcurrents.noaa.gov/api/prod
7
7
  export const METADATA_API_BASE_URL = "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi";
8
8
  /** NOAA CO-OPS Derived Product API (sea level trends, high tide flooding...). */
9
9
  export const DPAPI_BASE_URL = "https://api.tidesandcurrents.noaa.gov/dpapi/prod";
10
+ /** NWS Weather API (point/gridpoint forecasts, marine zone text products). */
11
+ export const NWS_API_BASE_URL = "https://api.weather.gov";
12
+ /**
13
+ * NWS requires a descriptive User-Agent identifying the application and a
14
+ * contact point (their abuse-mitigation mechanism; the API has no keys).
15
+ */
16
+ export const NWS_USER_AGENT = "noaa-tides-currents-mcp-server (perigeetides.com, ryandcardin@gmail.com)";
10
17
  /**
11
18
  * Sent as the `application` parameter on every Data API call so NOAA can
12
19
  * attribute traffic in their logs (not an API key; the API is open).
@@ -25,4 +32,8 @@ export const CACHE_TTL = {
25
32
  stationList: 6 * 60 * 60 * 1000,
26
33
  /** Individual station metadata (datums, sensors, harcon...). */
27
34
  stationResource: 60 * 60 * 1000,
35
+ /** Lat/lon → NWS gridpoint resolution (grid assignments drift rarely, but do drift). */
36
+ nwsPoint: 6 * 60 * 60 * 1000,
37
+ /** NWS forecasts and marine text products update roughly hourly. */
38
+ nwsForecast: 30 * 60 * 1000,
28
39
  };
@@ -5,8 +5,44 @@
5
5
  * tables with units spelled out) or "json" (complete structured payload).
6
6
  * Structured content is attached in both modes so MCP clients that support
7
7
  * it can consume typed output. Responses longer than CHARACTER_LIMIT are
8
- * truncated with guidance rather than flooding the agent's context.
8
+ * rejected with narrowing guidance, including their structured payload.
9
9
  */
10
+ import { z } from "zod";
11
+ /** Stable delivery contract; provider/domain fields remain additive. */
12
+ export declare const RawToolOutputSchema: z.ZodObject<{
13
+ _response: z.ZodObject<{
14
+ contractVersion: z.ZodLiteral<"2026-09-29.1">;
15
+ status: z.ZodLiteral<"complete">;
16
+ }, "strict", z.ZodTypeAny, {
17
+ status: "complete";
18
+ contractVersion: "2026-09-29.1";
19
+ }, {
20
+ status: "complete";
21
+ contractVersion: "2026-09-29.1";
22
+ }>;
23
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
24
+ _response: z.ZodObject<{
25
+ contractVersion: z.ZodLiteral<"2026-09-29.1">;
26
+ status: z.ZodLiteral<"complete">;
27
+ }, "strict", z.ZodTypeAny, {
28
+ status: "complete";
29
+ contractVersion: "2026-09-29.1";
30
+ }, {
31
+ status: "complete";
32
+ contractVersion: "2026-09-29.1";
33
+ }>;
34
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
35
+ _response: z.ZodObject<{
36
+ contractVersion: z.ZodLiteral<"2026-09-29.1">;
37
+ status: z.ZodLiteral<"complete">;
38
+ }, "strict", z.ZodTypeAny, {
39
+ status: "complete";
40
+ contractVersion: "2026-09-29.1";
41
+ }, {
42
+ status: "complete";
43
+ contractVersion: "2026-09-29.1";
44
+ }>;
45
+ }, z.ZodTypeAny, "passthrough">>;
10
46
  export type ResponseFormat = "markdown" | "json";
11
47
  export interface ToolResult {
12
48
  [key: string]: unknown;
@@ -5,27 +5,62 @@
5
5
  * tables with units spelled out) or "json" (complete structured payload).
6
6
  * Structured content is attached in both modes so MCP clients that support
7
7
  * it can consume typed output. Responses longer than CHARACTER_LIMIT are
8
- * truncated with guidance rather than flooding the agent's context.
8
+ * rejected with narrowing guidance, including their structured payload.
9
9
  */
10
+ import { z } from "zod";
10
11
  import { CHARACTER_LIMIT } from "../constants.js";
12
+ /** Stable delivery contract; provider/domain fields remain additive. */
13
+ export const RawToolOutputSchema = z
14
+ .object({
15
+ _response: z
16
+ .object({
17
+ contractVersion: z.literal("2026-09-29.1"),
18
+ status: z.literal("complete"),
19
+ })
20
+ .strict(),
21
+ })
22
+ .passthrough();
11
23
  /** Build a successful tool response in the requested format. */
12
24
  export function respond(format, structured, markdown) {
13
- let text = format === "json" ? JSON.stringify(structured, null, 2) : markdown;
14
- if (text.length > CHARACTER_LIMIT) {
15
- text =
16
- text.slice(0, CHARACTER_LIMIT) +
17
- "\n\n…[truncated: response exceeded the size limit. Narrow the date range, lower the limit parameter, or request fewer fields.]";
18
- }
19
- return {
20
- content: [{ type: "text", text }],
21
- structuredContent: structured,
25
+ const payload = {
26
+ ...structured,
27
+ _response: { contractVersion: "2026-09-29.1", status: "complete" },
22
28
  };
29
+ const json = JSON.stringify(payload);
30
+ const text = format === "json" ? json : markdown;
31
+ if (json.length > CHARACTER_LIMIT || text.length > CHARACTER_LIMIT) {
32
+ return {
33
+ isError: true,
34
+ content: [
35
+ {
36
+ type: "text",
37
+ text: "The complete response exceeds the delivery limit. Narrow the date range, lower the limit, or request fewer fields; no partial result is presented as complete.",
38
+ },
39
+ ],
40
+ _meta: {
41
+ error: {
42
+ code: "response_too_large",
43
+ retryable: false,
44
+ limitCharacters: CHARACTER_LIMIT,
45
+ },
46
+ },
47
+ };
48
+ }
49
+ return { content: [{ type: "text", text }], structuredContent: payload };
23
50
  }
24
51
  /** Build an error tool response (kept inside the result per MCP guidance). */
25
52
  export function respondError(error) {
26
53
  const message = error instanceof Error ? error.message : String(error);
27
54
  return {
28
55
  isError: true,
56
+ _meta: {
57
+ error: {
58
+ code: "provider_error",
59
+ retryable: error?.status === undefined ||
60
+ error.status === 429 ||
61
+ (error.status ?? 0) >= 500,
62
+ },
63
+ },
29
64
  content: [{ type: "text", text: `Error: ${message}` }],
30
65
  };
31
66
  }
package/dist/index.js CHANGED
File without changes
@@ -3,7 +3,7 @@
3
3
  * documentation (api.tidesandcurrents.noaa.gov). Served both by the
4
4
  * noaa_get_reference_guide tool and as noaa://reference/{topic} resources.
5
5
  */
6
- export declare const REFERENCE_TOPICS: readonly ["products", "datums", "units", "time_zones", "intervals", "station_types", "data_limits", "quality_flags", "date_formats"];
6
+ export declare const REFERENCE_TOPICS: readonly ["products", "datums", "units", "time_zones", "intervals", "station_types", "data_limits", "quality_flags", "date_formats", "marine_forecast"];
7
7
  export type ReferenceTopic = (typeof REFERENCE_TOPICS)[number];
8
8
  export declare const REFERENCE_CONTENT: Record<ReferenceTopic, string>;
9
9
  /** One-line summaries used in resource listings and the guide index. */
@@ -13,6 +13,7 @@ export const REFERENCE_TOPICS = [
13
13
  "data_limits",
14
14
  "quality_flags",
15
15
  "date_formats",
16
+ "marine_forecast",
16
17
  ];
17
18
  export const REFERENCE_CONTENT = {
18
19
  products: `# NOAA CO-OPS Data Products
@@ -187,6 +188,28 @@ Exactly ONE of these combinations per request:
187
188
  5. range alone — N hours back from now
188
189
 
189
190
  Timestamps in responses are in the requested time_zone (gmt | lst | lst_ldt).`,
191
+ marine_forecast: `# Wind & Marine Forecasts (NWS)
192
+
193
+ Forecasts come from the NWS Weather API (api.weather.gov) — a different NOAA
194
+ service from CO-OPS. CO-OPS observes and predicts tides; NWS forecasts weather.
195
+
196
+ | Need | Tool |
197
+ |---|---|
198
+ | Hourly wind forecast (numeric speed/gust/direction) at a lat/lon | nws_get_wind_forecast |
199
+ | Official marine narrative (Coastal Waters Forecast, advisories) | nws_get_marine_forecast |
200
+ | OBSERVED wind right now at a NOAA station | noaa_get_meteorological_data (product "wind") |
201
+
202
+ How it works:
203
+ - A lat/lon resolves to a forecast-office gridpoint (points → gridpoints);
204
+ values are numeric NDFD series with up to a ~7-day (156 h) horizon.
205
+ - Wave height appears on marine/nearshore gridpoints; swell period/direction
206
+ are generally only populated for open-ocean grids, not bays.
207
+ - Marine text forecasts are per-ZONE (e.g. GMZ350 "Freeport to Matagorda Ship
208
+ Channel out 20 NM"); one office bulletin covers many zones and the tool
209
+ extracts the matching segment. Winds in the narrative are in KNOTS.
210
+ - Coverage: US and territories only. No API key; forecasts update ~hourly.
211
+ - Forecast wind direction is where the wind blows FROM, degrees true — same
212
+ convention as CO-OPS wind observations.`,
190
213
  };
191
214
  /** One-line summaries used in resource listings and the guide index. */
192
215
  export const REFERENCE_SUMMARIES = {
@@ -199,4 +222,5 @@ export const REFERENCE_SUMMARIES = {
199
222
  data_limits: "Maximum date-range span per product",
200
223
  quality_flags: "Data quality fields (v/s/f/q), flag letters, HH/H/L/LL",
201
224
  date_formats: "Accepted date formats and parameter combinations",
225
+ marine_forecast: "NWS wind & marine forecast tools vs CO-OPS observations",
202
226
  };
@@ -19,6 +19,14 @@ Typical workflows:
19
19
  - noaa_get_meteorological_data (product "wind"/"air_temperature", date "latest")
20
20
  - noaa_get_currents (date "latest") at a current station (different ID scheme!)
21
21
 
22
+ ## "What will the wind do tomorrow near <place>?"
23
+ 1. Geocode the place to lat/lon (outside this server).
24
+ 2. nws_get_wind_forecast (hours 48) → hourly numeric speed/gust/direction.
25
+ 3. nws_get_marine_forecast for the official Coastal Waters Forecast narrative
26
+ (small-craft advisories, sea state) when the point is in US coastal waters.
27
+ Forecast ≠ observation: measured wind right now comes from
28
+ noaa_get_meteorological_data (product "wind") at a station with a met sensor.
29
+
22
30
  ## "How often does <station> flood, and what's projected?"
23
31
  - noaa_get_high_tide_flooding (report "annual", range 15)
24
32
  - noaa_get_sea_level_trends, noaa_get_sea_level_rise_projections
@@ -0,0 +1,115 @@
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
+ export interface NwsPoint {
19
+ gridId: string;
20
+ gridX: number;
21
+ gridY: number;
22
+ /** Issuing forecast office, e.g. "HGX". */
23
+ cwa: string;
24
+ /** IANA time zone for the point, e.g. "America/Chicago". */
25
+ timeZone: string;
26
+ /** "land" or "marine" — how NWS classifies the coordinate. */
27
+ pointType?: string;
28
+ /** Nearest named place, e.g. "Sargent, TX". */
29
+ place?: string;
30
+ }
31
+ export declare function resolvePoint(latitude: number, longitude: number): Promise<NwsPoint>;
32
+ export interface GridSeries {
33
+ uom?: string;
34
+ values?: Array<{
35
+ validTime: string;
36
+ value: number | null;
37
+ }>;
38
+ }
39
+ /** Parse an ISO 8601 duration like "PT3H", "P1D", "PT1H30M" to milliseconds. */
40
+ export declare function parseIsoDurationMs(duration: string): number;
41
+ /**
42
+ * Expand a gridpoint series (start-instant + hold-duration values) into a map
43
+ * of epoch-ms-per-hour → value.
44
+ */
45
+ export declare function expandGridSeries(series: GridSeries | undefined): Map<number, number>;
46
+ /** Convert a gridpoint speed value to knots based on its declared unit. */
47
+ export declare function speedToKnots(value: number, uom: string | undefined): number;
48
+ export declare function knotsToMs(knots: number): number;
49
+ /** Convert a gridpoint length value to meters based on its declared unit. */
50
+ export declare function lengthToMeters(value: number, uom: string | undefined): number;
51
+ export declare function metersToFeet(meters: number): number;
52
+ export declare function toCompass(degrees: number): string;
53
+ export interface HourlyWindSample {
54
+ /** UTC instant, ISO 8601. */
55
+ time: string;
56
+ /** Wind speed in knots (converted from the gridpoint's declared unit). */
57
+ speed_knots: number | null;
58
+ /** Wind gust in knots. */
59
+ gust_knots: number | null;
60
+ /** Direction the wind blows FROM, degrees true. */
61
+ direction_deg: number | null;
62
+ /** 16-point compass rendering of direction_deg. */
63
+ compass: string | null;
64
+ /** Significant wave height in meters (marine gridpoints only, often absent). */
65
+ wave_height_m: number | null;
66
+ }
67
+ export interface WindForecast {
68
+ point: NwsPoint;
69
+ updated?: string;
70
+ samples: HourlyWindSample[];
71
+ }
72
+ /**
73
+ * Hourly numeric wind forecast (plus wave height when the grid carries it)
74
+ * for a coordinate, starting at the current hour.
75
+ */
76
+ export declare function getWindForecast(latitude: number, longitude: number, hours: number): Promise<WindForecast>;
77
+ export interface MarineZone {
78
+ id: string;
79
+ name: string;
80
+ cwa: string;
81
+ }
82
+ /** Resolve the NWS coastal marine zone covering a coordinate (e.g. GMZ350). */
83
+ export declare function findCoastalZone(latitude: number, longitude: number): Promise<MarineZone>;
84
+ /**
85
+ * True when a CWF bulletin segment's UGC header covers the given zone.
86
+ * Headers look like "GMZ330-335-350-061015-" (list) or "GMZ350>355-061015-"
87
+ * (range), always ending in a 6-digit expiry.
88
+ */
89
+ export declare function segmentCoversZone(segment: string, zoneId: string): boolean;
90
+ /**
91
+ * Extract the segment of a multi-zone CWF bulletin covering one zone.
92
+ * Bulletins are segmented by "$$" terminators; each segment opens with a UGC
93
+ * zone-list header. Returns undefined when no segment matches.
94
+ */
95
+ export declare function extractZoneSegment(productText: string, zoneId: string): string | undefined;
96
+ /**
97
+ * Extract the office-wide synopsis block, when the bulletin carries one.
98
+ * Offices vary the header casing (".SYNOPSIS...", ".Synopsis For ...").
99
+ */
100
+ export declare function extractSynopsis(productText: string): string | undefined;
101
+ export interface MarineTextForecast {
102
+ zone: MarineZone;
103
+ productId?: string;
104
+ issuanceTime?: string;
105
+ /** The bulletin segment for the zone (day-part narrative, winds in knots). */
106
+ segment?: string;
107
+ /** The office-wide marine synopsis, when present. */
108
+ synopsis?: string;
109
+ }
110
+ /**
111
+ * Latest Coastal Waters Forecast text for the marine zone covering a point.
112
+ * CWF products are keyed by issuing office (not zone) and cover every zone
113
+ * that office serves in one bulletin, so the zone's segment is extracted here.
114
+ */
115
+ export declare function getMarineTextForecast(latitude: number, longitude: number): Promise<MarineTextForecast>;