@openephemeris/mcp-server 4.5.0 → 4.7.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 +104 -0
- package/dist/prompts.js +3 -2
- package/dist/tools/apps/bodygraph-app.js +56 -24
- package/dist/tools/apps/location-tools.js +67 -5
- package/dist/tools/apps/moon-phase-app.js +3 -2
- package/dist/tools/apps/transit-timeline-app.js +7 -2
- package/dist/tools/apps/vedic-chart-app.js +3 -3
- package/dist/tools/datetime-historical.d.ts +19 -0
- package/dist/tools/datetime-historical.js +77 -0
- package/dist/tools/specialized/acg.js +4 -3
- package/dist/tools/specialized/eclipse.js +6 -4
- package/dist/tools/specialized/ephemeris_extended.js +12 -4
- package/dist/tools/specialized/hd_bodygraph.js +12 -3
- package/dist/tools/specialized/hd_cycles.js +19 -3
- package/dist/tools/specialized/hd_group.js +31 -6
- package/dist/tools/specialized/human_design.js +3 -2
- package/dist/tools/specialized/moon.js +3 -2
- package/dist/tools/specialized/vedic.js +3 -2
- package/dist/ui/bazi.html +30 -30
- package/dist/ui/bi-wheel.html +35 -35
- package/dist/ui/bodygraph.html +39 -39
- package/dist/ui/chart-wheel.html +37 -37
- package/dist/ui/moon-phase.html +32 -32
- package/dist/ui/transit-timeline.html +30 -30
- package/dist/ui/vedic-chart.html +35 -35
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,110 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [4.7.0] — 2026-08-02
|
|
11
|
+
|
|
12
|
+
Historically correct birth-time conversion. IANA timezone databases — including
|
|
13
|
+
the one inside every JavaScript runtime — are only authoritative from 1970.
|
|
14
|
+
Before the US Uniform Time Act took effect in 1967, daylight saving was state
|
|
15
|
+
and municipal law, and a zone like `America/Chicago` models Chicago alone. A
|
|
16
|
+
1961 Minnesota birth converted with standard timezone math lands an hour off,
|
|
17
|
+
which flips the Ascendant sign and the Human Design design-Moon gate.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **Pre-1970 births now resolve through the API's historical correction
|
|
22
|
+
overlay.** Any tool given a naive local birth time with a timezone and
|
|
23
|
+
coordinates — natal, Human Design (chart, composite, penta, cycles, groups),
|
|
24
|
+
Vedic, BaZi apps, ACG, moon tools, and the embedded chart apps — routes the
|
|
25
|
+
local→UTC conversion through the API's `/timezone/offset` `datetime_local`
|
|
26
|
+
mode, which applies primary-source-cited state and local law (with a
|
|
27
|
+
`tz_confidence` grade and rule citation) instead of assuming the reference
|
|
28
|
+
city's rules. Post-1970 conversions stay on the local, zero-latency path,
|
|
29
|
+
which is exactly as authoritative as the server. If the server is
|
|
30
|
+
unreachable, tools fall back to the previous local conversion rather than
|
|
31
|
+
failing the chart call.
|
|
32
|
+
- **`timezone_resolve`** consults the same historical path for pre-1970 dates
|
|
33
|
+
and reports `tzConfidence`, `tzRuleSource`, and `datetimeStatus` (ambiguous
|
|
34
|
+
DST-fold and nonexistent-gap times are called out explicitly).
|
|
35
|
+
- **`location_search`** tags each suggestion's historical offset with a
|
|
36
|
+
confidence grade and a note when a pre-1970 date may be affected by
|
|
37
|
+
state/local law divergence.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- Three skills and one prompt still instructed the model to convert local
|
|
42
|
+
birth times to UTC itself with generic timezone math — the exact manual
|
|
43
|
+
conversion the historical overlay exists to prevent. They now direct all
|
|
44
|
+
conversion through the API.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## [4.6.0] — 2026-07-30
|
|
49
|
+
|
|
50
|
+
Fixes the tool that could not answer the question it is named for, closes the
|
|
51
|
+
last two places a chart was silently computed at 0°N 0°E, and stops two tools
|
|
52
|
+
discarding a timezone the caller supplied. The first three come from a 75-run
|
|
53
|
+
behavioural eval (25 prompts × 3, fresh context each) run against 4.4.0; the
|
|
54
|
+
last from the directory-submission readiness review.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- **`ephemeris_next_eclipse` can now find the next eclipse.** Without a
|
|
59
|
+
location it routed to `/eclipse/solar/global` or `/eclipse/lunar/global`,
|
|
60
|
+
which classify whatever eclipse is occurring *at* a given instant rather
|
|
61
|
+
than searching forward from one. The consequences compounded: `after_date`
|
|
62
|
+
was documented as optional and defaulting to today, but the backend
|
|
63
|
+
rejected the call without it; an ordinary date returned *"no solar eclipse
|
|
64
|
+
occurs at the requested date"*; and `eclipse_type: 'any'` was accepted and
|
|
65
|
+
ignored. Asking *"when is the next solar eclipse"* was unanswerable.
|
|
66
|
+
|
|
67
|
+
It now calls a new `/eclipse/next` endpoint that genuinely searches forward
|
|
68
|
+
from `after_date` (defaulting to now), honours `any` by returning whichever
|
|
69
|
+
of solar/lunar comes first, and needs no arguments beyond the type. Server
|
|
70
|
+
side in [`openephemeris#473`](https://github.com/openephemeris/openephemeris/pull/473).
|
|
71
|
+
|
|
72
|
+
Worth stating plainly, because it is the reason this is the lede: a broken
|
|
73
|
+
tool did not surface an error to the end user. Across three runs of the
|
|
74
|
+
same prompt the model answered from its own memory twice — once correctly,
|
|
75
|
+
once naming an eclipse in the wrong year — and disagreed with itself. Only
|
|
76
|
+
one run in three reported that the tool had failed.
|
|
77
|
+
|
|
78
|
+
- **`explore_human_design_transit` and `explore_human_design_connection` no
|
|
79
|
+
longer silently compute at 0°N 0°E.** 4.4.0 fixed this for
|
|
80
|
+
`explore_human_design` and wired `location` into four chart tools; these
|
|
81
|
+
two were missed. `explore_human_design_transit` accepted a `location` that
|
|
82
|
+
was display-only — its own description conceded the chart would be cast on
|
|
83
|
+
the Gulf of Guinea — and `explore_human_design_connection` had no location
|
|
84
|
+
field at all for either person. Both now resolve a place name through the
|
|
85
|
+
same lookup `location_search` uses, and reject the call outright when
|
|
86
|
+
neither coordinates nor a resolvable location is supplied.
|
|
87
|
+
|
|
88
|
+
- **`ephemeris_natal_batch` and `explore_transit_timeline` no longer discard a
|
|
89
|
+
supplied timezone.** Both declared a timezone parameter and then built the
|
|
90
|
+
request body without it, so a naive local birth time plus an IANA zone — the
|
|
91
|
+
remedy the server's own error message recommends — was rejected by the
|
|
92
|
+
datetime contract. `ephemeris_natal_batch` failed this way for *every*
|
|
93
|
+
subject in a batch, and its documented example was the failing shape;
|
|
94
|
+
`explore_transit_timeline` never read `natal_timezone` at all. Both now route
|
|
95
|
+
the value through the same canonical helper the rest of the surface uses, so
|
|
96
|
+
the zone reaches the wire.
|
|
97
|
+
|
|
98
|
+
### Changed
|
|
99
|
+
|
|
100
|
+
- **Two server-side fixes change what these tools return** (no MCP change
|
|
101
|
+
required, listed because the output differs):
|
|
102
|
+
`ephemeris_natal_chart` with `format: 'llm'` now distinguishes the mean and
|
|
103
|
+
true lunar nodes — both previously carried the id `north_node`, so a
|
|
104
|
+
consumer keying by id silently dropped one and the `aspects` indices
|
|
105
|
+
inherited the ambiguity ([`#475`](https://github.com/openephemeris/openephemeris/pull/475)).
|
|
106
|
+
And `server_version` in calculation metadata is now derived from the
|
|
107
|
+
deployed image reference rather than Fly's per-machine version counter, so
|
|
108
|
+
every instance serving a given release reports the same value; a separate
|
|
109
|
+
`instance_id` carries per-machine attribution
|
|
110
|
+
([`#474`](https://github.com/openephemeris/openephemeris/pull/474)).
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
10
114
|
## [4.5.0] — 2026-07-30
|
|
11
115
|
|
|
12
116
|
Adds a tool for the question the astrocartography tools could not answer:
|
package/dist/prompts.js
CHANGED
|
@@ -377,8 +377,9 @@ export const PROMPTS = [
|
|
|
377
377
|
"even 10–15 minutes can shift the Type, Authority, or Profile. If truly unknown, " +
|
|
378
378
|
"proceed with solar noon but flag that results may be imprecise.\n" +
|
|
379
379
|
"4. **Birth city and country** — needed for timezone conversion\n\n" +
|
|
380
|
-
"## Step 2 —
|
|
381
|
-
"Human Design
|
|
380
|
+
"## Step 2 — Resolve Coordinates and Timezone\n" +
|
|
381
|
+
"Human Design needs the exact birth instant; the tool resolves it from local time + " +
|
|
382
|
+
"timezone + coordinates automatically (historically correct even pre-1970).\n\n" +
|
|
382
383
|
"**Resolve the birth place with `location_search`** — pass `query: 'Birth City, Country'` " +
|
|
383
384
|
"and `date` set to the birth date (`YYYY-MM-DD`). One call returns decimal lat/lon, the " +
|
|
384
385
|
"IANA timezone, and `utcOffsetAtDate` — the offset that actually applied at that place on " +
|
|
@@ -25,7 +25,7 @@ import { fileURLToPath } from "node:url";
|
|
|
25
25
|
import { registerTool, SERVER_VERSION } from "../index.js";
|
|
26
26
|
import { getActiveClient } from "../../backend/client.js";
|
|
27
27
|
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
28
|
-
import {
|
|
28
|
+
import { localToUtcIsoHistorical } from "../datetime-historical.js";
|
|
29
29
|
import { coordsFromArgsOrLocation } from "./_location-resolver.js";
|
|
30
30
|
// ── Constants ─────────────────────────────────────────────────────────────
|
|
31
31
|
export const BODYGRAPH_RESOURCE_URI = "ui://openephemeris/bodygraph";
|
|
@@ -372,9 +372,9 @@ registerTool({
|
|
|
372
372
|
location: args.location,
|
|
373
373
|
});
|
|
374
374
|
const timezone = args.timezone ?? resolved.timezone;
|
|
375
|
-
const datetime = localToUtcIso("datetime", String(args.datetime), timezone);
|
|
376
375
|
const lat = resolved.latitude;
|
|
377
376
|
const lon = resolved.longitude;
|
|
377
|
+
const datetime = await localToUtcIsoHistorical("datetime", String(args.datetime), timezone, { latitude: lat, longitude: lon });
|
|
378
378
|
if (lat == null || lon == null) {
|
|
379
379
|
throw new Error("explore_human_design requires either `latitude` + `longitude` or a resolvable `location` name. " +
|
|
380
380
|
"Human Design is sensitive to birth location; a chart at 0°N 0°E is silently wrong.");
|
|
@@ -473,10 +473,11 @@ registerTool({
|
|
|
473
473
|
handler: async (args) => {
|
|
474
474
|
const client = getActiveClient();
|
|
475
475
|
const timezone = args.timezone;
|
|
476
|
-
// Convert local-time input → UTC using IANA timezone when provided
|
|
477
|
-
const datetime = localToUtcIso("datetime", String(args.datetime), timezone);
|
|
478
476
|
const lat = args.latitude;
|
|
479
477
|
const lon = args.longitude;
|
|
478
|
+
// Convert local-time input → UTC using IANA timezone when provided;
|
|
479
|
+
// pre-1970 births route through the API's historical correction overlay.
|
|
480
|
+
const datetime = await localToUtcIsoHistorical("datetime", String(args.datetime), timezone, { latitude: lat, longitude: lon });
|
|
480
481
|
const theme = args.theme === "light" ? "light" : "dark";
|
|
481
482
|
const layout = args.layout === "mandala" ? "mandala" : undefined;
|
|
482
483
|
const rings = typeof args.rings === "string" ? args.rings : undefined;
|
|
@@ -1319,9 +1320,9 @@ registerTool({
|
|
|
1319
1320
|
description: "Natal birth datetime, ISO 8601 (e.g. '1990-04-15T19:30:00Z'). Include 'Z' or an offset, " +
|
|
1320
1321
|
"or supply timezone for local time. HD is time-sensitive to the minute.",
|
|
1321
1322
|
},
|
|
1322
|
-
latitude: { type: "number", description: "Natal birth latitude
|
|
1323
|
-
longitude: { type: "number", description: "Natal birth longitude
|
|
1324
|
-
location: { type: "string", description: "Natal location
|
|
1323
|
+
latitude: { type: "number", description: "Natal birth latitude in decimal degrees (positive = North). Optional if `location` is a place name — the resolver fills it in." },
|
|
1324
|
+
longitude: { type: "number", description: "Natal birth longitude in decimal degrees (positive = East). Optional if `location` is a place name." },
|
|
1325
|
+
location: { type: "string", description: "Natal birth location. Prefer a plain place name like 'New York, NY' — the server resolves it via the same lookup `location_search` uses (unambiguous names → coords + timezone; ambiguous names throw with a disambiguation hint). If neither location nor lat/lon is supplied the call is rejected — no silent 0°N 0°E charts." },
|
|
1325
1326
|
timezone: { type: "string", description: "IANA timezone for the birth location (e.g. 'America/New_York')." },
|
|
1326
1327
|
transit_datetime: {
|
|
1327
1328
|
type: "string",
|
|
@@ -1355,16 +1356,30 @@ registerTool({
|
|
|
1355
1356
|
_meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["model", "app"] } },
|
|
1356
1357
|
handler: async (args) => {
|
|
1357
1358
|
const client = getActiveClient();
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
const
|
|
1359
|
+
// Resolve `location` → lat/lon/tz when the caller left coords unset.
|
|
1360
|
+
// If neither is provided we throw rather than silently computing at
|
|
1361
|
+
// 0°N 0°E (a plausible-looking chart nothing downstream can detect).
|
|
1362
|
+
const resolved = await coordsFromArgsOrLocation({
|
|
1363
|
+
latitude: args.latitude,
|
|
1364
|
+
longitude: args.longitude,
|
|
1365
|
+
timezone: args.timezone,
|
|
1366
|
+
location: args.location,
|
|
1367
|
+
});
|
|
1368
|
+
const timezone = args.timezone ?? resolved.timezone;
|
|
1369
|
+
const lat = resolved.latitude;
|
|
1370
|
+
const lon = resolved.longitude;
|
|
1371
|
+
const natalIso = await localToUtcIsoHistorical("datetime", String(args.datetime), timezone, { latitude: lat, longitude: lon });
|
|
1372
|
+
if (lat == null || lon == null) {
|
|
1373
|
+
throw new Error("explore_human_design_transit requires either `latitude` + `longitude` or a resolvable `location` name. " +
|
|
1374
|
+
"Human Design is sensitive to birth location; a chart at 0°N 0°E is silently wrong.");
|
|
1375
|
+
}
|
|
1376
|
+
const location = String(resolved.location ?? args.location ?? "Subject").slice(0, 120);
|
|
1362
1377
|
// SubjectRequest wire shape: name (required), birth_datetime.iso, and
|
|
1363
1378
|
// birth_location with struct-typed coordinate/timezone inputs (matches the
|
|
1364
1379
|
// live bi-wheel/transit-timeline tools — bare scalars are rejected).
|
|
1365
1380
|
const body = {
|
|
1366
1381
|
subject: {
|
|
1367
|
-
name:
|
|
1382
|
+
name: location,
|
|
1368
1383
|
birth_datetime: { iso: natalIso },
|
|
1369
1384
|
birth_location: {
|
|
1370
1385
|
latitude: { decimal: lat },
|
|
@@ -1375,7 +1390,7 @@ registerTool({
|
|
|
1375
1390
|
};
|
|
1376
1391
|
if (args.transit_datetime) {
|
|
1377
1392
|
body.transit_datetime = {
|
|
1378
|
-
iso:
|
|
1393
|
+
iso: await localToUtcIsoHistorical("transit_datetime", String(args.transit_datetime), args.timezone, { latitude: lat, longitude: lon }),
|
|
1379
1394
|
};
|
|
1380
1395
|
}
|
|
1381
1396
|
const bundleAvailable = Boolean(getBodygraphBundle());
|
|
@@ -1441,8 +1456,9 @@ registerTool({
|
|
|
1441
1456
|
description: "First person's birth data.",
|
|
1442
1457
|
properties: {
|
|
1443
1458
|
datetime: { type: "string", description: "Birth datetime, ISO 8601 (Z/offset, or local with timezone)." },
|
|
1444
|
-
latitude: { type: "number", description: "Birth latitude
|
|
1445
|
-
longitude: { type: "number", description: "Birth longitude
|
|
1459
|
+
latitude: { type: "number", description: "Birth latitude, decimal degrees (+N). Optional if `location` is set." },
|
|
1460
|
+
longitude: { type: "number", description: "Birth longitude, decimal degrees (+E). Optional if `location` is set." },
|
|
1461
|
+
location: { type: "string", description: "Birth location, e.g. 'New York, NY' — resolved like location_search (ambiguous names throw). Omitting both this and lat/lon rejects the call rather than defaulting to 0°N 0°E." },
|
|
1446
1462
|
timezone: { type: "string", description: "IANA timezone (e.g. 'America/New_York')." },
|
|
1447
1463
|
},
|
|
1448
1464
|
required: ["datetime"],
|
|
@@ -1452,8 +1468,9 @@ registerTool({
|
|
|
1452
1468
|
description: "Second person's birth data.",
|
|
1453
1469
|
properties: {
|
|
1454
1470
|
datetime: { type: "string", description: "Birth datetime, ISO 8601 (Z/offset, or local with timezone)." },
|
|
1455
|
-
latitude: { type: "number", description: "Birth latitude
|
|
1456
|
-
longitude: { type: "number", description: "Birth longitude
|
|
1471
|
+
latitude: { type: "number", description: "Birth latitude, decimal degrees (+N). Optional if `location` is set." },
|
|
1472
|
+
longitude: { type: "number", description: "Birth longitude, decimal degrees (+E). Optional if `location` is set." },
|
|
1473
|
+
location: { type: "string", description: "Birth location, e.g. 'New York, NY' — resolved like location_search (ambiguous names throw). Omitting both this and lat/lon rejects the call rather than defaulting to 0°N 0°E." },
|
|
1457
1474
|
timezone: { type: "string", description: "IANA timezone (e.g. 'America/New_York')." },
|
|
1458
1475
|
},
|
|
1459
1476
|
required: ["datetime"],
|
|
@@ -1486,17 +1503,32 @@ registerTool({
|
|
|
1486
1503
|
_meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["model", "app"] } },
|
|
1487
1504
|
handler: async (args) => {
|
|
1488
1505
|
const client = getActiveClient();
|
|
1489
|
-
|
|
1490
|
-
|
|
1506
|
+
// Resolve each person's `location` → lat/lon/tz when coords are unset.
|
|
1507
|
+
// If neither is provided we throw rather than silently computing at
|
|
1508
|
+
// 0°N 0°E (a plausible-looking chart nothing downstream can detect).
|
|
1509
|
+
const toSubject = async (p, label) => {
|
|
1510
|
+
const resolved = await coordsFromArgsOrLocation({
|
|
1511
|
+
latitude: p?.latitude,
|
|
1512
|
+
longitude: p?.longitude,
|
|
1513
|
+
timezone: p?.timezone,
|
|
1514
|
+
location: p?.location,
|
|
1515
|
+
});
|
|
1516
|
+
const tz = p?.timezone ?? resolved.timezone;
|
|
1517
|
+
const lat = resolved.latitude;
|
|
1518
|
+
const lon = resolved.longitude;
|
|
1519
|
+
if (lat == null || lon == null) {
|
|
1520
|
+
throw new Error(`${label} requires either \`latitude\` + \`longitude\` or a resolvable \`location\` name. ` +
|
|
1521
|
+
"Human Design is sensitive to birth location; a chart at 0°N 0°E is silently wrong.");
|
|
1522
|
+
}
|
|
1491
1523
|
return {
|
|
1492
|
-
birth_datetime_utc:
|
|
1493
|
-
latitude:
|
|
1494
|
-
longitude:
|
|
1524
|
+
birth_datetime_utc: await localToUtcIsoHistorical(`${label}.datetime`, String(p?.datetime), tz, { latitude: lat, longitude: lon }, `${label}.timezone`),
|
|
1525
|
+
latitude: lat,
|
|
1526
|
+
longitude: lon,
|
|
1495
1527
|
};
|
|
1496
1528
|
};
|
|
1497
1529
|
const body = {
|
|
1498
|
-
subject_1: toSubject(args.person_a, "person_a"),
|
|
1499
|
-
subject_2: toSubject(args.person_b, "person_b"),
|
|
1530
|
+
subject_1: await toSubject(args.person_a, "person_a"),
|
|
1531
|
+
subject_2: await toSubject(args.person_b, "person_b"),
|
|
1500
1532
|
};
|
|
1501
1533
|
const bundleAvailable = Boolean(getBodygraphBundle());
|
|
1502
1534
|
// Mirror the transit tool: explicit dark default, host-theme refetch.
|
|
@@ -1,6 +1,15 @@
|
|
|
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
|
+
import { TZDATA_AUTHORITATIVE_FROM_YEAR } from "../datetime-historical.js";
|
|
5
|
+
/** Render minutes east of UTC as `±HH:MM`. */
|
|
6
|
+
function formatOffsetMinutes(offsetMinutes) {
|
|
7
|
+
const sign = offsetMinutes < 0 ? "-" : "+";
|
|
8
|
+
const abs = Math.abs(offsetMinutes);
|
|
9
|
+
const hh = String(Math.floor(abs / 60)).padStart(2, "0");
|
|
10
|
+
const mm = String(Math.round(abs % 60)).padStart(2, "0");
|
|
11
|
+
return `${sign}${hh}:${mm}`;
|
|
12
|
+
}
|
|
4
13
|
/**
|
|
5
14
|
* The UTC offset of an IANA zone is a function of place AND date: 1987 US DST
|
|
6
15
|
* rules are not today's rules, and America/Chicago ran on CDT through the whole
|
|
@@ -143,6 +152,8 @@ registerTool({
|
|
|
143
152
|
// Map the API's snake_case response to the camelCase shape the app UI consumes.
|
|
144
153
|
const list = Array.isArray(raw?.suggestions) ? raw.suggestions : [];
|
|
145
154
|
const date = args.date != null && String(args.date).trim() !== "" ? String(args.date).trim() : null;
|
|
155
|
+
const dateYear = date ? Number(date.slice(0, 4)) : null;
|
|
156
|
+
const preTzdataEra = dateYear != null && Number.isFinite(dateYear) && dateYear < TZDATA_AUTHORITATIVE_FROM_YEAR;
|
|
146
157
|
const suggestions = list.map((s) => {
|
|
147
158
|
const mapped = {
|
|
148
159
|
displayName: s.display_name,
|
|
@@ -154,8 +165,17 @@ registerTool({
|
|
|
154
165
|
longitude: s.longitude,
|
|
155
166
|
timezone: s.timezone,
|
|
156
167
|
};
|
|
157
|
-
if (date && s.timezone)
|
|
158
|
-
|
|
168
|
+
if (date && s.timezone) {
|
|
169
|
+
const offset = offsetAtLocalNoon(s.timezone, date);
|
|
170
|
+
if (offset) {
|
|
171
|
+
// These offsets come from local tzdata, which is only
|
|
172
|
+
// authoritative from 1970 — before that it models the
|
|
173
|
+
// zone's reference city, not state/local law.
|
|
174
|
+
Object.assign(mapped, offset, {
|
|
175
|
+
tzConfidence: preTzdataEra ? "historical_estimate" : "authoritative",
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
159
179
|
return mapped;
|
|
160
180
|
});
|
|
161
181
|
// Ambiguity signal. "portland" really does match Oregon, Maine, Texas,
|
|
@@ -180,6 +200,11 @@ registerTool({
|
|
|
180
200
|
return {
|
|
181
201
|
suggestions,
|
|
182
202
|
matchCount: suggestions.length,
|
|
203
|
+
...(preTzdataEra
|
|
204
|
+
? {
|
|
205
|
+
historicalNote: "Pre-1970 date: tzdata offsets are reference-city estimates. For the chosen place, call timezone_resolve with the same date (or submit the naive local time + timezone + coordinates to the chart endpoint) to apply the API's historical correction overlay.",
|
|
206
|
+
}
|
|
207
|
+
: {}),
|
|
183
208
|
ambiguous,
|
|
184
209
|
...(ambiguous
|
|
185
210
|
? {
|
|
@@ -203,7 +228,7 @@ registerTool({
|
|
|
203
228
|
longitude: { type: "number" },
|
|
204
229
|
date: {
|
|
205
230
|
type: "string",
|
|
206
|
-
description: "Optional birth/event date
|
|
231
|
+
description: "Optional birth/event date 'YYYY-MM-DD'. Adds utcOffsetAtDate / utcOffsetMinutes / isDst / tzConfidence. Post-1970 resolves locally (free); pre-1970 consults the API's historical correction overlay (1 extra credit).",
|
|
207
232
|
},
|
|
208
233
|
},
|
|
209
234
|
required: ["latitude", "longitude"],
|
|
@@ -228,9 +253,46 @@ registerTool({
|
|
|
228
253
|
const date = args.date != null && String(args.date).trim() !== "" ? String(args.date).trim() : null;
|
|
229
254
|
const tz = typeof result?.timezone === "string" ? result.timezone : null;
|
|
230
255
|
if (date && tz) {
|
|
256
|
+
// Pre-1970 dates: tzdata (Node's and Go's alike) models the zone's
|
|
257
|
+
// reference city only, so ask the API — its historical correction
|
|
258
|
+
// overlay knows where state law diverged (e.g. Minnesota 1959-66).
|
|
259
|
+
// Costs one extra credit, only for pre-1970 dates.
|
|
260
|
+
const year = Number(date.slice(0, 4));
|
|
261
|
+
if (Number.isFinite(year) && year < TZDATA_AUTHORITATIVE_FROM_YEAR) {
|
|
262
|
+
try {
|
|
263
|
+
const off = (await getActiveClient().request("POST", "/timezone/offset", {
|
|
264
|
+
data: { lat: args.latitude, lon: args.longitude, datetime_local: `${date.slice(0, 10)}T12:00:00` },
|
|
265
|
+
}));
|
|
266
|
+
if (typeof off?.offset_seconds === "number") {
|
|
267
|
+
const minutes = off.offset_seconds / 60;
|
|
268
|
+
return {
|
|
269
|
+
...result,
|
|
270
|
+
utcOffsetAtDate: formatOffsetMinutes(minutes),
|
|
271
|
+
utcOffsetMinutes: minutes,
|
|
272
|
+
isDst: Boolean(off.is_dst),
|
|
273
|
+
date,
|
|
274
|
+
tzConfidence: typeof off.tz_confidence === "string" ? off.tz_confidence : "historical_estimate",
|
|
275
|
+
...(typeof off.tz_rule_source === "string" ? { tzRuleSource: off.tz_rule_source } : {}),
|
|
276
|
+
...(typeof off.datetime_status === "string" ? { datetimeStatus: off.datetime_status } : {}),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// Server path unavailable — fall through to the local
|
|
282
|
+
// tzdata estimate below, flagged as such.
|
|
283
|
+
}
|
|
284
|
+
}
|
|
231
285
|
const offset = offsetAtLocalNoon(tz, date);
|
|
232
|
-
if (offset)
|
|
233
|
-
return {
|
|
286
|
+
if (offset) {
|
|
287
|
+
return {
|
|
288
|
+
...result,
|
|
289
|
+
...offset,
|
|
290
|
+
date,
|
|
291
|
+
tzConfidence: Number.isFinite(year) && year < TZDATA_AUTHORITATIVE_FROM_YEAR
|
|
292
|
+
? "historical_estimate"
|
|
293
|
+
: "authoritative",
|
|
294
|
+
};
|
|
295
|
+
}
|
|
234
296
|
}
|
|
235
297
|
return result;
|
|
236
298
|
},
|
|
@@ -17,7 +17,8 @@ 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
|
|
20
|
+
import { TIMEZONE_PROPERTY } from "../datetime.js";
|
|
21
|
+
import { localToUtcIsoHistorical } from "../datetime-historical.js";
|
|
21
22
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
22
23
|
export const MOON_PHASE_RESOURCE_URI = "ui://openephemeris/moon-phase";
|
|
23
24
|
export const MOON_PHASE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
@@ -98,7 +99,7 @@ async function computeMoonData(args) {
|
|
|
98
99
|
const client = getActiveClient();
|
|
99
100
|
const params = {};
|
|
100
101
|
params.datetime = args.datetime
|
|
101
|
-
?
|
|
102
|
+
? await localToUtcIsoHistorical("datetime", String(args.datetime), args.timezone, { latitude: args.latitude, longitude: args.longitude })
|
|
102
103
|
: new Date().toISOString();
|
|
103
104
|
if (args.latitude != null)
|
|
104
105
|
params.latitude = args.latitude;
|
|
@@ -18,7 +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
|
+
import { DATETIME_DESC, WINDOW_DATE_DESC, timezoneProperty, toDateTimeInputBody } from "../datetime.js";
|
|
22
22
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
23
23
|
export const TRANSIT_TIMELINE_RESOURCE_URI = "ui://openephemeris/transit-timeline";
|
|
24
24
|
export const TRANSIT_TIMELINE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
@@ -150,10 +150,15 @@ registerTool({
|
|
|
150
150
|
validateRequired(args, ["natal_datetime", "natal_latitude", "natal_longitude", "start_date", "end_date"]);
|
|
151
151
|
const client = getActiveClient();
|
|
152
152
|
// ── Step 1: natal chart → real planetary longitudes ──────────────────────
|
|
153
|
+
// natal_timezone is a declared parameter — it must reach the wire body.
|
|
154
|
+
// It previously did not, so a caller following the server's own remedy for
|
|
155
|
+
// a naive datetime ("name the zone in the sibling `timezone` argument")
|
|
156
|
+
// had the zone silently dropped and got a 400 from the Go contract with no
|
|
157
|
+
// working alternative.
|
|
153
158
|
const natalBody = {
|
|
154
159
|
subject: {
|
|
155
160
|
name: "Transit Natal Subject",
|
|
156
|
-
birth_datetime:
|
|
161
|
+
birth_datetime: toDateTimeInputBody("natal_datetime", String(args.natal_datetime), args.natal_timezone, "natal_timezone"),
|
|
157
162
|
birth_location: {
|
|
158
163
|
latitude: { decimal: args.natal_latitude },
|
|
159
164
|
longitude: { decimal: args.natal_longitude },
|
|
@@ -23,7 +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 {
|
|
26
|
+
import { localToUtcIsoHistorical } from "../datetime-historical.js";
|
|
27
27
|
import { coordsFromArgsOrLocation } from "./_location-resolver.js";
|
|
28
28
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
29
29
|
export const VEDIC_CHART_RESOURCE_URI = "ui://openephemeris/vedic-chart";
|
|
@@ -165,9 +165,9 @@ registerTool({
|
|
|
165
165
|
validateCoordinates(argsWithResolved, "latitude", "longitude");
|
|
166
166
|
const client = getActiveClient();
|
|
167
167
|
const timezone = args.timezone ?? resolved.timezone;
|
|
168
|
-
const datetime = localToUtcIso("datetime", String(args.datetime), timezone);
|
|
169
168
|
const lat = Number(resolved.latitude);
|
|
170
169
|
const lon = Number(resolved.longitude);
|
|
170
|
+
const datetime = await localToUtcIsoHistorical("datetime", String(args.datetime), timezone, { latitude: lat, longitude: lon });
|
|
171
171
|
const ayanamsa = args.ayanamsa;
|
|
172
172
|
const location = String(resolved.location ?? args.location ?? `${lat}, ${lon}`).slice(0, 120);
|
|
173
173
|
const bundleAvailable = Boolean(getVedicChartBundle());
|
|
@@ -244,9 +244,9 @@ registerTool({
|
|
|
244
244
|
_meta: { ui: { resourceUri: VEDIC_CHART_RESOURCE_URI, visibility: ["app"] } },
|
|
245
245
|
handler: async (args) => {
|
|
246
246
|
const client = getActiveClient();
|
|
247
|
-
const datetime = localToUtcIso("datetime", String(args.datetime), args.timezone);
|
|
248
247
|
const lat = args.latitude != null ? Number(args.latitude) : undefined;
|
|
249
248
|
const lon = args.longitude != null ? Number(args.longitude) : undefined;
|
|
249
|
+
const datetime = await localToUtcIsoHistorical("datetime", String(args.datetime), args.timezone, { latitude: lat, longitude: lon });
|
|
250
250
|
const ayanamsa = args.ayanamsa;
|
|
251
251
|
const theme = args.theme === "light" ? "light" : "dark";
|
|
252
252
|
const body = {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Where IANA tzdata stops being reference-city best effort. */
|
|
2
|
+
export declare const TZDATA_AUTHORITATIVE_FROM_YEAR = 1970;
|
|
3
|
+
export interface BirthCoords {
|
|
4
|
+
latitude?: number | null;
|
|
5
|
+
longitude?: number | null;
|
|
6
|
+
}
|
|
7
|
+
/** True when a naive local datetime needs the server-side historical path. */
|
|
8
|
+
export declare function needsHistoricalResolution(dt: string, coords?: BirthCoords): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* `localToUtcIso`, but historically correct when it matters.
|
|
11
|
+
*
|
|
12
|
+
* Same contract as `localToUtcIso` (naive value with no `tz` throws the
|
|
13
|
+
* ambiguous-datetime error; zone-suffixed and date-only values pass through).
|
|
14
|
+
* For a pre-1970 naive value with known coordinates, the conversion is done
|
|
15
|
+
* by the API's `/timezone/offset` `datetime_local` mode so the historical
|
|
16
|
+
* correction overlay applies. Everything else — including every post-1970
|
|
17
|
+
* conversion — stays on the local, zero-latency Intl path.
|
|
18
|
+
*/
|
|
19
|
+
export declare function localToUtcIsoHistorical(field: string, dt: string, tz?: string, coords?: BirthCoords, timezoneField?: string): Promise<string>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Historically-correct local → UTC conversion.
|
|
3
|
+
*
|
|
4
|
+
* IANA tzdata — Node's Intl and the Go engine's tzdata alike — is only
|
|
5
|
+
* authoritative from 1970. Before the US Uniform Time Act (effective 1967),
|
|
6
|
+
* DST was a state and municipal matter, and tzdata models each zone's
|
|
7
|
+
* reference city only. `localToUtcIso`'s Intl math is therefore silently an
|
|
8
|
+
* hour off for births like Robbinsdale, MN 1961-10-09 17:56 (Minnesota was on
|
|
9
|
+
* CST while America/Chicago says CDT), which flips the Ascendant sign and the
|
|
10
|
+
* HD design-Moon gate.
|
|
11
|
+
*
|
|
12
|
+
* The Go API owns the correction: `POST /timezone/offset` with
|
|
13
|
+
* `datetime_local` + coordinates resolves through its historical-correction
|
|
14
|
+
* overlay (apps/api/go-sidecar/internal/historicaltz) and returns the true
|
|
15
|
+
* instant plus a `tz_confidence` grade. This module routes the conversions
|
|
16
|
+
* that need it — pre-1970, naive, with known coordinates — through that
|
|
17
|
+
* endpoint, and leaves every other case on the local Intl path, which is
|
|
18
|
+
* exactly as authoritative as the server for 1970+.
|
|
19
|
+
*
|
|
20
|
+
* Fallback discipline: if the server call fails or resolves a different zone
|
|
21
|
+
* than the caller explicitly named, we fall back to `localToUtcIso` — the
|
|
22
|
+
* pre-existing behavior — rather than failing the chart call. Best effort
|
|
23
|
+
* beats an outage; the server remains the single authority whenever it is
|
|
24
|
+
* reachable.
|
|
25
|
+
*/
|
|
26
|
+
import { getActiveClient } from "../backend/client.js";
|
|
27
|
+
import { hasZoneSuffix, isNaiveClockTime, localToUtcIso } from "./datetime.js";
|
|
28
|
+
/** Where IANA tzdata stops being reference-city best effort. */
|
|
29
|
+
export const TZDATA_AUTHORITATIVE_FROM_YEAR = 1970;
|
|
30
|
+
/** True when a naive local datetime needs the server-side historical path. */
|
|
31
|
+
export function needsHistoricalResolution(dt, coords) {
|
|
32
|
+
const value = (dt ?? "").trim();
|
|
33
|
+
if (!isNaiveClockTime(value) || hasZoneSuffix(value))
|
|
34
|
+
return false;
|
|
35
|
+
const year = Number(value.slice(0, 4));
|
|
36
|
+
return (Number.isFinite(year) &&
|
|
37
|
+
year < TZDATA_AUTHORITATIVE_FROM_YEAR &&
|
|
38
|
+
typeof coords?.latitude === "number" &&
|
|
39
|
+
typeof coords?.longitude === "number");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* `localToUtcIso`, but historically correct when it matters.
|
|
43
|
+
*
|
|
44
|
+
* Same contract as `localToUtcIso` (naive value with no `tz` throws the
|
|
45
|
+
* ambiguous-datetime error; zone-suffixed and date-only values pass through).
|
|
46
|
+
* For a pre-1970 naive value with known coordinates, the conversion is done
|
|
47
|
+
* by the API's `/timezone/offset` `datetime_local` mode so the historical
|
|
48
|
+
* correction overlay applies. Everything else — including every post-1970
|
|
49
|
+
* conversion — stays on the local, zero-latency Intl path.
|
|
50
|
+
*/
|
|
51
|
+
export async function localToUtcIsoHistorical(field, dt, tz, coords, timezoneField = "timezone") {
|
|
52
|
+
const value = (dt ?? "").trim();
|
|
53
|
+
if (!needsHistoricalResolution(value, coords) || !tz || tz.trim() === "") {
|
|
54
|
+
// Includes the naive-without-tz case, which must throw the same
|
|
55
|
+
// ambiguous-datetime error localToUtcIso throws.
|
|
56
|
+
return localToUtcIso(field, dt, tz, timezoneField);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const res = (await getActiveClient().request("POST", "/timezone/offset", {
|
|
60
|
+
data: {
|
|
61
|
+
lat: coords.latitude,
|
|
62
|
+
lon: coords.longitude,
|
|
63
|
+
datetime_local: value,
|
|
64
|
+
},
|
|
65
|
+
}));
|
|
66
|
+
// Respect an explicit caller zone: the server resolves the zone from
|
|
67
|
+
// the coordinates, so only trust its instant when the zones agree.
|
|
68
|
+
if (res?.resolved_utc && (!res.timezone || res.timezone === tz.trim())) {
|
|
69
|
+
return res.resolved_utc;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Server unreachable or rejected the request — fall through to the
|
|
74
|
+
// local conversion rather than failing the chart call.
|
|
75
|
+
}
|
|
76
|
+
return localToUtcIso(field, dt, tz, timezoneField);
|
|
77
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
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
|
-
import { DATETIME_DESC, TIMEZONE_PROPERTY
|
|
4
|
+
import { DATETIME_DESC, TIMEZONE_PROPERTY } from "../datetime.js";
|
|
5
|
+
import { localToUtcIsoHistorical } from "../datetime-historical.js";
|
|
5
6
|
const ACG_BODY_DESCRIPTION = "List of celestial bodies for line calculation. " +
|
|
6
7
|
"E.g. ['Sun', 'Moon', 'Venus', 'Mars', 'Jupiter', 'Saturn']. " +
|
|
7
8
|
"Aliases: 'NorthNode'/'Node'/'Rahu' → MeanNode, 'SouthNode'/'Ketu' → SouthNode. " +
|
|
@@ -67,7 +68,7 @@ registerTool({
|
|
|
67
68
|
birthplace_lat: args.birth_latitude,
|
|
68
69
|
birthplace_lon: args.birth_longitude,
|
|
69
70
|
},
|
|
70
|
-
epoch:
|
|
71
|
+
epoch: await localToUtcIsoHistorical("birth_datetime", args.birth_datetime, args.timezone, { latitude: args.birth_latitude, longitude: args.birth_longitude }),
|
|
71
72
|
};
|
|
72
73
|
if (args.bodies)
|
|
73
74
|
body.bodies = args.bodies;
|
|
@@ -164,7 +165,7 @@ registerTool({
|
|
|
164
165
|
birthplace_lat: args.birth_latitude,
|
|
165
166
|
birthplace_lon: args.birth_longitude,
|
|
166
167
|
},
|
|
167
|
-
epoch:
|
|
168
|
+
epoch: await localToUtcIsoHistorical("birth_datetime", args.birth_datetime, args.timezone, { latitude: args.birth_latitude, longitude: args.birth_longitude }),
|
|
168
169
|
query_lat: args.query_latitude,
|
|
169
170
|
query_lon: args.query_longitude,
|
|
170
171
|
};
|
|
@@ -60,12 +60,14 @@ registerTool({
|
|
|
60
60
|
return await getActiveClient().request("GET", "/eclipse/next-visible", { params, timeoutMs: 60_000 });
|
|
61
61
|
}
|
|
62
62
|
else {
|
|
63
|
-
// Global query —
|
|
64
|
-
|
|
63
|
+
// Global query — forward-search for the next eclipse of this type,
|
|
64
|
+
// anywhere on Earth. /eclipse/solar|lunar/global classify whatever
|
|
65
|
+
// eclipse (if any) is occurring exactly at a given date, so they
|
|
66
|
+
// can't answer "when is the next one" — /eclipse/next can.
|
|
67
|
+
const params = { eclipse_type: args.eclipse_type ?? "any" };
|
|
65
68
|
if (args.after_date)
|
|
66
69
|
params.date = args.after_date;
|
|
67
|
-
|
|
68
|
-
return await getActiveClient().request("GET", endpoint, { params, timeoutMs: 60_000 });
|
|
70
|
+
return await getActiveClient().request("GET", "/eclipse/next", { params, timeoutMs: 60_000 });
|
|
69
71
|
}
|
|
70
72
|
},
|
|
71
73
|
});
|