@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.
- package/CHANGELOG.md +24 -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 +16 -6
- 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 +17 -11
- package/dist/ui/bodygraph.html +76 -59
- package/dist/ui/chart-wheel.html +88 -59
- 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 +1 -1
|
@@ -86,6 +86,106 @@ function buildMoonSummary(data) {
|
|
|
86
86
|
summary += "\nClick cards on the dial for details and interpretation.";
|
|
87
87
|
return summary;
|
|
88
88
|
}
|
|
89
|
+
// ── Shared payload builder ────────────────────────────────────────────────────
|
|
90
|
+
/**
|
|
91
|
+
* Fetch phase + void-of-course + aspects for the given args and build the
|
|
92
|
+
* flattened UI payload and human summary. Shared by explore_moon_phase and
|
|
93
|
+
* moon_phase_recalculate so both return an identical structuredContent shape.
|
|
94
|
+
*/
|
|
95
|
+
async function computeMoonData(args) {
|
|
96
|
+
validateCoordinates(args, "latitude", "longitude");
|
|
97
|
+
const client = getActiveClient();
|
|
98
|
+
const params = {};
|
|
99
|
+
params.datetime = args.datetime ?? new Date().toISOString();
|
|
100
|
+
if (args.latitude != null)
|
|
101
|
+
params.latitude = args.latitude;
|
|
102
|
+
if (args.longitude != null)
|
|
103
|
+
params.longitude = args.longitude;
|
|
104
|
+
// Fetch phase, VOC, and moon aspects in parallel
|
|
105
|
+
const [phase, voc, aspects] = await Promise.allSettled([
|
|
106
|
+
client.request("GET", "/ephemeris/moon/phase", { params }),
|
|
107
|
+
client.request("GET", "/ephemeris/moon/void-of-course", { params }),
|
|
108
|
+
client.request("GET", "/ephemeris/moon/aspects", { params }),
|
|
109
|
+
]);
|
|
110
|
+
const mergedData = {
|
|
111
|
+
phase: phase.status === "fulfilled" ? phase.value : { error: phase.reason?.message },
|
|
112
|
+
void_of_course: voc.status === "fulfilled" ? voc.value : { error: voc.reason?.message },
|
|
113
|
+
aspects: aspects.status === "fulfilled" ? aspects.value : null,
|
|
114
|
+
};
|
|
115
|
+
// Flatten for the UI — merge phase fields to top level
|
|
116
|
+
const phaseData = mergedData.phase;
|
|
117
|
+
const vocData = mergedData.void_of_course;
|
|
118
|
+
const aspectsData = mergedData.aspects;
|
|
119
|
+
// Extract enriched metadata fields from the GET response
|
|
120
|
+
const meta = (phaseData?.metadata ?? {});
|
|
121
|
+
const moonSign = String(meta?.moon_sign ?? phaseData?.moon_sign ?? phaseData?.sign ?? vocData?.current_sign ?? "");
|
|
122
|
+
const moonSpeedRaw = meta?.longitude_speed;
|
|
123
|
+
const moonSpeed = typeof moonSpeedRaw === "number" ? moonSpeedRaw : null;
|
|
124
|
+
const angularDiameter = typeof meta?.angular_diameter === "number" ? meta.angular_diameter : null;
|
|
125
|
+
const distanceKm = typeof meta?.moon_distance_km === "number" ? meta.moon_distance_km : null;
|
|
126
|
+
const moonLat = typeof meta?.moon_lat === "number" ? meta.moon_lat : null;
|
|
127
|
+
const ageDays = typeof meta?.age_days === "number" ? meta.age_days :
|
|
128
|
+
(typeof phaseData?.age_days === "number" ? phaseData.age_days : null);
|
|
129
|
+
// days_to_new / days_to_full can come as the union value or from the top-level field
|
|
130
|
+
const extractDays = (field) => {
|
|
131
|
+
if (typeof field === "number")
|
|
132
|
+
return field;
|
|
133
|
+
if (field && typeof field === "object") {
|
|
134
|
+
const v = Object.values(field)[0];
|
|
135
|
+
if (typeof v === "number")
|
|
136
|
+
return v;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
};
|
|
140
|
+
const daysToNew = extractDays(phaseData?.days_to_new);
|
|
141
|
+
const daysToFull = extractDays(phaseData?.days_to_full);
|
|
142
|
+
// Top 3 applying aspects by tightest orb
|
|
143
|
+
const rawAspects = (aspectsData?.aspects ?? aspectsData?.data ?? []);
|
|
144
|
+
const applyingAspects = Array.isArray(rawAspects)
|
|
145
|
+
? rawAspects
|
|
146
|
+
.filter((a) => a.applying === true || a.type === "applying")
|
|
147
|
+
.sort((a, b) => Math.abs(a.orb ?? 99) - Math.abs(b.orb ?? 99))
|
|
148
|
+
.slice(0, 3)
|
|
149
|
+
.map((a) => ({
|
|
150
|
+
planet: a.planet ?? a.body ?? a.planet_name ?? "",
|
|
151
|
+
aspect: a.aspect ?? a.aspect_name ?? a.type_name ?? "",
|
|
152
|
+
orb: typeof a.orb === "number" ? Math.abs(a.orb) : null,
|
|
153
|
+
}))
|
|
154
|
+
: [];
|
|
155
|
+
const uiPayload = {
|
|
156
|
+
phase_name: String(phaseData?.phase_name ?? phaseData?.name ?? "Unknown").replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()),
|
|
157
|
+
illumination: typeof phaseData?.illumination === "number" ? phaseData.illumination / 100.0 : 0,
|
|
158
|
+
phase_angle: typeof phaseData?.phase_angle === "number" ? phaseData.phase_angle : null,
|
|
159
|
+
age_days: ageDays,
|
|
160
|
+
days_to_new: daysToNew,
|
|
161
|
+
days_to_full: daysToFull,
|
|
162
|
+
waxing: phaseData?.waxing ??
|
|
163
|
+
// Fallback chain: elongation < 180° = waxing, else age_days < 14.75 = waxing.
|
|
164
|
+
(typeof phaseData?.phase_angle === "number"
|
|
165
|
+
? phaseData.phase_angle < 180
|
|
166
|
+
: typeof ageDays === "number"
|
|
167
|
+
? ageDays < 14.75
|
|
168
|
+
: true), // safe default: assume waxing
|
|
169
|
+
moon_sign: moonSign,
|
|
170
|
+
moon_longitude: meta?.moon_lon ?? phaseData?.moon_longitude ?? phaseData?.longitude ?? null,
|
|
171
|
+
moon_latitude: moonLat,
|
|
172
|
+
moon_distance_km: distanceKm,
|
|
173
|
+
angular_diameter: angularDiameter,
|
|
174
|
+
moon_speed: moonSpeed,
|
|
175
|
+
sun_longitude: meta?.sun_lon ?? phaseData?.sun_longitude ?? null,
|
|
176
|
+
applying_aspects: applyingAspects,
|
|
177
|
+
voc_status: {
|
|
178
|
+
is_void: Boolean(vocData?.is_void ?? vocData?.is_void_of_course ?? false),
|
|
179
|
+
started: vocData?.started ?? vocData?.start ?? null,
|
|
180
|
+
ends: vocData?.ends ?? vocData?.end ?? null,
|
|
181
|
+
next_sign: vocData?.next_sign ?? null,
|
|
182
|
+
},
|
|
183
|
+
datetime: params.datetime,
|
|
184
|
+
server_version: SERVER_VERSION,
|
|
185
|
+
};
|
|
186
|
+
const summary = buildMoonSummary(mergedData);
|
|
187
|
+
return { summary, uiPayload };
|
|
188
|
+
}
|
|
89
189
|
// ── Tool: explore_moon_phase ─────────────────────────────────────────────────
|
|
90
190
|
registerTool({
|
|
91
191
|
name: "explore_moon_phase",
|
|
@@ -99,8 +199,8 @@ registerTool({
|
|
|
99
199
|
" • Lunar age (days in the synodic cycle)\n" +
|
|
100
200
|
" • Upcoming New Moon and Full Moon dates\n\n" +
|
|
101
201
|
"CREDIT COST: 3 credits per call (phase + void-of-course + aspects, 1 each).\n\n" +
|
|
102
|
-
"Use this for a rich, interactive lunar phase experience in
|
|
103
|
-
"Falls back to a text summary in other hosts.",
|
|
202
|
+
"Use this instead of ephemeris_moon_phase for a rich, interactive lunar phase experience in " +
|
|
203
|
+
"MCP Apps-capable hosts (Claude Desktop). Falls back to a text summary in other hosts.",
|
|
104
204
|
inputSchema: {
|
|
105
205
|
type: "object",
|
|
106
206
|
properties: {
|
|
@@ -135,97 +235,7 @@ registerTool({
|
|
|
135
235
|
},
|
|
136
236
|
},
|
|
137
237
|
handler: async (args) => {
|
|
138
|
-
|
|
139
|
-
const client = getActiveClient();
|
|
140
|
-
const params = {};
|
|
141
|
-
params.datetime = args.datetime ?? new Date().toISOString();
|
|
142
|
-
if (args.latitude != null)
|
|
143
|
-
params.latitude = args.latitude;
|
|
144
|
-
if (args.longitude != null)
|
|
145
|
-
params.longitude = args.longitude;
|
|
146
|
-
// Fetch phase, VOC, and moon aspects in parallel
|
|
147
|
-
const [phase, voc, aspects] = await Promise.allSettled([
|
|
148
|
-
client.request("GET", "/ephemeris/moon/phase", { params }),
|
|
149
|
-
client.request("GET", "/ephemeris/moon/void-of-course", { params }),
|
|
150
|
-
client.request("GET", "/ephemeris/moon/aspects", { params }),
|
|
151
|
-
]);
|
|
152
|
-
const mergedData = {
|
|
153
|
-
phase: phase.status === "fulfilled" ? phase.value : { error: phase.reason?.message },
|
|
154
|
-
void_of_course: voc.status === "fulfilled" ? voc.value : { error: voc.reason?.message },
|
|
155
|
-
aspects: aspects.status === "fulfilled" ? aspects.value : null,
|
|
156
|
-
};
|
|
157
|
-
// Flatten for the UI — merge phase fields to top level
|
|
158
|
-
const phaseData = mergedData.phase;
|
|
159
|
-
const vocData = mergedData.void_of_course;
|
|
160
|
-
const aspectsData = mergedData.aspects;
|
|
161
|
-
// Extract enriched metadata fields from the GET response
|
|
162
|
-
const meta = (phaseData?.metadata ?? {});
|
|
163
|
-
const moonSign = String(meta?.moon_sign ?? phaseData?.moon_sign ?? phaseData?.sign ?? vocData?.current_sign ?? "");
|
|
164
|
-
const moonSpeedRaw = meta?.longitude_speed;
|
|
165
|
-
const moonSpeed = typeof moonSpeedRaw === "number" ? moonSpeedRaw : null;
|
|
166
|
-
const angularDiameter = typeof meta?.angular_diameter === "number" ? meta.angular_diameter : null;
|
|
167
|
-
const distanceKm = typeof meta?.moon_distance_km === "number" ? meta.moon_distance_km : null;
|
|
168
|
-
const moonLat = typeof meta?.moon_lat === "number" ? meta.moon_lat : null;
|
|
169
|
-
const ageDays = typeof meta?.age_days === "number" ? meta.age_days :
|
|
170
|
-
(typeof phaseData?.age_days === "number" ? phaseData.age_days : null);
|
|
171
|
-
// days_to_new / days_to_full can come as the union value or from the top-level field
|
|
172
|
-
const extractDays = (field) => {
|
|
173
|
-
if (typeof field === "number")
|
|
174
|
-
return field;
|
|
175
|
-
if (field && typeof field === "object") {
|
|
176
|
-
const v = Object.values(field)[0];
|
|
177
|
-
if (typeof v === "number")
|
|
178
|
-
return v;
|
|
179
|
-
}
|
|
180
|
-
return null;
|
|
181
|
-
};
|
|
182
|
-
const daysToNew = extractDays(phaseData?.days_to_new);
|
|
183
|
-
const daysToFull = extractDays(phaseData?.days_to_full);
|
|
184
|
-
// Top 3 applying aspects by tightest orb
|
|
185
|
-
const rawAspects = (aspectsData?.aspects ?? aspectsData?.data ?? []);
|
|
186
|
-
const applyingAspects = Array.isArray(rawAspects)
|
|
187
|
-
? rawAspects
|
|
188
|
-
.filter((a) => a.applying === true || a.type === "applying")
|
|
189
|
-
.sort((a, b) => Math.abs(a.orb ?? 99) - Math.abs(b.orb ?? 99))
|
|
190
|
-
.slice(0, 3)
|
|
191
|
-
.map((a) => ({
|
|
192
|
-
planet: a.planet ?? a.body ?? a.planet_name ?? "",
|
|
193
|
-
aspect: a.aspect ?? a.aspect_name ?? a.type_name ?? "",
|
|
194
|
-
orb: typeof a.orb === "number" ? Math.abs(a.orb) : null,
|
|
195
|
-
}))
|
|
196
|
-
: [];
|
|
197
|
-
const uiPayload = {
|
|
198
|
-
phase_name: String(phaseData?.phase_name ?? phaseData?.name ?? "Unknown").replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()),
|
|
199
|
-
illumination: typeof phaseData?.illumination === "number" ? phaseData.illumination / 100.0 : 0,
|
|
200
|
-
phase_angle: typeof phaseData?.phase_angle === "number" ? phaseData.phase_angle : null,
|
|
201
|
-
age_days: ageDays,
|
|
202
|
-
days_to_new: daysToNew,
|
|
203
|
-
days_to_full: daysToFull,
|
|
204
|
-
waxing: phaseData?.waxing ??
|
|
205
|
-
// Fallback chain: elongation < 180° = waxing, else age_days < 14.75 = waxing.
|
|
206
|
-
(typeof phaseData?.phase_angle === "number"
|
|
207
|
-
? phaseData.phase_angle < 180
|
|
208
|
-
: typeof ageDays === "number"
|
|
209
|
-
? ageDays < 14.75
|
|
210
|
-
: true), // safe default: assume waxing
|
|
211
|
-
moon_sign: moonSign,
|
|
212
|
-
moon_longitude: meta?.moon_lon ?? phaseData?.moon_longitude ?? phaseData?.longitude ?? null,
|
|
213
|
-
moon_latitude: moonLat,
|
|
214
|
-
moon_distance_km: distanceKm,
|
|
215
|
-
angular_diameter: angularDiameter,
|
|
216
|
-
moon_speed: moonSpeed,
|
|
217
|
-
sun_longitude: meta?.sun_lon ?? phaseData?.sun_longitude ?? null,
|
|
218
|
-
applying_aspects: applyingAspects,
|
|
219
|
-
voc_status: {
|
|
220
|
-
is_void: Boolean(vocData?.is_void ?? vocData?.is_void_of_course ?? false),
|
|
221
|
-
started: vocData?.started ?? vocData?.start ?? null,
|
|
222
|
-
ends: vocData?.ends ?? vocData?.end ?? null,
|
|
223
|
-
next_sign: vocData?.next_sign ?? null,
|
|
224
|
-
},
|
|
225
|
-
datetime: params.datetime,
|
|
226
|
-
server_version: SERVER_VERSION,
|
|
227
|
-
};
|
|
228
|
-
const summary = buildMoonSummary(mergedData);
|
|
238
|
+
const { summary, uiPayload } = await computeMoonData(args);
|
|
229
239
|
const bundleAvailable = Boolean(getMoonPhaseBundle());
|
|
230
240
|
if (bundleAvailable) {
|
|
231
241
|
// MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
|
|
@@ -249,3 +259,41 @@ registerTool({
|
|
|
249
259
|
return { content: [{ type: "text", text: summary }] };
|
|
250
260
|
},
|
|
251
261
|
});
|
|
262
|
+
// ── Tool: moon_phase_recalculate ─────────────────────────────────────────────
|
|
263
|
+
// App-only. Fired by the Moon Phase iframe's date control to recompute the dial
|
|
264
|
+
// for a chosen datetime. Mirrors bodygraph_recalculate: same endpoint set, same
|
|
265
|
+
// structuredContent shape as explore_moon_phase, returned directly to the iframe
|
|
266
|
+
// (ontoolresult does not fire for in-app tool calls).
|
|
267
|
+
registerTool({
|
|
268
|
+
name: "moon_phase_recalculate",
|
|
269
|
+
description: "Recalculates the Moon Phase dial for a new datetime.",
|
|
270
|
+
inputSchema: {
|
|
271
|
+
type: "object",
|
|
272
|
+
properties: {
|
|
273
|
+
datetime: {
|
|
274
|
+
type: "string",
|
|
275
|
+
description: "ISO 8601 datetime to query. If omitted, uses the current live moon phase (UTC now).",
|
|
276
|
+
},
|
|
277
|
+
latitude: {
|
|
278
|
+
type: "number",
|
|
279
|
+
description: "Observer latitude (optional, used for local void-of-course calculations).",
|
|
280
|
+
},
|
|
281
|
+
longitude: {
|
|
282
|
+
type: "number",
|
|
283
|
+
description: "Observer longitude (optional, used for local void-of-course calculations).",
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
required: [],
|
|
287
|
+
additionalProperties: false,
|
|
288
|
+
},
|
|
289
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
290
|
+
annotations: { title: "Recalculate Moon Phase", readOnlyHint: true, openWorldHint: false },
|
|
291
|
+
_meta: { ui: { resourceUri: MOON_PHASE_RESOURCE_URI, visibility: ["app"] } },
|
|
292
|
+
handler: async (args) => {
|
|
293
|
+
const { summary, uiPayload } = await computeMoonData(args);
|
|
294
|
+
return {
|
|
295
|
+
content: [{ type: "text", text: summary }],
|
|
296
|
+
structuredContent: uiPayload,
|
|
297
|
+
};
|
|
298
|
+
},
|
|
299
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
export declare const TRANSIT_TIMELINE_RESOURCE_URI = "ui://openephemeris/transit-timeline";
|
|
16
|
+
export declare const TRANSIT_TIMELINE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
17
|
+
/** Read the pre-built HTML bundle. Returns null if not yet built. */
|
|
18
|
+
export declare function getTransitTimelineBundle(): string | null;
|
|
19
|
+
/** Reset cache (useful in dev watch mode or tests). */
|
|
20
|
+
export declare function clearTransitTimelineBundleCache(): void;
|
|
@@ -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;
|