@openephemeris/mcp-server 4.4.0 → 4.6.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 CHANGED
@@ -7,6 +7,101 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [4.6.0] — 2026-07-30
11
+
12
+ Fixes the tool that could not answer the question it is named for, closes the
13
+ last two places a chart was silently computed at 0°N 0°E, and stops two tools
14
+ discarding a timezone the caller supplied. The first three come from a 75-run
15
+ behavioural eval (25 prompts × 3, fresh context each) run against 4.4.0; the
16
+ last from the directory-submission readiness review.
17
+
18
+ ### Fixed
19
+
20
+ - **`ephemeris_next_eclipse` can now find the next eclipse.** Without a
21
+ location it routed to `/eclipse/solar/global` or `/eclipse/lunar/global`,
22
+ which classify whatever eclipse is occurring *at* a given instant rather
23
+ than searching forward from one. The consequences compounded: `after_date`
24
+ was documented as optional and defaulting to today, but the backend
25
+ rejected the call without it; an ordinary date returned *"no solar eclipse
26
+ occurs at the requested date"*; and `eclipse_type: 'any'` was accepted and
27
+ ignored. Asking *"when is the next solar eclipse"* was unanswerable.
28
+
29
+ It now calls a new `/eclipse/next` endpoint that genuinely searches forward
30
+ from `after_date` (defaulting to now), honours `any` by returning whichever
31
+ of solar/lunar comes first, and needs no arguments beyond the type. Server
32
+ side in [`openephemeris#473`](https://github.com/openephemeris/openephemeris/pull/473).
33
+
34
+ Worth stating plainly, because it is the reason this is the lede: a broken
35
+ tool did not surface an error to the end user. Across three runs of the
36
+ same prompt the model answered from its own memory twice — once correctly,
37
+ once naming an eclipse in the wrong year — and disagreed with itself. Only
38
+ one run in three reported that the tool had failed.
39
+
40
+ - **`explore_human_design_transit` and `explore_human_design_connection` no
41
+ longer silently compute at 0°N 0°E.** 4.4.0 fixed this for
42
+ `explore_human_design` and wired `location` into four chart tools; these
43
+ two were missed. `explore_human_design_transit` accepted a `location` that
44
+ was display-only — its own description conceded the chart would be cast on
45
+ the Gulf of Guinea — and `explore_human_design_connection` had no location
46
+ field at all for either person. Both now resolve a place name through the
47
+ same lookup `location_search` uses, and reject the call outright when
48
+ neither coordinates nor a resolvable location is supplied.
49
+
50
+ - **`ephemeris_natal_batch` and `explore_transit_timeline` no longer discard a
51
+ supplied timezone.** Both declared a timezone parameter and then built the
52
+ request body without it, so a naive local birth time plus an IANA zone — the
53
+ remedy the server's own error message recommends — was rejected by the
54
+ datetime contract. `ephemeris_natal_batch` failed this way for *every*
55
+ subject in a batch, and its documented example was the failing shape;
56
+ `explore_transit_timeline` never read `natal_timezone` at all. Both now route
57
+ the value through the same canonical helper the rest of the surface uses, so
58
+ the zone reaches the wire.
59
+
60
+ ### Changed
61
+
62
+ - **Two server-side fixes change what these tools return** (no MCP change
63
+ required, listed because the output differs):
64
+ `ephemeris_natal_chart` with `format: 'llm'` now distinguishes the mean and
65
+ true lunar nodes — both previously carried the id `north_node`, so a
66
+ consumer keying by id silently dropped one and the `aspects` indices
67
+ inherited the ambiguity ([`#475`](https://github.com/openephemeris/openephemeris/pull/475)).
68
+ And `server_version` in calculation metadata is now derived from the
69
+ deployed image reference rather than Fly's per-machine version counter, so
70
+ every instance serving a given release reports the same value; a separate
71
+ `instance_id` carries per-machine attribution
72
+ ([`#474`](https://github.com/openephemeris/openephemeris/pull/474)).
73
+
74
+ ---
75
+
76
+ ## [4.5.0] — 2026-07-30
77
+
78
+ Adds a tool for the question the astrocartography tools could not answer:
79
+ *when* a planet reaches an angle at a place, rather than what is angular
80
+ there at a fixed moment.
81
+
82
+ ### Added
83
+
84
+ - **`electional_angle_crossings` — find when a body crosses the Ascendant,
85
+ Descendant, Midheaven, or Imum Coeli at a location.** The time-inverse of
86
+ astrocartography. `acg_hits` answers "which lines pass near Warsaw at this
87
+ moment"; this answers "at what moment does Mercury cross the Descendant
88
+ over Warsaw". Give it a latitude/longitude and a date range and it returns
89
+ the crossing moments chronologically, with each body's position and a
90
+ daylight hint at that instant.
91
+
92
+ Crossing times are solved on the same in-mundo (equatorial RA/Dec) geometry
93
+ the astrocartography line engine draws, so casting a map at a returned
94
+ moment puts that line through the query point — the search and the map
95
+ cannot disagree.
96
+
97
+ Defaults to the classical seven bodies and all four angles over the current
98
+ UTC day. Circumpolar cases — a body that never rises or never sets at that
99
+ latitude — simply return no event rather than an error. Ranges up to 366
100
+ days; an over-long range is rejected rather than quietly shortened.
101
+
102
+ Advertised in the default tool surface. 5 credits per call, Developer plan
103
+ and above.
104
+
10
105
  ## [4.4.0] — 2026-07-30
11
106
 
12
107
  Puts the location resolver on the natural path for the four `explore_*`
package/README.md CHANGED
@@ -440,10 +440,10 @@ When you update the MCP server logic (handlers, bug fixes, hardening), you shoul
440
440
 
441
441
  Generated by `npm run sync:readme` from `config/dev-allowlist.json` and the live tool registry.
442
442
 
443
- - Allowlisted operations: **28**
444
- - Methods: `GET=4`, `POST=24`, `PUT=0`, `PATCH=0`, `DELETE=0`
445
- - Registered tools (`OPENEPHEMERIS_PROFILE=dev`): **91**
446
- - Typed tools: `account_usage`, `acg_hits`, `acg_power_lines`, `auth_login`, `auth_logout`, `auth_status`, `bazi_annual_pillar`, `bazi_chart`, `bazi_compatibility`, `bazi_element_balance`, `bazi_luck_pillars`, `bazi_recalculate`, `bazi_ten_gods`, `bi_wheel_on_cross_aspect_click`, `bi_wheel_on_house_click`, `bi_wheel_on_planet_click`, `bi_wheel_recalculate`, `bi_wheel_synopsis`, `bodygraph_recalculate`, `chart_wheel_on_aspect_click`, `chart_wheel_on_house_click`, `chart_wheel_on_planet_click`, `chart_wheel_recalculate`, `chinese_bazi`, `dev_list_allowed`, `dev_read_api`, `dev_write_api`, `electional_aspect_search`, `electional_moment_analysis`, `electional_station_tracker`, `ephemeris_angles_points`, `ephemeris_aspect_check`, `ephemeris_bi_wheel`, `ephemeris_chart_wheel`, `ephemeris_composite`, `ephemeris_composite_midpoint`, `ephemeris_dignities`, `ephemeris_electional`, `ephemeris_fixed_stars`, `ephemeris_hermetic_lots`, `ephemeris_house_cusps`, `ephemeris_lunar_return`, `ephemeris_midpoints`, `ephemeris_moon_phase`, `ephemeris_natal_batch`, `ephemeris_natal_chart`, `ephemeris_natal_transits`, `ephemeris_next_eclipse`, `ephemeris_next_lunar_phase`, `ephemeris_overlay`, `ephemeris_planet_position`, `ephemeris_planetary_return`, `ephemeris_progressed_chart`, `ephemeris_relocation`, `ephemeris_retrograde_status`, `ephemeris_solar_return`, `ephemeris_synastry`, `ephemeris_transits`, `explore_bazi_chart`, `explore_bi_wheel`, `explore_human_design`, `explore_human_design_connection`, `explore_human_design_transit`, `explore_moon_phase`, `explore_natal_chart`, `explore_transit_timeline`, `explore_vedic_chart`, `hd_on_center_click`, `hd_on_channel_click`, `hd_on_connection_channel_click`, `hd_on_gate_click`, `hd_on_planet_click`, `hd_on_transit_channel_click`, `hd_on_variable_click`, `hd_opposition`, `hd_planetary_return`, `human_design_bodygraph`, `human_design_chart`, `human_design_composite`, `human_design_penta`, `location_search`, `moon_phase_recalculate`, `timezone_resolve`, `vedic_chart`, `vedic_chart_recalculate`, `venus_eight_year_star`, `venus_elongations`, `venus_phase`, `venus_star_points`, `venus_star_points_conjunctions`, `venus_stations`
443
+ - Allowlisted operations: **31**
444
+ - Methods: `GET=5`, `POST=26`, `PUT=0`, `PATCH=0`, `DELETE=0`
445
+ - Registered tools (`OPENEPHEMERIS_PROFILE=dev`): **92**
446
+ - Typed tools: `account_usage`, `acg_hits`, `acg_power_lines`, `auth_login`, `auth_logout`, `auth_status`, `bazi_annual_pillar`, `bazi_chart`, `bazi_compatibility`, `bazi_element_balance`, `bazi_luck_pillars`, `bazi_recalculate`, `bazi_ten_gods`, `bi_wheel_on_cross_aspect_click`, `bi_wheel_on_house_click`, `bi_wheel_on_planet_click`, `bi_wheel_recalculate`, `bi_wheel_synopsis`, `bodygraph_recalculate`, `chart_wheel_on_aspect_click`, `chart_wheel_on_house_click`, `chart_wheel_on_planet_click`, `chart_wheel_recalculate`, `chinese_bazi`, `dev_list_allowed`, `dev_read_api`, `dev_write_api`, `electional_angle_crossings`, `electional_aspect_search`, `electional_moment_analysis`, `electional_station_tracker`, `ephemeris_angles_points`, `ephemeris_aspect_check`, `ephemeris_bi_wheel`, `ephemeris_chart_wheel`, `ephemeris_composite`, `ephemeris_composite_midpoint`, `ephemeris_dignities`, `ephemeris_electional`, `ephemeris_fixed_stars`, `ephemeris_hermetic_lots`, `ephemeris_house_cusps`, `ephemeris_lunar_return`, `ephemeris_midpoints`, `ephemeris_moon_phase`, `ephemeris_natal_batch`, `ephemeris_natal_chart`, `ephemeris_natal_transits`, `ephemeris_next_eclipse`, `ephemeris_next_lunar_phase`, `ephemeris_overlay`, `ephemeris_planet_position`, `ephemeris_planetary_return`, `ephemeris_progressed_chart`, `ephemeris_relocation`, `ephemeris_retrograde_status`, `ephemeris_solar_return`, `ephemeris_synastry`, `ephemeris_transits`, `explore_bazi_chart`, `explore_bi_wheel`, `explore_human_design`, `explore_human_design_connection`, `explore_human_design_transit`, `explore_moon_phase`, `explore_natal_chart`, `explore_transit_timeline`, `explore_vedic_chart`, `hd_on_center_click`, `hd_on_channel_click`, `hd_on_connection_channel_click`, `hd_on_gate_click`, `hd_on_planet_click`, `hd_on_transit_channel_click`, `hd_on_variable_click`, `hd_opposition`, `hd_planetary_return`, `human_design_bodygraph`, `human_design_chart`, `human_design_composite`, `human_design_penta`, `location_search`, `moon_phase_recalculate`, `timezone_resolve`, `vedic_chart`, `vedic_chart_recalculate`, `venus_eight_year_star`, `venus_elongations`, `venus_phase`, `venus_star_points`, `venus_star_points_conjunctions`, `venus_stations`
447
447
  - Generic tools:
448
448
 
449
449
  ### Allowlist Families
@@ -453,8 +453,9 @@ Generated by `npm run sync:readme` from `config/dev-allowlist.json` and the live
453
453
  | `acg` | 4 | `POST /acg/ccg`, `POST /acg/hits` |
454
454
  | `chinese` | 5 | `POST /chinese/bazi/compatibility`, `POST /chinese/bazi/element-balance` |
455
455
  | `comparative` | 5 | `POST /comparative/composite`, `POST /comparative/composite/midpoint` |
456
- | `electional` | 4 | `GET /electional/aspect-search`, `GET /electional/find-window` |
456
+ | `electional` | 5 | `GET /electional/angle-crossings`, `GET /electional/aspect-search` |
457
457
  | `ephemeris` | 1 | `POST /ephemeris/relocation` |
458
+ | `human-design` | 2 | `POST /human-design/composite`, `POST /human-design/transit-chart` |
458
459
  | `predictive` | 6 | `POST /predictive/returns`, `POST /predictive/returns/lunar` |
459
460
  | `visualization` | 3 | `POST /visualization/bi-wheel`, `POST /visualization/bodygraph` |
460
461
  <!-- GENERATED:RUNTIME_SNAPSHOT:END -->
@@ -71,6 +71,10 @@
71
71
  "method": "POST",
72
72
  "path": "/comparative/synastry"
73
73
  },
74
+ {
75
+ "method": "GET",
76
+ "path": "/electional/angle-crossings"
77
+ },
74
78
  {
75
79
  "method": "GET",
76
80
  "path": "/electional/aspect-search"
@@ -91,6 +95,14 @@
91
95
  "method": "POST",
92
96
  "path": "/ephemeris/relocation"
93
97
  },
98
+ {
99
+ "method": "POST",
100
+ "path": "/human-design/composite"
101
+ },
102
+ {
103
+ "method": "POST",
104
+ "path": "/human-design/transit-chart"
105
+ },
94
106
  {
95
107
  "method": "POST",
96
108
  "path": "/predictive/returns/lunar"
@@ -128,8 +140,8 @@
128
140
  "path": "/visualization/chart-wheel"
129
141
  }
130
142
  ],
131
- "last_generated_at": "2026-05-14T01:35:40.409Z",
132
- "openapi_sha256": "50112cd7017466815265d92e7d73cbc29be6bdc729a87db50989f189d562d201",
143
+ "last_generated_at": "2026-07-30T20:30:07.623Z",
144
+ "openapi_sha256": "c8d62588232551c514979433b2829ceb951ddce99334e65a3deed112a28bb11a",
133
145
  "candidates_get": [
134
146
  {
135
147
  "method": "GET",
@@ -243,6 +255,14 @@
243
255
  ],
244
256
  "operationId": "get_solar_eclipse_local_eclipse_solar_local_get"
245
257
  },
258
+ {
259
+ "method": "GET",
260
+ "path": "/electional/angle-crossings",
261
+ "tags": [
262
+ "electional"
263
+ ],
264
+ "operationId": "ElectionalAngleCrossingsElectionalAngleCrossingsGet"
265
+ },
246
266
  {
247
267
  "method": "GET",
248
268
  "path": "/electional/aspect-search",
@@ -925,6 +945,14 @@
925
945
  ],
926
946
  "operationId": "human_design_penta_human_design_penta_post"
927
947
  },
948
+ {
949
+ "method": "POST",
950
+ "path": "/human-design/transit-chart",
951
+ "tags": [
952
+ "Human Design"
953
+ ],
954
+ "operationId": "human_design_transit_chart_human_design_transit_chart_post"
955
+ },
928
956
  {
929
957
  "method": "POST",
930
958
  "path": "/human-design/transit",
@@ -1105,6 +1133,8 @@
1105
1133
  "Non-GET allowlisted: POST /comparative/overlay",
1106
1134
  "Non-GET allowlisted: POST /comparative/synastry",
1107
1135
  "Non-GET allowlisted: POST /ephemeris/relocation",
1136
+ "Non-GET allowlisted: POST /human-design/composite",
1137
+ "Non-GET allowlisted: POST /human-design/transit-chart",
1108
1138
  "Non-GET allowlisted: POST /predictive/returns/lunar",
1109
1139
  "Non-GET allowlisted: POST /predictive/returns/solar",
1110
1140
  "Non-GET allowlisted: POST /predictive/returns",
@@ -1319,9 +1319,9 @@ registerTool({
1319
1319
  description: "Natal birth datetime, ISO 8601 (e.g. '1990-04-15T19:30:00Z'). Include 'Z' or an offset, " +
1320
1320
  "or supply timezone for local time. HD is time-sensitive to the minute.",
1321
1321
  },
1322
- latitude: { type: "number", description: "Natal birth latitude (decimal degrees, +N). Optional." },
1323
- longitude: { type: "number", description: "Natal birth longitude (decimal degrees, +E). Optional." },
1324
- location: { type: "string", description: "Natal location name, display only. 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." },
1322
+ latitude: { type: "number", description: "Natal birth latitude in decimal degrees (positive = North). Optional if `location` is a place name — the resolver fills it in." },
1323
+ longitude: { type: "number", description: "Natal birth longitude in decimal degrees (positive = East). Optional if `location` is a place name." },
1324
+ 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
1325
  timezone: { type: "string", description: "IANA timezone for the birth location (e.g. 'America/New_York')." },
1326
1326
  transit_datetime: {
1327
1327
  type: "string",
@@ -1355,16 +1355,30 @@ registerTool({
1355
1355
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["model", "app"] } },
1356
1356
  handler: async (args) => {
1357
1357
  const client = getActiveClient();
1358
- const timezone = args.timezone;
1358
+ // Resolve `location` → lat/lon/tz when the caller left coords unset.
1359
+ // If neither is provided we throw rather than silently computing at
1360
+ // 0°N 0°E (a plausible-looking chart nothing downstream can detect).
1361
+ const resolved = await coordsFromArgsOrLocation({
1362
+ latitude: args.latitude,
1363
+ longitude: args.longitude,
1364
+ timezone: args.timezone,
1365
+ location: args.location,
1366
+ });
1367
+ const timezone = args.timezone ?? resolved.timezone;
1359
1368
  const natalIso = localToUtcIso("datetime", String(args.datetime), timezone);
1360
- const lat = args.latitude ?? 0;
1361
- const lon = args.longitude ?? 0;
1369
+ const lat = resolved.latitude;
1370
+ const lon = resolved.longitude;
1371
+ if (lat == null || lon == null) {
1372
+ throw new Error("explore_human_design_transit requires either `latitude` + `longitude` or a resolvable `location` name. " +
1373
+ "Human Design is sensitive to birth location; a chart at 0°N 0°E is silently wrong.");
1374
+ }
1375
+ const location = String(resolved.location ?? args.location ?? "Subject").slice(0, 120);
1362
1376
  // SubjectRequest wire shape: name (required), birth_datetime.iso, and
1363
1377
  // birth_location with struct-typed coordinate/timezone inputs (matches the
1364
1378
  // live bi-wheel/transit-timeline tools — bare scalars are rejected).
1365
1379
  const body = {
1366
1380
  subject: {
1367
- name: args.location ?? "Subject",
1381
+ name: location,
1368
1382
  birth_datetime: { iso: natalIso },
1369
1383
  birth_location: {
1370
1384
  latitude: { decimal: lat },
@@ -1441,8 +1455,9 @@ registerTool({
1441
1455
  description: "First person's birth data.",
1442
1456
  properties: {
1443
1457
  datetime: { type: "string", description: "Birth datetime, ISO 8601 (Z/offset, or local with timezone)." },
1444
- latitude: { type: "number", description: "Birth latitude (decimal degrees, +N). Optional." },
1445
- longitude: { type: "number", description: "Birth longitude (decimal degrees, +E). Optional." },
1458
+ latitude: { type: "number", description: "Birth latitude, decimal degrees (+N). Optional if `location` is set." },
1459
+ longitude: { type: "number", description: "Birth longitude, decimal degrees (+E). Optional if `location` is set." },
1460
+ 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
1461
  timezone: { type: "string", description: "IANA timezone (e.g. 'America/New_York')." },
1447
1462
  },
1448
1463
  required: ["datetime"],
@@ -1452,8 +1467,9 @@ registerTool({
1452
1467
  description: "Second person's birth data.",
1453
1468
  properties: {
1454
1469
  datetime: { type: "string", description: "Birth datetime, ISO 8601 (Z/offset, or local with timezone)." },
1455
- latitude: { type: "number", description: "Birth latitude (decimal degrees, +N). Optional." },
1456
- longitude: { type: "number", description: "Birth longitude (decimal degrees, +E). Optional." },
1470
+ latitude: { type: "number", description: "Birth latitude, decimal degrees (+N). Optional if `location` is set." },
1471
+ longitude: { type: "number", description: "Birth longitude, decimal degrees (+E). Optional if `location` is set." },
1472
+ 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
1473
  timezone: { type: "string", description: "IANA timezone (e.g. 'America/New_York')." },
1458
1474
  },
1459
1475
  required: ["datetime"],
@@ -1486,17 +1502,32 @@ registerTool({
1486
1502
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["model", "app"] } },
1487
1503
  handler: async (args) => {
1488
1504
  const client = getActiveClient();
1489
- const toSubject = (p, label) => {
1490
- const tz = p?.timezone;
1505
+ // Resolve each person's `location` → lat/lon/tz when coords are unset.
1506
+ // If neither is provided we throw rather than silently computing at
1507
+ // 0°N 0°E (a plausible-looking chart nothing downstream can detect).
1508
+ const toSubject = async (p, label) => {
1509
+ const resolved = await coordsFromArgsOrLocation({
1510
+ latitude: p?.latitude,
1511
+ longitude: p?.longitude,
1512
+ timezone: p?.timezone,
1513
+ location: p?.location,
1514
+ });
1515
+ const tz = p?.timezone ?? resolved.timezone;
1516
+ const lat = resolved.latitude;
1517
+ const lon = resolved.longitude;
1518
+ if (lat == null || lon == null) {
1519
+ throw new Error(`${label} requires either \`latitude\` + \`longitude\` or a resolvable \`location\` name. ` +
1520
+ "Human Design is sensitive to birth location; a chart at 0°N 0°E is silently wrong.");
1521
+ }
1491
1522
  return {
1492
1523
  birth_datetime_utc: localToUtcIso(`${label}.datetime`, String(p?.datetime), tz, `${label}.timezone`),
1493
- latitude: p?.latitude ?? 0,
1494
- longitude: p?.longitude ?? 0,
1524
+ latitude: lat,
1525
+ longitude: lon,
1495
1526
  };
1496
1527
  };
1497
1528
  const body = {
1498
- subject_1: toSubject(args.person_a, "person_a"),
1499
- subject_2: toSubject(args.person_b, "person_b"),
1529
+ subject_1: await toSubject(args.person_a, "person_a"),
1530
+ subject_2: await toSubject(args.person_b, "person_b"),
1500
1531
  };
1501
1532
  const bundleAvailable = Boolean(getBodygraphBundle());
1502
1533
  // Mirror the transit tool: explicit dark default, host-theme refetch.
@@ -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: { iso: args.natal_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 },
@@ -66,6 +66,7 @@ export const CORE_TOOL_NAMES = new Set([
66
66
  "ephemeris_electional",
67
67
  "electional_moment_analysis",
68
68
  "electional_station_tracker",
69
+ "electional_angle_crossings",
69
70
  // Astrocartography
70
71
  "acg_power_lines",
71
72
  "acg_hits",
@@ -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 — route to global endpoint
64
- const params = {};
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
- const endpoint = args.eclipse_type === "lunar" ? "/eclipse/lunar/global" : "/eclipse/solar/global";
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
  });
@@ -239,3 +239,93 @@ registerTool({
239
239
  });
240
240
  },
241
241
  });
242
+ // GET /electional/angle-crossings — OE-FEAT-2026-001
243
+ registerTool({
244
+ name: "electional_angle_crossings",
245
+ description: "Find WHEN a body crosses an angle (Ascendant, Descendant, Midheaven, Imum Coeli) at a specific " +
246
+ "place — the time-inverse of astrocartography. Answers 'when does Mercury cross the Descendant " +
247
+ "in Warsaw today?'\n\n" +
248
+ "✅ USE THIS FOR: 'when is Venus on my horizon in Lisbon?', 'what time does Jupiter culminate " +
249
+ "over Tokyo on Friday?', timing a moment by a planet's angularity at a place.\n" +
250
+ "❌ NOT FOR: which lines pass near a place at a fixed time → use acg_hits instead.\n\n" +
251
+ "Crossing times use the same in-mundo geometry as the astrocartography line engine, so casting a " +
252
+ "map at a returned moment puts that line through the query point. Circumpolar cases (a body that " +
253
+ "never rises or never sets at that latitude) simply return no event.\n\n" +
254
+ "CREDIT COST: 5 credits per call.\n\n" +
255
+ "EXAMPLE: When does Mercury cross the Descendant over Warsaw on 2026-07-30?\n" +
256
+ " latitude=52.2297, longitude=21.0122, bodies='Mercury', angles='DC',\n" +
257
+ " start='2026-07-30T00:00:00Z', end='2026-07-31T00:00:00Z'",
258
+ inputSchema: {
259
+ type: "object",
260
+ properties: {
261
+ latitude: {
262
+ type: "number",
263
+ description: "Latitude of the place in decimal degrees (positive = North). Resolve from a place name with location_search; never recall coordinates from memory.",
264
+ },
265
+ longitude: {
266
+ type: "number",
267
+ description: "Longitude of the place in decimal degrees (positive = East).",
268
+ },
269
+ start: {
270
+ type: "string",
271
+ description: "Start of the search range. " + WINDOW_DATE_DESC + " Defaults to the start of the current UTC day.",
272
+ },
273
+ end: {
274
+ type: "string",
275
+ description: "End of the search range. " + WINDOW_DATE_DESC + " Defaults to 24 hours after start. Maximum range 366 days.",
276
+ },
277
+ bodies: {
278
+ type: "string",
279
+ description: "Comma-separated body names, e.g. 'Mercury,Venus'. Defaults to the classical seven " +
280
+ "(Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn). Aliases: 'NorthNode'/'Rahu' → MeanNode, 'Ketu' → SouthNode.",
281
+ },
282
+ angles: {
283
+ type: "string",
284
+ description: "Comma-separated angles: AC, DC, MC, IC (ASC/DSC accepted). Defaults to all four.",
285
+ },
286
+ refraction_correction: {
287
+ type: "boolean",
288
+ description: "Apply atmospheric refraction to the AC/DC horizon. Defaults to false (geometric horizon), " +
289
+ "which is what the astrocartography endpoints use by default — leave it off to keep times and map lines aligned.",
290
+ },
291
+ max_results: {
292
+ type: "number",
293
+ description: "Maximum number of crossing events to return (default 5000).",
294
+ },
295
+ format: {
296
+ type: "string",
297
+ enum: ["json", "llm"],
298
+ description: "Output format. 'llm' = compact token-efficient output (available on all tiers).",
299
+ },
300
+ },
301
+ required: ["latitude", "longitude"],
302
+ additionalProperties: false,
303
+ },
304
+ outputSchema: OUTPUT_SCHEMA_JSON,
305
+ annotations: { title: "Angle Crossing Search", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
306
+ handler: async (args) => {
307
+ validateRequired(args, ["latitude", "longitude"]);
308
+ const query = {
309
+ latitude: args.latitude,
310
+ longitude: args.longitude,
311
+ };
312
+ if (args.start)
313
+ query.start = args.start;
314
+ if (args.end)
315
+ query.end = args.end;
316
+ if (args.bodies)
317
+ query.bodies = args.bodies;
318
+ if (args.angles)
319
+ query.angles = args.angles;
320
+ if (args.refraction_correction !== undefined)
321
+ query.refraction_correction = args.refraction_correction;
322
+ if (args.max_results !== undefined)
323
+ query.max_results = args.max_results;
324
+ if (args.format)
325
+ query.format = args.format;
326
+ return await getActiveClient().request("GET", "/electional/angle-crossings", {
327
+ data: {},
328
+ params: query,
329
+ });
330
+ },
331
+ });
@@ -2,10 +2,18 @@ import { registerTool, validateRequired, validateCoordinates } from "../index.js
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
4
  import { DATETIME_DESC, TIMEZONE_PROPERTY, assertZonedDatetime, toDateTimeInputBody } from "../datetime.js";
5
- function buildSubject(name, datetime, lat, lon) {
5
+ // `field`/`timezoneField` name the caller's own argument path (e.g.
6
+ // "subjects[2].datetime") so a zone-less value is rejected with a message
7
+ // that points at the offending subject rather than the batch as a whole.
8
+ //
9
+ // birth_datetime MUST go through toDateTimeInputBody: a naive clock time plus
10
+ // a sibling `timezone` is a valid input shape, and dropping the zone here
11
+ // turns it into an ambiguous datetime the Go contract rejects per-subject —
12
+ // which is what this tool used to do to every subject in the batch.
13
+ function buildSubject(name, datetime, lat, lon, timezone, field = "datetime", timezoneField = "timezone") {
6
14
  return {
7
15
  name,
8
- birth_datetime: { iso: datetime },
16
+ birth_datetime: toDateTimeInputBody(field, datetime, timezone, timezoneField),
9
17
  birth_location: {
10
18
  latitude: { decimal: lat },
11
19
  longitude: { decimal: lon },
@@ -52,8 +60,8 @@ registerTool({
52
60
  handler: async (args) => {
53
61
  validateRequired(args, ["subjects"]);
54
62
  args.subjects.forEach((s, i) => assertZonedDatetime(`subjects[${i}].datetime`, s?.datetime, s?.timezone, `subjects[${i}].timezone`));
55
- const items = args.subjects.map((s) => ({
56
- subject: buildSubject(s.name, s.datetime, s.latitude, s.longitude),
63
+ const items = args.subjects.map((s, i) => ({
64
+ subject: buildSubject(s.name, s.datetime, s.latitude, s.longitude, s?.timezone, `subjects[${i}].datetime`, `subjects[${i}].timezone`),
57
65
  }));
58
66
  return await getActiveClient().post("/ephemeris/natal/batch", { items });
59
67
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openephemeris/mcp-server",
3
- "version": "4.4.0",
3
+ "version": "4.6.0",
4
4
  "description": "Model Context Protocol server for the Open Ephemeris astronomical computation API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",