@openephemeris/mcp-server 3.12.0 → 3.13.1
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/README.md +2 -2
- package/dist/index.js +46 -27
- package/dist/server-sse.js +46 -27
- package/dist/tools/apps/bi-wheel-app.d.ts +38 -0
- package/dist/tools/apps/bi-wheel-app.js +459 -0
- package/dist/tools/apps/bodygraph-app.d.ts +22 -0
- package/dist/tools/apps/bodygraph-app.js +633 -0
- package/dist/tools/apps/chart-wheel-app.js +1 -1
- package/dist/tools/index.d.ts +1 -20
- package/dist/tools/index.js +12 -0
- package/dist/ui/bi-wheel.html +81 -0
- package/dist/ui/bodygraph.html +514 -0
- package/dist/ui/chart-wheel.html +136 -40
- package/package.json +2 -2
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bi-wheel-app.ts — MCP App tool registration for the Bi-Wheel Explorer.
|
|
3
|
+
*
|
|
4
|
+
* Registers 3 tools:
|
|
5
|
+
* • explore_bi_wheel — primary entry point: fetches two charts, computes
|
|
6
|
+
* cross-aspects, returns data + UI resource [model]
|
|
7
|
+
* • bi_wheel_on_cross_aspect_click — cross-aspect click handler [app-only]
|
|
8
|
+
* • bi_wheel_on_planet_click — planet click handler (inner or outer wheel) [app-only]
|
|
9
|
+
*
|
|
10
|
+
* Modes:
|
|
11
|
+
* synastry — fetch two natal charts in parallel (person1 inner, person2 outer)
|
|
12
|
+
* transit — fetch one natal chart (inner) + one transit chart (outer)
|
|
13
|
+
*
|
|
14
|
+
* Cross-aspects are computed client-side (UI) AND server-side (for summary/fallback).
|
|
15
|
+
* The payload cross_aspects array is capped at 30 tightest-orb aspects.
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { registerTool } from "../index.js";
|
|
21
|
+
import { getActiveClient } from "../../backend/client.js";
|
|
22
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
23
|
+
export const BI_WHEEL_RESOURCE_URI = "ui://openephemeris/bi-wheel";
|
|
24
|
+
export const BI_WHEEL_MIME_TYPE = "text/html;profile=mcp-app";
|
|
25
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const BUNDLE_PATH = path.resolve(here, "..", "..", "..", "dist", "ui", "bi-wheel.html");
|
|
27
|
+
let cachedBiWheelBundle = null;
|
|
28
|
+
/** Read the pre-built HTML bundle. Returns null if not yet built. */
|
|
29
|
+
export function getBiWheelBundle() {
|
|
30
|
+
if (cachedBiWheelBundle)
|
|
31
|
+
return cachedBiWheelBundle;
|
|
32
|
+
if (!fs.existsSync(BUNDLE_PATH))
|
|
33
|
+
return null;
|
|
34
|
+
try {
|
|
35
|
+
cachedBiWheelBundle = fs.readFileSync(BUNDLE_PATH, "utf-8");
|
|
36
|
+
return cachedBiWheelBundle;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function clearBiWheelBundleCache() {
|
|
43
|
+
cachedBiWheelBundle = null;
|
|
44
|
+
}
|
|
45
|
+
// ── House system helpers (shared with chart-wheel) ────────────────────────────
|
|
46
|
+
const HOUSE_SYSTEM_MAP = {
|
|
47
|
+
placidus: "P", whole_sign: "W", equal: "E", koch: "K",
|
|
48
|
+
campanus: "C", regiomontanus: "R",
|
|
49
|
+
};
|
|
50
|
+
function buildNatalBody(datetime, lat, lon, timezone) {
|
|
51
|
+
const body = {
|
|
52
|
+
subject: {
|
|
53
|
+
name: "MCP Request",
|
|
54
|
+
birth_datetime: { iso: datetime },
|
|
55
|
+
birth_location: {
|
|
56
|
+
latitude: { decimal: lat ?? 0 },
|
|
57
|
+
longitude: { decimal: lon ?? 0 },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
configuration: {
|
|
61
|
+
house_system: HOUSE_SYSTEM_MAP["placidus"],
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
if (timezone) {
|
|
65
|
+
body.subject.birth_location.timezone = { iana_name: timezone };
|
|
66
|
+
}
|
|
67
|
+
return body;
|
|
68
|
+
}
|
|
69
|
+
function buildTransitBody(transitDate, natalDatetime, lat, lon, timezone) {
|
|
70
|
+
return {
|
|
71
|
+
subject: {
|
|
72
|
+
name: "MCP Request",
|
|
73
|
+
birth_datetime: { iso: natalDatetime },
|
|
74
|
+
birth_location: {
|
|
75
|
+
latitude: { decimal: lat ?? 0 },
|
|
76
|
+
longitude: { decimal: lon ?? 0 },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
transit_datetime: { iso: transitDate },
|
|
80
|
+
...(timezone ? { timezone: { iana_name: timezone } } : {}),
|
|
81
|
+
configuration: { house_system: HOUSE_SYSTEM_MAP["placidus"] },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const CROSS_ASPECT_DEFS = [
|
|
85
|
+
{ type: "conjunction", angle: 0, orb: 8 },
|
|
86
|
+
{ type: "opposition", angle: 180, orb: 8 },
|
|
87
|
+
{ type: "trine", angle: 120, orb: 6 },
|
|
88
|
+
{ type: "square", angle: 90, orb: 5 }, // tightened from 6° per Ptolemaic convention
|
|
89
|
+
{ type: "sextile", angle: 60, orb: 4 },
|
|
90
|
+
];
|
|
91
|
+
export function computeCrossAspects(planets1, planets2) {
|
|
92
|
+
const results = [];
|
|
93
|
+
for (const p1 of planets1) {
|
|
94
|
+
for (const p2 of planets2) {
|
|
95
|
+
let diff = Math.abs(p1.longitude - p2.longitude) % 360;
|
|
96
|
+
if (diff > 180)
|
|
97
|
+
diff = 360 - diff;
|
|
98
|
+
for (const asp of CROSS_ASPECT_DEFS) {
|
|
99
|
+
const orb = Math.abs(diff - asp.angle);
|
|
100
|
+
if (orb <= asp.orb) {
|
|
101
|
+
const applying = ((p1.speed ?? 1) - (p2.speed ?? 0)) < 0
|
|
102
|
+
? diff < asp.angle
|
|
103
|
+
: diff > asp.angle;
|
|
104
|
+
results.push({
|
|
105
|
+
planet1: p1.name,
|
|
106
|
+
wheel1: "inner",
|
|
107
|
+
planet2: p2.name,
|
|
108
|
+
wheel2: "outer",
|
|
109
|
+
type: asp.type,
|
|
110
|
+
angle: asp.angle,
|
|
111
|
+
orb: Math.round(orb * 100) / 100,
|
|
112
|
+
applying,
|
|
113
|
+
});
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Sort by orb tightness, cap at 30
|
|
120
|
+
return results.sort((a, b) => a.orb - b.orb).slice(0, 30);
|
|
121
|
+
}
|
|
122
|
+
// ── Planet normalisation ───────────────────────────────────────────────────────
|
|
123
|
+
const CLASSICAL_PLANETS = new Set([
|
|
124
|
+
"sun", "moon", "mercury", "venus", "mars",
|
|
125
|
+
"jupiter", "saturn", "uranus", "neptune", "pluto",
|
|
126
|
+
"chiron", "north_node", "south_node", "true_node", "asc", "mc",
|
|
127
|
+
]);
|
|
128
|
+
function normalizePlanets(raw) {
|
|
129
|
+
if (Array.isArray(raw))
|
|
130
|
+
return raw;
|
|
131
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
132
|
+
return Object.entries(raw)
|
|
133
|
+
.filter(([k]) => CLASSICAL_PLANETS.has(k.toLowerCase()))
|
|
134
|
+
.map(([name, p]) => ({
|
|
135
|
+
name: name.toLowerCase(),
|
|
136
|
+
longitude: (p.longitude ?? p.lon ?? 0),
|
|
137
|
+
speed: (p.longitude_speed ?? p.speed),
|
|
138
|
+
retrograde: Boolean(p.is_retrograde ?? p.retrograde),
|
|
139
|
+
sign: (p.sign_name ?? p.sign),
|
|
140
|
+
house: p.house,
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
function normalizeHouses(raw) {
|
|
146
|
+
if (Array.isArray(raw))
|
|
147
|
+
return raw;
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
// ── Payload builder ────────────────────────────────────────────────────────────
|
|
151
|
+
function buildBiWheelPayload(innerData, outerData, mode, params1, params2) {
|
|
152
|
+
const innerPlanets = normalizePlanets(innerData.planets);
|
|
153
|
+
const outerPlanets = normalizePlanets(outerData.planets);
|
|
154
|
+
const innerHouses = normalizeHouses(innerData.houses);
|
|
155
|
+
const outerHouses = normalizeHouses(outerData.houses);
|
|
156
|
+
const crossAspects = computeCrossAspects(innerPlanets, outerPlanets);
|
|
157
|
+
return {
|
|
158
|
+
mode,
|
|
159
|
+
inner: innerPlanets,
|
|
160
|
+
outer: outerPlanets,
|
|
161
|
+
inner_houses: innerHouses,
|
|
162
|
+
outer_houses: outerHouses,
|
|
163
|
+
cross_aspects: crossAspects,
|
|
164
|
+
_birth_params_inner: params1,
|
|
165
|
+
_birth_params_outer: params2,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
// ── Summary builder ────────────────────────────────────────────────────────────
|
|
169
|
+
function capitalize(s) {
|
|
170
|
+
return s.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
171
|
+
}
|
|
172
|
+
function zodSignFromLon(lon) {
|
|
173
|
+
const signs = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
|
174
|
+
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"];
|
|
175
|
+
return signs[Math.floor(((lon % 360) + 360) % 360 / 30)];
|
|
176
|
+
}
|
|
177
|
+
function buildBiWheelSummary(innerPlanets, outerPlanets, crossAspects, mode, location1, location2) {
|
|
178
|
+
const getKey = (planets, name) => planets.find((p) => p.name === name.toLowerCase());
|
|
179
|
+
// Inner wheel basics
|
|
180
|
+
const innerSun = getKey(innerPlanets, "sun");
|
|
181
|
+
const innerMoon = getKey(innerPlanets, "moon");
|
|
182
|
+
const innerAsc = getKey(innerPlanets, "asc");
|
|
183
|
+
const innerBasics = [innerSun, innerMoon, innerAsc]
|
|
184
|
+
.filter(Boolean)
|
|
185
|
+
.map((p) => `${capitalize(p.name)} in ${zodSignFromLon(p.longitude)}`)
|
|
186
|
+
.join(", ");
|
|
187
|
+
// Top 5 cross-aspects by orb
|
|
188
|
+
const ASPECT_SYMBOL = {
|
|
189
|
+
conjunction: "☌", opposition: "☍", trine: "△",
|
|
190
|
+
square: "□", sextile: "⚹",
|
|
191
|
+
};
|
|
192
|
+
const top5 = crossAspects.slice(0, 5).map((ca) => `• ${capitalize(ca.planet1)} ${ASPECT_SYMBOL[ca.type] ?? ca.type} ${capitalize(ca.planet2)} (${ca.orb.toFixed(1)}° orb${ca.applying ? ", applying" : ""})`);
|
|
193
|
+
const modeLabel = mode === "synastry" ? "Synastry" : "Transit";
|
|
194
|
+
const label1 = mode === "synastry" ? "Person 1 (inner)" : "Natal (inner)";
|
|
195
|
+
const label2 = mode === "synastry" ? "Person 2 (outer)" : "Transit (outer)";
|
|
196
|
+
return (`**${modeLabel} Bi-Wheel**\n\n` +
|
|
197
|
+
`${label1}: ${location1} — ${innerBasics || "chart loaded"}\n` +
|
|
198
|
+
`${label2}: ${location2}\n\n` +
|
|
199
|
+
`**Top Cross-Aspects:**\n${top5.join("\n") || "No major cross-aspects within orb."}\n\n` +
|
|
200
|
+
"Click any planet or cross-aspect line in the bi-wheel for interpretation.");
|
|
201
|
+
}
|
|
202
|
+
// ── Tool: explore_bi_wheel ─────────────────────────────────────────────────────
|
|
203
|
+
registerTool({
|
|
204
|
+
name: "explore_bi_wheel",
|
|
205
|
+
description: "Generate an interactive bi-wheel chart for synastry (two natal charts) or transit analysis " +
|
|
206
|
+
"(natal chart + transiting planets). In synastry mode, the inner wheel shows Person 1's natal " +
|
|
207
|
+
"chart and the outer ring shows Person 2's natal chart. In transit mode, the inner wheel shows " +
|
|
208
|
+
"the natal chart and the outer ring shows transiting planets for the given date. " +
|
|
209
|
+
"Cross-aspects between both wheels are computed and displayed as coloured dashed lines. " +
|
|
210
|
+
"Click any planet or cross-aspect line for interpretation.",
|
|
211
|
+
inputSchema: {
|
|
212
|
+
type: "object",
|
|
213
|
+
properties: {
|
|
214
|
+
person1_datetime: {
|
|
215
|
+
type: "string",
|
|
216
|
+
description: "ISO 8601 datetime for Person 1 / Natal chart (e.g. '1990-04-15T14:30:00-05:00').",
|
|
217
|
+
},
|
|
218
|
+
person1_latitude: { type: "number", description: "Birth latitude for Person 1 (decimal degrees, positive = North)." },
|
|
219
|
+
person1_longitude: { type: "number", description: "Birth longitude for Person 1 (decimal degrees, positive = East)." },
|
|
220
|
+
person1_timezone: { type: "string", description: "IANA timezone for Person 1 (e.g. 'America/New_York')." },
|
|
221
|
+
person1_name: {
|
|
222
|
+
type: "string",
|
|
223
|
+
description: "Display name for Person 1 / Natal chart (e.g. 'Alice'). Used in the badge and summary.",
|
|
224
|
+
},
|
|
225
|
+
person2_datetime: {
|
|
226
|
+
type: "string",
|
|
227
|
+
description: "ISO 8601 datetime for Person 2 (synastry) or the transit date (transit mode). Required.",
|
|
228
|
+
},
|
|
229
|
+
person2_latitude: { type: "number", description: "Birth latitude for Person 2 / transit location (decimal degrees)." },
|
|
230
|
+
person2_longitude: { type: "number", description: "Birth longitude for Person 2 / transit location (decimal degrees)." },
|
|
231
|
+
person2_timezone: { type: "string", description: "IANA timezone for Person 2 / transit (e.g. 'America/Chicago')." },
|
|
232
|
+
person2_name: {
|
|
233
|
+
type: "string",
|
|
234
|
+
description: "Display name for Person 2 / Transit chart (e.g. 'Bob'). Used in the badge and summary.",
|
|
235
|
+
},
|
|
236
|
+
location: {
|
|
237
|
+
type: "string",
|
|
238
|
+
description: "Label for Person 1 / Natal location (display only, e.g. 'New York, NY').",
|
|
239
|
+
},
|
|
240
|
+
mode: {
|
|
241
|
+
type: "string",
|
|
242
|
+
enum: ["synastry", "transit"],
|
|
243
|
+
description: "Chart mode: 'synastry' (two natal charts) or 'transit' (natal + transits). Defaults to 'synastry'.",
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
required: ["person1_datetime", "person2_datetime",
|
|
247
|
+
"person1_latitude", "person1_longitude",
|
|
248
|
+
"person2_latitude", "person2_longitude"],
|
|
249
|
+
},
|
|
250
|
+
annotations: {
|
|
251
|
+
title: "Interactive Bi-Wheel Explorer",
|
|
252
|
+
readOnlyHint: true,
|
|
253
|
+
destructiveHint: false,
|
|
254
|
+
idempotentHint: true,
|
|
255
|
+
openWorldHint: false,
|
|
256
|
+
},
|
|
257
|
+
handler: async (args) => {
|
|
258
|
+
const client = getActiveClient();
|
|
259
|
+
const mode = args.mode ?? "synastry";
|
|
260
|
+
const dt1 = String(args.person1_datetime);
|
|
261
|
+
const lat1 = args.person1_latitude;
|
|
262
|
+
const lon1 = args.person1_longitude;
|
|
263
|
+
const tz1 = args.person1_timezone;
|
|
264
|
+
const dt2 = String(args.person2_datetime);
|
|
265
|
+
const lat2 = args.person2_latitude;
|
|
266
|
+
const lon2 = args.person2_longitude;
|
|
267
|
+
const tz2 = args.person2_timezone;
|
|
268
|
+
const loc1 = String(args.person1_name ?? args.location ?? `${lat1 ?? "?"},${lon1 ?? "?"}`);
|
|
269
|
+
const loc2 = mode === "transit"
|
|
270
|
+
? `Transit ${dt2.slice(0, 10)}`
|
|
271
|
+
: String(args.person2_name ?? `${lat2 ?? ""},${lon2 ?? ""}`);
|
|
272
|
+
// Fetch both charts in parallel
|
|
273
|
+
let innerData;
|
|
274
|
+
let outerData;
|
|
275
|
+
if (mode === "synastry") {
|
|
276
|
+
[innerData, outerData] = await Promise.all([
|
|
277
|
+
client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1)),
|
|
278
|
+
client.post("/ephemeris/natal-chart", buildNatalBody(dt2, lat2, lon2, tz2)),
|
|
279
|
+
]);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
// Transit mode: inner = natal (dt1), outer = transits for dt2 at natal location
|
|
283
|
+
[innerData, outerData] = await Promise.all([
|
|
284
|
+
client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1)),
|
|
285
|
+
client.post("/ephemeris/natal-chart", buildNatalBody(dt2, lat2 ?? lat1, lon2 ?? lon1, tz2 ?? tz1)),
|
|
286
|
+
]);
|
|
287
|
+
}
|
|
288
|
+
const innerPlanets = normalizePlanets(innerData.planets);
|
|
289
|
+
const outerPlanets = normalizePlanets(outerData.planets);
|
|
290
|
+
const crossAspects = computeCrossAspects(innerPlanets, outerPlanets);
|
|
291
|
+
const summary = buildBiWheelSummary(innerPlanets, outerPlanets, crossAspects, mode, loc1, loc2);
|
|
292
|
+
const params1 = { datetime: dt1, timezone: tz1 ?? null, latitude: lat1 ?? null, longitude: lon1 ?? null, location: loc1 };
|
|
293
|
+
const params2 = { datetime: dt2, timezone: tz2 ?? null, latitude: lat2 ?? null, longitude: lon2 ?? null, location: loc2 };
|
|
294
|
+
const payload = buildBiWheelPayload(innerData, outerData, mode, params1, params2);
|
|
295
|
+
const bundleAvailable = Boolean(getBiWheelBundle());
|
|
296
|
+
if (bundleAvailable) {
|
|
297
|
+
return {
|
|
298
|
+
content: [
|
|
299
|
+
{ type: "text", text: summary },
|
|
300
|
+
{ type: "text", text: JSON.stringify(payload) },
|
|
301
|
+
{
|
|
302
|
+
type: "resource",
|
|
303
|
+
resource: {
|
|
304
|
+
uri: BI_WHEEL_RESOURCE_URI,
|
|
305
|
+
mimeType: BI_WHEEL_MIME_TYPE,
|
|
306
|
+
text: getBiWheelBundle(),
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
_meta: {
|
|
311
|
+
ui: { resourceUri: BI_WHEEL_RESOURCE_URI },
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
// Fallback: text summary only
|
|
316
|
+
return { content: [{ type: "text", text: summary }] };
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
// ── Tool: bi_wheel_on_cross_aspect_click ──────────────────────────────────────
|
|
320
|
+
registerTool({
|
|
321
|
+
name: "bi_wheel_on_cross_aspect_click",
|
|
322
|
+
description: "Event handler called when the user clicks a cross-aspect line in the bi-wheel. " +
|
|
323
|
+
"Returns a synastry or transit interpretation of that aspect between the two wheels. " +
|
|
324
|
+
"This tool is called automatically by the bi-wheel UI; you do not need to call it directly.",
|
|
325
|
+
inputSchema: {
|
|
326
|
+
type: "object",
|
|
327
|
+
properties: {
|
|
328
|
+
planet1: { type: "string", description: "Planet name from the inner (natal) wheel" },
|
|
329
|
+
wheel1: { type: "string", enum: ["inner", "outer"], description: "Which wheel planet1 belongs to" },
|
|
330
|
+
planet2: { type: "string", description: "Planet name from the outer (synastry/transit) wheel" },
|
|
331
|
+
wheel2: { type: "string", enum: ["inner", "outer"], description: "Which wheel planet2 belongs to" },
|
|
332
|
+
aspect_type: { type: "string", description: "Aspect type: conjunction, opposition, trine, square, sextile" },
|
|
333
|
+
orb: { type: "number", description: "Orb in degrees" },
|
|
334
|
+
applying: { type: "boolean", description: "True if the aspect is applying (tightening)" },
|
|
335
|
+
mode: { type: "string", enum: ["synastry", "transit"], description: "Chart mode (synastry or transit). Determines interpretation style." },
|
|
336
|
+
},
|
|
337
|
+
required: ["planet1", "planet2", "aspect_type"],
|
|
338
|
+
},
|
|
339
|
+
annotations: { title: "Cross-Aspect Interpretation", readOnlyHint: true, openWorldHint: false },
|
|
340
|
+
_meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
|
|
341
|
+
handler: async (args) => {
|
|
342
|
+
const p1 = capitalize(String(args.planet1));
|
|
343
|
+
const p2 = capitalize(String(args.planet2));
|
|
344
|
+
const type = String(args.aspect_type ?? "aspect");
|
|
345
|
+
const orb = args.orb != null ? ` (${Number(args.orb).toFixed(1)}° orb)` : "";
|
|
346
|
+
const dir = args.applying != null
|
|
347
|
+
? (args.applying ? " — applying" : " — separating")
|
|
348
|
+
: "";
|
|
349
|
+
return {
|
|
350
|
+
content: [{
|
|
351
|
+
type: "text",
|
|
352
|
+
text: `**${p1} ${capitalize(type)} ${p2}${orb}${dir}** *(Cross-Wheel)*\n\n` +
|
|
353
|
+
interpretCrossAspect(String(args.planet1), String(args.planet2), type, Boolean(args.applying), args.mode ?? "synastry"),
|
|
354
|
+
}],
|
|
355
|
+
};
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
// ── Tool: bi_wheel_on_planet_click ────────────────────────────────────────────
|
|
359
|
+
registerTool({
|
|
360
|
+
name: "bi_wheel_on_planet_click",
|
|
361
|
+
description: "Event handler called when the user clicks a planet in the bi-wheel. " +
|
|
362
|
+
"Returns the planet's meaning in context — e.g. 'Your Sun conjuncts their Moon'. " +
|
|
363
|
+
"This tool is called automatically by the bi-wheel UI; you do not need to call it directly.",
|
|
364
|
+
inputSchema: {
|
|
365
|
+
type: "object",
|
|
366
|
+
properties: {
|
|
367
|
+
planet: { type: "string", description: "Planet name (e.g. 'saturn')" },
|
|
368
|
+
wheel: { type: "string", enum: ["inner", "outer"], description: "Which wheel (inner=natal/person1, outer=synastry/transit)" },
|
|
369
|
+
longitude: { type: "number", description: "Ecliptic longitude in degrees" },
|
|
370
|
+
sign: { type: "string", description: "Zodiac sign" },
|
|
371
|
+
house: { type: "number", description: "House number (1–12) if available" },
|
|
372
|
+
retrograde: { type: "boolean", description: "Whether the planet is retrograde" },
|
|
373
|
+
mode: { type: "string", enum: ["synastry", "transit"], description: "Chart mode" },
|
|
374
|
+
},
|
|
375
|
+
required: ["planet", "wheel", "longitude"],
|
|
376
|
+
},
|
|
377
|
+
annotations: { title: "Bi-Wheel Planet Interpretation", readOnlyHint: true, openWorldHint: false },
|
|
378
|
+
_meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
|
|
379
|
+
handler: async (args) => {
|
|
380
|
+
const planet = String(args.planet);
|
|
381
|
+
const wheel = args.wheel ?? "inner";
|
|
382
|
+
const sign = args.sign ? String(args.sign) : zodSignFromLon(Number(args.longitude));
|
|
383
|
+
const houseNum = args.house != null ? Number(args.house) : null;
|
|
384
|
+
const house = houseNum != null && houseNum > 0 ? ` in the ${ordinal(houseNum)} house` : "";
|
|
385
|
+
const wheelLabel = wheel === "inner" ? "Natal / Person 1" : "Synastry / Transit";
|
|
386
|
+
const deg = `${Math.floor(Number(args.longitude) % 30)}° ${sign}`;
|
|
387
|
+
return {
|
|
388
|
+
content: [{
|
|
389
|
+
type: "text",
|
|
390
|
+
text: `**${capitalize(planet)} at ${deg}${house}** *(${wheelLabel})*\n\n` +
|
|
391
|
+
interpretBiWheelPlanet(planet, sign, houseNum ?? 0, wheel, Boolean(args.retrograde)),
|
|
392
|
+
}],
|
|
393
|
+
};
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
// ── Interpretation helpers ─────────────────────────────────────────────────────
|
|
397
|
+
function ordinal(n) {
|
|
398
|
+
const s = ["th", "st", "nd", "rd"];
|
|
399
|
+
const v = n % 100;
|
|
400
|
+
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
|
401
|
+
}
|
|
402
|
+
const PLANET_THEMES = {
|
|
403
|
+
sun: "identity and conscious self-expression",
|
|
404
|
+
moon: "emotional nature and instinctive responses",
|
|
405
|
+
mercury: "thinking style, communication, and mental patterns",
|
|
406
|
+
venus: "love language, values, and relational style",
|
|
407
|
+
mars: "drive, desire, action, and assertiveness",
|
|
408
|
+
jupiter: "expansion, optimism, philosophy, and generosity",
|
|
409
|
+
saturn: "structure, discipline, karmic lessons, and boundaries",
|
|
410
|
+
uranus: "awakening, innovation, and sudden disruption",
|
|
411
|
+
neptune: "dreams, idealism, compassion, and dissolution",
|
|
412
|
+
pluto: "transformation, power dynamics, and profound change",
|
|
413
|
+
chiron: "the wounded healer — deep vulnerability, mastery through pain, and the gift of empathy forged in suffering",
|
|
414
|
+
north_node: "soul direction and karmic growth edge; the evolutionary path forward in this lifetime",
|
|
415
|
+
south_node: "accumulated karma and innate past-life gifts; the comfort zone that must be transcended",
|
|
416
|
+
true_node: "soul direction and karmic growth edge; the evolutionary path forward in this lifetime",
|
|
417
|
+
asc: "the rising sign and outer personality mask; the social self, physical appearance, and the lens through which life is encountered",
|
|
418
|
+
mc: "public vocation, highest ambition, and the world's face; the pinnacle of achievement this person strives toward",
|
|
419
|
+
};
|
|
420
|
+
const HOUSE_AREAS = {
|
|
421
|
+
1: "self and identity", 2: "values and resources",
|
|
422
|
+
3: "communication", 4: "home and roots",
|
|
423
|
+
5: "creativity and joy", 6: "work and health",
|
|
424
|
+
7: "partnerships", 8: "shared depth and transformation",
|
|
425
|
+
9: "beliefs and expansion", 10: "career and public life",
|
|
426
|
+
11: "community and hopes", 12: "solitude and the unconscious",
|
|
427
|
+
};
|
|
428
|
+
function interpretBiWheelPlanet(planet, sign, house, wheel, retrograde = false) {
|
|
429
|
+
const theme = PLANET_THEMES[planet.toLowerCase()] ?? "a deep archetypal force";
|
|
430
|
+
const houseCtx = house ? ` This energy operates in the sphere of ${HOUSE_AREAS[house] ?? "life experience"}.` : "";
|
|
431
|
+
const retroCtx = retrograde
|
|
432
|
+
? ` Note: this planet is **retrograde** — its energy tends to be more internalized, reflective, or nonconforming in its expression.`
|
|
433
|
+
: "";
|
|
434
|
+
const wheelCtx = wheel === "inner"
|
|
435
|
+
? "This is a **natal planet** — a core, stable part of this person's identity."
|
|
436
|
+
: "This is an **outer planet** — a transiting or synastry energy currently activating the inner wheel.";
|
|
437
|
+
return (`${capitalize(planet)} represents ${theme}. In ${capitalize(sign)}, this energy is expressed through the qualities of that sign.${houseCtx}${retroCtx}\n\n${wheelCtx}`);
|
|
438
|
+
}
|
|
439
|
+
const CROSS_ASPECT_NATURE = {
|
|
440
|
+
conjunction: "creates a powerful merging of these two planetary energies across the chart boundary. What one person (or the natal chart) embodies, the other amplifies — this can feel fated, intense, and highly activating.",
|
|
441
|
+
opposition: "creates a magnetic pull-and-push dynamic. The two energies are drawn to each other yet challenge each other to grow. In synastry, this often manifests as fascination and tension; in transit, as external pressure to integrate.",
|
|
442
|
+
trine: "creates an easy, harmonious flow of energy between the two charts. This aspect supports mutual understanding, natural rapport, and a sense of ease — gifts arrive without much effort.",
|
|
443
|
+
square: "creates productive friction between the two charts. Growth and evolution are required. In synastry, this generates passion and drive; in transit, it signals action and adjustment.",
|
|
444
|
+
sextile: "creates a gentle, supportive connection that rewards intentional effort. Opportunities arise where these two energies meet, but initiative is needed to activate them fully.",
|
|
445
|
+
};
|
|
446
|
+
function interpretCrossAspect(p1, p2, type, applying, mode) {
|
|
447
|
+
const nature = CROSS_ASPECT_NATURE[type.toLowerCase()] ?? "creates a notable connection across the two charts.";
|
|
448
|
+
let timing;
|
|
449
|
+
if (mode === "transit") {
|
|
450
|
+
timing = applying
|
|
451
|
+
? "This aspect is **applying** — its influence is building and moving toward maximum expression."
|
|
452
|
+
: "This aspect is **separating** — its peak has passed; integration of its themes is underway.";
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
// In synastry, both charts are frozen — applying/separating has no temporal meaning.
|
|
456
|
+
timing = "As a synastry aspect, this connection exists as a permanent feature of this relationship dynamic — neither building nor fading, but always present as a foundational thread between these two charts.";
|
|
457
|
+
}
|
|
458
|
+
return (`The ${type} between ${capitalize(p1)} and ${capitalize(p2)} ${nature}\n\n${timing}`);
|
|
459
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bodygraph-app.ts — MCP App tool registration for the Human Design Bodygraph Explorer.
|
|
3
|
+
*
|
|
4
|
+
* Registers 4 tools:
|
|
5
|
+
* • explore_human_design — primary entry point, returns data + UI resource [model]
|
|
6
|
+
* • hd_on_center_click — center click handler [app-only]
|
|
7
|
+
* • hd_on_gate_click — gate click handler [app-only]
|
|
8
|
+
* • hd_on_channel_click — channel click handler [app-only]
|
|
9
|
+
*
|
|
10
|
+
* The bodygraph is rendered client-side from structured JSON — no SVG endpoint
|
|
11
|
+
* is called, keeping latency at zero and the architecture consistent with
|
|
12
|
+
* the chart-wheel-app approach.
|
|
13
|
+
*
|
|
14
|
+
* Also exports resource helpers (getBodygraphBundle, etc.) for use in
|
|
15
|
+
* index.ts and server-sse.ts.
|
|
16
|
+
*/
|
|
17
|
+
export declare const BODYGRAPH_RESOURCE_URI = "ui://openephemeris/bodygraph";
|
|
18
|
+
export declare const BODYGRAPH_MIME_TYPE = "text/html;profile=mcp-app";
|
|
19
|
+
/** Read the pre-built HTML bundle. Returns null if not yet built. */
|
|
20
|
+
export declare function getBodygraphBundle(): string | null;
|
|
21
|
+
/** Reset cache (useful in dev watch mode or tests). */
|
|
22
|
+
export declare function clearBodygraphBundleCache(): void;
|