@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.
@@ -27,6 +27,8 @@ import { getActiveClient } from "../../backend/client.js";
27
27
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
28
28
  // ── Constants ─────────────────────────────────────────────────────────────
29
29
  export const BODYGRAPH_RESOURCE_URI = "ui://openephemeris/bodygraph";
30
+ // Appended to every model-visible HD tool description — trademark hygiene.
31
+ const HD_DISCLAIMER = " Human Design was originated by Ra Uru Hu; OpenEphemeris is an independent calculation service, not affiliated with Jovian Archive.";
30
32
  export const BODYGRAPH_MIME_TYPE = "text/html;profile=mcp-app";
31
33
  const here = path.dirname(fileURLToPath(import.meta.url));
32
34
  const BUNDLE_PATHS = [
@@ -346,11 +348,13 @@ function buildHdModelPayload(data, birthParams) {
346
348
  function buildHdSummary(payload, location) {
347
349
  const definedList = payload.centers.filter((c) => c.defined).map((c) => c.name);
348
350
  const undefinedList = payload.centers.filter((c) => !c.defined).map((c) => c.name);
351
+ const channelList = payload.active_channels.length > 0 ? payload.active_channels.join(", ") : "None";
349
352
  return (`**Human Design Chart \u2014 ${location}**\n\n` +
350
353
  `Type: **${payload.type}** | Profile: **${payload.profile}** | Authority: **${payload.authority}**\n` +
351
354
  `Definition: ${payload.definition} | Cross: ${payload.incarnation_cross}\n\n` +
352
355
  `Defined Centers: ${definedList.length > 0 ? definedList.join(", ") : "None"}\n` +
353
- `Undefined Centers: ${undefinedList.length > 0 ? undefinedList.join(", ") : "None"}\n\n` +
356
+ `Undefined Centers: ${undefinedList.length > 0 ? undefinedList.join(", ") : "None"}\n` +
357
+ `Channels (${payload.active_channels.length}): ${channelList}\n\n` +
354
358
  "Click any center, gate, or channel on the bodygraph for interpretation.");
355
359
  }
356
360
  // ── Tool: explore_human_design ────────────────────────────────────────────
@@ -363,8 +367,10 @@ registerTool({
363
367
  "Shows defined/undefined centers, active gates, channels, Type, Profile, Authority, " +
364
368
  "and Incarnation Cross. " +
365
369
  "The chart is calculated using NASA JPL DE440 ephemerides for both Personality and Design positions. " +
366
- "Use this for a rich, interactive HD experience in MCP Apps-capable hosts (Claude Desktop). " +
367
- "Falls back to a text summary in other hosts.",
370
+ "Use this instead of human_design_chart or human_design_bodygraph for a richer, interactive HD " +
371
+ "experience in MCP Apps-capable hosts (Claude Desktop). " +
372
+ "Falls back to a text summary in other hosts." +
373
+ HD_DISCLAIMER,
368
374
  inputSchema: {
369
375
  type: "object",
370
376
  properties: {
@@ -1331,7 +1337,8 @@ registerTool({
1331
1337
  "Highlights the channels a transit temporarily COMPLETES with the natal chart and any centers it " +
1332
1338
  "newly defines — the core of a Human Design transit reading. " +
1333
1339
  "Returns an interactive overlay bodygraph in MCP Apps-capable hosts (Claude Desktop), with a text " +
1334
- "summary fallback elsewhere. Premium (Pro tier). Calculated with NASA JPL DE440 ephemerides.",
1340
+ "summary fallback elsewhere. Premium (Pro tier). Calculated with NASA JPL DE440 ephemerides." +
1341
+ HD_DISCLAIMER,
1335
1342
  inputSchema: {
1336
1343
  type: "object",
1337
1344
  properties: {
@@ -1348,6 +1355,12 @@ registerTool({
1348
1355
  type: "string",
1349
1356
  description: "Transit moment, ISO 8601. Defaults to now (UTC) when omitted.",
1350
1357
  },
1358
+ theme: {
1359
+ type: "string",
1360
+ enum: ["light", "dark"],
1361
+ description: "Visual theme for the overlay bodygraph. Set automatically by the embedded app to match " +
1362
+ "the host; defaults to dark. You normally never need to pass this.",
1363
+ },
1351
1364
  },
1352
1365
  required: ["datetime"],
1353
1366
  },
@@ -1384,8 +1397,14 @@ registerTool({
1384
1397
  body.transit_datetime = { iso: ensureTimezone(String(args.transit_datetime)) };
1385
1398
  }
1386
1399
  const bundleAvailable = Boolean(getBodygraphBundle());
1387
- if (bundleAvailable)
1400
+ // The Go endpoint defaults visual_config.theme to "light", which mismatches
1401
+ // dark MCP hosts — mirror the natal path's explicit dark default. The
1402
+ // iframe re-calls this tool with theme once it detects the host theme.
1403
+ const theme = args.theme === "light" ? "light" : "dark";
1404
+ if (bundleAvailable) {
1388
1405
  body.include_visual = true;
1406
+ body.visual_config = { theme };
1407
+ }
1389
1408
  const resp = await client.request("POST", "/human-design/transit-chart", { data: body });
1390
1409
  const transit = (resp?.transit ?? {});
1391
1410
  const completed = Array.isArray(transit.completed_channels) ? transit.completed_channels : [];
@@ -1400,7 +1419,14 @@ registerTool({
1400
1419
  (newCenters.length
1401
1420
  ? `\n\nNewly-defined center${newCenters.length === 1 ? "" : "s"}: **${newCenters.join(", ")}** — a temporary shift while the transit holds.`
1402
1421
  : "");
1403
- const payload = { ...resp, _svg: svg, server_version: SERVER_VERSION };
1422
+ const payload = {
1423
+ ...resp,
1424
+ _svg: svg,
1425
+ _theme: theme,
1426
+ // Lets the iframe re-request this overlay in the host's theme.
1427
+ _refetch: { tool: "explore_human_design_transit", args: { ...args } },
1428
+ server_version: SERVER_VERSION,
1429
+ };
1404
1430
  if (bundleAvailable && svg) {
1405
1431
  return {
1406
1432
  content: [{ type: "text", text: summary }],
@@ -1417,10 +1443,13 @@ registerTool({
1417
1443
  registerTool({
1418
1444
  name: "explore_human_design_connection",
1419
1445
  description: "Compare two people's Human Design charts and classify every connected channel by HD connection " +
1420
- "theory. CREDIT COST: 3 credits per call. Classifications: " +
1446
+ "theory.\n\n" +
1447
+ "CREDIT COST: 3 credits per call.\n\n" +
1448
+ "Classifications: " +
1421
1449
  "electromagnetic (attraction), companionship (sameness), dominance (one defines), and " +
1422
1450
  "compromise (friction). Returns an interactive two-person overlay bodygraph in MCP Apps-capable " +
1423
- "hosts, with a text summary fallback elsewhere. Premium (Pro tier). NASA JPL DE440 ephemerides.",
1451
+ "hosts, with a text summary fallback elsewhere. Premium (Pro tier). NASA JPL DE440 ephemerides." +
1452
+ HD_DISCLAIMER,
1424
1453
  inputSchema: {
1425
1454
  type: "object",
1426
1455
  properties: {
@@ -1446,6 +1475,12 @@ registerTool({
1446
1475
  },
1447
1476
  required: ["datetime"],
1448
1477
  },
1478
+ theme: {
1479
+ type: "string",
1480
+ enum: ["light", "dark"],
1481
+ description: "Visual theme for the overlay bodygraph. Set automatically by the embedded app to match " +
1482
+ "the host; defaults to dark. You normally never need to pass this.",
1483
+ },
1449
1484
  },
1450
1485
  required: ["person_a", "person_b"],
1451
1486
  },
@@ -1473,8 +1508,12 @@ registerTool({
1473
1508
  subject_2: toSubject(args.person_b),
1474
1509
  };
1475
1510
  const bundleAvailable = Boolean(getBodygraphBundle());
1476
- if (bundleAvailable)
1511
+ // Mirror the transit tool: explicit dark default, host-theme refetch.
1512
+ const theme = args.theme === "light" ? "light" : "dark";
1513
+ if (bundleAvailable) {
1477
1514
  body.include_visual = true;
1515
+ body.visual_config = { theme };
1516
+ }
1478
1517
  const resp = await client.request("POST", "/human-design/composite", { data: body });
1479
1518
  const connections = Array.isArray(resp?.connections) ? resp.connections : [];
1480
1519
  const svg = resp?.visual?.data;
@@ -1490,7 +1529,13 @@ registerTool({
1490
1529
  line("Dominance — one defines", "dominance") +
1491
1530
  line("Compromise — friction", "compromise") +
1492
1531
  (connections.length ? "" : " none.");
1493
- const payload = { ...resp, _svg: svg, server_version: SERVER_VERSION };
1532
+ const payload = {
1533
+ ...resp,
1534
+ _svg: svg,
1535
+ _theme: theme,
1536
+ _refetch: { tool: "explore_human_design_connection", args: { ...args } },
1537
+ server_version: SERVER_VERSION,
1538
+ };
1494
1539
  if (bundleAvailable && svg) {
1495
1540
  return {
1496
1541
  content: [{ type: "text", text: summary }],
@@ -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 MCP Apps-capable hosts (Claude Desktop). " +
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
- validateCoordinates(args, "latitude", "longitude");
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;