@openephemeris/mcp-server 3.22.0 → 3.23.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.
@@ -0,0 +1,297 @@
1
+ /**
2
+ * vedic-chart-app.ts — MCP App tool registration for the Vedic Chart Explorer.
3
+ *
4
+ * Entry tool [model + app]:
5
+ * • explore_vedic_chart — natal Vedic (Jyotish) chart, data + UI resource
6
+ * App-only tool:
7
+ * • vedic_chart_recalculate — re-fetch with a different theme (dark/light
8
+ * reconciliation, mirrors bodygraph_recalculate) — no client-side param
9
+ * controls in v1, just the theme round-trip main.ts needs on mount.
10
+ *
11
+ * The South Indian Rashi grid is rendered server-side by the Go engine
12
+ * (handler_vedic_visual.go via POST /vedic/chart?include_visual=true) and
13
+ * inlined by the iframe — same Phase 4 "Go SVG is the source of truth"
14
+ * pattern as bodygraph-app.ts. /vedic/chart is not a binary endpoint, so the
15
+ * visual.data field is already a raw SVG string (no base64 decode needed).
16
+ *
17
+ * Also exports resource helpers (getVedicChartBundle, etc.) for use in
18
+ * index.ts and server-sse.ts.
19
+ */
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { registerTool, validateRequired, validateCoordinates, SERVER_VERSION } from "../index.js";
24
+ import { getActiveClient } from "../../backend/client.js";
25
+ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
26
+ // ── Constants ─────────────────────────────────────────────────────────────────
27
+ export const VEDIC_CHART_RESOURCE_URI = "ui://openephemeris/vedic-chart";
28
+ export const VEDIC_CHART_MIME_TYPE = "text/html;profile=mcp-app";
29
+ const here = path.dirname(fileURLToPath(import.meta.url));
30
+ const BUNDLE_PATHS = [
31
+ path.resolve(here, "..", "..", "..", "dist", "ui", "vedic-chart.html"),
32
+ ];
33
+ function findBundlePath() {
34
+ for (const p of BUNDLE_PATHS) {
35
+ if (fs.existsSync(p))
36
+ return p;
37
+ }
38
+ return null;
39
+ }
40
+ let cachedBundle = null;
41
+ /** Read the pre-built HTML bundle. Returns null if not yet built. */
42
+ export function getVedicChartBundle() {
43
+ if (cachedBundle)
44
+ return cachedBundle;
45
+ const bundlePath = findBundlePath();
46
+ if (!bundlePath)
47
+ return null;
48
+ try {
49
+ cachedBundle = fs.readFileSync(bundlePath, "utf-8");
50
+ return cachedBundle;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ /** Reset cache (useful in dev watch mode or tests). */
57
+ export function clearVedicChartBundleCache() {
58
+ cachedBundle = null;
59
+ }
60
+ // ── Helpers ──────────────────────────────────────────────────────────────────
61
+ /** Ensure datetime has a UTC offset for Go time.Time parsing. */
62
+ function ensureTimezone(dt) {
63
+ if (!dt)
64
+ return dt;
65
+ if (/[Zz]$/.test(dt) || /[+-]\d{2}:\d{2}$/.test(dt))
66
+ return dt;
67
+ return dt + "Z";
68
+ }
69
+ /**
70
+ * Convert a local datetime string (no offset) to a UTC ISO 8601 string using
71
+ * the IANA timezone. Mirrors bodygraph-app.ts's localToUtcIso.
72
+ */
73
+ function localToUtcIso(dt, tz) {
74
+ if (!dt || /[Zz]$/.test(dt) || /[+-]\d{2}:\d{2}$/.test(dt))
75
+ return ensureTimezone(dt);
76
+ if (!tz)
77
+ return dt + "Z";
78
+ try {
79
+ const [datePart, timePart = "00:00:00"] = dt.split("T");
80
+ const [year, month, day] = datePart.split("-").map(Number);
81
+ const [hour, min, sec = 0] = timePart.split(":").map(Number);
82
+ const candidateUtcMs = Date.UTC(year, month - 1, day, hour, min, sec);
83
+ const getOffsetMs = (utcMs) => {
84
+ const fmtParts = new Intl.DateTimeFormat("en-US", {
85
+ timeZone: tz,
86
+ year: "numeric", month: "2-digit", day: "2-digit",
87
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
88
+ hour12: false,
89
+ }).formatToParts(new Date(utcMs));
90
+ const get = (t) => Number(fmtParts.find((p) => p.type === t)?.value ?? 0);
91
+ const localizedUtcMs = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
92
+ return utcMs - localizedUtcMs;
93
+ };
94
+ const offsetMs1 = getOffsetMs(candidateUtcMs);
95
+ const correctedUtcMs1 = candidateUtcMs + offsetMs1;
96
+ const offsetMs2 = getOffsetMs(correctedUtcMs1);
97
+ const finalUtcMs = candidateUtcMs + offsetMs2;
98
+ return new Date(finalUtcMs).toISOString();
99
+ }
100
+ catch {
101
+ return dt + "Z";
102
+ }
103
+ }
104
+ /**
105
+ * Build the compact iframe model from the raw Go /vedic/chart response.
106
+ * The Go response nests ayanamsha/lagna under `metadata` (radians→degrees
107
+ * already applied) and carries `visual.data` as the raw SVG when
108
+ * include_visual was requested.
109
+ */
110
+ function buildVedicModelPayload(chartData, birthParams, theme) {
111
+ const meta = (chartData.metadata ?? {});
112
+ const lagnaLon = typeof meta.lagna === "number" ? meta.lagna : 0;
113
+ const lagnaSign = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"][Math.floor((((lagnaLon % 360) + 360) % 360) / 30)];
114
+ const retrogradeSet = new Set(meta.retrograde_planets ?? []);
115
+ const planets = (chartData.planets ?? []).map((p) => ({
116
+ ...p,
117
+ is_retrograde: retrogradeSet.has(String(p.planet ?? "")),
118
+ }));
119
+ const visual = chartData.visual;
120
+ return {
121
+ ayanamsa: String(meta.ayanamsha ?? "lahiri").replace(/\b\w/g, (c) => c.toUpperCase()),
122
+ lagna: lagnaSign,
123
+ planets,
124
+ _svg: visual?.data,
125
+ _theme: theme,
126
+ _birth_params: birthParams,
127
+ };
128
+ }
129
+ function buildVedicSummary(payload, location) {
130
+ const planetLines = payload.planets
131
+ .map((p) => `${p.planet} in ${p.rashi_western} ${Number(p.rashi_degree ?? 0).toFixed(1)}°${p.is_retrograde ? " ℞" : ""}`)
132
+ .join(", ");
133
+ return (`**Vedic (Jyotish) Chart — ${location}**\n\n` +
134
+ `Ayanamsa: **${payload.ayanamsa}** | Lagna: **${payload.lagna}**\n\n` +
135
+ `Planets: ${planetLines}\n\n` +
136
+ "Click any rashi in the grid for its themes and interpretation.");
137
+ }
138
+ // ── Tool: explore_vedic_chart ────────────────────────────────────────────────
139
+ registerTool({
140
+ name: "explore_vedic_chart",
141
+ description: "Generate an interactive Vedic (Jyotish) birth chart as a South Indian fixed-sign Rashi grid, " +
142
+ "with clickable rashis showing sidereal placements, nakshatras, and the Lagna.\n\n" +
143
+ "CREDIT COST: 3 credits per call (chart calculation + visual render).\n\n" +
144
+ "Returns an embedded visual explorer that lets you click any rashi cell for its themes and " +
145
+ "any planets placed there. Shows sidereal (Lahiri by default) planet placements, nakshatra with " +
146
+ "pada, navamsa, and the Lagna (Ascendant) rashi. Uses NASA JPL DE440 ephemerides. " +
147
+ "Use this for a rich, interactive Jyotish experience in MCP Apps-capable hosts (Claude Desktop). " +
148
+ "Falls back to a text summary in other hosts.",
149
+ inputSchema: {
150
+ type: "object",
151
+ properties: {
152
+ datetime: {
153
+ type: "string",
154
+ description: "ISO 8601 birth datetime in UTC, e.g. '1990-06-15T14:30:00Z'. Include 'Z' or a timezone offset.",
155
+ },
156
+ latitude: {
157
+ type: "number",
158
+ description: "Birth latitude in decimal degrees (positive = North).",
159
+ },
160
+ longitude: {
161
+ type: "number",
162
+ description: "Birth longitude in decimal degrees (positive = East).",
163
+ },
164
+ location: {
165
+ type: "string",
166
+ description: "Location name for display only (e.g. 'Mumbai, India').",
167
+ },
168
+ timezone: {
169
+ type: "string",
170
+ description: "IANA timezone name for the birth location (e.g. 'Asia/Kolkata'). " +
171
+ "Recommended for accuracy if providing local birth time without a UTC offset.",
172
+ },
173
+ ayanamsa: {
174
+ type: "string",
175
+ enum: ["lahiri", "fagan_bradley", "krishnamurti", "raman", "yukteshwar"],
176
+ description: "Ayanamsa system for sidereal conversion. Defaults to 'lahiri'.",
177
+ },
178
+ },
179
+ required: ["datetime", "latitude", "longitude"],
180
+ },
181
+ outputSchema: OUTPUT_SCHEMA_JSON,
182
+ annotations: {
183
+ title: "Interactive Vedic Chart Explorer",
184
+ readOnlyHint: true,
185
+ destructiveHint: false,
186
+ idempotentHint: true,
187
+ openWorldHint: false,
188
+ },
189
+ _meta: {
190
+ ui: {
191
+ resourceUri: VEDIC_CHART_RESOURCE_URI,
192
+ visibility: ["model", "app"],
193
+ },
194
+ },
195
+ handler: async (args) => {
196
+ validateRequired(args, ["datetime", "latitude", "longitude"]);
197
+ validateCoordinates(args, "latitude", "longitude");
198
+ const client = getActiveClient();
199
+ const timezone = args.timezone;
200
+ const datetime = localToUtcIso(String(args.datetime), timezone);
201
+ const lat = Number(args.latitude);
202
+ const lon = Number(args.longitude);
203
+ const ayanamsa = args.ayanamsa;
204
+ const location = String(args.location ?? `${lat}, ${lon}`).slice(0, 120);
205
+ const bundleAvailable = Boolean(getVedicChartBundle());
206
+ const theme = "dark"; // app shell default; iframe reconciles to host theme on load
207
+ const body = {
208
+ datetime_utc: datetime,
209
+ latitude: lat,
210
+ longitude: lon,
211
+ };
212
+ if (ayanamsa)
213
+ body.ayanamsa = ayanamsa;
214
+ if (bundleAvailable) {
215
+ body.include_visual = true;
216
+ body.visual_config = { theme, format: "svg", size: 640 };
217
+ }
218
+ const chartData = await client.request("POST", "/vedic/chart", { data: body });
219
+ const modelPayload = buildVedicModelPayload(chartData, {
220
+ datetime,
221
+ location: args.location ?? null,
222
+ timezone: timezone ?? null,
223
+ latitude: lat,
224
+ longitude: lon,
225
+ ayanamsa: ayanamsa ?? "lahiri",
226
+ }, theme);
227
+ const summary = buildVedicSummary(modelPayload, location);
228
+ if (bundleAvailable) {
229
+ // MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
230
+ // delivered through resources/read — NOT as a content block. structuredContent
231
+ // carries the data payload for non-rendering hosts.
232
+ return {
233
+ content: [{ type: "text", text: summary }],
234
+ structuredContent: { ...modelPayload, server_version: SERVER_VERSION },
235
+ _meta: {
236
+ "ui/resourceUri": VEDIC_CHART_RESOURCE_URI,
237
+ ui: { resourceUri: VEDIC_CHART_RESOURCE_URI },
238
+ },
239
+ };
240
+ }
241
+ return { content: [{ type: "text", text: summary }] };
242
+ },
243
+ });
244
+ // ── Tool: vedic_chart_recalculate [app-only] ─────────────────────────────────
245
+ // Fired by main.ts's applyTheme() to reconcile the server-default-dark SVG
246
+ // with the host's actual theme. No client-side param controls in v1.
247
+ registerTool({
248
+ name: "vedic_chart_recalculate",
249
+ description: "Re-renders a Vedic chart with a different theme (light/dark).",
250
+ inputSchema: {
251
+ type: "object",
252
+ properties: {
253
+ datetime: { type: "string" },
254
+ latitude: { type: "number" },
255
+ longitude: { type: "number" },
256
+ ayanamsa: { type: "string" },
257
+ theme: {
258
+ type: "string",
259
+ enum: ["light", "dark"],
260
+ description: "Render palette for the Rashi grid SVG. Mirrors the MCP host's light/dark color scheme.",
261
+ },
262
+ },
263
+ required: ["datetime"],
264
+ },
265
+ outputSchema: OUTPUT_SCHEMA_JSON,
266
+ annotations: { title: "Recalculate Vedic Chart", readOnlyHint: true, openWorldHint: false },
267
+ _meta: { ui: { resourceUri: VEDIC_CHART_RESOURCE_URI, visibility: ["app"] } },
268
+ handler: async (args) => {
269
+ const client = getActiveClient();
270
+ const datetime = ensureTimezone(String(args.datetime));
271
+ const lat = args.latitude != null ? Number(args.latitude) : undefined;
272
+ const lon = args.longitude != null ? Number(args.longitude) : undefined;
273
+ const ayanamsa = args.ayanamsa;
274
+ const theme = args.theme === "light" ? "light" : "dark";
275
+ const body = {
276
+ datetime_utc: datetime,
277
+ latitude: lat,
278
+ longitude: lon,
279
+ include_visual: true,
280
+ visual_config: { theme, format: "svg", size: 640 },
281
+ };
282
+ if (ayanamsa)
283
+ body.ayanamsa = ayanamsa;
284
+ const chartData = await client.request("POST", "/vedic/chart", { data: body });
285
+ const modelPayload = buildVedicModelPayload(chartData, {
286
+ datetime,
287
+ location: null,
288
+ timezone: null,
289
+ latitude: lat ?? null,
290
+ longitude: lon ?? null,
291
+ ayanamsa: ayanamsa ?? "lahiri",
292
+ }, theme);
293
+ return {
294
+ content: [{ type: "text", text: JSON.stringify({ ...modelPayload, server_version: SERVER_VERSION }) }],
295
+ };
296
+ },
297
+ });
@@ -83,6 +83,9 @@ export async function initTools(profile) {
83
83
  await import("./apps/bi-wheel-app.js");
84
84
  await import("./apps/bodygraph-app.js");
85
85
  await import("./apps/moon-phase-app.js");
86
+ await import("./apps/transit-timeline-app.js");
87
+ await import("./apps/vedic-chart-app.js");
88
+ await import("./apps/bazi-app.js");
86
89
  }
87
90
  }
88
91
  /**
@@ -11,6 +11,7 @@ const HOUSE_SYSTEM_MAP = {
11
11
  registerTool({
12
12
  name: "ephemeris_bi_wheel",
13
13
  description: "Generate a Bi-Wheel (Synastry/Transit) image (SVG) comparing two charts. Draws Subject A's planets on the inside wheel and Subject B's on the outside wheel. Returns a native SVG that Claude displays inline in the conversation.\n\n" +
14
+ "For a user-facing interactive bi-wheel, use explore_bi_wheel instead.\n\n" +
14
15
  "CREDIT COST: 2 credits per call.\n\n" +
15
16
  "EXAMPLE: Compare someone born April 15, 1990 (A) to someone born June 10, 1992 (B):\n" +
16
17
  " datetime_a='...', latitude_a=..., longitude_a=..., datetime_b='...', latitude_b=..., longitude_b=...",
@@ -11,6 +11,7 @@ const HOUSE_SYSTEM_MAP = {
11
11
  registerTool({
12
12
  name: "ephemeris_chart_wheel",
13
13
  description: "Generate a classic astrological Chart Wheel image (SVG) for a person/event. This draws a standard circular chart wheel with planets, aspects, and house cusps. The tool returns a native SVG image that Claude displays inline in the conversation — no external tools needed.\n\n" +
14
+ "For a user-facing interactive chart wheel, use explore_natal_chart instead.\n\n" +
14
15
  "CREDIT COST: 2 credits per call.\n\n" +
15
16
  "EXAMPLE: Generate a natal chart wheel for someone born April 15, 1990 in Chicago:\n" +
16
17
  " datetime='1990-04-15T14:30:00', latitude=41.8781, longitude=-87.6298",
@@ -14,6 +14,7 @@ registerTool({
14
14
  "with Personality (conscious) and Design (unconscious) activations color-coded. " +
15
15
  "Defined centers are filled with their HD doctrine color; open/undefined centers remain muted. " +
16
16
  "Returns a native SVG that Claude displays inline. Use format='png' to opt into raster output (requires server-side rasterizer).\n\n" +
17
+ "For a user-facing interactive bodygraph explorer, use explore_human_design instead.\n\n" +
17
18
  "CREDIT COST: 2 credits per call.\n\n" +
18
19
  "EXAMPLE: Generate a bodygraph for someone born April 15, 1990 at 19:30 UTC:\n" +
19
20
  " datetime='1990-04-15T19:30:00Z'",
@@ -19,6 +19,7 @@ registerTool({
19
19
  "(Generator, Manifesting Generator, Projector, Manifestor, Reflector), Strategy, Authority, " +
20
20
  "Profile (e.g. 1/3, 2/4), defined and undefined Centers, activated Gates and Channels, " +
21
21
  "Incarnation Cross, and both Personality (conscious) and Design (unconscious) planetary positions.\n\n" +
22
+ "Returns raw JSON. For a user-facing interactive bodygraph, use explore_human_design instead.\n\n" +
22
23
  "CREDIT COST: 2 credits per call.\n\n" +
23
24
  "Human Design uses two calculation moments: the birth time (Personality) and ~88° of Sun motion " +
24
25
  "before birth (~3 months prior, the Design calculation). The API handles this automatically.\n\n" +
@@ -9,6 +9,7 @@ registerTool({
9
9
  "⚠️ THIS TOOL ANSWERS: 'What phase is the moon in right now (or at a given datetime)?'\n" +
10
10
  "❌ THIS TOOL DOES NOT ANSWER: 'When is the next new moon / full moon?'\n" +
11
11
  "→ For upcoming phase DATES use ephemeris_next_lunar_phase instead.\n\n" +
12
+ "For a user-facing interactive moon-phase dial, use explore_moon_phase instead.\n\n" +
12
13
  "CREDIT COST: 1 credit per call.\n\n" +
13
14
  "If no datetime is provided, returns the current (live) moon phase.\n\n" +
14
15
  "EXAMPLE: Get moon phase for a specific date/time:\n" +
@@ -87,7 +87,11 @@ registerTool({
87
87
  latitude: { decimal: args.latitude },
88
88
  longitude: { decimal: args.longitude }
89
89
  },
90
- }
90
+ },
91
+ // The Go endpoint computes the aspect grid only when asked. The tool
92
+ // documents a "major aspect grid" as core output, so always opt in —
93
+ // otherwise the response comes back with `"aspects": []`.
94
+ options: { include_aspects: true },
91
95
  };
92
96
  if (args.timezone) {
93
97
  body.subject.birth_location.timezone = { iana_name: args.timezone };
@@ -104,6 +108,7 @@ registerTool({
104
108
  }
105
109
  if (args.include_arabic_parts) {
106
110
  body.options = {
111
+ ...body.options,
107
112
  include_hermetic_lots: true,
108
113
  };
109
114
  }