@openephemeris/mcp-server 4.16.0 → 4.18.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +15 -15
  3. package/config/dev-allowlist.json +31 -19
  4. package/dist/backend/client.d.ts +57 -1
  5. package/dist/backend/client.js +125 -15
  6. package/dist/prompts.js +26 -24
  7. package/dist/server-sse.d.ts +18 -0
  8. package/dist/server-sse.js +41 -6
  9. package/dist/tools/apps/_render-token.d.ts +34 -0
  10. package/dist/tools/apps/_render-token.js +50 -0
  11. package/dist/tools/apps/bazi-app.js +36 -12
  12. package/dist/tools/apps/bi-wheel-app.d.ts +9 -2
  13. package/dist/tools/apps/bi-wheel-app.js +76 -117
  14. package/dist/tools/apps/bodygraph-app.js +88 -41
  15. package/dist/tools/apps/chart-wheel-app.js +56 -16
  16. package/dist/tools/apps/location-tools.js +10 -3
  17. package/dist/tools/apps/moon-phase-app.js +10 -2
  18. package/dist/tools/apps/transit-timeline-app.d.ts +3 -0
  19. package/dist/tools/apps/transit-timeline-app.js +77 -9
  20. package/dist/tools/apps/vedic-chart-app.js +10 -1
  21. package/dist/tools/datetime-historical.js +7 -2
  22. package/dist/tools/datetime.js +2 -1
  23. package/dist/tools/dev.js +7 -6
  24. package/dist/tools/specialized/electional.js +4 -3
  25. package/dist/tools/specialized/ephemeris_extended.js +19 -4
  26. package/dist/tools/specialized/hd_group.js +2 -2
  27. package/dist/tools/specialized/moon.d.ts +1 -1
  28. package/dist/tools/specialized/moon.js +51 -43
  29. package/dist/tools/specialized/progressed.js +2 -25
  30. package/dist/tools/specialized/transits.js +5 -5
  31. package/dist/ui/bazi.html +1523 -1522
  32. package/dist/ui/bi-wheel.html +406 -374
  33. package/dist/ui/bodygraph.html +83 -79
  34. package/dist/ui/chart-wheel.html +394 -361
  35. package/dist/ui/transit-timeline.html +3 -3
  36. package/dist/ui/vedic-chart.html +818 -818
  37. package/package.json +1 -1
@@ -21,7 +21,7 @@ import { fileURLToPath } from "node:url";
21
21
  import { registerTool, SERVER_VERSION } from "../index.js";
22
22
  import { getActiveClient } from "../../backend/client.js";
23
23
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
24
- import { DATETIME_DESC, TIMEZONE_PROPERTY } from "../datetime.js";
24
+ import { DATETIME_DESC, TIMEZONE_PROPERTY, toDateTimeInputBody } from "../datetime.js";
25
25
  import { coordsFromArgsOrLocation } from "./_location-resolver.js";
26
26
  // ── Constants ─────────────────────────────────────────────────────────────
27
27
  export const CHART_WHEEL_RESOURCE_URI = "ui://openephemeris/chart-wheel";
@@ -159,9 +159,10 @@ registerTool({
159
159
  items: { type: "string" },
160
160
  description: "Optional list of body names to include. " +
161
161
  "Defaults to 13 classical bodies (Sun through Pluto + Chiron + Nodes). " +
162
- "Use 'all' as a single item to include every available body (Lilith, Ceres, Juno, Vesta, Pallas, Vertex, " +
162
+ "Use 'all' as a single item to include every available body (the three Liliths — mean, true and interpolated — Ceres, Juno, Vesta, Pallas, Pholus, " +
163
163
  "the 8 trans-Neptunian objects — eris, sedna, makemake, haumea, quaoar, orcus, ixion, varuna — and the " +
164
164
  "8 Uranian/Hamburg-School points — cupido, hades, zeus, kronos, apollon, admetos, vulkanus, poseidon). " +
165
+ "Lilith slugs: 'lilith' (mean), 'lilith_true', 'lilith_interpolated'. " +
165
166
  "Example: ['sun','moon','lilith','sedna'].",
166
167
  },
167
168
  },
@@ -410,19 +411,40 @@ registerTool({
410
411
  const baseBody = buildNatalBody(datetime, lat, lon, houseSystem, args.timezone);
411
412
  let endpoint = "/ephemeris/natal-chart";
412
413
  let requestBody = baseBody;
414
+ // Where the chart sits in the reply: natal-chart returns it top-level,
415
+ // /ephemeris/progressed under `data`, the return endpoints under `chart`.
416
+ let chartKey = null;
417
+ const target = toDateTimeInputBody("target_datetime", String(args.target_datetime ?? datetime), args.timezone);
413
418
  if (chartType === "solar_return") {
419
+ // SolarReturnRequest is flat — birth_datetime at the top level, no `subject`.
414
420
  endpoint = "/predictive/returns/solar";
415
- requestBody.configuration.target_datetime = { iso: args.target_datetime ?? datetime };
421
+ const subject = baseBody.subject;
422
+ requestBody = {
423
+ birth_datetime: toDateTimeInputBody("datetime", datetime, args.timezone),
424
+ birth_location: subject.birth_location,
425
+ target_datetime: target,
426
+ house_system: baseBody.configuration.house_system,
427
+ };
428
+ chartKey = "chart";
416
429
  }
417
430
  else if (chartType === "progressed") {
418
431
  // openapi.json exposes /ephemeris/progressed — /predictive/progressed
419
432
  // does not exist and 404'd every progressed recalculate in the iframe.
420
433
  endpoint = "/ephemeris/progressed";
421
- requestBody.configuration.target_datetime = { iso: args.target_datetime ?? datetime };
422
- requestBody.configuration.method = "secondary";
434
+ requestBody = {
435
+ subject: baseBody.subject,
436
+ configuration: { house_system: baseBody.configuration.house_system },
437
+ target_datetime: target,
438
+ progression_options: { method: "secondary" },
439
+ };
440
+ chartKey = "data";
423
441
  }
424
442
  // Fetch updated chart JSON only — chart renders client-side in the iframe
425
- const chartData = await client.post(endpoint, requestBody);
443
+ const reply = await client.post(endpoint, requestBody);
444
+ const chartData = chartKey ? reply[chartKey] : reply;
445
+ if (!chartData || typeof chartData !== "object") {
446
+ throw new Error(`Unexpected ${chartType} response: no \`${chartKey}\` chart in the API reply.`);
447
+ }
426
448
  // Normalise through buildModelPayload so house-switch results use the same
427
449
  // format as the initial response.
428
450
  const payload = buildModelPayload(chartData, {
@@ -457,7 +479,10 @@ const CLASSICAL_PLANETS = new Set([
457
479
  "sun", "moon", "mercury", "venus", "mars",
458
480
  "jupiter", "saturn", "uranus", "neptune", "pluto",
459
481
  // Nodes use space form to match the renderer's glyph table (chart-renderer.ts).
460
- "chiron", "north node", "south node", "true node", "asc", "mc",
482
+ "chiron", "asc", "mc",
483
+ // The four lunar nodes keep the API's own names (lowercased): Mean and True
484
+ // are different points and never share a name.
485
+ "north node (mean)", "north node (true)", "south node (mean)", "south node (true)",
461
486
  ]);
462
487
  /**
463
488
  * Canonicalise the Go backend's verbose node keys ("North Node (Mean)" /
@@ -469,25 +494,40 @@ const CLASSICAL_PLANETS = new Set([
469
494
  function canonicalizeBodyName(raw) {
470
495
  const lower = raw.toLowerCase().trim();
471
496
  if (lower === "north node (true)" || lower === "true node")
472
- return "true node";
497
+ return "north node (true)";
473
498
  if (lower === "north node (mean)" || lower === "mean node" || lower === "north node")
474
- return "north node";
475
- if (lower === "south node (mean)" || lower === "south node (true)" || lower === "south node")
476
- return "south node";
499
+ return "north node (mean)";
500
+ if (lower === "south node (true)" || lower === "true south node")
501
+ return "south node (true)";
502
+ if (lower === "south node (mean)" || lower === "south node")
503
+ return "south node (mean)";
504
+ // Lilith: three different points (mean apogee 12, osculating 13, interpolated
505
+ // 40), each kept under the API's own name — never merged into one "lilith".
506
+ if (lower === "lilith (true)" || lower === "lilith_true" || lower === "true_lilith" || lower === "osculating apogee")
507
+ return "lilith (true)";
508
+ if (lower === "lilith (interpolated)" || lower === "lilith_interpolated" || lower === "lilith_natural" || lower === "interpolated apogee")
509
+ return "lilith (interpolated)";
510
+ if (lower === "lilith (mean)" || lower === "lilith" || lower === "mean_lilith" || lower === "mean apogee" || lower === "black moon lilith")
511
+ return "lilith (mean)";
477
512
  return lower;
478
513
  }
479
514
  const EXTENDED_BODIES = new Set([
480
515
  // Slugs match the Go API's ephemerisIDForSlug (handler_wheel_resolver.go) so
481
- // additional_objects entries actually resolve server-side.
482
- "mean_lilith", "lilith", "lilith_true",
516
+ // additional_objects entries actually resolve server-side. Only ephemeris
517
+ // bodies belong here: vertex / part_of_fortune are not additional_objects and
518
+ // made the API reject every bodies:["all"] request.
519
+ "lilith", "mean_lilith", "lilith_true", "lilith_interpolated",
483
520
  "ceres", "juno", "vesta", "pallas", "pholus",
484
- "vertex", "part_of_fortune",
485
521
  // Trans-Neptunian objects (all 8 — #603 catalog expansion)
486
522
  "eris", "sedna", "makemake", "haumea", "quaoar", "orcus", "ixion", "varuna",
487
523
  // Uranian / Hamburg School hypothetical points (all 8 — #603 catalog expansion)
488
524
  "cupido", "hades", "zeus", "kronos", "apollon", "admetos", "vulkanus", "poseidon",
489
525
  ]);
490
- const ALL_KNOWN_BODIES = new Set([...CLASSICAL_PLANETS, ...EXTENDED_BODIES]);
526
+ // Names as they come back from the API (canonicalised), for display filtering.
527
+ const ALL_KNOWN_BODIES = new Set([
528
+ ...CLASSICAL_PLANETS, ...EXTENDED_BODIES,
529
+ "lilith (mean)", "lilith (true)", "lilith (interpolated)",
530
+ ]);
491
531
  /**
492
532
  * Build a lean, SVG-free payload for Claude's text context.
493
533
  * Strips SVG, filters to classical planets only, and packages birth params.
@@ -617,7 +657,7 @@ function buildChartSummary(data, location, houseSystem) {
617
657
  const CLASSICAL_ORDER = [
618
658
  "sun", "moon", "mercury", "venus", "mars",
619
659
  "jupiter", "saturn", "uranus", "neptune", "pluto",
620
- "chiron", "north_node", "south_node", "true_node", "asc", "mc",
660
+ "chiron", "north node (mean)", "north node (true)", "south node (mean)", "south node (true)", "asc", "mc",
621
661
  ];
622
662
  planetsArray.sort((a, b) => {
623
663
  const ai = CLASSICAL_ORDER.indexOf(a.name.toLowerCase());
@@ -1,5 +1,5 @@
1
1
  import { registerTool, validateRequired } from "../index.js";
2
- import { getActiveClient } from "../../backend/client.js";
2
+ import { getActiveClient, isBillingOrAuthError } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
4
  import { TZDATA_AUTHORITATIVE_FROM_YEAR } from "../datetime-historical.js";
5
5
  import { isQualifiedForTopHit } from "./_location-resolver.js";
@@ -245,7 +245,11 @@ registerTool({
245
245
  });
246
246
  }
247
247
  }
248
- catch {
248
+ catch (err) {
249
+ // The credit wall / auth / tier gate is not "server unavailable":
250
+ // surface it with its top-up link instead of a quiet estimate.
251
+ if (isBillingOrAuthError(err))
252
+ throw err;
249
253
  // Server path unavailable — the tzdata estimate stands, still
250
254
  // labelled historical_estimate. Degrading to a labelled
251
255
  // estimate is acceptable; failing the whole search is not.
@@ -349,7 +353,10 @@ registerTool({
349
353
  };
350
354
  }
351
355
  }
352
- catch {
356
+ catch (err) {
357
+ // Credit wall / auth / tier gate: surface it, don't degrade.
358
+ if (isBillingOrAuthError(err))
359
+ throw err;
353
360
  // Server path unavailable — fall through to the local
354
361
  // tzdata estimate below, flagged as such.
355
362
  }
@@ -15,7 +15,7 @@ import fs from "node:fs";
15
15
  import path from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { registerTool, validateCoordinates, SERVER_VERSION } from "../index.js";
18
- import { getActiveClient } from "../../backend/client.js";
18
+ import { getActiveClient, rethrowBillingOrAuth } from "../../backend/client.js";
19
19
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
20
20
  import { TIMEZONE_PROPERTY } from "../datetime.js";
21
21
  import { localToUtcIsoHistorical } from "../datetime-historical.js";
@@ -111,8 +111,16 @@ async function computeMoonData(args) {
111
111
  client.request("GET", "/ephemeris/moon/void-of-course", { params }),
112
112
  client.request("GET", "/ephemeris/moon/aspects", { params }),
113
113
  ]);
114
+ // A credit wall (402), expired auth (401) or tier gate (403) on ANY of the
115
+ // three calls must surface as the thrown error — with its top-up / sign-in
116
+ // link — not as an "Unknown ?%" dial that reads like real data. Only
117
+ // non-billing partial failures (VOC or aspects down) degrade gracefully.
118
+ rethrowBillingOrAuth([phase, voc, aspects]);
119
+ // Without the phase there is no dial to draw; fail rather than render "Unknown".
120
+ if (phase.status === "rejected")
121
+ throw phase.reason;
114
122
  const mergedData = {
115
- phase: phase.status === "fulfilled" ? phase.value : { error: phase.reason?.message },
123
+ phase: phase.value,
116
124
  void_of_course: voc.status === "fulfilled" ? voc.value : { error: voc.reason?.message },
117
125
  aspects: aspects.status === "fulfilled" ? aspects.value : null,
118
126
  };
@@ -8,6 +8,9 @@
8
8
  * chart to extract real planetary longitudes, searches predictive transits
9
9
  * against those targets, then flattens the (planet@target → hits) result map
10
10
  * into a single chronological array the iframe renders as a vertical timeline.
11
+ * The search asks for orb windows (orb_deg) too, so every exact hit carries the
12
+ * in-orb window it belongs to — when the transit starts and ends, and which
13
+ * pass it is when a retrograde brings the planet back over the same point.
11
14
  *
12
15
  * Also exports resource helpers (getTransitTimelineBundle, etc.) for use in
13
16
  * index.ts and server-sse.ts.
@@ -8,6 +8,9 @@
8
8
  * chart to extract real planetary longitudes, searches predictive transits
9
9
  * against those targets, then flattens the (planet@target → hits) result map
10
10
  * into a single chronological array the iframe renders as a vertical timeline.
11
+ * The search asks for orb windows (orb_deg) too, so every exact hit carries the
12
+ * in-orb window it belongs to — when the transit starts and ends, and which
13
+ * pass it is when a retrograde brings the planet back over the same point.
11
14
  *
12
15
  * Also exports resource helpers (getTransitTimelineBundle, etc.) for use in
13
16
  * index.ts and server-sse.ts.
@@ -67,18 +70,32 @@ function normDeg(d) {
67
70
  return ((d % 360) + 360) % 360;
68
71
  }
69
72
  // ── Summary builder ──────────────────────────────────────────────────────────
70
- function buildSummary(transits, aspectLabel, win) {
73
+ function shortDate(iso) {
74
+ return iso ? new Date(iso).toISOString().slice(0, 10) : "?";
75
+ }
76
+ function buildSummary(transits, windows, aspectLabel, orbDeg, win) {
77
+ const active = windows.filter((w) => w.phase_at_start);
78
+ const activeLines = active.slice(0, 6).map((w) => " • " + w.planet_name + " " + (w.aspect ?? aspectLabel) + (w.natal_point ? " natal " + w.natal_point : "") +
79
+ " — " + w.phase_at_start + ", " + (Math.round((w.orb_at_start ?? 0) * 100) / 100) + "° from exact, in orb until " +
80
+ shortDate(w.leave));
81
+ const activeBlock = activeLines.length
82
+ ? "\n\nAlready in orb (" + orbDeg + "°) at the start:\n" + activeLines.join("\n")
83
+ : "";
71
84
  if (transits.length === 0) {
72
- return "**Transit Timeline** — no exact transit hits matched the search window and criteria.";
85
+ return "**Transit Timeline** — no exact transit hits matched the search window and criteria." + activeBlock;
73
86
  }
74
87
  const planets = [...new Set(transits.map((t) => t.planet_name).filter(Boolean))];
75
88
  const lines = transits.slice(0, 8).map((t) => {
76
89
  const asp = t.aspect ?? aspectLabel;
77
90
  const natal = t.natal_point ? " natal " + t.natal_point : "";
78
91
  const when = new Date(t.date).toISOString().slice(0, 10);
92
+ const passes = t.pass_count > 1 ? ", pass " + t.pass_index + " of " + t.pass_count : "";
93
+ const inOrb = t.window_start !== undefined
94
+ ? " — in orb " + shortDate(t.window_start) + " → " + shortDate(t.window_end) + passes
95
+ : "";
79
96
  return " • " + when + " — " + t.planet_name + " " + asp + natal +
80
97
  (t.zodiac_sign ? " (" + (t.zodiac_degree ?? 0) + "° " + t.zodiac_sign + ")" : "") +
81
- (t.is_retrograde ? " ℞" : "");
98
+ (t.is_retrograde ? " ℞" : "") + inOrb;
82
99
  });
83
100
  let summary = "**Transit Timeline** — " + transits.length + (transits.length === 1 ? " event" : " events") +
84
101
  " from " + win.start_date + " to " + win.end_date +
@@ -86,6 +103,7 @@ function buildSummary(transits, aspectLabel, win) {
86
103
  lines.join("\n");
87
104
  if (transits.length > 8)
88
105
  summary += "\n … and " + (transits.length - 8) + " more.";
106
+ summary += activeBlock;
89
107
  summary += "\n\nClick any transit for details and interpretation.";
90
108
  return summary;
91
109
  }
@@ -97,12 +115,13 @@ registerTool({
97
115
  "astrologically for me in 2027' — a forecast they should SEE. Returns an interactive, " +
98
116
  "date-ordered timeline (inline in Claude and ChatGPT; text summary elsewhere) of exact transit " +
99
117
  "hits grouped by month — transiting planet glyph, the natal point it contacts, the aspect, zodiac " +
100
- "position and retrograde markers — each clickable for a focused interpretation.\n\n" +
118
+ "position and retrograde markers — each clickable for a focused interpretation. Every hit carries its " +
119
+ "in-orb window (when the transit starts and ends, and which pass it is when a retrograde repeats it), " +
120
+ "and transits already in orb at the start are listed with applying/separating.\n\n" +
101
121
  "ASPECT ANGLES: 0 = conjunction/return (default), 180 = opposition, 90 = square, 120 = trine, 60 " +
102
- "= sextile. EFFICIENCY: specify transiting_planets and natal_points to keep compute fast. DEFAULT " +
103
- "natal_points: sun, moon, mercury, venus, mars, jupiter, saturn. SEARCH RANGE LIMITS: " +
104
- "Explorer/PayG → 1 year; Pro → 5 years; Startup → 10 years.\n\n" +
105
- "CREDIT COST: 6 credits per call (natal chart + predictive transit search).\n\n" +
122
+ "= sextile. EFFICIENCY: specify transiting_planets and natal_points to keep compute fast. " +
123
+ "MAX SPAN and CREDIT COST as " +
124
+ "ephemeris_transits (6 for ≤1 year).\n\n" +
106
125
  "Do not use for transit data as JSON — use ephemeris_transits; for the sky today with no birth " +
107
126
  "data use electional_moment_analysis; to see one date's transits drawn around the natal wheel use " +
108
127
  "explore_bi_wheel (mode='transit').",
@@ -127,6 +146,10 @@ registerTool({
127
146
  type: "number",
128
147
  description: "Aspect angle in degrees. 0 = conjunction/return (default), 180 = opposition, 90 = square, 120 = trine, 60 = sextile.",
129
148
  },
149
+ orb_deg: {
150
+ type: "number",
151
+ description: "Orb in degrees that defines when a transit starts and ends (the in-orb window around each exact hit). Default 1. Greater than 0, at most 15.",
152
+ },
130
153
  },
131
154
  required: ["natal_datetime", "natal_latitude", "natal_longitude", "start_date", "end_date"],
132
155
  additionalProperties: false,
@@ -222,10 +245,12 @@ registerTool({
222
245
  let endStr = args.end_date;
223
246
  if (/^\d{4}-\d{2}-\d{2}$/.test(endStr))
224
247
  endStr += "T23:59:59Z";
248
+ const orbDeg = typeof args.orb_deg === "number" && args.orb_deg > 0 ? Math.min(args.orb_deg, 15) : 1;
225
249
  const transitBody = {
226
250
  start_date: startStr,
227
251
  end_date: endStr,
228
252
  target_degrees: effectiveTargets,
253
+ orb_deg: orbDeg,
229
254
  };
230
255
  if (args.transiting_planets)
231
256
  transitBody.planet_names = args.transiting_planets;
@@ -278,10 +303,53 @@ registerTool({
278
303
  }
279
304
  }
280
305
  transits.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
306
+ // ── Step 5: orb windows → attach each exact hit to its window ────────────
307
+ // A window groups every pass the planet makes without leaving the orb, so
308
+ // a retrograde triple pass is one window with three exacts. Hits are
309
+ // matched to windows by planet, target degree and exact time.
310
+ const rawWindows = Array.isArray(transitResult?.windows) ? transitResult.windows : [];
311
+ const windows = rawWindows.map((w) => {
312
+ const target = typeof w?.target_longitude === "number" ? w.target_longitude : null;
313
+ return {
314
+ planet: String(w?.planet_name ?? "").toLowerCase(),
315
+ planet_name: String(w?.planet_name ?? ""),
316
+ natal_point: target != null ? nearestPoint(target) : "",
317
+ aspect: aspectLabel,
318
+ target_longitude: target,
319
+ orb_deg: w?.orb_deg ?? orbDeg,
320
+ enter: w?.enter ?? null,
321
+ leave: w?.leave ?? null,
322
+ exacts: Array.isArray(w?.exacts)
323
+ ? w.exacts.map((e) => ({ time: e?.time, is_retrograde: Boolean(e?.is_retrograde) }))
324
+ : [],
325
+ duration_days: w?.duration_days ?? null,
326
+ phase_at_start: w?.phase_at_start ?? null,
327
+ orb_at_start: w?.orb_at_start ?? null,
328
+ };
329
+ });
330
+ for (const t of transits) {
331
+ const hitMs = new Date(t.date).getTime();
332
+ for (const w of windows) {
333
+ if (w.planet_name !== t.planet_name || w.target_longitude == null || t.target_longitude == null)
334
+ continue;
335
+ if (Math.abs(w.target_longitude - t.target_longitude) > 0.01)
336
+ continue;
337
+ const idx = w.exacts.findIndex((e) => Math.abs(new Date(e.time).getTime() - hitMs) <= 60_000);
338
+ if (idx < 0)
339
+ continue;
340
+ t.window_start = w.enter;
341
+ t.window_end = w.leave;
342
+ t.pass_index = idx + 1;
343
+ t.pass_count = w.exacts.length;
344
+ break;
345
+ }
346
+ }
281
347
  const win = { start_date: startStr.slice(0, 10), end_date: endStr.slice(0, 10) };
282
- const summary = buildSummary(transits, aspectLabel, win);
348
+ const summary = buildSummary(transits, windows, aspectLabel, orbDeg, win);
283
349
  const uiPayload = {
284
350
  transits,
351
+ windows,
352
+ orb_deg: orbDeg,
285
353
  natal_positions: natalPositionMap,
286
354
  aspect_angle: aspectAngle,
287
355
  aspect_label: aspectLabel,
@@ -25,6 +25,7 @@ import { getActiveClient } from "../../backend/client.js";
25
25
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
26
26
  import { localToUtcIsoHistorical } from "../datetime-historical.js";
27
27
  import { coordsFromArgsOrLocation } from "./_location-resolver.js";
28
+ import { RENDER_TOKEN_PROPERTY, renderTokenFromVisual, renderTokenHeaders } from "./_render-token.js";
28
29
  // ── Constants ─────────────────────────────────────────────────────────────────
29
30
  export const VEDIC_CHART_RESOURCE_URI = "ui://openephemeris/vedic-chart";
30
31
  export const VEDIC_CHART_MIME_TYPE = "text/html;profile=mcp-app";
@@ -75,12 +76,14 @@ function buildVedicModelPayload(chartData, birthParams, theme) {
75
76
  is_retrograde: retrogradeSet.has(String(p.planet ?? "")),
76
77
  }));
77
78
  const visual = chartData.visual;
79
+ const renderToken = renderTokenFromVisual(visual);
78
80
  return {
79
81
  ayanamsa: String(meta.ayanamsha ?? "lahiri").replace(/\b\w/g, (c) => c.toUpperCase()),
80
82
  lagna: lagnaSign,
81
83
  planets,
82
84
  _svg: visual?.data,
83
85
  _theme: theme,
86
+ ...(renderToken ? { _render_token: renderToken } : {}),
84
87
  _birth_params: birthParams,
85
88
  };
86
89
  }
@@ -237,6 +240,7 @@ registerTool({
237
240
  enum: ["light", "dark"],
238
241
  description: "Render palette for the Rashi grid SVG. Mirrors the MCP host's light/dark color scheme.",
239
242
  },
243
+ render_token: RENDER_TOKEN_PROPERTY,
240
244
  },
241
245
  required: ["datetime"],
242
246
  },
@@ -259,7 +263,12 @@ registerTool({
259
263
  };
260
264
  if (ayanamsa)
261
265
  body.ayanamsa = ayanamsa;
262
- const chartData = await client.request("POST", "/vedic/chart", { data: body });
266
+ // A theme reconcile of the chart explore_vedic_chart just rendered presents
267
+ // that render's token and is not billed again (same account + inputs).
268
+ const chartData = await client.request("POST", "/vedic/chart", {
269
+ data: body,
270
+ headers: renderTokenHeaders(args.render_token),
271
+ });
263
272
  const modelPayload = buildVedicModelPayload(chartData, {
264
273
  datetime,
265
274
  location: null,
@@ -23,7 +23,7 @@
23
23
  * beats an outage; the server remains the single authority whenever it is
24
24
  * reachable.
25
25
  */
26
- import { getActiveClient } from "../backend/client.js";
26
+ import { getActiveClient, isBillingOrAuthError } from "../backend/client.js";
27
27
  import { hasZoneSuffix, isNaiveClockTime, localToUtcIso } from "./datetime.js";
28
28
  /** Where IANA tzdata stops being reference-city best effort. */
29
29
  export const TZDATA_AUTHORITATIVE_FROM_YEAR = 1970;
@@ -69,7 +69,12 @@ export async function localToUtcIsoHistorical(field, dt, tz, coords, timezoneFie
69
69
  return res.resolved_utc;
70
70
  }
71
71
  }
72
- catch {
72
+ catch (err) {
73
+ // Out of credits / signed out / tier-gated: surface the credit wall
74
+ // now rather than silently converting with the pre-1970-wrong tzdata
75
+ // offset (the chart call that follows would hit the same wall anyway).
76
+ if (isBillingOrAuthError(err))
77
+ throw err;
73
78
  // Server unreachable or rejected the request — fall through to the
74
79
  // local conversion rather than failing the chart call.
75
80
  }
@@ -81,7 +81,8 @@ export const DATETIME_CONTRACT_INSTRUCTIONS = "Datetime contract: every clock ti
81
81
  "the naive local time and name the zone in the sibling `timezone` argument (IANA name, " +
82
82
  "e.g. `America/Chicago`). A zone-less clock time is a hard 400 — the engine never " +
83
83
  "guesses UTC, because an unstated zone shifts the Ascendant by ~15° per hour and moves " +
84
- "every house placement. A bare date (no clock time) resolves to 12:00 UTC.\n\n" +
84
+ "every house placement. A bare date (no clock time) resolves to 12:00 UTC. A pre-1970 " +
85
+ "local time with coordinates adds 1 credit (historical timezone lookup).\n\n" +
85
86
  "PREFER the local-time + `timezone` form over converting to UTC yourself. The server " +
86
87
  "resolves the IANA zone against the actual date, including historical DST rules — that " +
87
88
  "arithmetic (which offset applied on that specific day, in that specific year) is easy " +
package/dist/tools/dev.js CHANGED
@@ -46,10 +46,11 @@ const DEV_API_REFERENCE = "Target API: Open Ephemeris REST API (https://api.open
46
46
  "Call dev_list_allowed to see all currently available endpoint paths.\n\n" +
47
47
  // Prices mirror go-sidecar/internal/api/auth/usage_meter.go; tiers mirror
48
48
  // auth/middleware.go requiredTierForPath. /iching/* aliases /human-design/*.
49
- "CREDIT COST: 1 for most GET calls; /calendar/* 10; /acg/* 10 (Pro tier); /electional/* 5 " +
50
- "(find-window and aspect-search are Pro tier; moment-analysis and station-tracker are free); " +
51
- "/human-design/* and /iching/* 2; catalogs/health 0; requests over 30s add 1 " +
52
- "per 30s. Pass query {format: 'llm'} for compact output.\n\n";
49
+ "CREDIT COST: 1 for most; /predictive/* 5 (transits/search 5-70 by span); /comparative/* 3; " +
50
+ "/calendar/* 10 (moon-phases, lunar-standstill 2; cross-quarter 5); /acg/* 10 (Pro); " +
51
+ "/electional/* 5 (Pro, span-priced searches; moment-analysis, station-tracker free); " +
52
+ "/human-design/*, /iching/* 2 (composite, transit-chart 3; penta 2/member); catalogs 0; " +
53
+ "4xx/5xx refunded. Query {format: 'llm'} = compact output.\n\n";
53
54
  const READ_COMMON_CALLS = "COMMON CALLS:\n" +
54
55
  " GET /ephemeris/moon/phase — Current/queried moon phase\n" +
55
56
  " GET /ephemeris/moon/void-of-course — Next void-of-course period\n" +
@@ -60,13 +61,13 @@ const READ_COMMON_CALLS = "COMMON CALLS:\n" +
60
61
  " GET /eclipse/solar/global — Next global solar eclipse (query: date=YYYY-MM-DD)\n" +
61
62
  " GET /eclipse/solar/local — Local solar eclipse (query: lat, lon)\n" +
62
63
  " GET /tidal/forcing — Gravitational tidal forcing index\n" +
63
- " GET /calendar/astrology/moon-phases — Moon phase calendar for a date range\n" +
64
+ " GET /calendar/astrology/moon-phases — next New/Quarter/Full Moon after a date or jd\n" +
64
65
  " GET /location/autocomplete — Geocode a place name (query: query=City Name)\n" +
65
66
  " GET /chinese/zodiac — Chinese zodiac year element/animal\n" +
66
67
  " GET /catalogs/bodies — List all supported celestial bodies\n";
67
68
  const WRITE_COMMON_CALLS = "COMMON CALLS:\n" +
68
69
  " POST /ephemeris/natal-chart — Full natal chart (body: {subject: {name: 'Name', birth_datetime: {iso: '1990-04-15T14:30:00-05:00'}, birth_location: {latitude: {decimal: 40.0}, longitude: {decimal: -70.0}, timezone: {}}}})\n" +
69
- " POST /ephemeris/natal/batch — Up to 50 natal charts in one request\n" +
70
+ " POST /ephemeris/natal/batch — Up to 100 natal charts in one request (Startup tier)\n" +
70
71
  " POST /ephemeris/relocation — Relocated chart (same natal, new location)\n" +
71
72
  " POST /predictive/transits/search — Transit event search over a date range\n" +
72
73
  " POST /predictive/returns/solar — Solar return chart\n" +
@@ -10,7 +10,8 @@ registerTool({
10
10
  "date range, 'find me a good day next month' or 'pick an auspicious window'. Returns the best " +
11
11
  "continuous timing windows in the range, scored hourly on essential dignity, aspect quality, sect " +
12
12
  "and void-of-course Moon penalties, with optional filters (avoid_voc, lunar_phase).\n\n" +
13
- "CREDIT COST: 5 credits per call. Pro tier.\n\n" +
13
+ "CREDIT COST: ≤30 days 5, ≤60 days 8, ≤120 days 12. Pro tier; max window Pro 30 days, Startup " +
14
+ "60, Scale 120.\n\n" +
14
15
  "Do not use to score one specific moment or 'right now' — use electional_moment_analysis; for " +
15
16
  "retrograde dates use electional_station_tracker.",
16
17
  inputSchema: {
@@ -222,7 +223,7 @@ registerTool({
222
223
  description: "Find all active aspects between planets at a specific moment. Returns aspect type, " +
223
224
  "orb, quality score, and whether it's applying or separating. Great for checking " +
224
225
  "the 'weather' of a given day.\n\n" +
225
- "CREDIT COST: 5 credits per call.\n\n" +
226
+ "CREDIT COST: 5 credits per call. Pro tier.\n\n" +
226
227
  "EXAMPLE: What aspects are active on March 21, 2026?\n" +
227
228
  " date='2026-03-21T12:00:00Z'",
228
229
  inputSchema: {
@@ -276,7 +277,7 @@ registerTool({
276
277
  "Sun, Moon, Mercury, Venus, Mars and the lunar nodes — the same body set and formula the CCG hybrid " +
277
278
  "transit map (/acg/ccg mode=ccg_hybrid) draws with; every other body stays a transit. Progressed " +
278
279
  "crossings carry progressed: true and search_summary.progression names what was progressed.\n\n" +
279
- "CREDIT COST: 5 credits per call.\n\n" +
280
+ "CREDIT COST: priced by start→end span — ≤1 month 5, ≤3 months 8, ≤6 months 10, ≤1 year 12. Pro tier.\n\n" +
280
281
  "EXAMPLE: When does Mercury cross the Descendant over Warsaw on 2026-07-30?\n" +
281
282
  " latitude=52.2297, longitude=21.0122, bodies='Mercury', angles='DC',\n" +
282
283
  " start='2026-07-30T00:00:00Z', end='2026-07-31T00:00:00Z'",
@@ -1,5 +1,5 @@
1
1
  import { registerTool, validateRequired, validateCoordinates } from "../index.js";
2
- import { getActiveClient } from "../../backend/client.js";
2
+ import { getActiveClient, rethrowBillingOrAuth } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
4
  import { DATETIME_DESC, TIMEZONE_PROPERTY, assertZonedDatetime, toDateTimeInputBody } from "../datetime.js";
5
5
  // `field`/`timezoneField` name the caller's own argument path (e.g.
@@ -134,10 +134,19 @@ registerTool({
134
134
  }
135
135
  // Fan-out: the backend only handles one planet per call; query all 10 in parallel.
136
136
  const PLANET_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
137
- const results = await Promise.all(PLANET_IDS.map((pid) => client.post("/ephemeris/retrograde-status", {
137
+ const settled = await Promise.allSettled(PLANET_IDS.map((pid) => client.post("/ephemeris/retrograde-status", {
138
138
  date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
139
139
  planet_id: pid,
140
- }).catch(() => null)));
140
+ })));
141
+ // Out of credits / signed out / tier-gated: throw the credit-wall (or
142
+ // auth) error so its top-up link reaches the assistant. Swallowing it
143
+ // used to return `{planets: {}, success: true}` at zero credits.
144
+ rethrowBillingOrAuth(settled);
145
+ const results = settled.map((s) => (s.status === "fulfilled" ? s.value : null));
146
+ if (results.every((r) => r == null)) {
147
+ // Every planet failed for a non-billing reason — never report success with no data.
148
+ throw settled[0].reason;
149
+ }
141
150
  // Merge into a keyed object: { planet_name: {...status} }
142
151
  const merged = {};
143
152
  for (const res of results) {
@@ -151,7 +160,13 @@ registerTool({
151
160
  };
152
161
  }
153
162
  }
154
- return { planets: merged, datetime: args.datetime, success: true };
163
+ const failed = PLANET_IDS.filter((_, i) => results[i] == null);
164
+ return {
165
+ planets: merged,
166
+ datetime: args.datetime,
167
+ success: true,
168
+ ...(failed.length ? { failed_planet_ids: failed } : {}),
169
+ };
155
170
  },
156
171
  });
157
172
  // POST /ephemeris/midpoints
@@ -8,7 +8,7 @@ registerTool({
8
8
  name: "human_design_composite",
9
9
  description: "Calculate a Human Design composite chart for two people. Merges both bodygraphs " +
10
10
  "to show shared channels, authority dynamics, and relationship type.\n\n" +
11
- "CREDIT COST: 4 credits per call.\n\n" +
11
+ "CREDIT COST: 3 credits per call.\n\n" +
12
12
  "EXAMPLE (local times + zones):\n" +
13
13
  " person_a_datetime='1990-04-15T14:30:00', person_a_timezone='America/Chicago',\n" +
14
14
  " person_b_datetime='1988-09-22T08:15:00', person_b_timezone='America/Los_Angeles'\n" +
@@ -74,7 +74,7 @@ registerTool({
74
74
  name: "human_design_penta",
75
75
  description: "Calculate a Human Design Penta (group) chart for 3-5 people. Shows functional " +
76
76
  "attributes, leadership dynamics, channels, redundancies, and a group stability score.\n\n" +
77
- "CREDIT COST: 6 credits per call.\n\n" +
77
+ "CREDIT COST: 2 credits per member (6-10 for 3-5 people).\n\n" +
78
78
  "EXAMPLE (3 people — each datetime states its zone):\n" +
79
79
  " group_name='Team Alpha',\n" +
80
80
  " members=[\n" +
@@ -1 +1 @@
1
- export {};
1
+ export declare const NEXT_LUNAR_PHASE_MAX_COUNT = 12;