@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.
@@ -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
  });
@@ -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, localToUtcIso } from "../datetime.js";
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: { iso: localToUtcIso("birth_datetime", args.birth_datetime, args.timezone) },
75
- target_datetime: { iso: localToUtcIso("target_datetime", targetDt, args.timezone) },
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: { iso: localToUtcIso("birth_datetime", args.birth_datetime, args.timezone) },
147
- target_datetime: { iso: localToUtcIso("target_datetime", targetDt, args.timezone) },
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 = {