@openephemeris/mcp-server 4.1.0 → 4.2.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
CHANGED
|
@@ -7,6 +7,43 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [4.2.0] — 2026-07-29
|
|
11
|
+
|
|
12
|
+
`ephemeris_next_lunar_phase` could not answer the question it exists to answer. The
|
|
13
|
+
tool's response shape changes with this fix, hence a minor rather than a patch.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **`ephemeris_next_lunar_phase` returned zero results for every query.** "When is the
|
|
18
|
+
next full moon?" answered `result_count: 0` with the note *"No matching phase found in
|
|
19
|
+
window"* — an empty result that reads like a real one, so the answer came back as
|
|
20
|
+
"there is no full moon in the next month". Four faults stacked up:
|
|
21
|
+
|
|
22
|
+
- the tool sent `start_date`/`end_date`, but the calendar endpoint takes a single
|
|
23
|
+
`date`, so the search window was silently ignored;
|
|
24
|
+
- it looked for the phase array at `phases`/`data`/`events`, while the response nests
|
|
25
|
+
it at `data.events`, so the list was always empty;
|
|
26
|
+
- it matched phase names as `"full moon"` against the engine's `"full_moon"`, so
|
|
27
|
+
nothing would have matched even once the list was found;
|
|
28
|
+
- `last_quarter` had no match at all, because the engine names it `third_quarter`.
|
|
29
|
+
|
|
30
|
+
The search now walks the calendar forward one lunation at a time, so `count` above 1
|
|
31
|
+
works. An empty result is no longer reported as an answer: since every principal phase
|
|
32
|
+
recurs about every 29.5 days, zero results is a fault and the tool now says so instead
|
|
33
|
+
of handing back a plausible non-answer.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- **`ephemeris_retrograde_status` now leads with the single-planet path.** The
|
|
38
|
+
description opened on the all-planets sweep and mentioned `planet_id` last, so "is
|
|
39
|
+
Mercury retrograde?" tended to take the 10-credit route instead of the 1-credit one.
|
|
40
|
+
`planet_id` now carries the planet-id table and both costs are stated up front.
|
|
41
|
+
- **`ephemeris_next_lunar_phase` no longer claims to return the zodiac sign and degree.**
|
|
42
|
+
It never did — it returns exact UTC datetimes. It now points at `ephemeris_moon_phase`
|
|
43
|
+
for the sign, and states that cost scales with `count`.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
10
47
|
## [4.1.0] — 2026-07-26
|
|
11
48
|
|
|
12
49
|
Follow-up to 4.0.0. The new `400` was telling REST callers to do something that, on
|
|
@@ -88,19 +88,27 @@ registerTool({
|
|
|
88
88
|
// the documented "all planets" behaviour works correctly.
|
|
89
89
|
registerTool({
|
|
90
90
|
name: "ephemeris_retrograde_status",
|
|
91
|
-
description: "Get retrograde/direct status and speed for
|
|
92
|
-
"Returns is_retrograde flag, longitude speed, and station
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
91
|
+
description: "Get retrograde/direct status and speed for ONE planet — or, if you ask for it, all " +
|
|
92
|
+
"ten — at a given date/time. Returns is_retrograde flag, longitude speed, and station " +
|
|
93
|
+
"proximity.\n\n" +
|
|
94
|
+
"✅ Answers 'is X retrograde?' at ONE instant. Pass planet_id for the named planet; " +
|
|
95
|
+
"'is Mercury retrograde?' is planet_id=2, not a whole-sky sweep. For WHEN a planet turns " +
|
|
96
|
+
"retrograde or direct, or whether it stations anywhere in a date range, use " +
|
|
97
|
+
"electional_station_tracker.\n\n" +
|
|
98
|
+
"CREDIT COST: 1 credit for a single planet (pass planet_id). Omitting planet_id runs the " +
|
|
99
|
+
"all-planets sweep and costs 10 credits — the backend bills one credit per body and this " +
|
|
100
|
+
"fans out to 10. Only omit it when the question really is about every planet.",
|
|
98
101
|
inputSchema: {
|
|
99
102
|
type: "object",
|
|
100
103
|
properties: {
|
|
101
104
|
datetime: { type: "string", description: DATETIME_DESC },
|
|
102
105
|
timezone: TIMEZONE_PROPERTY,
|
|
103
|
-
planet_id: {
|
|
106
|
+
planet_id: {
|
|
107
|
+
type: "integer",
|
|
108
|
+
description: "Single planet ID (0=Sun, 1=Moon, 2=Mercury, 3=Venus, 4=Mars, 5=Jupiter, " +
|
|
109
|
+
"6=Saturn, 7=Uranus, 8=Neptune, 9=Pluto). Pass this whenever the question " +
|
|
110
|
+
"names a planet — 1 credit. Omit only to sweep all ten — 10 credits.",
|
|
111
|
+
},
|
|
104
112
|
},
|
|
105
113
|
required: ["datetime"],
|
|
106
114
|
additionalProperties: false,
|
|
@@ -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
|
});
|