@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 all planets at a given date/time. " +
92
- "Returns is_retrograde flag, longitude speed, and station proximity for every planet.\n\n" +
93
- "✅ Answers 'is X retrograde?' at ONE instant. For WHEN a planet turns retrograde or " +
94
- "direct, or whether it stations anywhere in a date range, use electional_station_tracker.\n\n" +
95
- "CREDIT COST: for a single planet — the common case — pass planet_id (0-9, e.g. 2 for " +
96
- "Mercury): 1 credit. Omitting planet_id runs the all-planets sweep: 10 credits (the " +
97
- "backend bills one credit per body and this fans out to 10).",
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: { type: "integer", description: "Optional single planet ID (0-9). Omit for all planets." },
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, zodiac sign, and degree.\n\n" +
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\n" +
74
- "CREDIT COST: 1 credit per call.\n\n" +
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
- // Map our clean enum to display names the calendar API returns
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
- const phaseLabel = phaseNameMap[args.phase] ?? args.phase;
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
- // Build a search window wide enough to capture `count` occurrences.
118
- // Lunar cycle is ~29.5 days, so count * 35 days guarantees coverage.
119
- const startDate = args.after_date
120
- ? args.after_date.substring(0, 10)
121
- : new Date().toISOString().substring(0, 10);
122
- const endMs = new Date(startDate + "T00:00:00Z").getTime() + count * 35 * 24 * 60 * 60 * 1000;
123
- const endDate = new Date(endMs).toISOString().substring(0, 10);
124
- const result = await getActiveClient().request("GET", "/calendar/astrology/moon-phases", {
125
- params: { start_date: startDate, end_date: endDate },
126
- });
127
- // Normalise response shape — defensively handle every known API envelope shape.
128
- // Crash "allPhases.filter is not a function" occurs when a property like
129
- // result.data is truthy but is an object, not an array.
130
- function extractArray(val) {
131
- if (Array.isArray(val))
132
- return val;
133
- if (val && typeof val === "object") {
134
- // one level deeper: { phases: [...] }, { data: [...] }, etc.
135
- for (const key of ["phases", "data", "events", "moon_phases", "results", "items"]) {
136
- const nested = val[key];
137
- if (Array.isArray(nested))
138
- return nested;
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 allPhases = extractArray(result) ?? [];
144
- const matching = allPhases
145
- .filter((p) => {
146
- const name = (p.phase_name ?? p.name ?? p.phase ?? "").toLowerCase();
147
- return (name === phaseLabel.toLowerCase() ||
148
- name === args.phase.replace("_", " ").toLowerCase());
149
- })
150
- .slice(0, count);
151
- if (matching.length === 0) {
152
- // Return full calendar as fallback so the LLM can inspect it
153
- return {
154
- requested_phase: phaseLabel,
155
- search_window: { start_date: startDate, end_date: endDate },
156
- result_count: 0,
157
- note: "No matching phase found in window. Full calendar returned for inspection.",
158
- full_calendar: allPhases,
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
- results: matching,
165
- result_count: matching.length,
190
+ result_count: results.length,
191
+ results,
166
192
  };
167
193
  },
168
194
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openephemeris/mcp-server",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "Model Context Protocol server for the Open Ephemeris astronomical computation API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",