@openephemeris/mcp-server 3.21.1 → 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.
- package/CHANGELOG.md +41 -0
- package/LICENSE +21 -21
- package/README.md +2 -2
- package/dist/index.js +45 -0
- package/dist/server-sse.js +52 -3
- package/dist/tools/apps/bazi-app.d.ts +24 -0
- package/dist/tools/apps/bazi-app.js +231 -0
- package/dist/tools/apps/bi-wheel-app.js +3 -1
- package/dist/tools/apps/bodygraph-app.js +55 -10
- package/dist/tools/apps/moon-phase-app.js +141 -93
- package/dist/tools/apps/transit-timeline-app.d.ts +20 -0
- package/dist/tools/apps/transit-timeline-app.js +302 -0
- package/dist/tools/apps/vedic-chart-app.d.ts +25 -0
- package/dist/tools/apps/vedic-chart-app.js +297 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/specialized/bi_wheel.js +1 -0
- package/dist/tools/specialized/chart_wheel.js +1 -0
- package/dist/tools/specialized/hd_bodygraph.js +1 -0
- package/dist/tools/specialized/human_design.js +1 -0
- package/dist/tools/specialized/moon.js +1 -0
- package/dist/tools/specialized/natal.js +6 -1
- package/dist/ui/bazi.html +6928 -0
- package/dist/ui/bi-wheel.html +2091 -2031
- package/dist/ui/bodygraph.html +286 -221
- package/dist/ui/chart-wheel.html +552 -453
- package/dist/ui/moon-phase.html +310 -42
- package/dist/ui/transit-timeline.html +6911 -0
- package/dist/ui/vedic-chart.html +7043 -0
- package/package.json +2 -2
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transit-timeline-app.ts — MCP App tool registration for the Transit Timeline.
|
|
3
|
+
*
|
|
4
|
+
* Registers 1 tool:
|
|
5
|
+
* • explore_transit_timeline — primary entry point, returns data + UI resource.
|
|
6
|
+
*
|
|
7
|
+
* The tool mirrors the `ephemeris_transits` plumbing: it computes the natal
|
|
8
|
+
* chart to extract real planetary longitudes, searches predictive transits
|
|
9
|
+
* against those targets, then flattens the (planet@target → hits) result map
|
|
10
|
+
* into a single chronological array the iframe renders as a vertical timeline.
|
|
11
|
+
*
|
|
12
|
+
* Also exports resource helpers (getTransitTimelineBundle, etc.) for use in
|
|
13
|
+
* index.ts and server-sse.ts.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { registerTool, validateRequired, SERVER_VERSION } from "../index.js";
|
|
19
|
+
import { getActiveClient } from "../../backend/client.js";
|
|
20
|
+
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
21
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
22
|
+
export const TRANSIT_TIMELINE_RESOURCE_URI = "ui://openephemeris/transit-timeline";
|
|
23
|
+
export const TRANSIT_TIMELINE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
24
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const BUNDLE_PATHS = [
|
|
26
|
+
path.resolve(here, "..", "..", "..", "dist", "ui", "transit-timeline.html"),
|
|
27
|
+
];
|
|
28
|
+
function findBundlePath() {
|
|
29
|
+
for (const p of BUNDLE_PATHS) {
|
|
30
|
+
if (fs.existsSync(p))
|
|
31
|
+
return p;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
let cachedBundle = null;
|
|
36
|
+
/** Read the pre-built HTML bundle. Returns null if not yet built. */
|
|
37
|
+
export function getTransitTimelineBundle() {
|
|
38
|
+
if (cachedBundle)
|
|
39
|
+
return cachedBundle;
|
|
40
|
+
const bundlePath = findBundlePath();
|
|
41
|
+
if (!bundlePath)
|
|
42
|
+
return null;
|
|
43
|
+
try {
|
|
44
|
+
cachedBundle = fs.readFileSync(bundlePath, "utf-8");
|
|
45
|
+
return cachedBundle;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Reset cache (useful in dev watch mode or tests). */
|
|
52
|
+
export function clearTransitTimelineBundleCache() {
|
|
53
|
+
cachedBundle = null;
|
|
54
|
+
}
|
|
55
|
+
// ── Aspect labels ──────────────────────────────────────────────────────────────
|
|
56
|
+
const ASPECT_LABELS = {
|
|
57
|
+
0: "conjunction", 60: "sextile", 90: "square", 120: "trine", 180: "opposition",
|
|
58
|
+
};
|
|
59
|
+
function aspectLabelFor(angle) {
|
|
60
|
+
return ASPECT_LABELS[angle] ?? angle + "°";
|
|
61
|
+
}
|
|
62
|
+
const CORE_BODIES_DEFAULT = new Set([
|
|
63
|
+
"sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn",
|
|
64
|
+
]);
|
|
65
|
+
function normDeg(d) {
|
|
66
|
+
return ((d % 360) + 360) % 360;
|
|
67
|
+
}
|
|
68
|
+
// ── Summary builder ──────────────────────────────────────────────────────────
|
|
69
|
+
function buildSummary(transits, aspectLabel, win) {
|
|
70
|
+
if (transits.length === 0) {
|
|
71
|
+
return "**Transit Timeline** — no exact transit hits matched the search window and criteria.";
|
|
72
|
+
}
|
|
73
|
+
const planets = [...new Set(transits.map((t) => t.planet_name).filter(Boolean))];
|
|
74
|
+
const lines = transits.slice(0, 8).map((t) => {
|
|
75
|
+
const asp = t.aspect ?? aspectLabel;
|
|
76
|
+
const natal = t.natal_point ? " natal " + t.natal_point : "";
|
|
77
|
+
const when = new Date(t.date).toISOString().slice(0, 10);
|
|
78
|
+
return " • " + when + " — " + t.planet_name + " " + asp + natal +
|
|
79
|
+
(t.zodiac_sign ? " (" + (t.zodiac_degree ?? 0) + "° " + t.zodiac_sign + ")" : "") +
|
|
80
|
+
(t.is_retrograde ? " ℞" : "");
|
|
81
|
+
});
|
|
82
|
+
let summary = "**Transit Timeline** — " + transits.length + (transits.length === 1 ? " event" : " events") +
|
|
83
|
+
" from " + win.start_date + " to " + win.end_date +
|
|
84
|
+
(planets.length ? " (" + planets.join(", ") + ")" : "") + "\n\n" +
|
|
85
|
+
lines.join("\n");
|
|
86
|
+
if (transits.length > 8)
|
|
87
|
+
summary += "\n … and " + (transits.length - 8) + " more.";
|
|
88
|
+
summary += "\n\nClick any transit for details and interpretation.";
|
|
89
|
+
return summary;
|
|
90
|
+
}
|
|
91
|
+
// ── Tool: explore_transit_timeline ───────────────────────────────────────────
|
|
92
|
+
registerTool({
|
|
93
|
+
name: "explore_transit_timeline",
|
|
94
|
+
description: "Generate an interactive Transit Timeline — a vertical, date-ordered list of " +
|
|
95
|
+
"upcoming transit hits (transiting planets forming a chosen aspect to natal " +
|
|
96
|
+
"chart positions) over a date range.\n\n" +
|
|
97
|
+
"Returns a visual timeline with:\n" +
|
|
98
|
+
" • Exact crossing dates grouped by month\n" +
|
|
99
|
+
" • Transiting planet glyph, the natal point it contacts, and the aspect\n" +
|
|
100
|
+
" • Zodiac position of each crossing and retrograde markers\n" +
|
|
101
|
+
" • Click any transit for a focused interpretation\n\n" +
|
|
102
|
+
"ASPECT ANGLES: 0 = conjunction/return (default), 180 = opposition, 90 = square, " +
|
|
103
|
+
"120 = trine, 60 = sextile. EFFICIENCY: specify transiting_planets and natal_points " +
|
|
104
|
+
"to keep compute fast. DEFAULT natal_points: sun, moon, mercury, venus, mars, jupiter, saturn. " +
|
|
105
|
+
"SEARCH RANGE LIMITS: Explorer/PayG → 1 year; Pro → 5 years; Startup → 10 years.\n\n" +
|
|
106
|
+
"CREDIT COST: 6 credits per call (natal chart + predictive transit search).\n\n" +
|
|
107
|
+
"Use this for a rich, interactive transit-forecast experience in MCP Apps-capable hosts " +
|
|
108
|
+
"(Claude Desktop). Falls back to a text summary in other hosts.",
|
|
109
|
+
inputSchema: {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: {
|
|
112
|
+
natal_datetime: { type: "string", description: "ISO 8601 birth datetime for the natal chart." },
|
|
113
|
+
natal_latitude: { type: "number", description: "Latitude of birth location in decimal degrees." },
|
|
114
|
+
natal_longitude: { type: "number", description: "Longitude of birth location in decimal degrees." },
|
|
115
|
+
start_date: { type: "string", description: "Start of the transit search window, ISO 8601 date or datetime (e.g. '2026-01-01')." },
|
|
116
|
+
end_date: { type: "string", description: "End of the transit search window, ISO 8601 date or datetime (e.g. '2026-12-31')." },
|
|
117
|
+
transiting_planets: {
|
|
118
|
+
type: "array", items: { type: "string" },
|
|
119
|
+
description: "Transiting planet IDs to search, e.g. ['saturn','jupiter','uranus','pluto','chiron']. Omit to search all outer planets.",
|
|
120
|
+
},
|
|
121
|
+
natal_points: {
|
|
122
|
+
type: "array", items: { type: "string" },
|
|
123
|
+
description: "Natal point IDs to target, e.g. ['sun','moon','asc','mc','saturn']. Omit for the 7 classical bodies.",
|
|
124
|
+
},
|
|
125
|
+
aspect_angle: {
|
|
126
|
+
type: "number",
|
|
127
|
+
description: "Aspect angle in degrees. 0 = conjunction/return (default), 180 = opposition, 90 = square, 120 = trine, 60 = sextile.",
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
required: ["natal_datetime", "natal_latitude", "natal_longitude", "start_date", "end_date"],
|
|
131
|
+
additionalProperties: false,
|
|
132
|
+
},
|
|
133
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
134
|
+
annotations: {
|
|
135
|
+
title: "Interactive Transit Timeline",
|
|
136
|
+
readOnlyHint: true,
|
|
137
|
+
destructiveHint: false,
|
|
138
|
+
idempotentHint: true,
|
|
139
|
+
openWorldHint: false,
|
|
140
|
+
},
|
|
141
|
+
_meta: {
|
|
142
|
+
ui: {
|
|
143
|
+
resourceUri: TRANSIT_TIMELINE_RESOURCE_URI,
|
|
144
|
+
visibility: ["model", "app"],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
handler: async (args) => {
|
|
148
|
+
validateRequired(args, ["natal_datetime", "natal_latitude", "natal_longitude", "start_date", "end_date"]);
|
|
149
|
+
const client = getActiveClient();
|
|
150
|
+
// ── Step 1: natal chart → real planetary longitudes ──────────────────────
|
|
151
|
+
const natalBody = {
|
|
152
|
+
subject: {
|
|
153
|
+
name: "Transit Natal Subject",
|
|
154
|
+
birth_datetime: { iso: args.natal_datetime },
|
|
155
|
+
birth_location: {
|
|
156
|
+
latitude: { decimal: args.natal_latitude },
|
|
157
|
+
longitude: { decimal: args.natal_longitude },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
const natalResult = await client.post("/ephemeris/natal-chart", natalBody);
|
|
162
|
+
const natalPositionMap = {};
|
|
163
|
+
const planets = natalResult?.planets || natalResult?.data?.planets || [];
|
|
164
|
+
const planetArray = Array.isArray(planets) ? planets : Object.values(planets);
|
|
165
|
+
const wantedPoints = args.natal_points
|
|
166
|
+
? new Set(args.natal_points.map((p) => p.toLowerCase()))
|
|
167
|
+
: CORE_BODIES_DEFAULT;
|
|
168
|
+
for (const planet of planetArray) {
|
|
169
|
+
const name = (planet.name || planet.id || "").toLowerCase();
|
|
170
|
+
const lon = planet.longitude ?? planet.ecliptic_longitude ?? planet.lon;
|
|
171
|
+
if (typeof lon !== "number" || !isFinite(lon))
|
|
172
|
+
continue;
|
|
173
|
+
if (!wantedPoints.has(name))
|
|
174
|
+
continue;
|
|
175
|
+
natalPositionMap[name] = lon;
|
|
176
|
+
}
|
|
177
|
+
// Angles (ASC/MC) only when explicitly requested.
|
|
178
|
+
if (args.natal_points) {
|
|
179
|
+
const angles = natalResult?.angles || natalResult?.data?.angles;
|
|
180
|
+
if (angles) {
|
|
181
|
+
const angleEntries = Array.isArray(angles)
|
|
182
|
+
? angles
|
|
183
|
+
: Object.entries(angles).map(([k, v]) => ({ name: k, ...(typeof v === "object" ? v : { longitude: v }) }));
|
|
184
|
+
for (const angle of angleEntries) {
|
|
185
|
+
const name = (angle.name || angle.id || "").toLowerCase();
|
|
186
|
+
const lon = angle.longitude ?? angle.ecliptic_longitude ?? angle.lon;
|
|
187
|
+
if (typeof lon !== "number" || !isFinite(lon))
|
|
188
|
+
continue;
|
|
189
|
+
if (!wantedPoints.has(name))
|
|
190
|
+
continue;
|
|
191
|
+
natalPositionMap[name] = lon;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const pointNames = Object.keys(natalPositionMap);
|
|
196
|
+
if (pointNames.length === 0) {
|
|
197
|
+
throw new Error("Could not extract natal planet longitudes from the natal chart calculation. " +
|
|
198
|
+
"Please verify natal_datetime, natal_latitude, and natal_longitude are correct.");
|
|
199
|
+
}
|
|
200
|
+
// ── Step 2: build effective targets + reverse-map (target → natal point) ──
|
|
201
|
+
const aspectAngle = typeof args.aspect_angle === "number" ? args.aspect_angle : 0;
|
|
202
|
+
const aspectLabel = aspectLabelFor(aspectAngle);
|
|
203
|
+
// Cap targets (matches ephemeris_transits: never send more than 12).
|
|
204
|
+
const cappedNames = pointNames.slice(0, 12);
|
|
205
|
+
const targetToPoint = new Map();
|
|
206
|
+
const effectiveTargets = [];
|
|
207
|
+
for (const name of cappedNames) {
|
|
208
|
+
const eff = Math.round(normDeg(natalPositionMap[name] + aspectAngle) * 100) / 100;
|
|
209
|
+
effectiveTargets.push(eff);
|
|
210
|
+
targetToPoint.set(eff, name);
|
|
211
|
+
}
|
|
212
|
+
// ── Step 3: search transits ──────────────────────────────────────────────
|
|
213
|
+
let startStr = args.start_date;
|
|
214
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(startStr))
|
|
215
|
+
startStr += "T00:00:00Z";
|
|
216
|
+
let endStr = args.end_date;
|
|
217
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(endStr))
|
|
218
|
+
endStr += "T23:59:59Z";
|
|
219
|
+
const transitBody = {
|
|
220
|
+
start_date: startStr,
|
|
221
|
+
end_date: endStr,
|
|
222
|
+
target_degrees: effectiveTargets,
|
|
223
|
+
};
|
|
224
|
+
if (args.transiting_planets)
|
|
225
|
+
transitBody.planet_names = args.transiting_planets;
|
|
226
|
+
if (aspectAngle !== 0)
|
|
227
|
+
transitBody.search_criteria = { aspect_angle: aspectAngle };
|
|
228
|
+
const transitResult = await client.request("POST", "/predictive/transits/search", {
|
|
229
|
+
data: transitBody,
|
|
230
|
+
timeoutMs: 90_000,
|
|
231
|
+
});
|
|
232
|
+
// ── Step 4: flatten results map → chronological transit array ─────────────
|
|
233
|
+
const nearestPoint = (targetLon) => {
|
|
234
|
+
let best = "";
|
|
235
|
+
let bestDelta = 0.06; // ~3.6 arc-minutes tolerance
|
|
236
|
+
for (const [tgt, name] of targetToPoint) {
|
|
237
|
+
const delta = Math.abs(normDeg(targetLon - tgt));
|
|
238
|
+
const wrapped = Math.min(delta, 360 - delta);
|
|
239
|
+
if (wrapped <= bestDelta) {
|
|
240
|
+
bestDelta = wrapped;
|
|
241
|
+
best = name;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return best;
|
|
245
|
+
};
|
|
246
|
+
const resultsMap = (transitResult?.results ?? {});
|
|
247
|
+
const transits = [];
|
|
248
|
+
for (const hits of Object.values(resultsMap)) {
|
|
249
|
+
if (!Array.isArray(hits))
|
|
250
|
+
continue;
|
|
251
|
+
for (const hit of hits) {
|
|
252
|
+
const date = hit?.exact_time;
|
|
253
|
+
if (!date)
|
|
254
|
+
continue;
|
|
255
|
+
const pname = String(hit?.planet_name ?? "");
|
|
256
|
+
const targetLon = typeof hit?.target_longitude === "number" ? hit.target_longitude : null;
|
|
257
|
+
const metaAsp = hit?.metadata?.aspect_type;
|
|
258
|
+
transits.push({
|
|
259
|
+
planet: pname.toLowerCase(),
|
|
260
|
+
planet_name: pname,
|
|
261
|
+
planet_id: hit?.planet_id ?? null,
|
|
262
|
+
date,
|
|
263
|
+
aspect: typeof metaAsp === "string" ? metaAsp : aspectLabel,
|
|
264
|
+
aspect_angle: aspectAngle,
|
|
265
|
+
natal_point: targetLon != null ? nearestPoint(targetLon) : "",
|
|
266
|
+
target_longitude: targetLon,
|
|
267
|
+
zodiac_sign: hit?.zodiac_sign ?? null,
|
|
268
|
+
zodiac_degree: hit?.zodiac_degree ?? null,
|
|
269
|
+
zodiac_minute: hit?.zodiac_minute ?? null,
|
|
270
|
+
is_retrograde: Boolean(hit?.is_retrograde),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
transits.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
275
|
+
const win = { start_date: startStr.slice(0, 10), end_date: endStr.slice(0, 10) };
|
|
276
|
+
const summary = buildSummary(transits, aspectLabel, win);
|
|
277
|
+
const uiPayload = {
|
|
278
|
+
transits,
|
|
279
|
+
natal_positions: natalPositionMap,
|
|
280
|
+
aspect_angle: aspectAngle,
|
|
281
|
+
aspect_label: aspectLabel,
|
|
282
|
+
search_window: win,
|
|
283
|
+
count: transits.length,
|
|
284
|
+
server_version: SERVER_VERSION,
|
|
285
|
+
};
|
|
286
|
+
const bundleAvailable = Boolean(getTransitTimelineBundle());
|
|
287
|
+
if (bundleAvailable) {
|
|
288
|
+
// MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
|
|
289
|
+
// delivered through resources/read — NOT as a content block.
|
|
290
|
+
// structuredContent carries the data payload for non-rendering hosts.
|
|
291
|
+
return {
|
|
292
|
+
content: [{ type: "text", text: summary }],
|
|
293
|
+
structuredContent: uiPayload,
|
|
294
|
+
_meta: {
|
|
295
|
+
"ui/resourceUri": TRANSIT_TIMELINE_RESOURCE_URI,
|
|
296
|
+
ui: { resourceUri: TRANSIT_TIMELINE_RESOURCE_URI },
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
return { content: [{ type: "text", text: summary }] };
|
|
301
|
+
},
|
|
302
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
export declare const VEDIC_CHART_RESOURCE_URI = "ui://openephemeris/vedic-chart";
|
|
21
|
+
export declare const VEDIC_CHART_MIME_TYPE = "text/html;profile=mcp-app";
|
|
22
|
+
/** Read the pre-built HTML bundle. Returns null if not yet built. */
|
|
23
|
+
export declare function getVedicChartBundle(): string | null;
|
|
24
|
+
/** Reset cache (useful in dev watch mode or tests). */
|
|
25
|
+
export declare function clearVedicChartBundleCache(): void;
|
|
@@ -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
|
+
});
|
package/dist/tools/index.js
CHANGED
|
@@ -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'",
|