@openephemeris/mcp-server 3.13.10 → 3.15.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 +53 -0
- package/README.md +6 -6
- package/dist/index.js +5 -43
- package/dist/oauth/discovery.d.ts +1 -0
- package/dist/oauth/discovery.js +8 -3
- package/dist/oauth/store.d.ts +20 -0
- package/dist/oauth/store.js +117 -3
- package/dist/oauth/token.js +4 -2
- package/dist/prompts.js +25 -16
- package/dist/server-sse.js +136 -78
- package/dist/tools/apps/bazi-app.js +2 -0
- package/dist/tools/apps/bi-wheel-app.js +65 -7
- package/dist/tools/apps/bodygraph-app.js +85 -11
- package/dist/tools/apps/chart-wheel-app.js +80 -7
- package/dist/tools/apps/location-tools.js +41 -5
- package/dist/tools/apps/moon-phase-app.js +82 -22
- package/dist/tools/apps/transit-timeline-app.js +75 -23
- package/dist/tools/apps/vedic-chart-app.js +2 -0
- package/dist/tools/auth.js +4 -0
- package/dist/tools/dev.js +104 -67
- package/dist/tools/index.d.ts +16 -0
- package/dist/tools/index.js +23 -8
- package/dist/tools/output-schemas.d.ts +37 -0
- package/dist/tools/output-schemas.js +130 -0
- package/dist/tools/specialized/acg.js +3 -0
- package/dist/tools/specialized/bazi.js +14 -6
- package/dist/tools/specialized/bi_wheel.js +5 -2
- package/dist/tools/specialized/chart_wheel.js +5 -2
- package/dist/tools/specialized/comparative.js +7 -2
- package/dist/tools/specialized/eclipse.js +2 -0
- package/dist/tools/specialized/electional.js +5 -0
- package/dist/tools/specialized/ephemeris_core.js +3 -0
- package/dist/tools/specialized/ephemeris_extended.js +9 -0
- package/dist/tools/specialized/hd_bodygraph.js +6 -3
- package/dist/tools/specialized/hd_cycles.js +3 -0
- package/dist/tools/specialized/hd_group.js +3 -0
- package/dist/tools/specialized/human_design.js +2 -0
- package/dist/tools/specialized/moon.js +3 -0
- package/dist/tools/specialized/natal.js +2 -0
- package/dist/tools/specialized/progressed.js +2 -0
- package/dist/tools/specialized/relocation.js +2 -0
- package/dist/tools/specialized/returns.js +4 -0
- package/dist/tools/specialized/synastry.js +2 -0
- package/dist/tools/specialized/transits.js +52 -43
- package/dist/tools/specialized/vedic.js +2 -0
- package/dist/tools/specialized/venus_star_points.js +7 -0
- package/dist/ui/bazi.html +22 -22
- package/dist/ui/bodygraph.html +1981 -1904
- package/dist/ui/chart-wheel.html +3148 -2806
- package/dist/ui/vedic-chart.html +22 -22
- package/package.json +18 -11
- package/smithery.yaml +16 -1
- package/dist/ui/bi-wheel.html +0 -7485
- package/dist/ui/moon-phase.html +0 -6758
- package/dist/ui/transit-timeline.html +0 -6835
|
@@ -19,6 +19,7 @@ import path from "node:path";
|
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { registerTool, SERVER_VERSION } from "../index.js";
|
|
21
21
|
import { getActiveClient } from "../../backend/client.js";
|
|
22
|
+
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
22
23
|
// ── Constants ─────────────────────────────────────────────────────────────
|
|
23
24
|
export const BODYGRAPH_RESOURCE_URI = "ui://openephemeris/bodygraph";
|
|
24
25
|
export const BODYGRAPH_MIME_TYPE = "text/html;profile=mcp-app";
|
|
@@ -65,6 +66,45 @@ function ensureTimezone(dt) {
|
|
|
65
66
|
return dt;
|
|
66
67
|
return dt + "Z";
|
|
67
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Fetch the canonical bodygraph SVG from the Go renderer.
|
|
71
|
+
*
|
|
72
|
+
* The Go engine is the source of truth for bodygraph visuals (Phase 4 of the
|
|
73
|
+
* renderer unification). This SVG is inlined directly into the iframe and
|
|
74
|
+
* bound to interaction handlers via its data-* attributes (data-center,
|
|
75
|
+
* data-gate, data-channel, data-hanging, data-highlighted, etc.).
|
|
76
|
+
*
|
|
77
|
+
* Returns null on failure so the caller can fall back to the legacy client-side
|
|
78
|
+
* renderer in bodygraph-renderer.ts. Non-fatal: a missing SVG should never
|
|
79
|
+
* block the tool result.
|
|
80
|
+
*/
|
|
81
|
+
async function fetchGoBodygraphSVG(body, theme = "dark") {
|
|
82
|
+
try {
|
|
83
|
+
const client = getActiveClient();
|
|
84
|
+
// /visualization/bodygraph is registered as binary in BINARY_ENDPOINT_PREFIXES.
|
|
85
|
+
// The client wraps it as { content_type, encoding: "base64", data_base64 } —
|
|
86
|
+
// we decode back to UTF-8 SVG text for inlining.
|
|
87
|
+
//
|
|
88
|
+
// style=light|dark drives the Go renderer's palette. The endpoint defaults
|
|
89
|
+
// to "light" when style is omitted, which mismatches dark MCP hosts — so we
|
|
90
|
+
// always pass an explicit theme (default "dark" to match the app shell; the
|
|
91
|
+
// iframe re-fetches with the host-detected theme on load / theme change).
|
|
92
|
+
const resp = await client.post(`/visualization/bodygraph?format=svg&size=800&style=${theme}`, body);
|
|
93
|
+
if (typeof resp === "string")
|
|
94
|
+
return resp;
|
|
95
|
+
if (resp && typeof resp === "object") {
|
|
96
|
+
const r = resp;
|
|
97
|
+
if (typeof r.data_base64 === "string") {
|
|
98
|
+
return Buffer.from(r.data_base64, "base64").toString("utf-8");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
console.warn("[bodygraph] Go SVG fetch failed, will fall back to client render:", err);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
68
108
|
/**
|
|
69
109
|
* Convert a local datetime string (no offset) to a UTC ISO 8601 string.
|
|
70
110
|
* Uses the IANA timezone to compute the correct UTC offset.
|
|
@@ -349,6 +389,7 @@ registerTool({
|
|
|
349
389
|
},
|
|
350
390
|
required: ["datetime"],
|
|
351
391
|
},
|
|
392
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
352
393
|
annotations: {
|
|
353
394
|
title: "Interactive Human Design Bodygraph Explorer",
|
|
354
395
|
readOnlyHint: true,
|
|
@@ -376,9 +417,18 @@ registerTool({
|
|
|
376
417
|
body.latitude = lat;
|
|
377
418
|
if (lon != null)
|
|
378
419
|
body.longitude = lon;
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
420
|
+
const bundleAvailable = Boolean(getBodygraphBundle());
|
|
421
|
+
// Fetch chart data and Go-rendered SVG in parallel. SVG only fetched when
|
|
422
|
+
// the iframe bundle is available (text-fallback hosts have no use for it,
|
|
423
|
+
// and the visualization endpoint costs a credit reservation).
|
|
424
|
+
const [chartData, svg] = await Promise.all([
|
|
425
|
+
client.request("POST", "/human-design/chart", {
|
|
426
|
+
data: body,
|
|
427
|
+
}),
|
|
428
|
+
bundleAvailable
|
|
429
|
+
? fetchGoBodygraphSVG(body)
|
|
430
|
+
: Promise.resolve(null),
|
|
431
|
+
]);
|
|
382
432
|
// API returns { chart: {...}, metadata: {...} } — unwrap the inner chart object
|
|
383
433
|
const chart = chartData.chart ?? chartData;
|
|
384
434
|
// Build payload once — summary and model data both derive from the same object
|
|
@@ -389,14 +439,20 @@ registerTool({
|
|
|
389
439
|
latitude: lat ?? null,
|
|
390
440
|
longitude: lon ?? null,
|
|
391
441
|
});
|
|
442
|
+
if (svg)
|
|
443
|
+
modelPayload._svg = svg;
|
|
392
444
|
const summary = buildHdSummary(modelPayload, location);
|
|
393
|
-
const bundleAvailable = Boolean(getBodygraphBundle());
|
|
394
445
|
if (bundleAvailable) {
|
|
446
|
+
// MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
|
|
447
|
+
// delivered through resources/read — NOT as a content block. A URI-only
|
|
448
|
+
// `type: "resource"` block lacks the inline text/blob the base MCP schema
|
|
449
|
+
// requires and fails strict client validation. structuredContent carries
|
|
450
|
+
// the data for non-rendering hosts.
|
|
395
451
|
return {
|
|
396
452
|
content: [
|
|
397
453
|
{ type: "text", text: summary },
|
|
398
|
-
{ type: "text", text: JSON.stringify({ ...modelPayload, server_version: SERVER_VERSION }) },
|
|
399
454
|
],
|
|
455
|
+
structuredContent: { ...modelPayload, server_version: SERVER_VERSION },
|
|
400
456
|
_meta: {
|
|
401
457
|
"ui/resourceUri": BODYGRAPH_RESOURCE_URI,
|
|
402
458
|
ui: { resourceUri: BODYGRAPH_RESOURCE_URI },
|
|
@@ -422,9 +478,15 @@ registerTool({
|
|
|
422
478
|
type: "string",
|
|
423
479
|
description: "IANA timezone name (e.g. 'America/New_York') for accurate UTC conversion.",
|
|
424
480
|
},
|
|
481
|
+
theme: {
|
|
482
|
+
type: "string",
|
|
483
|
+
enum: ["light", "dark"],
|
|
484
|
+
description: "Render palette for the bodygraph SVG. Mirrors the MCP host's light/dark color scheme.",
|
|
485
|
+
},
|
|
425
486
|
},
|
|
426
487
|
required: ["datetime"],
|
|
427
488
|
},
|
|
489
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
428
490
|
annotations: { title: "Recalculate Bodygraph", readOnlyHint: true, openWorldHint: false },
|
|
429
491
|
_meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
|
|
430
492
|
handler: async (args) => {
|
|
@@ -434,6 +496,7 @@ registerTool({
|
|
|
434
496
|
const datetime = localToUtcIso(String(args.datetime), timezone);
|
|
435
497
|
const lat = args.latitude;
|
|
436
498
|
const lon = args.longitude;
|
|
499
|
+
const theme = args.theme === "light" ? "light" : "dark";
|
|
437
500
|
const body = {
|
|
438
501
|
birth_datetime_utc: datetime,
|
|
439
502
|
};
|
|
@@ -441,9 +504,13 @@ registerTool({
|
|
|
441
504
|
body.latitude = lat;
|
|
442
505
|
if (lon != null)
|
|
443
506
|
body.longitude = lon;
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
507
|
+
// Recalculate is iframe-only (visibility: ["app"]) — always co-fetch the SVG.
|
|
508
|
+
const [chartData, svg] = await Promise.all([
|
|
509
|
+
client.request("POST", "/human-design/chart", {
|
|
510
|
+
data: body,
|
|
511
|
+
}),
|
|
512
|
+
fetchGoBodygraphSVG(body, theme),
|
|
513
|
+
]);
|
|
447
514
|
const chart = chartData.chart ?? chartData;
|
|
448
515
|
const modelPayload = buildHdModelPayload(chart, {
|
|
449
516
|
datetime,
|
|
@@ -452,6 +519,8 @@ registerTool({
|
|
|
452
519
|
latitude: lat ?? null,
|
|
453
520
|
longitude: lon ?? null,
|
|
454
521
|
});
|
|
522
|
+
if (svg)
|
|
523
|
+
modelPayload._svg = svg;
|
|
455
524
|
return {
|
|
456
525
|
content: [{ type: "text", text: JSON.stringify({ ...modelPayload, server_version: SERVER_VERSION }) }],
|
|
457
526
|
};
|
|
@@ -478,6 +547,7 @@ registerTool({
|
|
|
478
547
|
},
|
|
479
548
|
required: ["center", "defined"],
|
|
480
549
|
},
|
|
550
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
481
551
|
annotations: { title: "Center Interpretation", readOnlyHint: true, openWorldHint: false },
|
|
482
552
|
_meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
|
|
483
553
|
handler: async (args) => {
|
|
@@ -535,6 +605,7 @@ registerTool({
|
|
|
535
605
|
},
|
|
536
606
|
required: ["gate"],
|
|
537
607
|
},
|
|
608
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
538
609
|
annotations: { title: "Gate Interpretation", readOnlyHint: true, openWorldHint: false },
|
|
539
610
|
_meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
|
|
540
611
|
handler: async (args) => {
|
|
@@ -589,6 +660,7 @@ registerTool({
|
|
|
589
660
|
},
|
|
590
661
|
required: ["channel"],
|
|
591
662
|
},
|
|
663
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
592
664
|
annotations: { title: "Channel Interpretation", readOnlyHint: true, openWorldHint: false },
|
|
593
665
|
_meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
|
|
594
666
|
handler: async (args) => {
|
|
@@ -734,7 +806,7 @@ const HD_GATE_DATA = {
|
|
|
734
806
|
38: { name: "The Fighter", hexagram: "Hex 38 — Opposition", keynote: "Struggle in the search for purpose", gift: "Perseverance — fighting for what is meaningful", shadow: "Struggle — fighting against everything without purpose", circuit: "Individual (Knowing)" },
|
|
735
807
|
39: { name: "Provocation", hexagram: "Hex 39 — Obstruction", keynote: "Freeing the spirit through provocation", gift: "Dynamism — provocateur that liberates", shadow: "Provocation — stirring trouble unconsciously", circuit: "Individual (Knowing)" },
|
|
736
808
|
40: { name: "Aloneness", hexagram: "Hex 40 — Deliverance", keynote: "The demand for aloneness as restoration", gift: "Resolve — willpower renewed in solitude", shadow: "Exhaustion — depleted by giving without replenishment", circuit: "Tribal (Ego)" },
|
|
737
|
-
41: { name: "Contraction", hexagram: "Hex 41 — Decrease", keynote: "The beginning of all experience — fantasy and desire", gift: "Fantasy — imagination that seeds new experience", shadow: "
|
|
809
|
+
41: { name: "Contraction", hexagram: "Hex 41 — Decrease", keynote: "The beginning of all experience — fantasy and desire", gift: "Fantasy — imagination that seeds new experience", shadow: "Escapism — escapism", circuit: "Collective (Abstract)" },
|
|
738
810
|
42: { name: "Growth", hexagram: "Hex 42 — Increase", keynote: "The ability to perpetuate and complete growth cycles", gift: "Detachment — completing cycles with grace", shadow: "Incompletion — abandoning cycles before they end", circuit: "Collective (Abstract)" },
|
|
739
811
|
43: { name: "Insight", hexagram: "Hex 43 — Breakthrough", keynote: "Breakthrough from within", gift: "Insight — inner knowing that arrives as genius", shadow: "Deafness — genius unrecognised or unheard", circuit: "Individual (Knowing)" },
|
|
740
812
|
44: { name: "Alertness", hexagram: "Hex 44 — Coming to Meet", keynote: "Instinctive memory and pattern recognition", gift: "Teamwork — bringing the right people together", shadow: "Interference — driven by karmic patterns from the past", circuit: "Tribal (Defence)" },
|
|
@@ -848,8 +920,8 @@ const HD_PLANET_HD_THEMES = {
|
|
|
848
920
|
"to act before the mind has a chance to deliberate. This energy can surprise even yourself.",
|
|
849
921
|
},
|
|
850
922
|
Jupiter: {
|
|
851
|
-
conscious: "**Jupiter** represents the law of your Personality — the karmic gifts and the
|
|
852
|
-
"higher wisdom
|
|
923
|
+
conscious: "**Jupiter** represents the law of your Personality — the karmic gifts and the principled framework through which you expand beyond " +
|
|
924
|
+
"higher wisdom and justice flow. It shows where you move beyond the self-limiting " +
|
|
853
925
|
"patterns of your conditioning.",
|
|
854
926
|
unconscious: "**Design Jupiter** shows the unconscious grace embedded in your design — the karmic correction " +
|
|
855
927
|
"your body naturally applies when you are living true to your nature.",
|
|
@@ -932,6 +1004,7 @@ registerTool({
|
|
|
932
1004
|
},
|
|
933
1005
|
required: ["planet", "column"],
|
|
934
1006
|
},
|
|
1007
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
935
1008
|
annotations: { title: "Planet Sidebar Interpretation", readOnlyHint: true, openWorldHint: false },
|
|
936
1009
|
_meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
|
|
937
1010
|
handler: async (args) => {
|
|
@@ -1000,6 +1073,7 @@ registerTool({
|
|
|
1000
1073
|
},
|
|
1001
1074
|
required: ["variable", "direction"],
|
|
1002
1075
|
},
|
|
1076
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
1003
1077
|
annotations: { title: "Variable Arrow Interpretation", readOnlyHint: true, openWorldHint: false },
|
|
1004
1078
|
_meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
|
|
1005
1079
|
handler: async (args) => {
|
|
@@ -20,6 +20,7 @@ import path from "node:path";
|
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { registerTool, SERVER_VERSION } from "../index.js";
|
|
22
22
|
import { getActiveClient } from "../../backend/client.js";
|
|
23
|
+
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
23
24
|
// ── Constants ─────────────────────────────────────────────────────────────
|
|
24
25
|
export const CHART_WHEEL_RESOURCE_URI = "ui://openephemeris/chart-wheel";
|
|
25
26
|
export const CHART_WHEEL_MIME_TYPE = "text/html;profile=mcp-app";
|
|
@@ -139,9 +140,18 @@ registerTool({
|
|
|
139
140
|
enum: ["placidus", "whole_sign", "equal", "koch", "regiomontanus", "campanus"],
|
|
140
141
|
description: "House system to use (default: placidus).",
|
|
141
142
|
},
|
|
143
|
+
bodies: {
|
|
144
|
+
type: "array",
|
|
145
|
+
items: { type: "string" },
|
|
146
|
+
description: "Optional list of body names to include. " +
|
|
147
|
+
"Defaults to 13 classical bodies (Sun through Pluto + Chiron + Nodes). " +
|
|
148
|
+
"Use 'all' as a single item to include every available body (Lilith, Ceres, Juno, Vesta, Pallas, Vertex, etc.). " +
|
|
149
|
+
"Example: ['sun','moon','lilith','vertex'].",
|
|
150
|
+
},
|
|
142
151
|
},
|
|
143
152
|
required: ["datetime", "latitude", "longitude"],
|
|
144
153
|
},
|
|
154
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
145
155
|
annotations: {
|
|
146
156
|
title: "Interactive Chart Wheel Explorer",
|
|
147
157
|
readOnlyHint: true,
|
|
@@ -162,6 +172,17 @@ registerTool({
|
|
|
162
172
|
const lat = args.latitude;
|
|
163
173
|
const lon = args.longitude;
|
|
164
174
|
const natalBody = buildNatalBody(datetime, lat, lon, houseSystem, args.timezone);
|
|
175
|
+
// Determine which bodies are requested
|
|
176
|
+
const requestedBodies = args.bodies;
|
|
177
|
+
const wantsAll = requestedBodies?.some(b => b.toLowerCase() === "all");
|
|
178
|
+
const wantsExtended = wantsAll || requestedBodies?.some(b => EXTENDED_BODIES.has(b.toLowerCase()));
|
|
179
|
+
// Enable extended body computation on the API side when needed
|
|
180
|
+
if (wantsExtended) {
|
|
181
|
+
const config = natalBody.configuration;
|
|
182
|
+
config.include_asteroids = true;
|
|
183
|
+
config.include_lilith = true;
|
|
184
|
+
config.include_chiron = true;
|
|
185
|
+
}
|
|
165
186
|
// Fetch natal chart JSON. The chart is rendered client-side in the UI iframe,
|
|
166
187
|
// so we do NOT call the /visualization/chart-wheel endpoint — that was the
|
|
167
188
|
// source of the ~90 second blocking delay.
|
|
@@ -175,14 +196,20 @@ registerTool({
|
|
|
175
196
|
latitude: lat ?? null,
|
|
176
197
|
longitude: lon ?? null,
|
|
177
198
|
location: args.location ?? null,
|
|
178
|
-
}, houseSystem);
|
|
199
|
+
}, houseSystem, undefined, requestedBodies);
|
|
179
200
|
const bundleAvailable = Boolean(getChartWheelBundle());
|
|
180
201
|
if (bundleAvailable) {
|
|
202
|
+
// MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
|
|
203
|
+
// delivered through resources/read — NOT as a content block. A URI-only
|
|
204
|
+
// `type: "resource"` block lacks the inline text/blob the base MCP schema
|
|
205
|
+
// requires and fails strict client validation. Keep `structuredContent`
|
|
206
|
+
// so non-rendering hosts still get the data, and a text summary so chat
|
|
207
|
+
// transcripts read coherently.
|
|
181
208
|
return {
|
|
182
209
|
content: [
|
|
183
210
|
{ type: "text", text: summary },
|
|
184
|
-
{ type: "text", text: JSON.stringify({ ...modelPayload, server_version: SERVER_VERSION }) },
|
|
185
211
|
],
|
|
212
|
+
structuredContent: { ...modelPayload, server_version: SERVER_VERSION },
|
|
186
213
|
_meta: {
|
|
187
214
|
"ui/resourceUri": CHART_WHEEL_RESOURCE_URI,
|
|
188
215
|
ui: { resourceUri: CHART_WHEEL_RESOURCE_URI },
|
|
@@ -216,6 +243,7 @@ registerTool({
|
|
|
216
243
|
},
|
|
217
244
|
required: ["planet", "longitude"],
|
|
218
245
|
},
|
|
246
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
219
247
|
annotations: { title: "Planet Interpretation", readOnlyHint: true, openWorldHint: false },
|
|
220
248
|
_meta: { ui: { resourceUri: CHART_WHEEL_RESOURCE_URI, visibility: ["app"] } },
|
|
221
249
|
handler: async (args) => {
|
|
@@ -250,6 +278,7 @@ registerTool({
|
|
|
250
278
|
},
|
|
251
279
|
required: ["house_number"],
|
|
252
280
|
},
|
|
281
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
253
282
|
annotations: { title: "House Interpretation", readOnlyHint: true, openWorldHint: false },
|
|
254
283
|
_meta: { ui: { resourceUri: CHART_WHEEL_RESOURCE_URI, visibility: ["app"] } },
|
|
255
284
|
handler: async (args) => {
|
|
@@ -285,6 +314,7 @@ registerTool({
|
|
|
285
314
|
},
|
|
286
315
|
required: ["planet1", "planet2"],
|
|
287
316
|
},
|
|
317
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
288
318
|
annotations: { title: "Aspect Interpretation", readOnlyHint: true, openWorldHint: false },
|
|
289
319
|
_meta: { ui: { resourceUri: CHART_WHEEL_RESOURCE_URI, visibility: ["app"] } },
|
|
290
320
|
handler: async (args) => {
|
|
@@ -333,6 +363,7 @@ registerTool({
|
|
|
333
363
|
},
|
|
334
364
|
required: ["datetime", "latitude", "longitude", "house_system"],
|
|
335
365
|
},
|
|
366
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
336
367
|
annotations: { title: "Recalculate Chart", readOnlyHint: true, openWorldHint: false },
|
|
337
368
|
_meta: { ui: { resourceUri: CHART_WHEEL_RESOURCE_URI, visibility: ["app"] } },
|
|
338
369
|
handler: async (args) => {
|
|
@@ -389,8 +420,33 @@ function zodSignFromLon(lon) {
|
|
|
389
420
|
const CLASSICAL_PLANETS = new Set([
|
|
390
421
|
"sun", "moon", "mercury", "venus", "mars",
|
|
391
422
|
"jupiter", "saturn", "uranus", "neptune", "pluto",
|
|
392
|
-
|
|
423
|
+
// Nodes use space form to match the renderer's glyph table (chart-renderer.ts).
|
|
424
|
+
"chiron", "north node", "south node", "true node", "asc", "mc",
|
|
425
|
+
]);
|
|
426
|
+
/**
|
|
427
|
+
* Canonicalise the Go backend's verbose node keys ("North Node (Mean)" /
|
|
428
|
+
* "North Node (True)") to the space-form names the renderer expects, keeping
|
|
429
|
+
* mean vs true DISTINCT. Without this the natal wheel dropped the nodes entirely
|
|
430
|
+
* (the raw key never matched the classical-planet set). All other body names are
|
|
431
|
+
* returned lowercased and otherwise unchanged.
|
|
432
|
+
*/
|
|
433
|
+
function canonicalizeBodyName(raw) {
|
|
434
|
+
const lower = raw.toLowerCase().trim();
|
|
435
|
+
if (lower === "north node (true)" || lower === "true node")
|
|
436
|
+
return "true node";
|
|
437
|
+
if (lower === "north node (mean)" || lower === "mean node" || lower === "north node")
|
|
438
|
+
return "north node";
|
|
439
|
+
if (lower === "south node (mean)" || lower === "south node (true)" || lower === "south node")
|
|
440
|
+
return "south node";
|
|
441
|
+
return lower;
|
|
442
|
+
}
|
|
443
|
+
const EXTENDED_BODIES = new Set([
|
|
444
|
+
"mean_lilith", "lilith", "true_lilith",
|
|
445
|
+
"ceres", "juno", "vesta", "pallas", "pholus",
|
|
446
|
+
"vertex", "part_of_fortune",
|
|
447
|
+
"eris", "sedna",
|
|
393
448
|
]);
|
|
449
|
+
const ALL_KNOWN_BODIES = new Set([...CLASSICAL_PLANETS, ...EXTENDED_BODIES]);
|
|
394
450
|
/**
|
|
395
451
|
* Build a lean, SVG-free payload for Claude's text context.
|
|
396
452
|
* Strips SVG, filters to classical planets only, and packages birth params.
|
|
@@ -422,7 +478,13 @@ function computeAspects(planets) {
|
|
|
422
478
|
diff = 360 - diff;
|
|
423
479
|
for (const asp of ASPECTS) {
|
|
424
480
|
const orb = Math.abs(diff - asp.angle);
|
|
425
|
-
|
|
481
|
+
// Tighten orbs when one or both bodies are extended (asteroids, etc.)
|
|
482
|
+
const p1Extended = EXTENDED_BODIES.has(p1.name.toLowerCase());
|
|
483
|
+
const p2Extended = EXTENDED_BODIES.has(p2.name.toLowerCase());
|
|
484
|
+
const effectiveOrb = (p1Extended || p2Extended)
|
|
485
|
+
? Math.min(asp.orb * 0.5, 3) // half standard, capped at 3°
|
|
486
|
+
: asp.orb;
|
|
487
|
+
if (orb <= effectiveOrb) {
|
|
426
488
|
// Applying: combined longitudinal speed closing the orb
|
|
427
489
|
const applying = ((p1.speed ?? 1) - (p2.speed ?? 0)) < 0 ? diff < asp.angle : diff > asp.angle;
|
|
428
490
|
results.push({
|
|
@@ -440,7 +502,7 @@ function computeAspects(planets) {
|
|
|
440
502
|
}
|
|
441
503
|
return results;
|
|
442
504
|
}
|
|
443
|
-
function buildModelPayload(chartData, birthParams, houseSystem, svgBase) {
|
|
505
|
+
function buildModelPayload(chartData, birthParams, houseSystem, svgBase, bodyFilter) {
|
|
444
506
|
// ── 1. Normalize planets: keyed object → typed PlanetData array ──────────
|
|
445
507
|
// The natal API returns { sun: { longitude, sign_name, is_retrograde, ... }, moon: {...} }.
|
|
446
508
|
// The chart wheel UI expects PlanetData[] with canonical field names.
|
|
@@ -448,9 +510,20 @@ function buildModelPayload(chartData, birthParams, houseSystem, svgBase) {
|
|
|
448
510
|
let planetsArray = [];
|
|
449
511
|
if (rawPlanets && typeof rawPlanets === "object" && !Array.isArray(rawPlanets)) {
|
|
450
512
|
planetsArray = Object.entries(rawPlanets)
|
|
451
|
-
.filter(([k]) =>
|
|
513
|
+
.filter(([k]) => {
|
|
514
|
+
const key = canonicalizeBodyName(k);
|
|
515
|
+
// If specific bodies were requested, use that filter
|
|
516
|
+
if (bodyFilter) {
|
|
517
|
+
const wantsAll = bodyFilter.some(b => b.toLowerCase() === "all");
|
|
518
|
+
if (wantsAll)
|
|
519
|
+
return ALL_KNOWN_BODIES.has(key);
|
|
520
|
+
return bodyFilter.some(b => canonicalizeBodyName(b) === key);
|
|
521
|
+
}
|
|
522
|
+
// Default: classical planets only
|
|
523
|
+
return CLASSICAL_PLANETS.has(key);
|
|
524
|
+
})
|
|
452
525
|
.map(([name, p]) => ({
|
|
453
|
-
name: name
|
|
526
|
+
name: canonicalizeBodyName(name),
|
|
454
527
|
longitude: (p.longitude ?? p.lon ?? 0),
|
|
455
528
|
latitude: (p.latitude ?? p.lat),
|
|
456
529
|
speed: (p.longitude_speed ?? p.speed),
|
|
@@ -1,19 +1,37 @@
|
|
|
1
1
|
import { registerTool, validateRequired } from "../index.js";
|
|
2
2
|
import { getActiveClient } from "../../backend/client.js";
|
|
3
|
+
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
3
4
|
registerTool({
|
|
4
5
|
name: "location_search",
|
|
5
|
-
description: "App-only tool for getting location autocomplete suggestions. Returns latitude, longitude, and IANA timezone.",
|
|
6
|
+
description: "App-only tool for getting location autocomplete suggestions. Returns display name, region (state/province), latitude, longitude, and IANA timezone. Optional bias params (country/region/near) improve ranking; a trailing \"City, ST\" qualifier in the query is also honored.",
|
|
6
7
|
inputSchema: {
|
|
7
8
|
type: "object",
|
|
8
9
|
properties: {
|
|
9
10
|
query: {
|
|
10
11
|
type: "string",
|
|
11
|
-
description: "Search query for location, e.g. 'Los Angeles'",
|
|
12
|
+
description: "Search query for location, e.g. 'Los Angeles' or 'Wakefield, MI'",
|
|
13
|
+
},
|
|
14
|
+
country: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: "Optional ISO 3166-1 alpha-2 country code (e.g. 'US') to bias ranking. Boost, not filter.",
|
|
17
|
+
},
|
|
18
|
+
region: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description: "Optional admin1 (state/province) qualifier to bias ranking — full name ('Michigan'), ASCII name, or code ('MI'). Boost, not filter.",
|
|
21
|
+
},
|
|
22
|
+
admin1: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "Alias for region (region wins if both supplied).",
|
|
25
|
+
},
|
|
26
|
+
near: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description: "Optional 'lat,lon' proximity hint (e.g. '37.77,-122.42') to bias ranking toward nearby places.",
|
|
12
29
|
},
|
|
13
30
|
},
|
|
14
31
|
required: ["query"],
|
|
15
32
|
additionalProperties: false,
|
|
16
33
|
},
|
|
34
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
17
35
|
_meta: {
|
|
18
36
|
ui: {
|
|
19
37
|
visibility: ["app"],
|
|
@@ -21,9 +39,26 @@ registerTool({
|
|
|
21
39
|
},
|
|
22
40
|
handler: async (args) => {
|
|
23
41
|
validateRequired(args, ["query"]);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
42
|
+
// NB: the endpoint param is `query` (only /geocode/search reads `q`).
|
|
43
|
+
const params = { query: args.query };
|
|
44
|
+
for (const k of ["country", "region", "admin1", "near"]) {
|
|
45
|
+
if (args[k] != null && String(args[k]).trim() !== "")
|
|
46
|
+
params[k] = String(args[k]);
|
|
47
|
+
}
|
|
48
|
+
const raw = (await getActiveClient().request("GET", "/location/autocomplete", { params }));
|
|
49
|
+
// Map the API's snake_case response to the camelCase shape the app UI consumes.
|
|
50
|
+
const list = Array.isArray(raw?.suggestions) ? raw.suggestions : [];
|
|
51
|
+
const suggestions = list.map((s) => ({
|
|
52
|
+
displayName: s.display_name,
|
|
53
|
+
shortName: s.short_name,
|
|
54
|
+
region: s.region,
|
|
55
|
+
countryCode: s.country_code,
|
|
56
|
+
placeId: s.place_id,
|
|
57
|
+
latitude: s.latitude,
|
|
58
|
+
longitude: s.longitude,
|
|
59
|
+
timezone: s.timezone,
|
|
60
|
+
}));
|
|
61
|
+
return { suggestions };
|
|
27
62
|
},
|
|
28
63
|
});
|
|
29
64
|
registerTool({
|
|
@@ -38,6 +73,7 @@ registerTool({
|
|
|
38
73
|
required: ["latitude", "longitude"],
|
|
39
74
|
additionalProperties: false,
|
|
40
75
|
},
|
|
76
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
41
77
|
_meta: {
|
|
42
78
|
ui: {
|
|
43
79
|
visibility: ["app"],
|
|
@@ -16,6 +16,7 @@ import path from "node:path";
|
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { registerTool, validateCoordinates, SERVER_VERSION } from "../index.js";
|
|
18
18
|
import { getActiveClient } from "../../backend/client.js";
|
|
19
|
+
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
19
20
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
20
21
|
export const MOON_PHASE_RESOURCE_URI = "ui://openephemeris/moon-phase";
|
|
21
22
|
export const MOON_PHASE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
@@ -54,15 +55,26 @@ export function clearMoonPhaseBundleCache() {
|
|
|
54
55
|
function buildMoonSummary(data) {
|
|
55
56
|
const phase = data.phase;
|
|
56
57
|
const voc = data.void_of_course;
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
const
|
|
58
|
+
const meta = (phase?.metadata ?? {});
|
|
59
|
+
const phaseName = String(phase?.phase_name ?? phase?.name ?? "Unknown").replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
|
60
|
+
const illumination = typeof phase?.illumination === "number" ? Math.round(phase.illumination) : "?";
|
|
61
|
+
const moonSign = String(meta?.moon_sign ?? phase?.moon_sign ?? phase?.sign ?? voc?.current_sign ?? "");
|
|
60
62
|
const isVoc = Boolean(voc?.is_void ?? voc?.is_void_of_course ?? false);
|
|
63
|
+
const speed = typeof meta?.longitude_speed === "number" ? meta.longitude_speed : null;
|
|
64
|
+
const daysToFull = typeof meta?.days_to_full === "number" ? meta.days_to_full :
|
|
65
|
+
typeof phase?.days_to_full === "number" ? phase.days_to_full : null;
|
|
61
66
|
let summary = "**Moon Phase** — " + phaseName + "\n\n" +
|
|
62
67
|
"Illumination: **" + illumination + "%**\n";
|
|
63
68
|
if (moonSign) {
|
|
64
69
|
summary += "Moon Sign: **" + moonSign + "**\n";
|
|
65
70
|
}
|
|
71
|
+
if (speed !== null) {
|
|
72
|
+
const tempo = speed > 14.5 ? "fast" : speed < 12.0 ? "slow" : "average";
|
|
73
|
+
summary += "Speed: **" + speed.toFixed(2) + "°/day** (" + tempo + ")\n";
|
|
74
|
+
}
|
|
75
|
+
if (daysToFull !== null) {
|
|
76
|
+
summary += "Full Moon in: **" + daysToFull.toFixed(1) + " days**\n";
|
|
77
|
+
}
|
|
66
78
|
if (isVoc) {
|
|
67
79
|
summary += "\n⚠️ **Void of Course** — The Moon is currently VOC.";
|
|
68
80
|
if (voc?.ends)
|
|
@@ -107,6 +119,7 @@ registerTool({
|
|
|
107
119
|
required: [],
|
|
108
120
|
additionalProperties: false,
|
|
109
121
|
},
|
|
122
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
110
123
|
annotations: {
|
|
111
124
|
title: "Interactive Moon Phase Explorer",
|
|
112
125
|
readOnlyHint: true,
|
|
@@ -129,38 +142,79 @@ registerTool({
|
|
|
129
142
|
params.latitude = args.latitude;
|
|
130
143
|
if (args.longitude != null)
|
|
131
144
|
params.longitude = args.longitude;
|
|
132
|
-
// Fetch phase and
|
|
133
|
-
const [phase, voc] = await Promise.allSettled([
|
|
145
|
+
// Fetch phase, VOC, and moon aspects in parallel
|
|
146
|
+
const [phase, voc, aspects] = await Promise.allSettled([
|
|
134
147
|
client.request("GET", "/ephemeris/moon/phase", { params }),
|
|
135
148
|
client.request("GET", "/ephemeris/moon/void-of-course", { params }),
|
|
149
|
+
client.request("GET", "/ephemeris/moon/aspects", { params }),
|
|
136
150
|
]);
|
|
137
151
|
const mergedData = {
|
|
138
152
|
phase: phase.status === "fulfilled" ? phase.value : { error: phase.reason?.message },
|
|
139
153
|
void_of_course: voc.status === "fulfilled" ? voc.value : { error: voc.reason?.message },
|
|
154
|
+
aspects: aspects.status === "fulfilled" ? aspects.value : null,
|
|
140
155
|
};
|
|
141
156
|
// Flatten for the UI — merge phase fields to top level
|
|
142
157
|
const phaseData = mergedData.phase;
|
|
143
158
|
const vocData = mergedData.void_of_course;
|
|
159
|
+
const aspectsData = mergedData.aspects;
|
|
160
|
+
// Extract enriched metadata fields from the GET response
|
|
161
|
+
const meta = (phaseData?.metadata ?? {});
|
|
162
|
+
const moonSign = String(meta?.moon_sign ?? phaseData?.moon_sign ?? phaseData?.sign ?? vocData?.current_sign ?? "");
|
|
163
|
+
const moonSpeedRaw = meta?.longitude_speed;
|
|
164
|
+
const moonSpeed = typeof moonSpeedRaw === "number" ? moonSpeedRaw : null;
|
|
165
|
+
const angularDiameter = typeof meta?.angular_diameter === "number" ? meta.angular_diameter : null;
|
|
166
|
+
const distanceKm = typeof meta?.moon_distance_km === "number" ? meta.moon_distance_km : null;
|
|
167
|
+
const moonLat = typeof meta?.moon_lat === "number" ? meta.moon_lat : null;
|
|
168
|
+
const ageDays = typeof meta?.age_days === "number" ? meta.age_days :
|
|
169
|
+
(typeof phaseData?.age_days === "number" ? phaseData.age_days : null);
|
|
170
|
+
// days_to_new / days_to_full can come as the union value or from the top-level field
|
|
171
|
+
const extractDays = (field) => {
|
|
172
|
+
if (typeof field === "number")
|
|
173
|
+
return field;
|
|
174
|
+
if (field && typeof field === "object") {
|
|
175
|
+
const v = Object.values(field)[0];
|
|
176
|
+
if (typeof v === "number")
|
|
177
|
+
return v;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
};
|
|
181
|
+
const daysToNew = extractDays(phaseData?.days_to_new);
|
|
182
|
+
const daysToFull = extractDays(phaseData?.days_to_full);
|
|
183
|
+
// Top 3 applying aspects by tightest orb
|
|
184
|
+
const rawAspects = (aspectsData?.aspects ?? aspectsData?.data ?? []);
|
|
185
|
+
const applyingAspects = Array.isArray(rawAspects)
|
|
186
|
+
? rawAspects
|
|
187
|
+
.filter((a) => a.applying === true || a.type === "applying")
|
|
188
|
+
.sort((a, b) => Math.abs(a.orb ?? 99) - Math.abs(b.orb ?? 99))
|
|
189
|
+
.slice(0, 3)
|
|
190
|
+
.map((a) => ({
|
|
191
|
+
planet: a.planet ?? a.body ?? a.planet_name ?? "",
|
|
192
|
+
aspect: a.aspect ?? a.aspect_name ?? a.type_name ?? "",
|
|
193
|
+
orb: typeof a.orb === "number" ? Math.abs(a.orb) : null,
|
|
194
|
+
}))
|
|
195
|
+
: [];
|
|
144
196
|
const uiPayload = {
|
|
145
|
-
phase_name: phaseData?.phase_name ?? phaseData?.name ?? "Unknown",
|
|
146
|
-
illumination: phaseData?.illumination
|
|
147
|
-
|
|
197
|
+
phase_name: String(phaseData?.phase_name ?? phaseData?.name ?? "Unknown").replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()),
|
|
198
|
+
illumination: typeof phaseData?.illumination === "number" ? phaseData.illumination / 100.0 : 0,
|
|
199
|
+
phase_angle: typeof phaseData?.phase_angle === "number" ? phaseData.phase_angle : null,
|
|
200
|
+
age_days: ageDays,
|
|
201
|
+
days_to_new: daysToNew,
|
|
202
|
+
days_to_full: daysToFull,
|
|
148
203
|
waxing: phaseData?.waxing ??
|
|
149
204
|
// Fallback chain: elongation < 180° = waxing, else age_days < 14.75 = waxing.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
? phaseData.age_days < 14.75
|
|
205
|
+
(typeof phaseData?.phase_angle === "number"
|
|
206
|
+
? phaseData.phase_angle < 180
|
|
207
|
+
: typeof ageDays === "number"
|
|
208
|
+
? ageDays < 14.75
|
|
155
209
|
: true), // safe default: assume waxing
|
|
156
|
-
moon_sign:
|
|
157
|
-
moon_longitude: phaseData?.moon_longitude ?? phaseData?.longitude ?? null,
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
210
|
+
moon_sign: moonSign,
|
|
211
|
+
moon_longitude: meta?.moon_lon ?? phaseData?.moon_longitude ?? phaseData?.longitude ?? null,
|
|
212
|
+
moon_latitude: moonLat,
|
|
213
|
+
moon_distance_km: distanceKm,
|
|
214
|
+
angular_diameter: angularDiameter,
|
|
215
|
+
moon_speed: moonSpeed,
|
|
216
|
+
sun_longitude: meta?.sun_lon ?? phaseData?.sun_longitude ?? null,
|
|
217
|
+
applying_aspects: applyingAspects,
|
|
164
218
|
voc_status: {
|
|
165
219
|
is_void: Boolean(vocData?.is_void ?? vocData?.is_void_of_course ?? false),
|
|
166
220
|
started: vocData?.started ?? vocData?.start ?? null,
|
|
@@ -173,11 +227,17 @@ registerTool({
|
|
|
173
227
|
const summary = buildMoonSummary(mergedData);
|
|
174
228
|
const bundleAvailable = Boolean(getMoonPhaseBundle());
|
|
175
229
|
if (bundleAvailable) {
|
|
230
|
+
// MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
|
|
231
|
+
// delivered through resources/read — NOT as a content block. An embedded
|
|
232
|
+
// `type: "resource"` block must carry inline text/blob per the base MCP
|
|
233
|
+
// schema, so a URI-only reference fails strict client validation; the
|
|
234
|
+
// _meta keys below are what the host uses to render the iframe.
|
|
235
|
+
// structuredContent carries the data payload for non-rendering hosts.
|
|
176
236
|
return {
|
|
177
237
|
content: [
|
|
178
238
|
{ type: "text", text: summary },
|
|
179
|
-
{ type: "text", text: JSON.stringify(uiPayload) },
|
|
180
239
|
],
|
|
240
|
+
structuredContent: uiPayload,
|
|
181
241
|
_meta: {
|
|
182
242
|
"ui/resourceUri": MOON_PHASE_RESOURCE_URI,
|
|
183
243
|
ui: { resourceUri: MOON_PHASE_RESOURCE_URI },
|