@ryancardin/noaa-tides-currents-mcp-server 1.0.0 → 2.0.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 (88) hide show
  1. package/README.md +164 -199
  2. package/dist/client/cache.d.ts +15 -0
  3. package/dist/client/cache.js +44 -0
  4. package/dist/client/http.d.ts +28 -0
  5. package/dist/client/http.js +178 -0
  6. package/dist/constants.d.ts +28 -0
  7. package/dist/constants.js +28 -0
  8. package/dist/format/respond.d.ts +25 -0
  9. package/dist/format/respond.js +40 -0
  10. package/dist/format/series.d.ts +16 -0
  11. package/dist/format/series.js +42 -0
  12. package/dist/format/units.d.ts +16 -0
  13. package/dist/format/units.js +37 -0
  14. package/dist/index.d.ts +14 -0
  15. package/dist/index.js +78 -8
  16. package/dist/interfaces/moon.d.ts +4 -4
  17. package/dist/interfaces/moon.js +58 -17
  18. package/dist/interfaces/sun.d.ts +4 -4
  19. package/dist/interfaces/sun.js +94 -25
  20. package/dist/prompts/index.d.ts +6 -0
  21. package/dist/prompts/index.js +89 -0
  22. package/dist/reference/content.d.ts +10 -0
  23. package/dist/reference/content.js +202 -0
  24. package/dist/resources/index.d.ts +7 -0
  25. package/dist/resources/index.js +73 -0
  26. package/dist/schemas/common.d.ts +38 -14
  27. package/dist/schemas/common.js +82 -18
  28. package/dist/services/data-api.d.ts +81 -0
  29. package/dist/services/data-api.js +117 -0
  30. package/dist/services/dpapi.d.ts +55 -0
  31. package/dist/services/dpapi.js +60 -0
  32. package/dist/services/metadata-api.d.ts +62 -0
  33. package/dist/services/metadata-api.js +105 -0
  34. package/dist/services/moon-phase-service.d.ts +2 -2
  35. package/dist/services/moon-phase-service.js +23 -25
  36. package/dist/services/sun-service.d.ts +2 -2
  37. package/dist/services/sun-service.js +53 -31
  38. package/dist/tools/astronomy.d.ts +7 -0
  39. package/dist/tools/astronomy.js +270 -0
  40. package/dist/tools/currents.d.ts +5 -0
  41. package/dist/tools/currents.js +168 -0
  42. package/dist/tools/derived.d.ts +6 -0
  43. package/dist/tools/derived.js +296 -0
  44. package/dist/tools/index.d.ts +3 -14
  45. package/dist/tools/index.js +17 -32
  46. package/dist/tools/met.d.ts +5 -0
  47. package/dist/tools/met.js +110 -0
  48. package/dist/tools/reference.d.ts +6 -0
  49. package/dist/tools/reference.js +33 -0
  50. package/dist/tools/station-metadata.d.ts +6 -0
  51. package/dist/tools/station-metadata.js +208 -0
  52. package/dist/tools/stations.d.ts +5 -0
  53. package/dist/tools/stations.js +265 -0
  54. package/dist/tools/water.d.ts +5 -0
  55. package/dist/tools/water.js +270 -0
  56. package/dist/validation/dates.d.ts +50 -0
  57. package/dist/validation/dates.js +139 -0
  58. package/package.json +27 -14
  59. package/.claude/settings.local.json +0 -29
  60. package/CLAUDE.md +0 -71
  61. package/Dockerfile +0 -14
  62. package/smithery.yaml +0 -16
  63. package/src/index.ts +0 -13
  64. package/src/interfaces/moon.ts +0 -44
  65. package/src/interfaces/noaa.ts +0 -130
  66. package/src/interfaces/parameters.ts +0 -20
  67. package/src/interfaces/sun.ts +0 -57
  68. package/src/schemas/common.ts +0 -23
  69. package/src/schemas/dpapi.ts +0 -99
  70. package/src/server/config.ts +0 -43
  71. package/src/server/mcp-server.ts +0 -135
  72. package/src/services/dpapi-service.ts +0 -187
  73. package/src/services/moon-phase-service.ts +0 -167
  74. package/src/services/noaa-parameters-service.ts +0 -139
  75. package/src/services/noaa-service.ts +0 -171
  76. package/src/services/sun-service.ts +0 -275
  77. package/src/tools/derived-product-tools.ts +0 -180
  78. package/src/tools/index.ts +0 -40
  79. package/src/tools/moon-tools.ts +0 -79
  80. package/src/tools/parameter-tools.ts +0 -82
  81. package/src/tools/station-tools.ts +0 -57
  82. package/src/tools/sun-tools.ts +0 -120
  83. package/src/tools/water-tools.ts +0 -166
  84. package/src/types/moon.ts +0 -27
  85. package/src/types/sun.ts +0 -51
  86. package/src/types/suncalc.d.ts +0 -110
  87. package/test-dpapi.js +0 -0
  88. package/tsconfig.json +0 -15
@@ -1,4 +1,4 @@
1
- import SunCalc from 'suncalc';
1
+ import SunCalc from "suncalc";
2
2
  /**
3
3
  * Service for sun calculations
4
4
  */
@@ -19,7 +19,9 @@ export class SunService {
19
19
  return null;
20
20
  if (params.timezone) {
21
21
  try {
22
- return time.toLocaleTimeString('en-US', { timeZone: params.timezone });
22
+ return time.toLocaleTimeString("en-US", {
23
+ timeZone: params.timezone,
24
+ });
23
25
  }
24
26
  catch (error) {
25
27
  // If timezone is invalid, fall back to ISO string
@@ -32,11 +34,14 @@ export class SunService {
32
34
  const sunrise = sunTimes.sunrise;
33
35
  const sunset = sunTimes.sunset;
34
36
  let dayLength = 0;
35
- if (sunrise && sunset && !isNaN(sunrise.getTime()) && !isNaN(sunset.getTime())) {
37
+ if (sunrise &&
38
+ sunset &&
39
+ !isNaN(sunrise.getTime()) &&
40
+ !isNaN(sunset.getTime())) {
36
41
  dayLength = (sunset.getTime() - sunrise.getTime()) / (60 * 1000);
37
42
  }
38
43
  return {
39
- date: date.toISOString().split('T')[0],
44
+ date: date.toISOString().split("T")[0],
40
45
  sunrise: formatTime(sunTimes.sunrise),
41
46
  sunset: formatTime(sunTimes.sunset),
42
47
  solarNoon: formatTime(sunTimes.solarNoon),
@@ -48,9 +53,11 @@ export class SunService {
48
53
  goldenHourEnd: formatTime(sunTimes.goldenHourEnd),
49
54
  nauticalDawn: formatTime(sunTimes.nauticalDawn),
50
55
  nauticalDusk: formatTime(sunTimes.nauticalDusk),
51
- astronomicalDawn: formatTime(sunTimes.astronomicalDawn),
52
- astronomicalDusk: formatTime(sunTimes.astronomicalDusk),
53
- dayLength
56
+ // suncalc has no astronomicalDawn/Dusk keys: astronomical dawn is
57
+ // nightEnd and astronomical dusk is night (start of astronomical night)
58
+ astronomicalDawn: formatTime(sunTimes.nightEnd),
59
+ astronomicalDusk: formatTime(sunTimes.night),
60
+ dayLength,
54
61
  };
55
62
  }
56
63
  /**
@@ -62,19 +69,19 @@ export class SunService {
62
69
  const startDate = new Date(params.start_date);
63
70
  const endDate = new Date(params.end_date);
64
71
  if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
65
- throw new Error('Invalid date format. Please use YYYY-MM-DD format.');
72
+ throw new Error("Invalid date format. Please use YYYY-MM-DD format.");
66
73
  }
67
74
  if (startDate > endDate) {
68
- throw new Error('Start date must be before end date.');
75
+ throw new Error("Start date must be before end date.");
69
76
  }
70
77
  const result = [];
71
78
  const currentDate = new Date(startDate);
72
79
  while (currentDate <= endDate) {
73
80
  result.push(this.getSunTimes({
74
- date: currentDate.toISOString().split('T')[0],
81
+ date: currentDate.toISOString().split("T")[0],
75
82
  latitude: params.latitude,
76
83
  longitude: params.longitude,
77
- timezone: params.timezone
84
+ timezone: params.timezone,
78
85
  }));
79
86
  // Move to next day
80
87
  currentDate.setDate(currentDate.getDate() + 1);
@@ -92,12 +99,12 @@ export class SunService {
92
99
  const { latitude, longitude } = params;
93
100
  // Set the time if provided
94
101
  if (time) {
95
- const [hours, minutes, seconds] = time.split(':').map(Number);
102
+ const [hours, minutes, seconds] = time.split(":").map(Number);
96
103
  if (!isNaN(hours) && !isNaN(minutes) && (!seconds || !isNaN(seconds))) {
97
104
  date.setHours(hours, minutes, seconds || 0, 0);
98
105
  }
99
106
  else {
100
- throw new Error('Invalid time format. Please use HH:MM:SS format.');
107
+ throw new Error("Invalid time format. Please use HH:MM:SS format.");
101
108
  }
102
109
  }
103
110
  // Get sun position data
@@ -106,12 +113,12 @@ export class SunService {
106
113
  // Note: These are approximate calculations and may not be precise
107
114
  const equatorialCoords = this.calculateEquatorialCoordinates(date, position.azimuth, position.altitude, latitude, longitude);
108
115
  return {
109
- date: date.toISOString().split('T')[0],
110
- time: date.toISOString().split('T')[1].split('.')[0],
116
+ date: date.toISOString().split("T")[0],
117
+ time: date.toISOString().split("T")[1].split(".")[0],
111
118
  azimuth: position.azimuth * (180 / Math.PI),
112
119
  altitude: position.altitude * (180 / Math.PI),
113
120
  declination: equatorialCoords.declination,
114
- rightAscension: equatorialCoords.rightAscension
121
+ rightAscension: equatorialCoords.rightAscension,
115
122
  };
116
123
  }
117
124
  /**
@@ -123,27 +130,36 @@ export class SunService {
123
130
  const startDate = params.date ? new Date(params.date) : new Date();
124
131
  const count = params.count !== undefined ? params.count : 1;
125
132
  const { latitude, longitude } = params;
126
- const timezone = params.timezone !== undefined ? params.timezone : 'UTC';
133
+ const timezone = params.timezone !== undefined ? params.timezone : "UTC";
127
134
  const results = [];
128
135
  let currentDate = new Date(startDate);
136
+ // Map our event names to suncalc's keys where they differ.
137
+ const suncalcKeyOverrides = {
138
+ goldenHourStart: "goldenHour",
139
+ astronomicalDawn: "nightEnd",
140
+ astronomicalDusk: "night",
141
+ };
142
+ const eventKey = suncalcKeyOverrides[params.event] ?? params.event;
129
143
  // Find the next occurrences
130
144
  while (results.length < count) {
131
145
  const sunTimes = SunCalc.getTimes(currentDate, latitude, longitude);
132
- const eventTime = sunTimes[params.event];
146
+ const eventTime = sunTimes[eventKey];
133
147
  if (eventTime && !isNaN(eventTime.getTime()) && eventTime > startDate) {
134
148
  let formattedTime;
135
149
  try {
136
- formattedTime = eventTime.toLocaleTimeString('en-US', { timeZone: timezone });
150
+ formattedTime = eventTime.toLocaleTimeString("en-US", {
151
+ timeZone: timezone,
152
+ });
137
153
  }
138
154
  catch (error) {
139
155
  // If timezone is invalid, fall back to ISO string
140
156
  console.warn(`Invalid timezone: ${timezone}. Using UTC.`);
141
- formattedTime = eventTime.toISOString().split('T')[1].split('.')[0];
157
+ formattedTime = eventTime.toISOString().split("T")[1].split(".")[0];
142
158
  }
143
159
  results.push({
144
- date: eventTime.toISOString().split('T')[0],
160
+ date: eventTime.toISOString().split("T")[0],
145
161
  time: formattedTime,
146
- event: params.event
162
+ event: params.event,
147
163
  });
148
164
  // Move to next day to find the next occurrence
149
165
  currentDate.setDate(currentDate.getDate() + 1);
@@ -153,8 +169,9 @@ export class SunService {
153
169
  currentDate.setDate(currentDate.getDate() + 1);
154
170
  }
155
171
  // Safety check to prevent infinite loops
156
- if (results.length === 0 && currentDate.getTime() - startDate.getTime() > 366 * 24 * 60 * 60 * 1000) {
157
- throw new Error('Could not find the specified sun event within a year.');
172
+ if (results.length === 0 &&
173
+ currentDate.getTime() - startDate.getTime() > 366 * 24 * 60 * 60 * 1000) {
174
+ throw new Error("Could not find the specified sun event within a year.");
158
175
  }
159
176
  }
160
177
  return results;
@@ -174,12 +191,14 @@ export class SunService {
174
191
  // Convert degrees to radians
175
192
  const lat = latitude * (Math.PI / 180);
176
193
  // Calculate hour angle and declination
177
- const sinDec = Math.sin(altitude) * Math.sin(lat) + Math.cos(altitude) * Math.cos(lat) * Math.cos(azimuth);
194
+ const sinDec = Math.sin(altitude) * Math.sin(lat) +
195
+ Math.cos(altitude) * Math.cos(lat) * Math.cos(azimuth);
178
196
  const declination = Math.asin(sinDec) * (180 / Math.PI);
179
- const cosH = (Math.sin(altitude) - Math.sin(lat) * sinDec) / (Math.cos(lat) * Math.cos(declination * (Math.PI / 180)));
197
+ const cosH = (Math.sin(altitude) - Math.sin(lat) * sinDec) /
198
+ (Math.cos(lat) * Math.cos(declination * (Math.PI / 180)));
180
199
  const hourAngle = Math.acos(Math.max(-1, Math.min(1, cosH)));
181
200
  // Adjust hour angle based on azimuth
182
- const adjustedHourAngle = (azimuth > 0 && azimuth < Math.PI) ? (2 * Math.PI - hourAngle) : hourAngle;
201
+ const adjustedHourAngle = azimuth > 0 && azimuth < Math.PI ? 2 * Math.PI - hourAngle : hourAngle;
183
202
  // Calculate right ascension
184
203
  const localSiderealTime = this.calculateLocalSiderealTime(date, longitude);
185
204
  let rightAscension = (localSiderealTime - adjustedHourAngle) * (12 / Math.PI);
@@ -221,14 +240,17 @@ export class SunService {
221
240
  const m = date.getMonth() + 1;
222
241
  const d = date.getDate();
223
242
  // Calculate Julian day
224
- const jd = 367 * y - Math.floor(7 * (y + Math.floor((m + 9) / 12)) / 4) -
225
- Math.floor(3 * (Math.floor((y + (m - 9) / 7) / 100) + 1) / 4) +
226
- Math.floor(275 * m / 9) + d + 1721028.5;
243
+ const jd = 367 * y -
244
+ Math.floor((7 * (y + Math.floor((m + 9) / 12))) / 4) -
245
+ Math.floor((3 * (Math.floor((y + (m - 9) / 7) / 100) + 1)) / 4) +
246
+ Math.floor((275 * m) / 9) +
247
+ d +
248
+ 1721028.5;
227
249
  // Add time of day
228
250
  const hours = date.getUTCHours();
229
251
  const minutes = date.getUTCMinutes();
230
252
  const seconds = date.getUTCSeconds();
231
253
  const milliseconds = date.getUTCMilliseconds();
232
- return jd + (hours + minutes / 60 + seconds / 3600 + milliseconds / 3600000) / 24;
254
+ return (jd + (hours + minutes / 60 + seconds / 3600 + milliseconds / 3600000) / 24);
233
255
  }
234
256
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Sun and moon calculation tools (computed locally with suncalc — no network).
3
+ * Useful companions to tide data for boating, fishing, and photography:
4
+ * spring tides follow new/full moons, and slack/golden-hour timing matters.
5
+ */
6
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ export declare function registerAstronomyTools(server: McpServer): void;
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Sun and moon calculation tools (computed locally with suncalc — no network).
3
+ * Useful companions to tide data for boating, fishing, and photography:
4
+ * spring tides follow new/full moons, and slack/golden-hour timing matters.
5
+ */
6
+ import { z } from "zod";
7
+ import { MoonPhaseService } from "../services/moon-phase-service.js";
8
+ import { SunService } from "../services/sun-service.js";
9
+ import { MoonPhaseName } from "../types/moon.js";
10
+ import { SunEventType } from "../types/sun.js";
11
+ import { LatitudeSchema, LOCAL_COMPUTE_ANNOTATIONS, LongitudeSchema, ResponseFormatSchema, } from "../schemas/common.js";
12
+ import { markdownTable, respond, respondError } from "../format/respond.js";
13
+ const IsoDateSchema = z
14
+ .string()
15
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD format.")
16
+ .describe("Date in YYYY-MM-DD format. Defaults to today.");
17
+ const TimezoneSchema = z
18
+ .string()
19
+ .optional()
20
+ .describe('IANA timezone for output times (e.g. "America/New_York"). Defaults to UTC ISO timestamps.');
21
+ export function registerAstronomyTools(server) {
22
+ const moonService = new MoonPhaseService();
23
+ const sunService = new SunService();
24
+ server.registerTool("astro_get_moon_phase", {
25
+ title: "Get Moon Phase",
26
+ description: `Get the moon phase for a date (or each day of a date range if end_date is given): phase name (New Moon, Waxing Crescent, ...), illuminated fraction, age in days within the 29.53-day cycle, distance (km), apparent diameter (degrees), and waxing/waning.
27
+
28
+ Tide context: spring tides (largest range) occur just after new and full moons; neap tides after quarter moons. Computed locally — no NOAA data involved.`,
29
+ inputSchema: {
30
+ date: IsoDateSchema.optional(),
31
+ end_date: IsoDateSchema.optional().describe("Optional range end (YYYY-MM-DD): returns one entry per day from date through end_date."),
32
+ latitude: LatitudeSchema.optional().describe("Optional latitude for distance/diameter precision."),
33
+ longitude: LongitudeSchema.optional(),
34
+ response_format: ResponseFormatSchema,
35
+ },
36
+ annotations: LOCAL_COMPUTE_ANNOTATIONS,
37
+ }, async (params) => {
38
+ try {
39
+ const phases = params.end_date
40
+ ? moonService.getMoonPhasesRange({
41
+ start_date: params.date ?? new Date().toISOString().split("T")[0],
42
+ end_date: params.end_date,
43
+ latitude: params.latitude,
44
+ longitude: params.longitude,
45
+ })
46
+ : [
47
+ moonService.getMoonPhase({
48
+ date: params.date,
49
+ latitude: params.latitude,
50
+ longitude: params.longitude,
51
+ }),
52
+ ];
53
+ const structured = { count: phases.length, phases };
54
+ const markdown = [
55
+ "# Moon Phase",
56
+ "",
57
+ markdownTable([
58
+ "Date",
59
+ "Phase",
60
+ "Illumination",
61
+ "Age (days)",
62
+ "Distance (km)",
63
+ "Waxing?",
64
+ ], phases.map((p) => [
65
+ p.date,
66
+ p.phaseName,
67
+ `${(p.illumination * 100).toFixed(1)}%`,
68
+ p.age.toFixed(1),
69
+ Math.round(p.distance).toLocaleString(),
70
+ p.isWaxing ? "yes" : "no",
71
+ ])),
72
+ "",
73
+ "_Spring tides (largest range) follow new/full moons; neap tides follow quarters._",
74
+ ].join("\n");
75
+ return respond(params.response_format, structured, markdown);
76
+ }
77
+ catch (error) {
78
+ return respondError(error);
79
+ }
80
+ });
81
+ server.registerTool("astro_get_next_moon_phase", {
82
+ title: "Get Next Moon Phase Occurrence",
83
+ description: `Find the date(s) of the next occurrence(s) of a principal moon phase (New Moon, First Quarter, Full Moon, Last Quarter) from a starting date.
84
+
85
+ Use for: "when is the next full moon?", planning around spring tides (which follow new/full moons by 1–2 days).`,
86
+ inputSchema: {
87
+ phase: z
88
+ .enum([
89
+ MoonPhaseName.NEW_MOON,
90
+ MoonPhaseName.FIRST_QUARTER,
91
+ MoonPhaseName.FULL_MOON,
92
+ MoonPhaseName.LAST_QUARTER,
93
+ ])
94
+ .describe("Which principal phase to find."),
95
+ date: IsoDateSchema.optional().describe("Search start date (YYYY-MM-DD). Defaults to today."),
96
+ count: z
97
+ .number()
98
+ .int()
99
+ .min(1)
100
+ .max(24)
101
+ .default(1)
102
+ .describe("How many occurrences to return."),
103
+ response_format: ResponseFormatSchema,
104
+ },
105
+ annotations: LOCAL_COMPUTE_ANNOTATIONS,
106
+ }, async (params) => {
107
+ try {
108
+ const occurrences = moonService.getNextMoonPhase({
109
+ phase: params.phase,
110
+ date: params.date,
111
+ count: params.count,
112
+ });
113
+ const structured = { phase: params.phase, occurrences };
114
+ const markdown = [
115
+ `# Next ${params.phase}`,
116
+ "",
117
+ ...occurrences.map((o, i) => `${i + 1}. ${o.date}`),
118
+ ].join("\n");
119
+ return respond(params.response_format, structured, markdown);
120
+ }
121
+ catch (error) {
122
+ return respondError(error);
123
+ }
124
+ });
125
+ server.registerTool("astro_get_sun_times", {
126
+ title: "Get Sun Times",
127
+ description: `Get sunrise, sunset, solar noon, dawn/dusk (civil), nautical dawn/dusk, astronomical dawn/dusk, golden hour, and day length for a location and date (or each day of a range if end_date is given).
128
+
129
+ Times are ISO UTC unless an IANA timezone is provided. At high latitudes some events may be null (e.g. no astronomical night in summer). Computed locally.`,
130
+ inputSchema: {
131
+ latitude: LatitudeSchema,
132
+ longitude: LongitudeSchema,
133
+ date: IsoDateSchema.optional(),
134
+ end_date: IsoDateSchema.optional().describe("Optional range end: one entry per day."),
135
+ timezone: TimezoneSchema,
136
+ response_format: ResponseFormatSchema,
137
+ },
138
+ annotations: LOCAL_COMPUTE_ANNOTATIONS,
139
+ }, async (params) => {
140
+ try {
141
+ const times = params.end_date
142
+ ? sunService.getSunTimesRange({
143
+ start_date: params.date ?? new Date().toISOString().split("T")[0],
144
+ end_date: params.end_date,
145
+ latitude: params.latitude,
146
+ longitude: params.longitude,
147
+ timezone: params.timezone,
148
+ })
149
+ : [
150
+ sunService.getSunTimes({
151
+ date: params.date,
152
+ latitude: params.latitude,
153
+ longitude: params.longitude,
154
+ timezone: params.timezone,
155
+ }),
156
+ ];
157
+ const structured = { count: times.length, sun_times: times };
158
+ const markdown = [
159
+ `# Sun Times (${params.latitude}, ${params.longitude})${params.timezone ? ` — ${params.timezone}` : " — UTC"}`,
160
+ "",
161
+ markdownTable([
162
+ "Date",
163
+ "Dawn",
164
+ "Sunrise",
165
+ "Solar noon",
166
+ "Sunset",
167
+ "Dusk",
168
+ "Day length",
169
+ ], times.map((t) => [
170
+ t.date,
171
+ t.dawn,
172
+ t.sunrise,
173
+ t.solarNoon,
174
+ t.sunset,
175
+ t.dusk,
176
+ `${Math.floor(t.dayLength / 60)}h ${Math.round(t.dayLength % 60)}m`,
177
+ ])),
178
+ "",
179
+ "_Golden hour, nautical and astronomical twilight times are in the JSON payload._",
180
+ ].join("\n");
181
+ return respond(params.response_format, structured, markdown);
182
+ }
183
+ catch (error) {
184
+ return respondError(error);
185
+ }
186
+ });
187
+ server.registerTool("astro_get_sun_position", {
188
+ title: "Get Sun Position",
189
+ description: `Get the sun's position for a date, time, and location: azimuth (degrees clockwise from north as rendered here), altitude above the horizon (degrees), plus approximate declination and right ascension.
190
+
191
+ Use for: shadow/lighting analysis, solar exposure. Computed locally with suncalc; declination/RA are approximate.`,
192
+ inputSchema: {
193
+ latitude: LatitudeSchema,
194
+ longitude: LongitudeSchema,
195
+ date: IsoDateSchema.optional(),
196
+ time: z
197
+ .string()
198
+ .regex(/^\d{2}:\d{2}(:\d{2})?$/, "Use HH:MM or HH:MM:SS.")
199
+ .optional()
200
+ .describe("Time of day (HH:MM[:SS], interpreted in the local runtime timezone). Defaults to now."),
201
+ response_format: ResponseFormatSchema,
202
+ },
203
+ annotations: LOCAL_COMPUTE_ANNOTATIONS,
204
+ }, async (params) => {
205
+ try {
206
+ const position = sunService.getSunPosition({
207
+ date: params.date,
208
+ time: params.time,
209
+ latitude: params.latitude,
210
+ longitude: params.longitude,
211
+ });
212
+ const structured = { position };
213
+ const markdown = [
214
+ `# Sun Position (${params.latitude}, ${params.longitude}) at ${position.date} ${position.time} UTC`,
215
+ "",
216
+ `- **Azimuth**: ${position.azimuth.toFixed(2)}°`,
217
+ `- **Altitude**: ${position.altitude.toFixed(2)}° ${position.altitude > 0 ? "(above horizon)" : "(below horizon)"}`,
218
+ `- **Declination**: ${position.declination.toFixed(2)}° (approx.)`,
219
+ `- **Right ascension**: ${position.rightAscension.toFixed(2)}h (approx.)`,
220
+ ].join("\n");
221
+ return respond(params.response_format, structured, markdown);
222
+ }
223
+ catch (error) {
224
+ return respondError(error);
225
+ }
226
+ });
227
+ server.registerTool("astro_get_next_sun_event", {
228
+ title: "Get Next Sun Event",
229
+ description: `Find the next occurrence(s) of a sun event (sunrise, sunset, dawn, dusk, solarNoon, night, nightEnd, goldenHourStart, goldenHourEnd, nauticalDawn, nauticalDusk, astronomicalDawn, astronomicalDusk) at a location from a starting date.
230
+
231
+ Use for: "when is sunset today?", planning golden-hour photography or dawn fishing around tide windows.`,
232
+ inputSchema: {
233
+ event: z.nativeEnum(SunEventType).describe("Sun event to find."),
234
+ latitude: LatitudeSchema,
235
+ longitude: LongitudeSchema,
236
+ date: IsoDateSchema.optional().describe("Search start date. Defaults to today."),
237
+ count: z
238
+ .number()
239
+ .int()
240
+ .min(1)
241
+ .max(30)
242
+ .default(1)
243
+ .describe("How many occurrences to return."),
244
+ timezone: TimezoneSchema,
245
+ response_format: ResponseFormatSchema,
246
+ },
247
+ annotations: LOCAL_COMPUTE_ANNOTATIONS,
248
+ }, async (params) => {
249
+ try {
250
+ const occurrences = sunService.getNextSunEvent({
251
+ event: params.event,
252
+ latitude: params.latitude,
253
+ longitude: params.longitude,
254
+ date: params.date,
255
+ count: params.count,
256
+ timezone: params.timezone,
257
+ });
258
+ const structured = { event: params.event, occurrences };
259
+ const markdown = [
260
+ `# Next ${params.event} at (${params.latitude}, ${params.longitude})`,
261
+ "",
262
+ ...occurrences.map((o, i) => `${i + 1}. ${o.date} at ${o.time}${params.timezone ? ` (${params.timezone})` : ""}`),
263
+ ].join("\n");
264
+ return respond(params.response_format, structured, markdown);
265
+ }
266
+ catch (error) {
267
+ return respondError(error);
268
+ }
269
+ });
270
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Observed and predicted current tools.
3
+ */
4
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ export declare function registerCurrentTools(server: McpServer): void;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Observed and predicted current tools.
3
+ */
4
+ import { z } from "zod";
5
+ import { getCurrents, getCurrentPredictions } from "../services/data-api.js";
6
+ import { dateFields, READ_ONLY_ANNOTATIONS, ResponseFormatSchema, StationIdSchema, TimeZoneSchema, UnitsSchema, } from "../schemas/common.js";
7
+ import { respond, respondError } from "../format/respond.js";
8
+ import { seriesMarkdown, timeZoneLabel } from "../format/series.js";
9
+ import { unitLabel } from "../format/units.js";
10
+ const BinSchema = z
11
+ .number()
12
+ .int()
13
+ .min(0)
14
+ .describe('Depth bin number (current meters measure at multiple depths). Find valid bins with noaa_get_station_info (expand ["bins"]). bin=0 returns ALL bins but caps the request at 7 days. Omit at single-bin stations.');
15
+ export function registerCurrentTools(server) {
16
+ server.registerTool("noaa_get_currents", {
17
+ title: "Get Observed Currents",
18
+ description: `Get observed current speed and direction from a NOAA current meter station.
19
+
20
+ Current stations use alphanumeric IDs (e.g. "cb0102" Chesapeake, "bh0101" Boston Harbor) — NOT the 7-digit water-level station IDs. Find them with noaa_search_stations (type "currents") or noaa_find_nearest_stations.
21
+
22
+ UNITS WARNING: english = knots, but metric = cm/s (centimeters/second, not m/s).
23
+
24
+ Returns per record: t (time), s (speed), d (direction, degrees true), b (bin number). Max 7 days per request. Set expand_detailed=true to include per-beam echo intensity and correlation diagnostics.`,
25
+ inputSchema: {
26
+ station: StationIdSchema,
27
+ bin: BinSchema.optional(),
28
+ expand_detailed: z
29
+ .boolean()
30
+ .default(false)
31
+ .describe("Include ADCP beam diagnostics (echo1-4, corr1-4) per record."),
32
+ units: UnitsSchema,
33
+ time_zone: TimeZoneSchema,
34
+ ...dateFields,
35
+ response_format: ResponseFormatSchema,
36
+ },
37
+ annotations: READ_ONLY_ANNOTATIONS,
38
+ }, async (params) => {
39
+ try {
40
+ const response = await getCurrents(params);
41
+ const data = response.data ?? [];
42
+ const speedLabel = unitLabel("currents", params.units);
43
+ const structured = {
44
+ station: params.station,
45
+ product: "currents",
46
+ bin: params.bin,
47
+ units: params.units,
48
+ speed_units: speedLabel,
49
+ direction_units: "degrees true",
50
+ time_zone: params.time_zone,
51
+ count: data.length,
52
+ data,
53
+ };
54
+ const markdown = seriesMarkdown({
55
+ title: "Observed Currents",
56
+ station: params.station,
57
+ unitsLabel: `speed in ${speedLabel}, direction in degrees true`,
58
+ timeZone: timeZoneLabel(params.time_zone),
59
+ }, ["Time", `Speed (${speedLabel})`, "Direction (°T)", "Bin"], data.map((d) => [d.t, d.s, d.d, d.b]));
60
+ return respond(params.response_format, structured, markdown);
61
+ }
62
+ catch (error) {
63
+ return respondError(error);
64
+ }
65
+ });
66
+ server.registerTool("noaa_get_current_predictions", {
67
+ title: "Get Current Predictions",
68
+ description: `Get predicted tidal currents for a NOAA current prediction station.
69
+
70
+ interval="max_slack" (recommended for navigation) returns the tidal-current events — maximum flood, maximum ebb, and slack water times — up to 1 year per request. Other intervals (h, 1, 6, 10, 30, 60 minutes) return a velocity time series — up to 31 days.
71
+
72
+ UNITS WARNING: english = knots; metric = cm/s.
73
+
74
+ vel_type="speed_dir" returns Speed/Direction pairs; "default" returns velocities projected on the flood/ebb axis (Velocity_Major: positive = flood direction, negative = ebb) with meanFloodDir/meanEbbDir. Subordinate (type "S") prediction stations derive from a reference station — see noaa_get_prediction_offsets.`,
75
+ inputSchema: {
76
+ station: StationIdSchema,
77
+ bin: BinSchema.optional(),
78
+ interval: z
79
+ .enum(["max_slack", "h", "1", "6", "10", "30", "60"])
80
+ .default("max_slack")
81
+ .describe('"max_slack" = max flood/ebb + slack events (1-year max span); h/1/6/10/30/60 = time series (31-day max span).'),
82
+ vel_type: z
83
+ .enum(["default", "speed_dir"])
84
+ .default("default")
85
+ .describe('"default" = flood/ebb-axis velocity (Velocity_Major signed: + flood, - ebb); "speed_dir" = speed and compass direction.'),
86
+ units: UnitsSchema,
87
+ time_zone: TimeZoneSchema,
88
+ ...dateFields,
89
+ response_format: ResponseFormatSchema,
90
+ },
91
+ annotations: READ_ONLY_ANNOTATIONS,
92
+ }, async (params) => {
93
+ try {
94
+ const response = await getCurrentPredictions(params);
95
+ const raw = response.current_predictions;
96
+ const data = Array.isArray(raw) ? raw : (raw?.cp ?? []);
97
+ const speedLabel = unitLabel("currents", params.units);
98
+ const structured = {
99
+ station: params.station,
100
+ product: "currents_predictions",
101
+ interval: params.interval,
102
+ vel_type: params.vel_type,
103
+ bin: params.bin,
104
+ units: params.units,
105
+ speed_units: speedLabel,
106
+ time_zone: params.time_zone,
107
+ count: data.length,
108
+ predictions: data,
109
+ };
110
+ let headers;
111
+ let rows;
112
+ if (params.vel_type === "speed_dir") {
113
+ headers = [
114
+ "Time",
115
+ `Speed (${speedLabel})`,
116
+ "Direction (°T)",
117
+ "Depth",
118
+ "Bin",
119
+ ];
120
+ rows = data.map((d) => [
121
+ d.Time,
122
+ d.Speed,
123
+ d.Direction,
124
+ d.Depth,
125
+ d.Bin,
126
+ ]);
127
+ }
128
+ else {
129
+ headers = [
130
+ "Time",
131
+ `Velocity along flood/ebb axis (${speedLabel})`,
132
+ "Type",
133
+ "Depth",
134
+ "Bin",
135
+ ];
136
+ rows = data.map((d) => {
137
+ const velocity = Number(d.Velocity_Major);
138
+ const type = d.Type ??
139
+ (isNaN(velocity)
140
+ ? ""
141
+ : Math.abs(velocity) < 0.05
142
+ ? "slack"
143
+ : velocity > 0
144
+ ? "flood"
145
+ : "ebb");
146
+ return [d.Time, d.Velocity_Major, String(type), d.Depth, d.Bin];
147
+ });
148
+ }
149
+ const markdown = seriesMarkdown({
150
+ title: params.interval === "max_slack"
151
+ ? "Current Predictions — Max Flood/Ebb & Slack"
152
+ : "Current Predictions",
153
+ station: params.station,
154
+ unitsLabel: `speed in ${speedLabel}`,
155
+ timeZone: timeZoneLabel(params.time_zone),
156
+ extra: params.vel_type === "default"
157
+ ? [
158
+ "_Velocity sign: positive = flood direction, negative = ebb direction._",
159
+ ]
160
+ : undefined,
161
+ }, headers, rows);
162
+ return respond(params.response_format, structured, markdown);
163
+ }
164
+ catch (error) {
165
+ return respondError(error);
166
+ }
167
+ });
168
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Derived Product API tools: sea level trends & rise projections, extreme
3
+ * water levels, top-ten/peak water levels, high tide flooding.
4
+ */
5
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ export declare function registerDerivedProductTools(server: McpServer): void;