@openephemeris/mcp-server 4.1.0 → 4.3.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/CHANGELOG.md +105 -0
- package/dist/index.js +10 -0
- package/dist/server-sse.js +3 -1
- package/dist/tools/apps/bodygraph-app.js +20 -2
- package/dist/tools/datetime.d.ts +53 -4
- package/dist/tools/datetime.js +72 -19
- package/dist/tools/specialized/comparative.js +5 -7
- package/dist/tools/specialized/ephemeris_core.js +3 -3
- package/dist/tools/specialized/ephemeris_extended.js +24 -16
- package/dist/tools/specialized/moon.js +74 -48
- package/dist/tools/specialized/returns.js +5 -5
- package/dist/ui/bodygraph.html +1057 -1051
- package/package.json +1 -1
|
@@ -68,10 +68,13 @@ registerTool({
|
|
|
68
68
|
registerTool({
|
|
69
69
|
name: "ephemeris_next_lunar_phase",
|
|
70
70
|
description: "Find the next occurrence of a specific Moon phase after a given date. " +
|
|
71
|
-
"Returns the exact UTC datetime
|
|
71
|
+
"Returns the exact UTC datetime of each occurrence.\n\n" +
|
|
72
72
|
"✅ USE THIS TOOL FOR: 'When is the next new moon?', 'When is the next full moon?', " +
|
|
73
|
-
"'What date is the next quarter moon?', or any question about UPCOMING phase dates.\n
|
|
74
|
-
"
|
|
73
|
+
"'What date is the next quarter moon?', or any question about UPCOMING phase dates.\n" +
|
|
74
|
+
"❌ NOT FOR: 'What phase is the moon in right now?' or 'What sign is the moon in?'\n" +
|
|
75
|
+
"→ For the phase, sign and degree AT a moment, use ephemeris_moon_phase.\n\n" +
|
|
76
|
+
"CREDIT COST: about 1 credit per occurrence returned — count=1 costs 1 credit, " +
|
|
77
|
+
"count=3 costs 3.\n\n" +
|
|
75
78
|
"EXAMPLE: Find the next new moon:\n" +
|
|
76
79
|
" phase='new_moon'\n\n" +
|
|
77
80
|
"EXAMPLE: Find the next full moon after a specific date:\n" +
|
|
@@ -105,64 +108,87 @@ registerTool({
|
|
|
105
108
|
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
106
109
|
annotations: { title: "Next Lunar Phase", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
107
110
|
handler: async (args) => {
|
|
108
|
-
|
|
109
|
-
const phaseNameMap = {
|
|
111
|
+
const PHASE_LABELS = {
|
|
110
112
|
new_moon: "New Moon",
|
|
111
113
|
full_moon: "Full Moon",
|
|
112
114
|
first_quarter: "First Quarter",
|
|
113
115
|
last_quarter: "Last Quarter",
|
|
114
116
|
};
|
|
115
|
-
|
|
117
|
+
// The engine names the waning quarter `third_quarter`; accept both spellings.
|
|
118
|
+
const PHASE_IDS = {
|
|
119
|
+
new_moon: ["new_moon"],
|
|
120
|
+
full_moon: ["full_moon"],
|
|
121
|
+
first_quarter: ["first_quarter"],
|
|
122
|
+
last_quarter: ["last_quarter", "third_quarter"],
|
|
123
|
+
};
|
|
124
|
+
const phaseLabel = PHASE_LABELS[args.phase];
|
|
125
|
+
const wanted = PHASE_IDS[args.phase];
|
|
126
|
+
if (!wanted) {
|
|
127
|
+
throw new Error(`Unknown phase '${args.phase}'. Use one of: ${Object.keys(PHASE_IDS).join(", ")}.`);
|
|
128
|
+
}
|
|
116
129
|
const count = Math.max(1, Math.min(12, args.count ?? 1));
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
130
|
+
const startDate = (args.after_date ?? new Date().toISOString()).substring(0, 10);
|
|
131
|
+
const startMs = Date.parse(startDate + "T00:00:00Z");
|
|
132
|
+
if (Number.isNaN(startMs)) {
|
|
133
|
+
throw new Error(`after_date must be an ISO date like '2026-06-01', got '${args.after_date}'.`);
|
|
134
|
+
}
|
|
135
|
+
const HALF_DAY = 12 * 60 * 60 * 1000;
|
|
136
|
+
// The calendar endpoint takes a single `date` (it searches forward from noon UTC
|
|
137
|
+
// that day) and returns the first occurrence of each principal phase within the
|
|
138
|
+
// following ~30 days. So one request yields at most one hit for the phase we want.
|
|
139
|
+
async function phaseTimesFrom(probeDay) {
|
|
140
|
+
const result = await getActiveClient().request("GET", "/calendar/astrology/moon-phases", { params: { date: probeDay } });
|
|
141
|
+
const events = Array.isArray(result?.data?.events) ? result.data.events : [];
|
|
142
|
+
// The endpoint prepends the Moon's *current* 8-phase bucket, stamped with the
|
|
143
|
+
// query instant (noon UTC of `date`) rather than an event time. Left in, it
|
|
144
|
+
// reads as a real phase occurring at exactly 12:00:00. Computed moments always
|
|
145
|
+
// carry sub-second precision, so an exact hit on noon is that placeholder.
|
|
146
|
+
const probeNoonMs = Date.parse(probeDay + "T12:00:00Z");
|
|
147
|
+
return events
|
|
148
|
+
.filter((e) => wanted.includes(String(e?.phase ?? e?.phase_name ?? "").toLowerCase()))
|
|
149
|
+
.map((e) => Date.parse(e?.time ?? e?.iso_time ?? ""))
|
|
150
|
+
.filter((ms) => !Number.isNaN(ms) && ms !== probeNoonMs)
|
|
151
|
+
.sort((a, b) => a - b);
|
|
152
|
+
}
|
|
153
|
+
// Probe from the day containing `afterMs - 12h`, so its noon-UTC search origin is
|
|
154
|
+
// always at or before `afterMs` and an occurrence earlier the same day is still
|
|
155
|
+
// visible. That leaves under a day of slack behind the cursor, which can hold at
|
|
156
|
+
// most one occurrence (they are ~29.5 days apart) — hence a single re-probe.
|
|
157
|
+
async function findNext(afterMs) {
|
|
158
|
+
let probeMs = afterMs - HALF_DAY;
|
|
159
|
+
for (let probe = 0; probe < 2; probe++) {
|
|
160
|
+
const hits = await phaseTimesFrom(new Date(probeMs).toISOString().substring(0, 10));
|
|
161
|
+
const next = hits.find((ms) => ms > afterMs);
|
|
162
|
+
if (next != null)
|
|
163
|
+
return next;
|
|
164
|
+
if (hits.length === 0)
|
|
165
|
+
return null;
|
|
166
|
+
probeMs = hits[hits.length - 1] + HALF_DAY;
|
|
140
167
|
}
|
|
141
168
|
return null;
|
|
142
169
|
}
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
};
|
|
170
|
+
const results = [];
|
|
171
|
+
let cursorMs = startMs - 1; // include an occurrence at exactly 00:00 on after_date
|
|
172
|
+
for (let i = 0; i < count; i++) {
|
|
173
|
+
const hitMs = await findNext(cursorMs);
|
|
174
|
+
if (hitMs == null)
|
|
175
|
+
break;
|
|
176
|
+
results.push({ phase: args.phase, datetime: new Date(hitMs).toISOString() });
|
|
177
|
+
cursorMs = hitMs;
|
|
178
|
+
}
|
|
179
|
+
// Every principal phase recurs every ~29.5 days, so an empty result is impossible.
|
|
180
|
+
// Fail loudly rather than handing back a plausible "there isn't one" non-answer.
|
|
181
|
+
if (results.length === 0) {
|
|
182
|
+
throw new Error(`Lunar phase search failed: the backend returned no ${phaseLabel} after ` +
|
|
183
|
+
`${startDate}. A ${phaseLabel} occurs roughly every 29.5 days, so this is a ` +
|
|
184
|
+
`backend fault, not a real answer — do not report that there is no upcoming ` +
|
|
185
|
+
`${phaseLabel}.`);
|
|
160
186
|
}
|
|
161
187
|
return {
|
|
162
188
|
requested_phase: phaseLabel,
|
|
163
189
|
after_date: startDate,
|
|
164
|
-
|
|
165
|
-
|
|
190
|
+
result_count: results.length,
|
|
191
|
+
results,
|
|
166
192
|
};
|
|
167
193
|
},
|
|
168
194
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { registerTool, validateRequired } from "../index.js";
|
|
2
2
|
import { getActiveClient } from "../../backend/client.js";
|
|
3
3
|
import { OUTPUT_SCHEMA_IMAGE_AND_JSON, OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
4
|
-
import { DATETIME_DESC, TIMEZONE_PROPERTY, assertZonedDatetime,
|
|
4
|
+
import { DATETIME_DESC, TIMEZONE_PROPERTY, assertZonedDatetime, toDateTimeInputBody } from "../datetime.js";
|
|
5
5
|
// POST /predictive/returns/solar
|
|
6
6
|
registerTool({
|
|
7
7
|
name: "ephemeris_solar_return",
|
|
@@ -71,8 +71,8 @@ registerTool({
|
|
|
71
71
|
// Default target to current year if not provided
|
|
72
72
|
const targetDt = args.target_datetime ?? new Date().toISOString();
|
|
73
73
|
const body = {
|
|
74
|
-
birth_datetime:
|
|
75
|
-
target_datetime:
|
|
74
|
+
birth_datetime: toDateTimeInputBody("birth_datetime", args.birth_datetime, args.timezone),
|
|
75
|
+
target_datetime: toDateTimeInputBody("target_datetime", targetDt, args.timezone),
|
|
76
76
|
};
|
|
77
77
|
if (args.birth_latitude != null && args.birth_longitude != null) {
|
|
78
78
|
body.location = {
|
|
@@ -143,8 +143,8 @@ registerTool({
|
|
|
143
143
|
assertZonedDatetime("target_datetime", args.target_datetime, args.timezone);
|
|
144
144
|
const targetDt = args.target_datetime ?? new Date().toISOString();
|
|
145
145
|
const body = {
|
|
146
|
-
birth_datetime:
|
|
147
|
-
target_datetime:
|
|
146
|
+
birth_datetime: toDateTimeInputBody("birth_datetime", args.birth_datetime, args.timezone),
|
|
147
|
+
target_datetime: toDateTimeInputBody("target_datetime", targetDt, args.timezone),
|
|
148
148
|
};
|
|
149
149
|
if (args.birth_latitude != null && args.birth_longitude != null) {
|
|
150
150
|
body.location = {
|