@openephemeris/mcp-server 4.17.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 (35) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +10 -10
  3. package/dist/backend/client.d.ts +57 -1
  4. package/dist/backend/client.js +125 -15
  5. package/dist/prompts.js +26 -24
  6. package/dist/server-sse.d.ts +18 -0
  7. package/dist/server-sse.js +41 -6
  8. package/dist/tools/apps/_render-token.d.ts +34 -0
  9. package/dist/tools/apps/_render-token.js +50 -0
  10. package/dist/tools/apps/bazi-app.js +36 -12
  11. package/dist/tools/apps/bi-wheel-app.d.ts +9 -2
  12. package/dist/tools/apps/bi-wheel-app.js +76 -117
  13. package/dist/tools/apps/bodygraph-app.js +88 -41
  14. package/dist/tools/apps/chart-wheel-app.js +56 -16
  15. package/dist/tools/apps/location-tools.js +10 -3
  16. package/dist/tools/apps/moon-phase-app.js +10 -2
  17. package/dist/tools/apps/transit-timeline-app.js +3 -4
  18. package/dist/tools/apps/vedic-chart-app.js +10 -1
  19. package/dist/tools/datetime-historical.js +7 -2
  20. package/dist/tools/datetime.js +2 -1
  21. package/dist/tools/dev.js +7 -6
  22. package/dist/tools/specialized/electional.js +4 -3
  23. package/dist/tools/specialized/ephemeris_extended.js +19 -4
  24. package/dist/tools/specialized/hd_group.js +2 -2
  25. package/dist/tools/specialized/moon.d.ts +1 -1
  26. package/dist/tools/specialized/moon.js +51 -43
  27. package/dist/tools/specialized/progressed.js +2 -25
  28. package/dist/tools/specialized/transits.js +5 -5
  29. package/dist/ui/bazi.html +1523 -1522
  30. package/dist/ui/bi-wheel.html +406 -374
  31. package/dist/ui/bodygraph.html +83 -79
  32. package/dist/ui/chart-wheel.html +394 -361
  33. package/dist/ui/transit-timeline.html +1 -1
  34. package/dist/ui/vedic-chart.html +818 -818
  35. package/package.json +1 -1
@@ -23,10 +23,11 @@ import fs from "node:fs";
23
23
  import path from "node:path";
24
24
  import { fileURLToPath } from "node:url";
25
25
  import { registerTool, SERVER_VERSION } from "../index.js";
26
- import { getActiveClient } from "../../backend/client.js";
26
+ import { getActiveClient, isBillingOrAuthError } from "../../backend/client.js";
27
27
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
28
28
  import { localToUtcIsoHistorical } from "../datetime-historical.js";
29
29
  import { coordsFromArgsOrLocation } from "./_location-resolver.js";
30
+ import { RENDER_TOKEN_PROPERTY, renderTokenFromSvg, renderTokenFromVisual, renderTokenHeaders, withoutRenderToken, } from "./_render-token.js";
30
31
  // ── Constants ─────────────────────────────────────────────────────────────
31
32
  export const BODYGRAPH_RESOURCE_URI = "ui://openephemeris/bodygraph";
32
33
  // Appended to every model-visible HD tool description — trademark hygiene.
@@ -67,7 +68,7 @@ export function clearBodygraphBundleCache() {
67
68
  function capitalize(s) {
68
69
  return s.charAt(0).toUpperCase() + s.slice(1);
69
70
  }
70
- async function fetchGoBodygraphSVG(body, theme = "dark", view) {
71
+ async function fetchGoBodygraphSVG(body, theme = "dark", view, renderToken) {
71
72
  try {
72
73
  const client = getActiveClient();
73
74
  // /visualization/bodygraph is registered as binary in BINARY_ENDPOINT_PREFIXES.
@@ -89,7 +90,14 @@ async function fetchGoBodygraphSVG(body, theme = "dark", view) {
89
90
  qs += `&rings=${encodeURIComponent(view.rings)}`;
90
91
  if (view?.houseSystem)
91
92
  qs += `&house_system=${encodeURIComponent(view.houseSystem)}`;
92
- const resp = await client.post(`/visualization/bodygraph?${qs}`, body);
93
+ // A re-render of a chart this account already paid for presents its render
94
+ // token; the API waives the charge only if it verifies for these inputs.
95
+ // The API returns the (new or echoed) token as data-oe-render-token on the
96
+ // root <svg> — read it back with renderTokenFromSvg.
97
+ const resp = await client.request("POST", `/visualization/bodygraph?${qs}`, {
98
+ data: body,
99
+ headers: renderTokenHeaders(renderToken),
100
+ });
93
101
  if (typeof resp === "string")
94
102
  return resp;
95
103
  if (resp && typeof resp === "object") {
@@ -101,6 +109,10 @@ async function fetchGoBodygraphSVG(body, theme = "dark", view) {
101
109
  return null;
102
110
  }
103
111
  catch (err) {
112
+ // Out of credits / signed out must reach the assistant with the credit-wall
113
+ // message and top-up link — never be papered over by the client render.
114
+ if (isBillingOrAuthError(err))
115
+ throw err;
104
116
  console.warn("[bodygraph] Go SVG fetch failed, will fall back to client render:", err);
105
117
  return null;
106
118
  }
@@ -413,6 +425,9 @@ registerTool({
413
425
  });
414
426
  if (svg)
415
427
  modelPayload._svg = svg;
428
+ const renderToken = renderTokenFromSvg(svg);
429
+ if (renderToken)
430
+ modelPayload._render_token = renderToken;
416
431
  const summary = buildHdSummary(modelPayload, location);
417
432
  if (bundleAvailable) {
418
433
  // MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
@@ -468,6 +483,7 @@ registerTool({
468
483
  type: "string",
469
484
  description: "House system for the mandala house ring. Only used when layout=mandala and rings includes 'houses' with a birth location. Adds 1 credit when the house ring renders.",
470
485
  },
486
+ render_token: RENDER_TOKEN_PROPERTY,
471
487
  },
472
488
  required: ["datetime"],
473
489
  },
@@ -494,11 +510,15 @@ registerTool({
494
510
  if (lon != null)
495
511
  body.longitude = lon;
496
512
  // Recalculate is iframe-only (visibility: ["app"]) — always co-fetch the SVG.
513
+ // Both halves present the render token: a theme / layout flip of the chart
514
+ // already on screen is then free (the house ring's 1-credit surcharge still
515
+ // applies when it renders); new birth data is charged as a new chart.
497
516
  const [chartData, svg] = await Promise.all([
498
517
  client.request("POST", "/human-design/chart", {
499
518
  data: body,
519
+ headers: renderTokenHeaders(args.render_token),
500
520
  }),
501
- fetchGoBodygraphSVG(body, theme, { layout, rings, houseSystem }),
521
+ fetchGoBodygraphSVG(body, theme, { layout, rings, houseSystem }, args.render_token),
502
522
  ]);
503
523
  const chart = chartData.chart ?? chartData;
504
524
  const modelPayload = buildHdModelPayload(chart, {
@@ -510,6 +530,11 @@ registerTool({
510
530
  });
511
531
  if (svg)
512
532
  modelPayload._svg = svg;
533
+ // The API echoes the presented token on a waived re-render, or mints a new
534
+ // one when this was charged as a new chart (e.g. edited birth data).
535
+ const renderToken = renderTokenFromSvg(svg);
536
+ if (renderToken)
537
+ modelPayload._render_token = renderToken;
513
538
  return {
514
539
  content: [{ type: "text", text: JSON.stringify({ ...modelPayload, server_version: SERVER_VERSION }) }],
515
540
  };
@@ -1176,9 +1201,8 @@ registerTool({
1176
1201
  "The Variable describes the four arrow directions attached to the Personality Sun/Earth " +
1177
1202
  "and Design Sun/Earth positions, encoding the PHS (Primary Health System) — the optimal conditions " +
1178
1203
  "for the body-mind to receive information and make decisions. " +
1179
- "Returns an interpretation of Digestion (right/left Personality Sun arrow), " +
1180
- "Perspective (right/left Personality Earth arrow), " +
1181
- "Environment (right/left Design Sun arrow), and Motivation (right/left Design Earth arrow). " +
1204
+ "Returns an interpretation of Motivation (Personality Sun), Digestion (Design Sun), " +
1205
+ "Perspective (Personality Node) and Environment (Design Node). " +
1182
1206
  "This tool is called automatically by the bodygraph UI; you do not need to call it directly.",
1183
1207
  inputSchema: {
1184
1208
  type: "object",
@@ -1224,13 +1248,13 @@ registerTool({
1224
1248
  "and being drawn by appetite and desire rather than routine. " +
1225
1249
  "Left Digestion types often thrive with more novelty, conversation, and engagement at the table — " +
1226
1250
  "your body needs to be activated, not passively nourished.",
1227
- tones: {
1228
- 1: "Tone 1 — Consecutive: best with orderly, sequential eating — simple and unvaried meals in a fixed rhythm.",
1229
- 2: "Tone 2 — Alternating: eat a variety of foods but alternate types — not all mixed together.",
1230
- 3: "Tone 3 — Cyclic: allow the body to cycle through periods of different dietary focuses.",
1231
- 4: "Tone 4 — Calm: eat only when the emotional atmosphere is calm and undisturbed.",
1232
- 5: "Tone 5 — Open: eat in open spaces or with good air circulation; avoid enclosed or stagnant settings.",
1233
- 6: "Tone 6 — Closed: eat in private, quiet, familiar environments — protected from public exposure.",
1251
+ colors: {
1252
+ 1: { left: "Appetite, Consecutive: orderly, one-at-a-time eating at regular times.", right: "Appetite, Alternating: grazing and switching between kinds of food across the day." },
1253
+ 2: { left: "Taste, Open: sampling first; a bite tells the body what fits today.", right: "Taste, Closed: knowing what fits by sight or smell, without needing to try it." },
1254
+ 3: { left: "Thirst, Hot: warm food, warm drinks, warm settings.", right: "Thirst, Cold: cool food, cool drinks, calm and cool settings." },
1255
+ 4: { left: "Touch, Calm: eating in a quiet, settled atmosphere, often alone.", right: "Touch, Nervous: eating with some activity or buzz around." },
1256
+ 5: { left: "Sound, High: eating with conversation, music or background sound.", right: "Sound, Low: eating in quiet." },
1257
+ 6: { left: "Light, Direct: eating in daylight, earlier in the day.", right: "Light, Indirect: eating in softer light or later in the day." },
1234
1258
  },
1235
1259
  },
1236
1260
  perspective: {
@@ -1251,13 +1275,13 @@ registerTool({
1251
1275
  "stimulating environments. This is an Active environment variable: you are designed to encounter " +
1252
1276
  "variety, new places, and fresh stimulation. Being too long in the same environment can lead to " +
1253
1277
  "stagnation, restlessness, or decreased vitality.",
1254
- tones: {
1255
- 1: "Tone 1 — Caves: you do best in enclosed, cozy, cave-like environments with shelter overhead.",
1256
- 2: "Tone 2 — Markets: you thrive in places of exchange and activity — social, commercial, varied.",
1257
- 3: "Tone 3 — Kitchens: hearth-centered environments — where nourishment and community gather.",
1258
- 4: "Tone 4 — Mountains: elevated, expansive, airy environments with altitude and horizon.",
1259
- 5: "Tone 5 — Valleys: sheltered, low, protected geographies — river valleys, low land, sheltered bays.",
1260
- 6: "Tone 6 — Shores: boundary environments — coast, lakeside, edge-of-land — where elements meet.",
1278
+ colors: {
1279
+ 1: { left: "Caves, Exclusive: enclosed, protected spaces with few people.", right: "Caves, Blending: sheltered spaces shared with a trusted group." },
1280
+ 2: { left: "Markets, Internal: places of exchange where you have a clear role inside.", right: "Markets, External: open places of exchange you move through." },
1281
+ 3: { left: "Kitchens, Wet: hearth and gathering places with moisture and life.", right: "Kitchens, Dry: hearth and gathering places that are dry and warm." },
1282
+ 4: { left: "Mountains, Active: high, open ground with movement.", right: "Mountains, Passive: high, open ground to rest in and overlook." },
1283
+ 5: { left: "Valleys, Narrow: sheltered low ground with a close view.", right: "Valleys, Wide: sheltered low ground with a wide view." },
1284
+ 6: { left: "Shores, Natural: edges where land meets water, in nature.", right: "Shores, Artificial: edges and thresholds in built places." },
1261
1285
  },
1262
1286
  },
1263
1287
  motivation: {
@@ -1267,13 +1291,13 @@ registerTool({
1267
1291
  left: "**Left Motivation** (Active) \u2014 Your decision-making is best guided by active, intentional pursuit " +
1268
1292
  "of what aligns with your values and strategy. You are designed to initiate and drive toward what matters to you — " +
1269
1293
  "though always filtered through your Authority to ensure the body’s wisdom is guiding the mind.",
1270
- tones: {
1271
- 1: "Tone 1 — Fear: the motivation to move away from what limits or threatens, turning fear into healthy caution.",
1272
- 2: "Tone 2 — Hope: the motivation toward a better, more expanded future possibility.",
1273
- 3: "Tone 3 — Desire: the motivation driven by genuine wanting and attraction.",
1274
- 4: "Tone 4 — Need: the motivation arising from a felt sense of lack or necessity.",
1275
- 5: "Tone 5 — Guilt: the motivation arising from responsibility to others and the tribe.",
1276
- 6: "Tone 6 — Innocence: the motivation of pure openness, not knowing, and beginner’s mind.",
1294
+ colors: {
1295
+ 1: { left: "Fear: the drive to understand the unknown and stay safe.", right: "Fear: the drive to understand the unknown and stay safe." },
1296
+ 2: { left: "Hope: trust in a better possible outcome.", right: "Hope: trust in a better possible outcome." },
1297
+ 3: { left: "Desire: the push to change or affect the world.", right: "Desire: the push to change or affect the world." },
1298
+ 4: { left: "Need: identifying what is necessary.", right: "Need: identifying what is necessary." },
1299
+ 5: { left: "Guilt: the drive to fix and improve.", right: "Guilt: the drive to fix and improve." },
1300
+ 6: { left: "Innocence: acting without a personal agenda.", right: "Innocence: acting without a personal agenda." },
1277
1301
  },
1278
1302
  },
1279
1303
  };
@@ -1284,11 +1308,8 @@ registerTool({
1284
1308
  };
1285
1309
  }
1286
1310
  const main = direction === "right" ? info.right : info.left;
1287
- const toneNote = tone != null && info.tones?.[tone]
1288
- ? `\n\n**Your Specific Tone (${tone}):** ${info.tones[tone]}`
1289
- : "";
1290
- const colorNote = color != null
1291
- ? `\n\n**Color ${color}** subtly modulates this Variable's expression — these nuances are covered in advanced PHS reading.`
1311
+ const colorNote = color != null && info.colors?.[color]
1312
+ ? `\n\n**Your Color ${color}${tone != null ? `, Tone ${tone}` : ""}:** ${info.colors[color][direction]}`
1292
1313
  : "";
1293
1314
  const label = `${capitalize(variable)} Arrow — ${capitalize(direction)} Variable`;
1294
1315
  const icon = variable === "digestion" ? "🔵" : variable === "perspective" ? "🟢" : variable === "environment" ? "⚪" : "🔴";
@@ -1296,7 +1317,7 @@ registerTool({
1296
1317
  content: [{
1297
1318
  type: "text",
1298
1319
  text: `**${label}** ${icon}\n\n` +
1299
- main + toneNote + colorNote +
1320
+ main + colorNote +
1300
1321
  "\n\n*The Variable is a refinement layer within the Primary Health System (PHS). " +
1301
1322
  "It describes the optimal conditions for the body-brain to receive and process information correctly. " +
1302
1323
  "Living according to your Variable is an advanced practice that supports deeper health, clarity, and cognitive accuracy.*",
@@ -1315,7 +1336,7 @@ registerTool({
1315
1336
  "(inline in Claude and ChatGPT; text summary elsewhere) highlighting the channels the transit " +
1316
1337
  "temporarily COMPLETES with the natal chart and any centers it newly defines, for now or a chosen " +
1317
1338
  "datetime. Birthplace may be a place name (`location`).\n\n" +
1318
- "CREDIT COST: 3 credits per call (+1 to resolve a place name).\n\n" +
1339
+ "CREDIT COST: 5 credits per call (chart 3 + render 2; +1 per place name).\n\n" +
1319
1340
  "Do not use for the person's own natal bodygraph — use explore_human_design; for another person's " +
1320
1341
  "chart overlaid on theirs use explore_human_design_connection." +
1321
1342
  HD_DISCLAIMER,
@@ -1349,6 +1370,7 @@ registerTool({
1349
1370
  "concentric ring set the natal chart uses. Set by the in-iframe layout toggle, never by " +
1350
1371
  "the model.",
1351
1372
  },
1373
+ render_token: RENDER_TOKEN_PROPERTY,
1352
1374
  },
1353
1375
  required: ["datetime"],
1354
1376
  },
@@ -1410,7 +1432,12 @@ registerTool({
1410
1432
  body.include_visual = true;
1411
1433
  body.visual_config = { theme, ...(layout ? { layout } : {}) };
1412
1434
  }
1413
- const resp = await client.request("POST", "/human-design/transit-chart", { data: body });
1435
+ // The iframe's theme / layout refetch presents the token of the overlay it
1436
+ // is showing; the API waives it only for the same account + inputs.
1437
+ const resp = await client.request("POST", "/human-design/transit-chart", {
1438
+ data: body,
1439
+ headers: renderTokenHeaders(args.render_token),
1440
+ });
1414
1441
  const transit = (resp?.transit ?? {});
1415
1442
  const completed = Array.isArray(transit.completed_channels) ? transit.completed_channels : [];
1416
1443
  const newCenters = Array.isArray(transit.newly_defined_centers) ? transit.newly_defined_centers : [];
@@ -1428,8 +1455,15 @@ registerTool({
1428
1455
  ...resp,
1429
1456
  _svg: svg,
1430
1457
  _theme: theme,
1431
- // Lets the iframe re-request this overlay in the host's theme.
1432
- _refetch: { tool: "explore_human_design_transit", args: { ...args } },
1458
+ ...(renderTokenFromVisual(resp?.visual) ? { _render_token: renderTokenFromVisual(resp?.visual) } : {}),
1459
+ // Lets the iframe re-request this overlay in the host's theme. The
1460
+ // resolved coordinates/zone/name are pinned so the refetch neither
1461
+ // re-geocodes (another credit) nor drifts to a different request body,
1462
+ // which would no longer match the render token.
1463
+ _refetch: {
1464
+ tool: "explore_human_design_transit",
1465
+ args: { ...withoutRenderToken(args), latitude: lat, longitude: lon, location, ...(timezone ? { timezone } : {}) },
1466
+ },
1433
1467
  server_version: SERVER_VERSION,
1434
1468
  };
1435
1469
  if (bundleAvailable && svg) {
@@ -1453,7 +1487,7 @@ registerTool({
1453
1487
  "text summary elsewhere) with every connected channel classified by HD connection theory: " +
1454
1488
  "electromagnetic (attraction), companionship (sameness), dominance (one defines), compromise " +
1455
1489
  "(friction).\n\n" +
1456
- "CREDIT COST: 3 credits per call.\n\n" +
1490
+ "CREDIT COST: 5 credits per call (composite 3 + render 2).\n\n" +
1457
1491
  "Do not use for one person's chart — use explore_human_design; for the sky's transit on one chart " +
1458
1492
  "use explore_human_design_transit; for Western compatibility use explore_bi_wheel." +
1459
1493
  HD_DISCLAIMER,
@@ -1498,6 +1532,7 @@ registerTool({
1498
1532
  "concentric ring set the natal chart uses. Set by the in-iframe layout toggle, never by " +
1499
1533
  "the model.",
1500
1534
  },
1535
+ render_token: RENDER_TOKEN_PROPERTY,
1501
1536
  },
1502
1537
  required: ["person_a", "person_b"],
1503
1538
  },
@@ -1529,12 +1564,15 @@ registerTool({
1529
1564
  throw new Error(`${label} requires either \`latitude\` + \`longitude\` or a resolvable \`location\` name. ` +
1530
1565
  "Human Design is sensitive to birth location; a chart at 0°N 0°E is silently wrong.");
1531
1566
  }
1567
+ // Pin the resolved coordinates for the iframe's refetch (see _refetch).
1568
+ pinned[label] = { ...(p ?? {}), latitude: lat, longitude: lon, ...(tz ? { timezone: tz } : {}) };
1532
1569
  return {
1533
1570
  birth_datetime_utc: await localToUtcIsoHistorical(`${label}.datetime`, String(p?.datetime), tz, { latitude: lat, longitude: lon }, `${label}.timezone`),
1534
1571
  latitude: lat,
1535
1572
  longitude: lon,
1536
1573
  };
1537
1574
  };
1575
+ const pinned = {};
1538
1576
  const body = {
1539
1577
  subject_1: await toSubject(args.person_a, "person_a"),
1540
1578
  subject_2: await toSubject(args.person_b, "person_b"),
@@ -1547,7 +1585,10 @@ registerTool({
1547
1585
  body.include_visual = true;
1548
1586
  body.visual_config = { theme, ...(layout ? { layout } : {}) };
1549
1587
  }
1550
- const resp = await client.request("POST", "/human-design/composite", { data: body });
1588
+ const resp = await client.request("POST", "/human-design/composite", {
1589
+ data: body,
1590
+ headers: renderTokenHeaders(args.render_token),
1591
+ });
1551
1592
  const connections = Array.isArray(resp?.connections) ? resp.connections : [];
1552
1593
  const svg = resp?.visual?.data;
1553
1594
  const byType = (t) => connections.filter((c) => c?.type === t).map((c) => c.id);
@@ -1566,7 +1607,13 @@ registerTool({
1566
1607
  ...resp,
1567
1608
  _svg: svg,
1568
1609
  _theme: theme,
1569
- _refetch: { tool: "explore_human_design_connection", args: { ...args } },
1610
+ ...(renderTokenFromVisual(resp?.visual) ? { _render_token: renderTokenFromVisual(resp?.visual) } : {}),
1611
+ // Resolved coordinates pinned so the refetch neither re-geocodes nor
1612
+ // drifts from the request the render token was issued for.
1613
+ _refetch: {
1614
+ tool: "explore_human_design_connection",
1615
+ args: { ...withoutRenderToken(args), person_a: pinned.person_a, person_b: pinned.person_b },
1616
+ },
1570
1617
  server_version: SERVER_VERSION,
1571
1618
  };
1572
1619
  if (bundleAvailable && svg) {
@@ -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
  };
@@ -119,10 +119,9 @@ registerTool({
119
119
  "in-orb window (when the transit starts and ends, and which pass it is when a retrograde repeats it), " +
120
120
  "and transits already in orb at the start are listed with applying/separating.\n\n" +
121
121
  "ASPECT ANGLES: 0 = conjunction/return (default), 180 = opposition, 90 = square, 120 = trine, 60 " +
122
- "= sextile. EFFICIENCY: specify transiting_planets and natal_points to keep compute fast. DEFAULT " +
123
- "natal_points: sun, moon, mercury, venus, mars, jupiter, saturn. SEARCH RANGE LIMITS: " +
124
- "Explorer/PayG → 1 year; Pro → 5 years; Startup → 10 years.\n\n" +
125
- "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" +
126
125
  "Do not use for transit data as JSON — use ephemeris_transits; for the sky today with no birth " +
127
126
  "data use electional_moment_analysis; to see one date's transits drawn around the natal wheel use " +
128
127
  "explore_bi_wheel (mode='transit').",
@@ -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 " +