@openephemeris/mcp-server 3.13.9 → 3.14.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 (70) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +3 -3
  3. package/config/dev-allowlist.json +28 -4
  4. package/dist/index.js +20 -13
  5. package/dist/oauth/dcr.d.ts +11 -0
  6. package/dist/oauth/dcr.js +49 -0
  7. package/dist/oauth/discovery.d.ts +12 -0
  8. package/dist/oauth/discovery.js +45 -0
  9. package/dist/oauth/pkce.d.ts +18 -0
  10. package/dist/oauth/pkce.js +34 -0
  11. package/dist/oauth/rate-limit.d.ts +20 -0
  12. package/dist/oauth/rate-limit.js +65 -0
  13. package/dist/oauth/store.d.ts +88 -0
  14. package/dist/oauth/store.js +361 -0
  15. package/dist/oauth/supabase-jwt.d.ts +39 -0
  16. package/dist/oauth/supabase-jwt.js +194 -0
  17. package/dist/oauth/token.d.ts +21 -0
  18. package/dist/oauth/token.js +215 -0
  19. package/dist/prompts.js +180 -159
  20. package/dist/server-sse.js +154 -49
  21. package/dist/tools/apps/bazi-app.d.ts +23 -0
  22. package/dist/tools/apps/bazi-app.js +226 -0
  23. package/dist/tools/apps/bi-wheel-app.d.ts +14 -7
  24. package/dist/tools/apps/bi-wheel-app.js +347 -113
  25. package/dist/tools/apps/bodygraph-app.js +106 -13
  26. package/dist/tools/apps/chart-wheel-app.js +53 -4
  27. package/dist/tools/apps/location-tools.js +3 -0
  28. package/dist/tools/apps/moon-phase-app.d.ts +19 -0
  29. package/dist/tools/apps/moon-phase-app.js +244 -0
  30. package/dist/tools/apps/transit-timeline-app.d.ts +20 -0
  31. package/dist/tools/apps/transit-timeline-app.js +295 -0
  32. package/dist/tools/apps/vedic-chart-app.d.ts +17 -0
  33. package/dist/tools/apps/vedic-chart-app.js +228 -0
  34. package/dist/tools/auth.js +4 -0
  35. package/dist/tools/dev.js +3 -0
  36. package/dist/tools/index.d.ts +6 -0
  37. package/dist/tools/index.js +9 -4
  38. package/dist/tools/output-schemas.d.ts +37 -0
  39. package/dist/tools/output-schemas.js +130 -0
  40. package/dist/tools/specialized/acg.js +5 -2
  41. package/dist/tools/specialized/bazi.js +375 -44
  42. package/dist/tools/specialized/bi_wheel.js +3 -1
  43. package/dist/tools/specialized/chart_wheel.js +3 -1
  44. package/dist/tools/specialized/comparative.js +9 -4
  45. package/dist/tools/specialized/eclipse.js +3 -1
  46. package/dist/tools/specialized/electional.js +9 -4
  47. package/dist/tools/specialized/ephemeris_core.js +5 -2
  48. package/dist/tools/specialized/ephemeris_extended.js +17 -8
  49. package/dist/tools/specialized/hd_bodygraph.js +3 -1
  50. package/dist/tools/specialized/hd_cycles.js +5 -2
  51. package/dist/tools/specialized/hd_group.js +5 -2
  52. package/dist/tools/specialized/human_design.js +3 -1
  53. package/dist/tools/specialized/moon.js +5 -2
  54. package/dist/tools/specialized/natal.js +3 -1
  55. package/dist/tools/specialized/progressed.js +3 -1
  56. package/dist/tools/specialized/relocation.js +3 -1
  57. package/dist/tools/specialized/returns.js +7 -3
  58. package/dist/tools/specialized/synastry.js +3 -1
  59. package/dist/tools/specialized/transits.js +53 -44
  60. package/dist/tools/specialized/vedic.js +3 -1
  61. package/dist/tools/specialized/venus_star_points.js +13 -6
  62. package/dist/ui/bazi.html +213 -0
  63. package/dist/ui/bi-wheel.html +3714 -3048
  64. package/dist/ui/bodygraph.html +1952 -1766
  65. package/dist/ui/chart-wheel.html +3431 -2964
  66. package/dist/ui/moon-phase.html +6764 -0
  67. package/dist/ui/transit-timeline.html +6874 -0
  68. package/dist/ui/vedic-chart.html +210 -0
  69. package/package.json +15 -12
  70. package/smithery.yaml +16 -1
@@ -19,6 +19,7 @@ import path from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { registerTool, SERVER_VERSION } from "../index.js";
21
21
  import { getActiveClient } from "../../backend/client.js";
22
+ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
22
23
  // ── Constants ─────────────────────────────────────────────────────────────
23
24
  export const BODYGRAPH_RESOURCE_URI = "ui://openephemeris/bodygraph";
24
25
  export const BODYGRAPH_MIME_TYPE = "text/html;profile=mcp-app";
@@ -65,6 +66,40 @@ function ensureTimezone(dt) {
65
66
  return dt;
66
67
  return dt + "Z";
67
68
  }
69
+ /**
70
+ * Fetch the canonical bodygraph SVG from the Go renderer.
71
+ *
72
+ * The Go engine is the source of truth for bodygraph visuals (Phase 4 of the
73
+ * renderer unification). This SVG is inlined directly into the iframe and
74
+ * bound to interaction handlers via its data-* attributes (data-center,
75
+ * data-gate, data-channel, data-hanging, data-highlighted, etc.).
76
+ *
77
+ * Returns null on failure so the caller can fall back to the legacy client-side
78
+ * renderer in bodygraph-renderer.ts. Non-fatal: a missing SVG should never
79
+ * block the tool result.
80
+ */
81
+ async function fetchGoBodygraphSVG(body) {
82
+ try {
83
+ const client = getActiveClient();
84
+ // /visualization/bodygraph is registered as binary in BINARY_ENDPOINT_PREFIXES.
85
+ // The client wraps it as { content_type, encoding: "base64", data_base64 } —
86
+ // we decode back to UTF-8 SVG text for inlining.
87
+ const resp = await client.post("/visualization/bodygraph?format=svg&size=800", body);
88
+ if (typeof resp === "string")
89
+ return resp;
90
+ if (resp && typeof resp === "object") {
91
+ const r = resp;
92
+ if (typeof r.data_base64 === "string") {
93
+ return Buffer.from(r.data_base64, "base64").toString("utf-8");
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+ catch (err) {
99
+ console.warn("[bodygraph] Go SVG fetch failed, will fall back to client render:", err);
100
+ return null;
101
+ }
102
+ }
68
103
  /**
69
104
  * Convert a local datetime string (no offset) to a UTC ISO 8601 string.
70
105
  * Uses the IANA timezone to compute the correct UTC offset.
@@ -234,9 +269,44 @@ function buildHdModelPayload(data, birthParams) {
234
269
  const dGateList = Array.isArray(rawD) ? rawD : Object.values(rawD);
235
270
  const personality_gates = [...new Set(extractGateNums(pGateList))].sort((a, b) => a - b);
236
271
  const design_gates = [...new Set(extractGateNums(dGateList))].sort((a, b) => a - b);
237
- // keep the maps
238
- const personality_activations = !Array.isArray(rawP) && typeof rawP === 'object' ? rawP : undefined;
239
- const design_activations = !Array.isArray(rawD) && typeof rawD === 'object' ? rawD : undefined;
272
+ // Convert activation arrays or maps → planet-keyed Record<string, ActivationSummary>
273
+ // The Go API returns arrays like [{ planet: "Sun", gate: 62, line: 1, color: 3, tone: 4 }].
274
+ // The renderer needs a map keyed by planet name: { "Sun": { gate, line, color, tone } }.
275
+ function toActivationMap(raw) {
276
+ if (!raw || (typeof raw !== 'object'))
277
+ return undefined;
278
+ // Already a planet-keyed map (non-array object)
279
+ if (!Array.isArray(raw)) {
280
+ const obj = raw;
281
+ // Make sure it looks like planet-keyed (values have 'gate' property)
282
+ const first = Object.values(obj)[0];
283
+ if (first && typeof first === 'object' && 'gate' in first) {
284
+ return obj;
285
+ }
286
+ return undefined;
287
+ }
288
+ // Array of activation objects — key by the 'planet' field
289
+ const arr = raw;
290
+ if (arr.length === 0)
291
+ return undefined;
292
+ const map = {};
293
+ for (const item of arr) {
294
+ if (!item || typeof item !== 'object')
295
+ continue;
296
+ const planetName = String(item.planet ?? item.name ?? '');
297
+ if (!planetName)
298
+ continue;
299
+ map[planetName] = {
300
+ gate: Number(item.gate ?? 0),
301
+ line: Number(item.line ?? 0),
302
+ color: Number(item.color ?? 0),
303
+ tone: Number(item.tone ?? 0),
304
+ };
305
+ }
306
+ return Object.keys(map).length > 0 ? map : undefined;
307
+ }
308
+ const personality_activations = toActivationMap(rawP);
309
+ const design_activations = toActivationMap(rawD);
240
310
  return {
241
311
  type,
242
312
  profile,
@@ -314,6 +384,7 @@ registerTool({
314
384
  },
315
385
  required: ["datetime"],
316
386
  },
387
+ outputSchema: OUTPUT_SCHEMA_JSON,
317
388
  annotations: {
318
389
  title: "Interactive Human Design Bodygraph Explorer",
319
390
  readOnlyHint: true,
@@ -341,9 +412,18 @@ registerTool({
341
412
  body.latitude = lat;
342
413
  if (lon != null)
343
414
  body.longitude = lon;
344
- const chartData = await client.request("POST", "/human-design/chart", {
345
- data: body,
346
- });
415
+ const bundleAvailable = Boolean(getBodygraphBundle());
416
+ // Fetch chart data and Go-rendered SVG in parallel. SVG only fetched when
417
+ // the iframe bundle is available (text-fallback hosts have no use for it,
418
+ // and the visualization endpoint costs a credit reservation).
419
+ const [chartData, svg] = await Promise.all([
420
+ client.request("POST", "/human-design/chart", {
421
+ data: body,
422
+ }),
423
+ bundleAvailable
424
+ ? fetchGoBodygraphSVG(body)
425
+ : Promise.resolve(null),
426
+ ]);
347
427
  // API returns { chart: {...}, metadata: {...} } — unwrap the inner chart object
348
428
  const chart = chartData.chart ?? chartData;
349
429
  // Build payload once — summary and model data both derive from the same object
@@ -354,8 +434,9 @@ registerTool({
354
434
  latitude: lat ?? null,
355
435
  longitude: lon ?? null,
356
436
  });
437
+ if (svg)
438
+ modelPayload._svg = svg;
357
439
  const summary = buildHdSummary(modelPayload, location);
358
- const bundleAvailable = Boolean(getBodygraphBundle());
359
440
  if (bundleAvailable) {
360
441
  return {
361
442
  content: [
@@ -390,6 +471,7 @@ registerTool({
390
471
  },
391
472
  required: ["datetime"],
392
473
  },
474
+ outputSchema: OUTPUT_SCHEMA_JSON,
393
475
  annotations: { title: "Recalculate Bodygraph", readOnlyHint: true, openWorldHint: false },
394
476
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
395
477
  handler: async (args) => {
@@ -406,9 +488,13 @@ registerTool({
406
488
  body.latitude = lat;
407
489
  if (lon != null)
408
490
  body.longitude = lon;
409
- const chartData = await client.request("POST", "/human-design/chart", {
410
- data: body,
411
- });
491
+ // Recalculate is iframe-only (visibility: ["app"]) — always co-fetch the SVG.
492
+ const [chartData, svg] = await Promise.all([
493
+ client.request("POST", "/human-design/chart", {
494
+ data: body,
495
+ }),
496
+ fetchGoBodygraphSVG(body),
497
+ ]);
412
498
  const chart = chartData.chart ?? chartData;
413
499
  const modelPayload = buildHdModelPayload(chart, {
414
500
  datetime,
@@ -417,6 +503,8 @@ registerTool({
417
503
  latitude: lat ?? null,
418
504
  longitude: lon ?? null,
419
505
  });
506
+ if (svg)
507
+ modelPayload._svg = svg;
420
508
  return {
421
509
  content: [{ type: "text", text: JSON.stringify({ ...modelPayload, server_version: SERVER_VERSION }) }],
422
510
  };
@@ -443,6 +531,7 @@ registerTool({
443
531
  },
444
532
  required: ["center", "defined"],
445
533
  },
534
+ outputSchema: OUTPUT_SCHEMA_JSON,
446
535
  annotations: { title: "Center Interpretation", readOnlyHint: true, openWorldHint: false },
447
536
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
448
537
  handler: async (args) => {
@@ -500,6 +589,7 @@ registerTool({
500
589
  },
501
590
  required: ["gate"],
502
591
  },
592
+ outputSchema: OUTPUT_SCHEMA_JSON,
503
593
  annotations: { title: "Gate Interpretation", readOnlyHint: true, openWorldHint: false },
504
594
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
505
595
  handler: async (args) => {
@@ -554,6 +644,7 @@ registerTool({
554
644
  },
555
645
  required: ["channel"],
556
646
  },
647
+ outputSchema: OUTPUT_SCHEMA_JSON,
557
648
  annotations: { title: "Channel Interpretation", readOnlyHint: true, openWorldHint: false },
558
649
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
559
650
  handler: async (args) => {
@@ -699,7 +790,7 @@ const HD_GATE_DATA = {
699
790
  38: { name: "The Fighter", hexagram: "Hex 38 — Opposition", keynote: "Struggle in the search for purpose", gift: "Perseverance — fighting for what is meaningful", shadow: "Struggle — fighting against everything without purpose", circuit: "Individual (Knowing)" },
700
791
  39: { name: "Provocation", hexagram: "Hex 39 — Obstruction", keynote: "Freeing the spirit through provocation", gift: "Dynamism — provocateur that liberates", shadow: "Provocation — stirring trouble unconsciously", circuit: "Individual (Knowing)" },
701
792
  40: { name: "Aloneness", hexagram: "Hex 40 — Deliverance", keynote: "The demand for aloneness as restoration", gift: "Resolve — willpower renewed in solitude", shadow: "Exhaustion — depleted by giving without replenishment", circuit: "Tribal (Ego)" },
702
- 41: { name: "Contraction", hexagram: "Hex 41 — Decrease", keynote: "The beginning of all experience — fantasy and desire", gift: "Fantasy — imagination that seeds new experience", shadow: "Fantasy misused — escapism", circuit: "Collective (Abstract)" },
793
+ 41: { name: "Contraction", hexagram: "Hex 41 — Decrease", keynote: "The beginning of all experience — fantasy and desire", gift: "Fantasy — imagination that seeds new experience", shadow: "Escapism — escapism", circuit: "Collective (Abstract)" },
703
794
  42: { name: "Growth", hexagram: "Hex 42 — Increase", keynote: "The ability to perpetuate and complete growth cycles", gift: "Detachment — completing cycles with grace", shadow: "Incompletion — abandoning cycles before they end", circuit: "Collective (Abstract)" },
704
795
  43: { name: "Insight", hexagram: "Hex 43 — Breakthrough", keynote: "Breakthrough from within", gift: "Insight — inner knowing that arrives as genius", shadow: "Deafness — genius unrecognised or unheard", circuit: "Individual (Knowing)" },
705
796
  44: { name: "Alertness", hexagram: "Hex 44 — Coming to Meet", keynote: "Instinctive memory and pattern recognition", gift: "Teamwork — bringing the right people together", shadow: "Interference — driven by karmic patterns from the past", circuit: "Tribal (Defence)" },
@@ -813,8 +904,8 @@ const HD_PLANET_HD_THEMES = {
813
904
  "to act before the mind has a chance to deliberate. This energy can surprise even yourself.",
814
905
  },
815
906
  Jupiter: {
816
- conscious: "**Jupiter** represents the law of your Personality — the karmic gifts and the areas of life where " +
817
- "higher wisdom, abundance, and justice flow. It shows where and how you expand beyond the self-limiting " +
907
+ conscious: "**Jupiter** represents the law of your Personality — the karmic gifts and the principled framework through which you expand beyond " +
908
+ "higher wisdom and justice flow. It shows where you move beyond the self-limiting " +
818
909
  "patterns of your conditioning.",
819
910
  unconscious: "**Design Jupiter** shows the unconscious grace embedded in your design — the karmic correction " +
820
911
  "your body naturally applies when you are living true to your nature.",
@@ -897,6 +988,7 @@ registerTool({
897
988
  },
898
989
  required: ["planet", "column"],
899
990
  },
991
+ outputSchema: OUTPUT_SCHEMA_JSON,
900
992
  annotations: { title: "Planet Sidebar Interpretation", readOnlyHint: true, openWorldHint: false },
901
993
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
902
994
  handler: async (args) => {
@@ -965,6 +1057,7 @@ registerTool({
965
1057
  },
966
1058
  required: ["variable", "direction"],
967
1059
  },
1060
+ outputSchema: OUTPUT_SCHEMA_JSON,
968
1061
  annotations: { title: "Variable Arrow Interpretation", readOnlyHint: true, openWorldHint: false },
969
1062
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
970
1063
  handler: async (args) => {
@@ -20,6 +20,7 @@ import path from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { registerTool, SERVER_VERSION } from "../index.js";
22
22
  import { getActiveClient } from "../../backend/client.js";
23
+ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
23
24
  // ── Constants ─────────────────────────────────────────────────────────────
24
25
  export const CHART_WHEEL_RESOURCE_URI = "ui://openephemeris/chart-wheel";
25
26
  export const CHART_WHEEL_MIME_TYPE = "text/html;profile=mcp-app";
@@ -139,9 +140,18 @@ registerTool({
139
140
  enum: ["placidus", "whole_sign", "equal", "koch", "regiomontanus", "campanus"],
140
141
  description: "House system to use (default: placidus).",
141
142
  },
143
+ bodies: {
144
+ type: "array",
145
+ items: { type: "string" },
146
+ description: "Optional list of body names to include. " +
147
+ "Defaults to 13 classical bodies (Sun through Pluto + Chiron + Nodes). " +
148
+ "Use 'all' as a single item to include every available body (Lilith, Ceres, Juno, Vesta, Pallas, Vertex, etc.). " +
149
+ "Example: ['sun','moon','lilith','vertex'].",
150
+ },
142
151
  },
143
152
  required: ["datetime", "latitude", "longitude"],
144
153
  },
154
+ outputSchema: OUTPUT_SCHEMA_JSON,
145
155
  annotations: {
146
156
  title: "Interactive Chart Wheel Explorer",
147
157
  readOnlyHint: true,
@@ -162,6 +172,17 @@ registerTool({
162
172
  const lat = args.latitude;
163
173
  const lon = args.longitude;
164
174
  const natalBody = buildNatalBody(datetime, lat, lon, houseSystem, args.timezone);
175
+ // Determine which bodies are requested
176
+ const requestedBodies = args.bodies;
177
+ const wantsAll = requestedBodies?.some(b => b.toLowerCase() === "all");
178
+ const wantsExtended = wantsAll || requestedBodies?.some(b => EXTENDED_BODIES.has(b.toLowerCase()));
179
+ // Enable extended body computation on the API side when needed
180
+ if (wantsExtended) {
181
+ const config = natalBody.configuration;
182
+ config.include_asteroids = true;
183
+ config.include_lilith = true;
184
+ config.include_chiron = true;
185
+ }
165
186
  // Fetch natal chart JSON. The chart is rendered client-side in the UI iframe,
166
187
  // so we do NOT call the /visualization/chart-wheel endpoint — that was the
167
188
  // source of the ~90 second blocking delay.
@@ -175,7 +196,7 @@ registerTool({
175
196
  latitude: lat ?? null,
176
197
  longitude: lon ?? null,
177
198
  location: args.location ?? null,
178
- }, houseSystem);
199
+ }, houseSystem, undefined, requestedBodies);
179
200
  const bundleAvailable = Boolean(getChartWheelBundle());
180
201
  if (bundleAvailable) {
181
202
  return {
@@ -216,6 +237,7 @@ registerTool({
216
237
  },
217
238
  required: ["planet", "longitude"],
218
239
  },
240
+ outputSchema: OUTPUT_SCHEMA_JSON,
219
241
  annotations: { title: "Planet Interpretation", readOnlyHint: true, openWorldHint: false },
220
242
  _meta: { ui: { resourceUri: CHART_WHEEL_RESOURCE_URI, visibility: ["app"] } },
221
243
  handler: async (args) => {
@@ -250,6 +272,7 @@ registerTool({
250
272
  },
251
273
  required: ["house_number"],
252
274
  },
275
+ outputSchema: OUTPUT_SCHEMA_JSON,
253
276
  annotations: { title: "House Interpretation", readOnlyHint: true, openWorldHint: false },
254
277
  _meta: { ui: { resourceUri: CHART_WHEEL_RESOURCE_URI, visibility: ["app"] } },
255
278
  handler: async (args) => {
@@ -285,6 +308,7 @@ registerTool({
285
308
  },
286
309
  required: ["planet1", "planet2"],
287
310
  },
311
+ outputSchema: OUTPUT_SCHEMA_JSON,
288
312
  annotations: { title: "Aspect Interpretation", readOnlyHint: true, openWorldHint: false },
289
313
  _meta: { ui: { resourceUri: CHART_WHEEL_RESOURCE_URI, visibility: ["app"] } },
290
314
  handler: async (args) => {
@@ -333,6 +357,7 @@ registerTool({
333
357
  },
334
358
  required: ["datetime", "latitude", "longitude", "house_system"],
335
359
  },
360
+ outputSchema: OUTPUT_SCHEMA_JSON,
336
361
  annotations: { title: "Recalculate Chart", readOnlyHint: true, openWorldHint: false },
337
362
  _meta: { ui: { resourceUri: CHART_WHEEL_RESOURCE_URI, visibility: ["app"] } },
338
363
  handler: async (args) => {
@@ -391,6 +416,13 @@ const CLASSICAL_PLANETS = new Set([
391
416
  "jupiter", "saturn", "uranus", "neptune", "pluto",
392
417
  "chiron", "north_node", "south_node", "true_node", "asc", "mc",
393
418
  ]);
419
+ const EXTENDED_BODIES = new Set([
420
+ "mean_lilith", "lilith", "true_lilith",
421
+ "ceres", "juno", "vesta", "pallas", "pholus",
422
+ "vertex", "part_of_fortune",
423
+ "eris", "sedna",
424
+ ]);
425
+ const ALL_KNOWN_BODIES = new Set([...CLASSICAL_PLANETS, ...EXTENDED_BODIES]);
394
426
  /**
395
427
  * Build a lean, SVG-free payload for Claude's text context.
396
428
  * Strips SVG, filters to classical planets only, and packages birth params.
@@ -422,7 +454,13 @@ function computeAspects(planets) {
422
454
  diff = 360 - diff;
423
455
  for (const asp of ASPECTS) {
424
456
  const orb = Math.abs(diff - asp.angle);
425
- if (orb <= asp.orb) {
457
+ // Tighten orbs when one or both bodies are extended (asteroids, etc.)
458
+ const p1Extended = EXTENDED_BODIES.has(p1.name.toLowerCase());
459
+ const p2Extended = EXTENDED_BODIES.has(p2.name.toLowerCase());
460
+ const effectiveOrb = (p1Extended || p2Extended)
461
+ ? Math.min(asp.orb * 0.5, 3) // half standard, capped at 3°
462
+ : asp.orb;
463
+ if (orb <= effectiveOrb) {
426
464
  // Applying: combined longitudinal speed closing the orb
427
465
  const applying = ((p1.speed ?? 1) - (p2.speed ?? 0)) < 0 ? diff < asp.angle : diff > asp.angle;
428
466
  results.push({
@@ -440,7 +478,7 @@ function computeAspects(planets) {
440
478
  }
441
479
  return results;
442
480
  }
443
- function buildModelPayload(chartData, birthParams, houseSystem, svgBase) {
481
+ function buildModelPayload(chartData, birthParams, houseSystem, svgBase, bodyFilter) {
444
482
  // ── 1. Normalize planets: keyed object → typed PlanetData array ──────────
445
483
  // The natal API returns { sun: { longitude, sign_name, is_retrograde, ... }, moon: {...} }.
446
484
  // The chart wheel UI expects PlanetData[] with canonical field names.
@@ -448,7 +486,18 @@ function buildModelPayload(chartData, birthParams, houseSystem, svgBase) {
448
486
  let planetsArray = [];
449
487
  if (rawPlanets && typeof rawPlanets === "object" && !Array.isArray(rawPlanets)) {
450
488
  planetsArray = Object.entries(rawPlanets)
451
- .filter(([k]) => CLASSICAL_PLANETS.has(k.toLowerCase()))
489
+ .filter(([k]) => {
490
+ const key = k.toLowerCase();
491
+ // If specific bodies were requested, use that filter
492
+ if (bodyFilter) {
493
+ const wantsAll = bodyFilter.some(b => b.toLowerCase() === "all");
494
+ if (wantsAll)
495
+ return ALL_KNOWN_BODIES.has(key);
496
+ return bodyFilter.some(b => b.toLowerCase() === key);
497
+ }
498
+ // Default: classical planets only
499
+ return CLASSICAL_PLANETS.has(key);
500
+ })
452
501
  .map(([name, p]) => ({
453
502
  name: name.toLowerCase(),
454
503
  longitude: (p.longitude ?? p.lon ?? 0),
@@ -1,5 +1,6 @@
1
1
  import { registerTool, validateRequired } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
+ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
3
4
  registerTool({
4
5
  name: "location_search",
5
6
  description: "App-only tool for getting location autocomplete suggestions. Returns latitude, longitude, and IANA timezone.",
@@ -14,6 +15,7 @@ registerTool({
14
15
  required: ["query"],
15
16
  additionalProperties: false,
16
17
  },
18
+ outputSchema: OUTPUT_SCHEMA_JSON,
17
19
  _meta: {
18
20
  ui: {
19
21
  visibility: ["app"],
@@ -38,6 +40,7 @@ registerTool({
38
40
  required: ["latitude", "longitude"],
39
41
  additionalProperties: false,
40
42
  },
43
+ outputSchema: OUTPUT_SCHEMA_JSON,
41
44
  _meta: {
42
45
  ui: {
43
46
  visibility: ["app"],
@@ -0,0 +1,19 @@
1
+ /**
2
+ * moon-phase-app.ts — MCP App tool registration for the Moon Phase Explorer.
3
+ *
4
+ * Registers 1 tool:
5
+ * • explore_moon_phase — primary entry point, returns data + UI resource [model]
6
+ *
7
+ * The dial is rendered client-side from structured JSON. The tool calls the
8
+ * existing moon phase and VOC endpoints, merges the results, and returns them
9
+ * with a UI resource link so the iframe can render a circular phase dial.
10
+ *
11
+ * Also exports resource helpers (getMoonPhaseBundle, etc.) for use in
12
+ * index.ts and server-sse.ts.
13
+ */
14
+ export declare const MOON_PHASE_RESOURCE_URI = "ui://openephemeris/moon-phase";
15
+ export declare const MOON_PHASE_MIME_TYPE = "text/html;profile=mcp-app";
16
+ /** Read the pre-built HTML bundle. Returns null if not yet built. */
17
+ export declare function getMoonPhaseBundle(): string | null;
18
+ /** Reset cache (useful in dev watch mode or tests). */
19
+ export declare function clearMoonPhaseBundleCache(): void;
@@ -0,0 +1,244 @@
1
+ /**
2
+ * moon-phase-app.ts — MCP App tool registration for the Moon Phase Explorer.
3
+ *
4
+ * Registers 1 tool:
5
+ * • explore_moon_phase — primary entry point, returns data + UI resource [model]
6
+ *
7
+ * The dial is rendered client-side from structured JSON. The tool calls the
8
+ * existing moon phase and VOC endpoints, merges the results, and returns them
9
+ * with a UI resource link so the iframe can render a circular phase dial.
10
+ *
11
+ * Also exports resource helpers (getMoonPhaseBundle, etc.) for use in
12
+ * index.ts and server-sse.ts.
13
+ */
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { registerTool, validateCoordinates, SERVER_VERSION } from "../index.js";
18
+ import { getActiveClient } from "../../backend/client.js";
19
+ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
20
+ // ── Constants ─────────────────────────────────────────────────────────────────
21
+ export const MOON_PHASE_RESOURCE_URI = "ui://openephemeris/moon-phase";
22
+ export const MOON_PHASE_MIME_TYPE = "text/html;profile=mcp-app";
23
+ const here = path.dirname(fileURLToPath(import.meta.url));
24
+ const BUNDLE_PATHS = [
25
+ path.resolve(here, "..", "..", "..", "dist", "ui", "moon-phase.html"),
26
+ ];
27
+ function findBundlePath() {
28
+ for (const p of BUNDLE_PATHS) {
29
+ if (fs.existsSync(p))
30
+ return p;
31
+ }
32
+ return null;
33
+ }
34
+ let cachedBundle = null;
35
+ /** Read the pre-built HTML bundle. Returns null if not yet built. */
36
+ export function getMoonPhaseBundle() {
37
+ if (cachedBundle)
38
+ return cachedBundle;
39
+ const bundlePath = findBundlePath();
40
+ if (!bundlePath)
41
+ return null;
42
+ try {
43
+ cachedBundle = fs.readFileSync(bundlePath, "utf-8");
44
+ return cachedBundle;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /** Reset cache (useful in dev watch mode or tests). */
51
+ export function clearMoonPhaseBundleCache() {
52
+ cachedBundle = null;
53
+ }
54
+ // ── Summary builder ──────────────────────────────────────────────────────────
55
+ function buildMoonSummary(data) {
56
+ const phase = data.phase;
57
+ const voc = data.void_of_course;
58
+ const meta = (phase?.metadata ?? {});
59
+ const phaseName = String(phase?.phase_name ?? phase?.name ?? "Unknown").replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
60
+ const illumination = typeof phase?.illumination === "number" ? Math.round(phase.illumination) : "?";
61
+ const moonSign = String(meta?.moon_sign ?? phase?.moon_sign ?? phase?.sign ?? voc?.current_sign ?? "");
62
+ const isVoc = Boolean(voc?.is_void ?? voc?.is_void_of_course ?? false);
63
+ const speed = typeof meta?.longitude_speed === "number" ? meta.longitude_speed : null;
64
+ const daysToFull = typeof meta?.days_to_full === "number" ? meta.days_to_full :
65
+ typeof phase?.days_to_full === "number" ? phase.days_to_full : null;
66
+ let summary = "**Moon Phase** — " + phaseName + "\n\n" +
67
+ "Illumination: **" + illumination + "%**\n";
68
+ if (moonSign) {
69
+ summary += "Moon Sign: **" + moonSign + "**\n";
70
+ }
71
+ if (speed !== null) {
72
+ const tempo = speed > 14.5 ? "fast" : speed < 12.0 ? "slow" : "average";
73
+ summary += "Speed: **" + speed.toFixed(2) + "°/day** (" + tempo + ")\n";
74
+ }
75
+ if (daysToFull !== null) {
76
+ summary += "Full Moon in: **" + daysToFull.toFixed(1) + " days**\n";
77
+ }
78
+ if (isVoc) {
79
+ summary += "\n⚠️ **Void of Course** — The Moon is currently VOC.";
80
+ if (voc?.ends)
81
+ summary += " Ends at " + String(voc.ends) + ".";
82
+ if (voc?.next_sign)
83
+ summary += " Next sign: " + String(voc.next_sign) + ".";
84
+ summary += "\n";
85
+ }
86
+ summary += "\nClick cards on the dial for details and interpretation.";
87
+ return summary;
88
+ }
89
+ // ── Tool: explore_moon_phase ─────────────────────────────────────────────────
90
+ registerTool({
91
+ name: "explore_moon_phase",
92
+ description: "Generate an interactive Moon Phase dial showing the current lunar illumination, " +
93
+ "phase name, zodiac sign, and void-of-course status as a beautiful circular visualization.\n\n" +
94
+ "Returns a visual dial with:\n" +
95
+ " • SVG crescent Moon showing real-time illumination percentage\n" +
96
+ " • Phase name and waxing/waning indicator\n" +
97
+ " • Current Moon sign with degree\n" +
98
+ " • Void-of-Course status with timing details\n" +
99
+ " • Lunar age (days in the synodic cycle)\n" +
100
+ " • Upcoming New Moon and Full Moon dates\n\n" +
101
+ "Use this for a rich, interactive lunar phase experience in MCP Apps-capable hosts (Claude Desktop). " +
102
+ "Falls back to a text summary in other hosts.",
103
+ inputSchema: {
104
+ type: "object",
105
+ properties: {
106
+ datetime: {
107
+ type: "string",
108
+ description: "ISO 8601 datetime to query. If omitted, returns the current live moon phase (UTC now).",
109
+ },
110
+ latitude: {
111
+ type: "number",
112
+ description: "Observer latitude (optional, used for local void-of-course calculations).",
113
+ },
114
+ longitude: {
115
+ type: "number",
116
+ description: "Observer longitude (optional, used for local void-of-course calculations).",
117
+ },
118
+ },
119
+ required: [],
120
+ additionalProperties: false,
121
+ },
122
+ outputSchema: OUTPUT_SCHEMA_JSON,
123
+ annotations: {
124
+ title: "Interactive Moon Phase Explorer",
125
+ readOnlyHint: true,
126
+ destructiveHint: false,
127
+ idempotentHint: true,
128
+ openWorldHint: false,
129
+ },
130
+ _meta: {
131
+ ui: {
132
+ resourceUri: MOON_PHASE_RESOURCE_URI,
133
+ visibility: ["model", "app"],
134
+ },
135
+ },
136
+ handler: async (args) => {
137
+ validateCoordinates(args, "latitude", "longitude");
138
+ const client = getActiveClient();
139
+ const params = {};
140
+ params.datetime = args.datetime ?? new Date().toISOString();
141
+ if (args.latitude != null)
142
+ params.latitude = args.latitude;
143
+ if (args.longitude != null)
144
+ params.longitude = args.longitude;
145
+ // Fetch phase, VOC, and moon aspects in parallel
146
+ const [phase, voc, aspects] = await Promise.allSettled([
147
+ client.request("GET", "/ephemeris/moon/phase", { params }),
148
+ client.request("GET", "/ephemeris/moon/void-of-course", { params }),
149
+ client.request("GET", "/ephemeris/moon/aspects", { params }),
150
+ ]);
151
+ const mergedData = {
152
+ phase: phase.status === "fulfilled" ? phase.value : { error: phase.reason?.message },
153
+ void_of_course: voc.status === "fulfilled" ? voc.value : { error: voc.reason?.message },
154
+ aspects: aspects.status === "fulfilled" ? aspects.value : null,
155
+ };
156
+ // Flatten for the UI — merge phase fields to top level
157
+ const phaseData = mergedData.phase;
158
+ const vocData = mergedData.void_of_course;
159
+ const aspectsData = mergedData.aspects;
160
+ // Extract enriched metadata fields from the GET response
161
+ const meta = (phaseData?.metadata ?? {});
162
+ const moonSign = String(meta?.moon_sign ?? phaseData?.moon_sign ?? phaseData?.sign ?? vocData?.current_sign ?? "");
163
+ const moonSpeedRaw = meta?.longitude_speed;
164
+ const moonSpeed = typeof moonSpeedRaw === "number" ? moonSpeedRaw : null;
165
+ const angularDiameter = typeof meta?.angular_diameter === "number" ? meta.angular_diameter : null;
166
+ const distanceKm = typeof meta?.moon_distance_km === "number" ? meta.moon_distance_km : null;
167
+ const moonLat = typeof meta?.moon_lat === "number" ? meta.moon_lat : null;
168
+ const ageDays = typeof meta?.age_days === "number" ? meta.age_days :
169
+ (typeof phaseData?.age_days === "number" ? phaseData.age_days : null);
170
+ // days_to_new / days_to_full can come as the union value or from the top-level field
171
+ const extractDays = (field) => {
172
+ if (typeof field === "number")
173
+ return field;
174
+ if (field && typeof field === "object") {
175
+ const v = Object.values(field)[0];
176
+ if (typeof v === "number")
177
+ return v;
178
+ }
179
+ return null;
180
+ };
181
+ const daysToNew = extractDays(phaseData?.days_to_new);
182
+ const daysToFull = extractDays(phaseData?.days_to_full);
183
+ // Top 3 applying aspects by tightest orb
184
+ const rawAspects = (aspectsData?.aspects ?? aspectsData?.data ?? []);
185
+ const applyingAspects = Array.isArray(rawAspects)
186
+ ? rawAspects
187
+ .filter((a) => a.applying === true || a.type === "applying")
188
+ .sort((a, b) => Math.abs(a.orb ?? 99) - Math.abs(b.orb ?? 99))
189
+ .slice(0, 3)
190
+ .map((a) => ({
191
+ planet: a.planet ?? a.body ?? a.planet_name ?? "",
192
+ aspect: a.aspect ?? a.aspect_name ?? a.type_name ?? "",
193
+ orb: typeof a.orb === "number" ? Math.abs(a.orb) : null,
194
+ }))
195
+ : [];
196
+ const uiPayload = {
197
+ phase_name: String(phaseData?.phase_name ?? phaseData?.name ?? "Unknown").replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()),
198
+ illumination: typeof phaseData?.illumination === "number" ? phaseData.illumination / 100.0 : 0,
199
+ phase_angle: typeof phaseData?.phase_angle === "number" ? phaseData.phase_angle : null,
200
+ age_days: ageDays,
201
+ days_to_new: daysToNew,
202
+ days_to_full: daysToFull,
203
+ waxing: phaseData?.waxing ??
204
+ // Fallback chain: elongation < 180° = waxing, else age_days < 14.75 = waxing.
205
+ (typeof phaseData?.phase_angle === "number"
206
+ ? phaseData.phase_angle < 180
207
+ : typeof ageDays === "number"
208
+ ? ageDays < 14.75
209
+ : true), // safe default: assume waxing
210
+ moon_sign: moonSign,
211
+ moon_longitude: meta?.moon_lon ?? phaseData?.moon_longitude ?? phaseData?.longitude ?? null,
212
+ moon_latitude: moonLat,
213
+ moon_distance_km: distanceKm,
214
+ angular_diameter: angularDiameter,
215
+ moon_speed: moonSpeed,
216
+ sun_longitude: meta?.sun_lon ?? phaseData?.sun_longitude ?? null,
217
+ applying_aspects: applyingAspects,
218
+ voc_status: {
219
+ is_void: Boolean(vocData?.is_void ?? vocData?.is_void_of_course ?? false),
220
+ started: vocData?.started ?? vocData?.start ?? null,
221
+ ends: vocData?.ends ?? vocData?.end ?? null,
222
+ next_sign: vocData?.next_sign ?? null,
223
+ },
224
+ datetime: params.datetime,
225
+ server_version: SERVER_VERSION,
226
+ };
227
+ const summary = buildMoonSummary(mergedData);
228
+ const bundleAvailable = Boolean(getMoonPhaseBundle());
229
+ if (bundleAvailable) {
230
+ return {
231
+ content: [
232
+ { type: "text", text: summary },
233
+ { type: "text", text: JSON.stringify(uiPayload) },
234
+ ],
235
+ _meta: {
236
+ "ui/resourceUri": MOON_PHASE_RESOURCE_URI,
237
+ ui: { resourceUri: MOON_PHASE_RESOURCE_URI },
238
+ },
239
+ };
240
+ }
241
+ // Fallback: text summary only
242
+ return { content: [{ type: "text", text: summary }] };
243
+ },
244
+ });