@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.
- package/README.md +164 -199
- package/dist/client/cache.d.ts +15 -0
- package/dist/client/cache.js +44 -0
- package/dist/client/http.d.ts +28 -0
- package/dist/client/http.js +178 -0
- package/dist/constants.d.ts +28 -0
- package/dist/constants.js +28 -0
- package/dist/format/respond.d.ts +25 -0
- package/dist/format/respond.js +40 -0
- package/dist/format/series.d.ts +16 -0
- package/dist/format/series.js +42 -0
- package/dist/format/units.d.ts +16 -0
- package/dist/format/units.js +37 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +78 -8
- package/dist/interfaces/moon.d.ts +4 -4
- package/dist/interfaces/moon.js +58 -17
- package/dist/interfaces/sun.d.ts +4 -4
- package/dist/interfaces/sun.js +94 -25
- package/dist/prompts/index.d.ts +6 -0
- package/dist/prompts/index.js +89 -0
- package/dist/reference/content.d.ts +10 -0
- package/dist/reference/content.js +202 -0
- package/dist/resources/index.d.ts +7 -0
- package/dist/resources/index.js +73 -0
- package/dist/schemas/common.d.ts +38 -14
- package/dist/schemas/common.js +82 -18
- package/dist/services/data-api.d.ts +81 -0
- package/dist/services/data-api.js +117 -0
- package/dist/services/dpapi.d.ts +55 -0
- package/dist/services/dpapi.js +60 -0
- package/dist/services/metadata-api.d.ts +62 -0
- package/dist/services/metadata-api.js +105 -0
- package/dist/services/moon-phase-service.d.ts +2 -2
- package/dist/services/moon-phase-service.js +23 -25
- package/dist/services/sun-service.d.ts +2 -2
- package/dist/services/sun-service.js +53 -31
- package/dist/tools/astronomy.d.ts +7 -0
- package/dist/tools/astronomy.js +270 -0
- package/dist/tools/currents.d.ts +5 -0
- package/dist/tools/currents.js +168 -0
- package/dist/tools/derived.d.ts +6 -0
- package/dist/tools/derived.js +296 -0
- package/dist/tools/index.d.ts +3 -14
- package/dist/tools/index.js +17 -32
- package/dist/tools/met.d.ts +5 -0
- package/dist/tools/met.js +110 -0
- package/dist/tools/reference.d.ts +6 -0
- package/dist/tools/reference.js +33 -0
- package/dist/tools/station-metadata.d.ts +6 -0
- package/dist/tools/station-metadata.js +208 -0
- package/dist/tools/stations.d.ts +5 -0
- package/dist/tools/stations.js +265 -0
- package/dist/tools/water.d.ts +5 -0
- package/dist/tools/water.js +270 -0
- package/dist/validation/dates.d.ts +50 -0
- package/dist/validation/dates.js +139 -0
- package/package.json +27 -14
- package/.claude/settings.local.json +0 -29
- package/CLAUDE.md +0 -71
- package/Dockerfile +0 -14
- package/smithery.yaml +0 -16
- package/src/index.ts +0 -13
- package/src/interfaces/moon.ts +0 -44
- package/src/interfaces/noaa.ts +0 -130
- package/src/interfaces/parameters.ts +0 -20
- package/src/interfaces/sun.ts +0 -57
- package/src/schemas/common.ts +0 -23
- package/src/schemas/dpapi.ts +0 -99
- package/src/server/config.ts +0 -43
- package/src/server/mcp-server.ts +0 -135
- package/src/services/dpapi-service.ts +0 -187
- package/src/services/moon-phase-service.ts +0 -167
- package/src/services/noaa-parameters-service.ts +0 -139
- package/src/services/noaa-service.ts +0 -171
- package/src/services/sun-service.ts +0 -275
- package/src/tools/derived-product-tools.ts +0 -180
- package/src/tools/index.ts +0 -40
- package/src/tools/moon-tools.ts +0 -79
- package/src/tools/parameter-tools.ts +0 -82
- package/src/tools/station-tools.ts +0 -57
- package/src/tools/sun-tools.ts +0 -120
- package/src/tools/water-tools.ts +0 -166
- package/src/types/moon.ts +0 -27
- package/src/types/sun.ts +0 -51
- package/src/types/suncalc.d.ts +0 -110
- package/test-dpapi.js +0 -0
- package/tsconfig.json +0 -15
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import SunCalc from
|
|
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(
|
|
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 &&
|
|
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(
|
|
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:
|
|
52
|
-
|
|
53
|
-
|
|
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(
|
|
72
|
+
throw new Error("Invalid date format. Please use YYYY-MM-DD format.");
|
|
66
73
|
}
|
|
67
74
|
if (startDate > endDate) {
|
|
68
|
-
throw new Error(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
110
|
-
time: date.toISOString().split(
|
|
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 :
|
|
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[
|
|
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(
|
|
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(
|
|
157
|
+
formattedTime = eventTime.toISOString().split("T")[1].split(".")[0];
|
|
142
158
|
}
|
|
143
159
|
results.push({
|
|
144
|
-
date: eventTime.toISOString().split(
|
|
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 &&
|
|
157
|
-
|
|
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) +
|
|
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) /
|
|
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 =
|
|
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 -
|
|
225
|
-
Math.floor(
|
|
226
|
-
Math.floor(
|
|
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,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;
|