@openephemeris/mcp-server 3.23.1 → 4.0.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 +105 -0
- package/LICENSE +21 -21
- package/README.md +52 -3
- package/dist/analytics.js +37 -5
- package/dist/backend/client.d.ts +7 -0
- package/dist/backend/client.js +39 -38
- package/dist/index.js +64 -2
- package/dist/oauth/session-utils.d.ts +42 -18
- package/dist/oauth/session-utils.js +79 -0
- package/dist/server-sse.js +114 -14
- package/dist/tools/apps/bazi-app.js +12 -26
- package/dist/tools/apps/bi-wheel-app.js +2 -2
- package/dist/tools/apps/bodygraph-app.d.ts +5 -5
- package/dist/tools/apps/bodygraph-app.js +187 -225
- package/dist/tools/apps/chart-wheel-app.js +5 -3
- package/dist/tools/apps/location-tools.js +167 -18
- package/dist/tools/apps/moon-phase-app.js +10 -3
- package/dist/tools/apps/transit-timeline-app.js +6 -4
- package/dist/tools/apps/vedic-chart-app.js +15 -49
- package/dist/tools/datetime.d.ts +65 -0
- package/dist/tools/datetime.js +153 -0
- package/dist/tools/index.d.ts +45 -2
- package/dist/tools/index.js +78 -2
- package/dist/tools/specialized/account.d.ts +1 -0
- package/dist/tools/specialized/account.js +100 -0
- package/dist/tools/specialized/acg.js +16 -14
- package/dist/tools/specialized/bazi.d.ts +7 -1
- package/dist/tools/specialized/bazi.js +86 -19
- package/dist/tools/specialized/bi_wheel.js +5 -4
- package/dist/tools/specialized/chart_wheel.js +5 -8
- package/dist/tools/specialized/comparative.js +13 -5
- package/dist/tools/specialized/electional.js +7 -7
- package/dist/tools/specialized/ephemeris_core.js +13 -8
- package/dist/tools/specialized/ephemeris_extended.js +27 -17
- package/dist/tools/specialized/hd_bodygraph.js +8 -10
- package/dist/tools/specialized/hd_cycles.js +7 -14
- package/dist/tools/specialized/hd_group.js +20 -13
- package/dist/tools/specialized/human_design.js +11 -17
- package/dist/tools/specialized/moon.js +8 -2
- package/dist/tools/specialized/natal.js +7 -9
- package/dist/tools/specialized/progressed.js +12 -8
- package/dist/tools/specialized/relocation.js +9 -3
- package/dist/tools/specialized/returns.js +23 -11
- package/dist/tools/specialized/synastry.js +17 -6
- package/dist/tools/specialized/transits.js +9 -5
- package/dist/tools/specialized/vedic.js +5 -3
- package/dist/tools/specialized/venus_star_points.js +14 -9
- package/dist/ui/bazi.html +1063 -1049
- package/dist/ui/bi-wheel.html +4197 -4120
- package/dist/ui/bodygraph.html +3855 -3720
- package/dist/ui/chart-wheel.html +3779 -3706
- package/dist/ui/moon-phase.html +3228 -3145
- package/dist/ui/transit-timeline.html +199 -170
- package/dist/ui/vedic-chart.html +1116 -1098
- package/package.json +6 -3
- package/smithery.yaml +1 -1
|
@@ -127,7 +127,7 @@ registerTool({
|
|
|
127
127
|
},
|
|
128
128
|
latitude: {
|
|
129
129
|
type: "number",
|
|
130
|
-
description: "Birth latitude in decimal degrees (positive = North).",
|
|
130
|
+
description: "Birth latitude in decimal degrees (positive = North). Resolve from a place name with location_search; never recall coordinates from memory.",
|
|
131
131
|
},
|
|
132
132
|
longitude: {
|
|
133
133
|
type: "number",
|
|
@@ -135,7 +135,7 @@ registerTool({
|
|
|
135
135
|
},
|
|
136
136
|
location: {
|
|
137
137
|
type: "string",
|
|
138
|
-
description: "Location name for display only (e.g. 'New York, NY').",
|
|
138
|
+
description: "Location name for display only (e.g. 'New York, NY'). This is a caption, NOT a geocoder — it does not set the chart location. Supply latitude/longitude too (resolve them with location_search), or the chart is computed at 0N 0E.",
|
|
139
139
|
},
|
|
140
140
|
house_system: {
|
|
141
141
|
type: "string",
|
|
@@ -383,7 +383,9 @@ registerTool({
|
|
|
383
383
|
requestBody.configuration.target_datetime = { iso: args.target_datetime ?? datetime };
|
|
384
384
|
}
|
|
385
385
|
else if (chartType === "progressed") {
|
|
386
|
-
|
|
386
|
+
// openapi.json exposes /ephemeris/progressed — /predictive/progressed
|
|
387
|
+
// does not exist and 404'd every progressed recalculate in the iframe.
|
|
388
|
+
endpoint = "/ephemeris/progressed";
|
|
387
389
|
requestBody.configuration.target_datetime = { iso: args.target_datetime ?? datetime };
|
|
388
390
|
requestBody.configuration.method = "secondary";
|
|
389
391
|
}
|
|
@@ -1,9 +1,95 @@
|
|
|
1
1
|
import { registerTool, validateRequired } from "../index.js";
|
|
2
2
|
import { getActiveClient } from "../../backend/client.js";
|
|
3
3
|
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
4
|
+
/**
|
|
5
|
+
* The UTC offset of an IANA zone is a function of place AND date: 1987 US DST
|
|
6
|
+
* rules are not today's rules, and America/Chicago ran on CDT through the whole
|
|
7
|
+
* of January 1974. A bare zone name is therefore not enough to turn a local
|
|
8
|
+
* birth time into an instant — the caller needs the offset *on the birth date*.
|
|
9
|
+
*
|
|
10
|
+
* We resolve it here from Node's bundled IANA tzdata rather than by calling
|
|
11
|
+
* `/timezone/offset`, for two reasons:
|
|
12
|
+
* 1. Credits meter per URL path, so resolving N suggestions server-side would
|
|
13
|
+
* cost N extra credits per search. Local resolution costs nothing.
|
|
14
|
+
* 2. Node's ICU tzdata and the Go sidecar's tzdata agree on every historical
|
|
15
|
+
* discriminator we test (see test/location-tools.test.ts), so there is no
|
|
16
|
+
* accuracy argument for the round trip.
|
|
17
|
+
*
|
|
18
|
+
* The offset is evaluated at 12:00 **local** time on the given date. DST
|
|
19
|
+
* transitions happen around 02:00 local, so local noon is unambiguous for every
|
|
20
|
+
* real zone — this avoids both the "which side of the boundary" problem and the
|
|
21
|
+
* date-rollover problem that evaluating at 12:00 UTC would create for zones at
|
|
22
|
+
* extreme offsets (e.g. Pacific/Kiritimati at +14).
|
|
23
|
+
*/
|
|
24
|
+
function offsetAtLocalNoon(timezone, date) {
|
|
25
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(date).trim());
|
|
26
|
+
if (!m || !timezone)
|
|
27
|
+
return null;
|
|
28
|
+
const [year, month, day] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
29
|
+
try {
|
|
30
|
+
// Offset (in ms) that must be ADDED to a UTC instant to get local wall time.
|
|
31
|
+
const offsetMsAt = (utcMs) => {
|
|
32
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
33
|
+
timeZone: timezone,
|
|
34
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
35
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
36
|
+
hour12: false,
|
|
37
|
+
}).formatToParts(new Date(utcMs));
|
|
38
|
+
const get = (t) => Number(parts.find((p) => p.type === t)?.value ?? 0);
|
|
39
|
+
const wallMs = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
|
|
40
|
+
return wallMs - utcMs;
|
|
41
|
+
};
|
|
42
|
+
// Find the UTC instant corresponding to 12:00 local on `date`. Two passes
|
|
43
|
+
// settle the fixed point (the first guess can land on the wrong side of a
|
|
44
|
+
// transition; noon never sits close enough to one for a third to matter).
|
|
45
|
+
const wantWallMs = Date.UTC(year, month - 1, day, 12, 0, 0);
|
|
46
|
+
let utcMs = wantWallMs - offsetMsAt(wantWallMs);
|
|
47
|
+
utcMs = wantWallMs - offsetMsAt(utcMs);
|
|
48
|
+
const offsetMinutes = offsetMsAt(utcMs) / 60000;
|
|
49
|
+
if (!Number.isFinite(offsetMinutes))
|
|
50
|
+
return null;
|
|
51
|
+
// DST flag. The long zone name is tzdata's own answer and is the only
|
|
52
|
+
// reliable source: comparing against a January/July baseline gets 1974
|
|
53
|
+
// wrong, because the US observed DST for the WHOLE of that year, so
|
|
54
|
+
// both bracket months are daylight time and the comparison reports
|
|
55
|
+
// "not DST" for a date that genuinely was. Locale is pinned to en-US so
|
|
56
|
+
// the wording is stable.
|
|
57
|
+
let isDst;
|
|
58
|
+
const longName = new Intl.DateTimeFormat("en-US", { timeZone: timezone, timeZoneName: "long" })
|
|
59
|
+
.formatToParts(new Date(utcMs))
|
|
60
|
+
.find((p) => p.type === "timeZoneName")?.value ?? "";
|
|
61
|
+
if (/daylight|summer/i.test(longName)) {
|
|
62
|
+
isDst = true;
|
|
63
|
+
}
|
|
64
|
+
else if (/standard/i.test(longName)) {
|
|
65
|
+
isDst = false;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// Zone has no localized name (Intl fell back to "GMT+11"). Bracket the
|
|
69
|
+
// year instead: the smaller of the January and July offsets is standard
|
|
70
|
+
// time in either hemisphere.
|
|
71
|
+
const janOffset = offsetMsAt(Date.UTC(year, 0, 15, 12, 0, 0)) / 60000;
|
|
72
|
+
const julOffset = offsetMsAt(Date.UTC(year, 6, 15, 12, 0, 0)) / 60000;
|
|
73
|
+
isDst = offsetMinutes > Math.min(janOffset, julOffset);
|
|
74
|
+
}
|
|
75
|
+
const sign = offsetMinutes < 0 ? "-" : "+";
|
|
76
|
+
const abs = Math.abs(offsetMinutes);
|
|
77
|
+
const hh = String(Math.floor(abs / 60)).padStart(2, "0");
|
|
78
|
+
const mm = String(Math.round(abs % 60)).padStart(2, "0");
|
|
79
|
+
return {
|
|
80
|
+
utcOffsetAtDate: `${sign}${hh}:${mm}`,
|
|
81
|
+
utcOffsetMinutes: offsetMinutes,
|
|
82
|
+
isDst,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Unknown/invalid zone — the caller still gets the IANA name back.
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
4
90
|
registerTool({
|
|
5
91
|
name: "location_search",
|
|
6
|
-
description: "
|
|
92
|
+
description: "Resolve a place name to coordinates and IANA timezone — use this first whenever a user gives a birth city rather than latitude/longitude. NEVER recall coordinates from memory; always resolve them here. CREDIT COST: 1 credit per call. Returns display name, region (state/province), latitude, longitude, and IANA timezone. Pass the birth date as `date` to also get `utcOffsetAtDate`, the historically-correct UTC offset for that place on that date (1987 DST rules differ from today's) — this costs no extra credits. When the result is `ambiguous` (several places share the name, e.g. \"portland\"), ASK the user which one they mean rather than assuming the first. Optional bias params (country/region/near) improve ranking; a trailing \"City, ST\" qualifier in the query is also honored.",
|
|
7
93
|
inputSchema: {
|
|
8
94
|
type: "object",
|
|
9
95
|
properties: {
|
|
@@ -27,14 +113,22 @@ registerTool({
|
|
|
27
113
|
type: "string",
|
|
28
114
|
description: "Optional 'lat,lon' proximity hint (e.g. '37.77,-122.42') to bias ranking toward nearby places.",
|
|
29
115
|
},
|
|
116
|
+
date: {
|
|
117
|
+
type: "string",
|
|
118
|
+
description: "Optional birth/event date as 'YYYY-MM-DD'. When given, each suggestion also carries utcOffsetAtDate / utcOffsetMinutes / isDst — the UTC offset that actually applied at that place on that date, using historical DST rules. Free: adds no API call and no credits.",
|
|
119
|
+
},
|
|
30
120
|
},
|
|
31
121
|
required: ["query"],
|
|
32
122
|
additionalProperties: false,
|
|
33
123
|
},
|
|
34
124
|
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
125
|
+
annotations: { title: "Location Search", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
126
|
+
// Model-visible as well as app-visible: without this the model had no
|
|
127
|
+
// geocoding tool and every prompt had to route through
|
|
128
|
+
// `dev_read_api /location/autocomplete` instead.
|
|
35
129
|
_meta: {
|
|
36
130
|
ui: {
|
|
37
|
-
visibility: ["app"],
|
|
131
|
+
visibility: ["model", "app"],
|
|
38
132
|
},
|
|
39
133
|
},
|
|
40
134
|
handler: async (args) => {
|
|
@@ -48,41 +142,96 @@ registerTool({
|
|
|
48
142
|
const raw = (await getActiveClient().request("GET", "/location/autocomplete", { params }));
|
|
49
143
|
// Map the API's snake_case response to the camelCase shape the app UI consumes.
|
|
50
144
|
const list = Array.isArray(raw?.suggestions) ? raw.suggestions : [];
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
145
|
+
const date = args.date != null && String(args.date).trim() !== "" ? String(args.date).trim() : null;
|
|
146
|
+
const suggestions = list.map((s) => {
|
|
147
|
+
const mapped = {
|
|
148
|
+
displayName: s.display_name,
|
|
149
|
+
shortName: s.short_name,
|
|
150
|
+
region: s.region,
|
|
151
|
+
countryCode: s.country_code,
|
|
152
|
+
placeId: s.place_id,
|
|
153
|
+
latitude: s.latitude,
|
|
154
|
+
longitude: s.longitude,
|
|
155
|
+
timezone: s.timezone,
|
|
156
|
+
};
|
|
157
|
+
if (date && s.timezone)
|
|
158
|
+
Object.assign(mapped, offsetAtLocalNoon(s.timezone, date) ?? {});
|
|
159
|
+
return mapped;
|
|
160
|
+
});
|
|
161
|
+
// Ambiguity signal. "portland" really does match Oregon, Maine, Texas,
|
|
162
|
+
// England, ... — an agent that silently takes suggestions[0] produces a
|
|
163
|
+
// chart nothing downstream can detect as wrong.
|
|
164
|
+
//
|
|
165
|
+
// What matters is *rivals for the top hit*, not the row count. The
|
|
166
|
+
// endpoint pads toward its limit of 8, so "dallas texas" comes back with
|
|
167
|
+
// eight rows (Dallas GA, Dallas OR, ...) even though the query is already
|
|
168
|
+
// unambiguous — counting rows would flag essentially every search.
|
|
169
|
+
const norm = (v) => String(v ?? "").trim().toLowerCase();
|
|
170
|
+
const top = suggestions[0];
|
|
171
|
+
const rivals = top ? suggestions.filter((s) => norm(s.shortName) === norm(top.shortName)) : [];
|
|
172
|
+
// If the caller already pinned the place — "dallas texas", "portland uk" —
|
|
173
|
+
// the top hit is what they asked for and there is nothing to ask about.
|
|
174
|
+
const q = norm(args.query);
|
|
175
|
+
const qTokens = new Set(q.split(/[^a-z0-9]+/).filter(Boolean));
|
|
176
|
+
const qualified = !!top &&
|
|
177
|
+
((norm(top.region) !== "" && q.includes(norm(top.region))) ||
|
|
178
|
+
(norm(top.countryCode) !== "" && qTokens.has(norm(top.countryCode))));
|
|
179
|
+
const ambiguous = rivals.length > 1 && !qualified;
|
|
180
|
+
return {
|
|
181
|
+
suggestions,
|
|
182
|
+
matchCount: suggestions.length,
|
|
183
|
+
ambiguous,
|
|
184
|
+
...(ambiguous
|
|
185
|
+
? {
|
|
186
|
+
disambiguationHint: `"${args.query}" matches ${rivals.length} places of that name (${rivals
|
|
187
|
+
.slice(0, 4)
|
|
188
|
+
.map((s) => s.region ?? s.countryCode)
|
|
189
|
+
.filter(Boolean)
|
|
190
|
+
.join(", ")}${rivals.length > 4 ? ", ..." : ""}). Confirm which one the user means before building a chart — do not assume the first result.`,
|
|
191
|
+
}
|
|
192
|
+
: {}),
|
|
193
|
+
};
|
|
62
194
|
},
|
|
63
195
|
});
|
|
64
196
|
registerTool({
|
|
65
197
|
name: "timezone_resolve",
|
|
66
|
-
description: "
|
|
198
|
+
description: "Resolve the IANA timezone for a latitude/longitude pair. Use when you have coordinates but need the timezone to interpret a local birth time. Pass `date` to also get the historically-correct UTC offset that applied on that date. CREDIT COST: 1 credit per call. If you are starting from a place name rather than coordinates, use location_search instead — it returns the timezone too, in the same single call.",
|
|
67
199
|
inputSchema: {
|
|
68
200
|
type: "object",
|
|
69
201
|
properties: {
|
|
70
202
|
latitude: { type: "number" },
|
|
71
203
|
longitude: { type: "number" },
|
|
204
|
+
date: {
|
|
205
|
+
type: "string",
|
|
206
|
+
description: "Optional birth/event date as 'YYYY-MM-DD'. When given, the result also carries utcOffsetAtDate / utcOffsetMinutes / isDst for that date under that zone's historical DST rules. Free: adds no API call and no credits.",
|
|
207
|
+
},
|
|
72
208
|
},
|
|
73
209
|
required: ["latitude", "longitude"],
|
|
74
210
|
additionalProperties: false,
|
|
75
211
|
},
|
|
76
212
|
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
213
|
+
annotations: { title: "Timezone Resolve", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
77
214
|
_meta: {
|
|
78
215
|
ui: {
|
|
79
|
-
visibility: ["app"],
|
|
216
|
+
visibility: ["model", "app"],
|
|
80
217
|
},
|
|
81
218
|
},
|
|
82
219
|
handler: async (args) => {
|
|
83
220
|
validateRequired(args, ["latitude", "longitude"]);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
221
|
+
// /timezone/lookup is registered POST-only (main.go via api.gen.go) and
|
|
222
|
+
// takes latitude/longitude in the BODY. This tool used to issue
|
|
223
|
+
// `GET /timezone/lookup?lat=&lon=`, which the API answered with 405 —
|
|
224
|
+
// i.e. the tool failed 100% of the time in production.
|
|
225
|
+
const result = (await getActiveClient().request("POST", "/timezone/lookup", {
|
|
226
|
+
data: { latitude: args.latitude, longitude: args.longitude },
|
|
227
|
+
}));
|
|
228
|
+
const date = args.date != null && String(args.date).trim() !== "" ? String(args.date).trim() : null;
|
|
229
|
+
const tz = typeof result?.timezone === "string" ? result.timezone : null;
|
|
230
|
+
if (date && tz) {
|
|
231
|
+
const offset = offsetAtLocalNoon(tz, date);
|
|
232
|
+
if (offset)
|
|
233
|
+
return { ...result, ...offset, date };
|
|
234
|
+
}
|
|
235
|
+
return result;
|
|
87
236
|
},
|
|
88
237
|
});
|
|
@@ -17,6 +17,7 @@ import { fileURLToPath } from "node:url";
|
|
|
17
17
|
import { registerTool, validateCoordinates, SERVER_VERSION } from "../index.js";
|
|
18
18
|
import { getActiveClient } from "../../backend/client.js";
|
|
19
19
|
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
20
|
+
import { TIMEZONE_PROPERTY, localToUtcIso } from "../datetime.js";
|
|
20
21
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
21
22
|
export const MOON_PHASE_RESOURCE_URI = "ui://openephemeris/moon-phase";
|
|
22
23
|
export const MOON_PHASE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
@@ -96,7 +97,9 @@ async function computeMoonData(args) {
|
|
|
96
97
|
validateCoordinates(args, "latitude", "longitude");
|
|
97
98
|
const client = getActiveClient();
|
|
98
99
|
const params = {};
|
|
99
|
-
params.datetime = args.datetime
|
|
100
|
+
params.datetime = args.datetime
|
|
101
|
+
? localToUtcIso("datetime", String(args.datetime), args.timezone)
|
|
102
|
+
: new Date().toISOString();
|
|
100
103
|
if (args.latitude != null)
|
|
101
104
|
params.latitude = args.latitude;
|
|
102
105
|
if (args.longitude != null)
|
|
@@ -206,8 +209,10 @@ registerTool({
|
|
|
206
209
|
properties: {
|
|
207
210
|
datetime: {
|
|
208
211
|
type: "string",
|
|
209
|
-
description: "ISO 8601 datetime to query
|
|
212
|
+
description: "ISO 8601 datetime to query, stating its zone ('2026-03-20T12:00:00Z'), or a local " +
|
|
213
|
+
"time together with timezone. If omitted, returns the current live moon phase (UTC now).",
|
|
210
214
|
},
|
|
215
|
+
timezone: TIMEZONE_PROPERTY,
|
|
211
216
|
latitude: {
|
|
212
217
|
type: "number",
|
|
213
218
|
description: "Observer latitude (optional, used for local void-of-course calculations).",
|
|
@@ -272,8 +277,10 @@ registerTool({
|
|
|
272
277
|
properties: {
|
|
273
278
|
datetime: {
|
|
274
279
|
type: "string",
|
|
275
|
-
description: "ISO 8601 datetime to query
|
|
280
|
+
description: "ISO 8601 datetime to query, stating its zone ('2026-03-20T12:00:00Z'), or a local " +
|
|
281
|
+
"time together with timezone. If omitted, uses the current live moon phase (UTC now).",
|
|
276
282
|
},
|
|
283
|
+
timezone: TIMEZONE_PROPERTY,
|
|
277
284
|
latitude: {
|
|
278
285
|
type: "number",
|
|
279
286
|
description: "Observer latitude (optional, used for local void-of-course calculations).",
|
|
@@ -18,6 +18,7 @@ import { fileURLToPath } from "node:url";
|
|
|
18
18
|
import { registerTool, validateRequired, SERVER_VERSION } from "../index.js";
|
|
19
19
|
import { getActiveClient } from "../../backend/client.js";
|
|
20
20
|
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
21
|
+
import { DATETIME_DESC, WINDOW_DATE_DESC, timezoneProperty } from "../datetime.js";
|
|
21
22
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
22
23
|
export const TRANSIT_TIMELINE_RESOURCE_URI = "ui://openephemeris/transit-timeline";
|
|
23
24
|
export const TRANSIT_TIMELINE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
@@ -109,11 +110,12 @@ registerTool({
|
|
|
109
110
|
inputSchema: {
|
|
110
111
|
type: "object",
|
|
111
112
|
properties: {
|
|
112
|
-
natal_datetime: { type: "string", description:
|
|
113
|
-
|
|
113
|
+
natal_datetime: { type: "string", description: DATETIME_DESC },
|
|
114
|
+
natal_timezone: timezoneProperty("the natal birth location"),
|
|
115
|
+
natal_latitude: { type: "number", description: "Latitude of birth location in decimal degrees. Resolve from a place name with location_search; never recall coordinates from memory." },
|
|
114
116
|
natal_longitude: { type: "number", description: "Longitude of birth location in decimal degrees." },
|
|
115
|
-
start_date: { type: "string", description: "Start of the transit search window
|
|
116
|
-
end_date: { type: "string", description: "End of the transit search window
|
|
117
|
+
start_date: { type: "string", description: "Start of the transit search window. " + WINDOW_DATE_DESC },
|
|
118
|
+
end_date: { type: "string", description: "End of the transit search window. " + WINDOW_DATE_DESC },
|
|
117
119
|
transiting_planets: {
|
|
118
120
|
type: "array", items: { type: "string" },
|
|
119
121
|
description: "Transiting planet IDs to search, e.g. ['saturn','jupiter','uranus','pluto','chiron']. Omit to search all outer planets.",
|
|
@@ -23,6 +23,7 @@ import { fileURLToPath } from "node:url";
|
|
|
23
23
|
import { registerTool, validateRequired, validateCoordinates, SERVER_VERSION } from "../index.js";
|
|
24
24
|
import { getActiveClient } from "../../backend/client.js";
|
|
25
25
|
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
26
|
+
import { localToUtcIso } from "../datetime.js";
|
|
26
27
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
27
28
|
export const VEDIC_CHART_RESOURCE_URI = "ui://openephemeris/vedic-chart";
|
|
28
29
|
export const VEDIC_CHART_MIME_TYPE = "text/html;profile=mcp-app";
|
|
@@ -57,50 +58,6 @@ export function getVedicChartBundle() {
|
|
|
57
58
|
export function clearVedicChartBundleCache() {
|
|
58
59
|
cachedBundle = null;
|
|
59
60
|
}
|
|
60
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
61
|
-
/** Ensure datetime has a UTC offset for Go time.Time parsing. */
|
|
62
|
-
function ensureTimezone(dt) {
|
|
63
|
-
if (!dt)
|
|
64
|
-
return dt;
|
|
65
|
-
if (/[Zz]$/.test(dt) || /[+-]\d{2}:\d{2}$/.test(dt))
|
|
66
|
-
return dt;
|
|
67
|
-
return dt + "Z";
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Convert a local datetime string (no offset) to a UTC ISO 8601 string using
|
|
71
|
-
* the IANA timezone. Mirrors bodygraph-app.ts's localToUtcIso.
|
|
72
|
-
*/
|
|
73
|
-
function localToUtcIso(dt, tz) {
|
|
74
|
-
if (!dt || /[Zz]$/.test(dt) || /[+-]\d{2}:\d{2}$/.test(dt))
|
|
75
|
-
return ensureTimezone(dt);
|
|
76
|
-
if (!tz)
|
|
77
|
-
return dt + "Z";
|
|
78
|
-
try {
|
|
79
|
-
const [datePart, timePart = "00:00:00"] = dt.split("T");
|
|
80
|
-
const [year, month, day] = datePart.split("-").map(Number);
|
|
81
|
-
const [hour, min, sec = 0] = timePart.split(":").map(Number);
|
|
82
|
-
const candidateUtcMs = Date.UTC(year, month - 1, day, hour, min, sec);
|
|
83
|
-
const getOffsetMs = (utcMs) => {
|
|
84
|
-
const fmtParts = new Intl.DateTimeFormat("en-US", {
|
|
85
|
-
timeZone: tz,
|
|
86
|
-
year: "numeric", month: "2-digit", day: "2-digit",
|
|
87
|
-
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
88
|
-
hour12: false,
|
|
89
|
-
}).formatToParts(new Date(utcMs));
|
|
90
|
-
const get = (t) => Number(fmtParts.find((p) => p.type === t)?.value ?? 0);
|
|
91
|
-
const localizedUtcMs = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
|
|
92
|
-
return utcMs - localizedUtcMs;
|
|
93
|
-
};
|
|
94
|
-
const offsetMs1 = getOffsetMs(candidateUtcMs);
|
|
95
|
-
const correctedUtcMs1 = candidateUtcMs + offsetMs1;
|
|
96
|
-
const offsetMs2 = getOffsetMs(correctedUtcMs1);
|
|
97
|
-
const finalUtcMs = candidateUtcMs + offsetMs2;
|
|
98
|
-
return new Date(finalUtcMs).toISOString();
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
return dt + "Z";
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
61
|
/**
|
|
105
62
|
* Build the compact iframe model from the raw Go /vedic/chart response.
|
|
106
63
|
* The Go response nests ayanamsha/lagna under `metadata` (radians→degrees
|
|
@@ -155,7 +112,7 @@ registerTool({
|
|
|
155
112
|
},
|
|
156
113
|
latitude: {
|
|
157
114
|
type: "number",
|
|
158
|
-
description: "Birth latitude in decimal degrees (positive = North).",
|
|
115
|
+
description: "Birth latitude in decimal degrees (positive = North). Resolve from a place name with location_search; never recall coordinates from memory.",
|
|
159
116
|
},
|
|
160
117
|
longitude: {
|
|
161
118
|
type: "number",
|
|
@@ -163,7 +120,7 @@ registerTool({
|
|
|
163
120
|
},
|
|
164
121
|
location: {
|
|
165
122
|
type: "string",
|
|
166
|
-
description: "Location name for display only (e.g. 'Mumbai, India').",
|
|
123
|
+
description: "Location name for display only (e.g. 'Mumbai, India'). This is a caption, NOT a geocoder — it does not set the chart location. Supply latitude/longitude too (resolve them with location_search), or the chart is computed at 0N 0E.",
|
|
167
124
|
},
|
|
168
125
|
timezone: {
|
|
169
126
|
type: "string",
|
|
@@ -197,7 +154,7 @@ registerTool({
|
|
|
197
154
|
validateCoordinates(args, "latitude", "longitude");
|
|
198
155
|
const client = getActiveClient();
|
|
199
156
|
const timezone = args.timezone;
|
|
200
|
-
const datetime = localToUtcIso(String(args.datetime), timezone);
|
|
157
|
+
const datetime = localToUtcIso("datetime", String(args.datetime), timezone);
|
|
201
158
|
const lat = Number(args.latitude);
|
|
202
159
|
const lon = Number(args.longitude);
|
|
203
160
|
const ayanamsa = args.ayanamsa;
|
|
@@ -250,7 +207,16 @@ registerTool({
|
|
|
250
207
|
inputSchema: {
|
|
251
208
|
type: "object",
|
|
252
209
|
properties: {
|
|
253
|
-
datetime: {
|
|
210
|
+
datetime: {
|
|
211
|
+
type: "string",
|
|
212
|
+
description: "ISO 8601 birth datetime stating its zone, or a local time together with timezone. " +
|
|
213
|
+
"Must match the value used by explore_vedic_chart or the re-render will show a different chart.",
|
|
214
|
+
},
|
|
215
|
+
timezone: {
|
|
216
|
+
type: "string",
|
|
217
|
+
description: "IANA timezone name for the birth location (e.g. 'Asia/Kolkata'). Required when datetime " +
|
|
218
|
+
"has no 'Z' or ±HH:MM offset — the same value passed to explore_vedic_chart.",
|
|
219
|
+
},
|
|
254
220
|
latitude: { type: "number" },
|
|
255
221
|
longitude: { type: "number" },
|
|
256
222
|
ayanamsa: { type: "string" },
|
|
@@ -267,7 +233,7 @@ registerTool({
|
|
|
267
233
|
_meta: { ui: { resourceUri: VEDIC_CHART_RESOURCE_URI, visibility: ["app"] } },
|
|
268
234
|
handler: async (args) => {
|
|
269
235
|
const client = getActiveClient();
|
|
270
|
-
const datetime =
|
|
236
|
+
const datetime = localToUtcIso("datetime", String(args.datetime), args.timezone);
|
|
271
237
|
const lat = args.latitude != null ? Number(args.latitude) : undefined;
|
|
272
238
|
const lon = args.longitude != null ? Number(args.longitude) : undefined;
|
|
273
239
|
const ayanamsa = args.ayanamsa;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The datetime contract, stated once for the whole tool surface.
|
|
3
|
+
*
|
|
4
|
+
* A datetime that names a clock time but not a zone does not name an instant.
|
|
5
|
+
* The API used to read such a value as UTC, so a birth time of 09:01 in Chicago
|
|
6
|
+
* was computed as 09:01 UTC — five hours early. Every planet keeps its sign at
|
|
7
|
+
* that error scale, so the answer looked right while the Ascendant was two signs
|
|
8
|
+
* off and every house placement was wrong.
|
|
9
|
+
*
|
|
10
|
+
* The rules, identical here and in the Go engine
|
|
11
|
+
* (apps/api/go-sidecar/internal/api/handlers/datetime_contract.go):
|
|
12
|
+
*
|
|
13
|
+
* 1. A datetime WITH a clock time must carry its zone — either a `Z`/`±HH:MM`
|
|
14
|
+
* suffix on the string, or a companion `timezone` argument.
|
|
15
|
+
* 2. A date-only value ("1987-07-15") has no clock time to be ambiguous about
|
|
16
|
+
* and resolves to 12:00 UTC.
|
|
17
|
+
* 3. Nothing in this layer ever appends a `Z` to a zone-less value. Doing so
|
|
18
|
+
* asserts a fact the caller never stated.
|
|
19
|
+
*
|
|
20
|
+
* Violations are rejected here rather than at the API so the model gets the
|
|
21
|
+
* correction immediately and no credit is spent on a chart that is wrong.
|
|
22
|
+
*/
|
|
23
|
+
/** True when `value` states a clock time but not the zone that clock is in. */
|
|
24
|
+
export declare function isNaiveClockTime(value: string): boolean;
|
|
25
|
+
/** True when `value` already carries its own zone. */
|
|
26
|
+
export declare function hasZoneSuffix(value: string): boolean;
|
|
27
|
+
/** The canonical description for a birth / chart-moment datetime parameter. */
|
|
28
|
+
export declare const DATETIME_DESC: string;
|
|
29
|
+
/** The canonical description for the companion `timezone` parameter. */
|
|
30
|
+
export declare const TIMEZONE_DESC: string;
|
|
31
|
+
/** The canonical description for a search-window date parameter (date or datetime). */
|
|
32
|
+
export declare const WINDOW_DATE_DESC: string;
|
|
33
|
+
/** The `timezone` property to spread into a tool's inputSchema. */
|
|
34
|
+
export declare const TIMEZONE_PROPERTY: {
|
|
35
|
+
readonly type: "string";
|
|
36
|
+
readonly description: string;
|
|
37
|
+
};
|
|
38
|
+
/** Build a `<prefix>_timezone` property with a tailored example. */
|
|
39
|
+
export declare function timezoneProperty(label: string, example?: string): {
|
|
40
|
+
readonly type: "string";
|
|
41
|
+
readonly description: string;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The rejection message. Names both remedies, rewriting the caller's own value
|
|
45
|
+
* into each — the commonest failure is not realising the value was ambiguous.
|
|
46
|
+
*/
|
|
47
|
+
export declare function ambiguousDatetimeMessage(field: string, value: string, timezoneField?: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Throws unless `value` names an unambiguous instant.
|
|
50
|
+
*
|
|
51
|
+
* Call this in every tool handler before building a request body. Passing a
|
|
52
|
+
* `timezone` satisfies the contract for a zone-less value; the zone itself is
|
|
53
|
+
* resolved server-side (or by `localToUtcIso` for the `*_utc` endpoints).
|
|
54
|
+
*/
|
|
55
|
+
export declare function assertZonedDatetime(field: string, value: unknown, timezone?: unknown, timezoneField?: string): void;
|
|
56
|
+
/**
|
|
57
|
+
* Convert a datetime to a UTC ISO 8601 string for the endpoints whose field is
|
|
58
|
+
* named `*_utc` and whose Go type is a strict RFC 3339 `time.Time`.
|
|
59
|
+
*
|
|
60
|
+
* Unlike the four ad-hoc `ensureTimezone` helpers this replaces, it never
|
|
61
|
+
* appends a bare "Z" to a zone-less value — it throws instead. Appending Z
|
|
62
|
+
* asserts the input was UTC, which is exactly the silent assumption that made
|
|
63
|
+
* the original defect invisible.
|
|
64
|
+
*/
|
|
65
|
+
export declare function localToUtcIso(field: string, dt: string, tz?: string, timezoneField?: string): string;
|