@openephemeris/mcp-server 4.1.0 → 4.3.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,111 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [4.3.0] — 2026-07-29
11
+
12
+ The bodygraph iframes get the same view toggle across every mode (natal, transit,
13
+ connection), the datetime contract stops being repeated on every datetime tool,
14
+ and eleven tools stop lying about which zone the caller named.
15
+
16
+ ### Added
17
+
18
+ - **Bodygraph mandala layout works on transit and connection overlay iframes.**
19
+ The natal bodygraph iframe has had a graph ↔ mandala toggle since 3.19.0;
20
+ the transit and connection overlays did not, because the overlay render path
21
+ dropped `layout` on the floor. The mandala scene builder already delegates
22
+ channel and center rendering to the same overlay-aware helpers the graph
23
+ layout uses, so this was purely a matter of wiring: openapi
24
+ `VisualRenderConfig` gains an optional `layout` (`graph` | `mandala`),
25
+ `visualConfigFromRender` copies it through, `RenderBodygraphOverlayEmbed`
26
+ applies the same mandala scaling the natal path already had, and both
27
+ `explore_human_design_transit` and `explore_human_design_connection` accept
28
+ a `layout` argument that forwards to `visual_config.layout`. The iframe's
29
+ existing view-toggle button renders on overlay payloads too now, and
30
+ reroutes via the `_refetch` metadata so the model never sees the switch.
31
+ Overlay attributes (`data-connection-type`, `data-transit-new`, the overlay
32
+ legend) survive the mandala switch — the mandala inherits the overlay-aware
33
+ channel builder unchanged.
34
+
35
+ ### Changed
36
+
37
+ - **The datetime contract paragraph moved off every tool description and into the
38
+ server `instructions` field.** The 130-token contract text used to be sent on
39
+ every datetime-accepting tool and re-sent on every model pass — ~40 sites'
40
+ worth of pure repetition. It's now stated once, up front, in `instructions`
41
+ (both stdio and HTTP transports), with each parameter description carrying a
42
+ one-sentence rule + a pointer back. The 400 rejection still rewrites the
43
+ caller's own value into each remedy, so a host that fails to propagate
44
+ `instructions` learns the rule from the first violation. Core surface dropped
45
+ from ~18.7k to ~17.2k tokens (−1.5k, 8%); full surface dropped from ~35.1k to
46
+ ~29.9k (−5.2k, 15%). NEW-4 from the Phase-0 v4.1 audit.
47
+
48
+ ### Fixed
49
+
50
+ - **`ephemeris_house_cusps` (and ten other DateTimeInput tools) lied about how the
51
+ caller supplied the zone.** Calling with `datetime="1987-07-15T09:01:00"` +
52
+ `timezone="America/Chicago"` came back with `datetime_zone: "UTC"` and
53
+ `datetime_zone_source: "offset"` — because the tool converted the value to a
54
+ Z-suffixed UTC string on the client before sending it, and the server (correctly)
55
+ described what it received. `ephemeris_natal_chart` reported the same input as
56
+ `America/Chicago` / `timezone`, so the two tools disagreed about the same fact,
57
+ and the mislabelled `"UTC"` was the exact wrong value the naive-datetime contract
58
+ was written to catch — a false alarm on a correct call.
59
+
60
+ The 11 tools that take a `DateTimeInput` body field (`ephemeris_house_cusps`,
61
+ `ephemeris_planet_position`, `ephemeris_dignities`, `ephemeris_retrograde_status`,
62
+ `ephemeris_midpoints`, `ephemeris_fixed_stars`, `ephemeris_hermetic_lots`,
63
+ `ephemeris_angles_points`, `ephemeris_solar_return`, `ephemeris_lunar_return`,
64
+ `ephemeris_natal_transits`) now pass a naive datetime + IANA zone through as
65
+ `{ iso, timezone: { iana_name } }` — the shape the Go handler already resolves
66
+ correctly and stamps as `datetime_zone_source: "timezone"`. Endpoints whose body
67
+ field is named `*_utc` (`vedic_chart`, Human Design, the ACG `epoch`) still
68
+ pre-convert client-side, because their Go types are strict RFC 3339 `time.Time`
69
+ and cannot accept a companion zone.
70
+
71
+ Contract test asserts, for every fixed tool, that the wire body carries the naive
72
+ datetime and the IANA name — not a synthesized Z-suffixed value — and the Go
73
+ handler test proves all three input forms (`Z`, `±HH:MM`, naive + IANA) resolve
74
+ to the same instant and each self-describes its provenance correctly.
75
+
76
+ ---
77
+
78
+ ## [4.2.0] — 2026-07-29
79
+
80
+ `ephemeris_next_lunar_phase` could not answer the question it exists to answer. The
81
+ tool's response shape changes with this fix, hence a minor rather than a patch.
82
+
83
+ ### Fixed
84
+
85
+ - **`ephemeris_next_lunar_phase` returned zero results for every query.** "When is the
86
+ next full moon?" answered `result_count: 0` with the note *"No matching phase found in
87
+ window"* — an empty result that reads like a real one, so the answer came back as
88
+ "there is no full moon in the next month". Four faults stacked up:
89
+
90
+ - the tool sent `start_date`/`end_date`, but the calendar endpoint takes a single
91
+ `date`, so the search window was silently ignored;
92
+ - it looked for the phase array at `phases`/`data`/`events`, while the response nests
93
+ it at `data.events`, so the list was always empty;
94
+ - it matched phase names as `"full moon"` against the engine's `"full_moon"`, so
95
+ nothing would have matched even once the list was found;
96
+ - `last_quarter` had no match at all, because the engine names it `third_quarter`.
97
+
98
+ The search now walks the calendar forward one lunation at a time, so `count` above 1
99
+ works. An empty result is no longer reported as an answer: since every principal phase
100
+ recurs about every 29.5 days, zero results is a fault and the tool now says so instead
101
+ of handing back a plausible non-answer.
102
+
103
+ ### Changed
104
+
105
+ - **`ephemeris_retrograde_status` now leads with the single-planet path.** The
106
+ description opened on the all-planets sweep and mentioned `planet_id` last, so "is
107
+ Mercury retrograde?" tended to take the 10-credit route instead of the 1-credit one.
108
+ `planet_id` now carries the planet-id table and both costs are stated up front.
109
+ - **`ephemeris_next_lunar_phase` no longer claims to return the zodiac sign and degree.**
110
+ It never did — it returns exact UTC datetimes. It now points at `ephemeris_moon_phase`
111
+ for the sign, and states that cost scales with `count`.
112
+
113
+ ---
114
+
10
115
  ## [4.1.0] — 2026-07-26
11
116
 
12
117
  Follow-up to 4.0.0. The new `400` was telling REST callers to do something that, on
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
7
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
8
8
  import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools, parseToolSurface, SERVER_VERSION } from "./tools/index.js";
9
+ import { DATETIME_CONTRACT_INSTRUCTIONS } from "./tools/datetime.js";
9
10
  import { captureEvent, distinctIdFor } from "./analytics.js";
10
11
  import { listPrompts, getPromptContent } from "./prompts.js";
11
12
  // ── MCP App resource imports ────────────────────────────────────────────────
@@ -62,6 +63,15 @@ const server = new Server({
62
63
  },
63
64
  },
64
65
  },
66
+ instructions: "Open Ephemeris computes real astronomy (JPL DE440, sub-arcsecond) — never guess " +
67
+ "or approximate positions yourself; always call a tool. " +
68
+ "For anything the user should SEE (natal chart, bi-wheel, bodygraph, moon phase), " +
69
+ "prefer the explore_* tools — they render interactive visuals inline. " +
70
+ "ephemeris_* tools return data; use format='llm' on them for compact output. " +
71
+ "If the user has no birth data handy, start with the sky right now — " +
72
+ "explore_moon_phase and ephemeris_retrograde_status need none. " +
73
+ "See the 'welcome_to_open_ephemeris' prompt for orientation.\n\n" +
74
+ DATETIME_CONTRACT_INSTRUCTIONS,
65
75
  });
66
76
  // Which slice of the registry this process advertises. Tools outside the core
67
77
  // surface stay callable by name — this only controls what tools/list returns.
@@ -24,6 +24,7 @@ import { InMemoryEventStore } from "./event-store.js";
24
24
  import { captureEvent, distinctIdFor } from "./analytics.js";
25
25
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
26
26
  import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools, parseToolSurface } from "./tools/index.js";
27
+ import { DATETIME_CONTRACT_INSTRUCTIONS } from "./tools/datetime.js";
27
28
  import { BackendClient, runWithClient } from "./backend/client.js";
28
29
  import { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
29
30
  import { BODYGRAPH_RESOURCE_URI, BODYGRAPH_MIME_TYPE, getBodygraphBundle, } from "./tools/apps/bodygraph-app.js";
@@ -269,7 +270,8 @@ function createMcpServer(analyticsId = "anonymous", surface = "core") {
269
270
  "ephemeris_* tools return data; use format='llm' on them for compact output. " +
270
271
  "If the user has no birth data handy, start with the sky right now — " +
271
272
  "explore_moon_phase and ephemeris_retrograde_status need none. " +
272
- "See the 'welcome_to_open_ephemeris' prompt for orientation.",
273
+ "See the 'welcome_to_open_ephemeris' prompt for orientation.\n\n" +
274
+ DATETIME_CONTRACT_INSTRUCTIONS,
273
275
  });
274
276
  // --- Tool handlers ---
275
277
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -1321,6 +1321,14 @@ registerTool({
1321
1321
  description: "Visual theme for the overlay bodygraph. Set automatically by the embedded app to match " +
1322
1322
  "the host; defaults to dark. You normally never need to pass this.",
1323
1323
  },
1324
+ layout: {
1325
+ type: "string",
1326
+ enum: ["graph", "mandala"],
1327
+ description: "Overlay composition. 'graph' (default) is the classic bodygraph rectangle with the " +
1328
+ "transit's activated channels highlighted; 'mandala' nests the same overlay inside the " +
1329
+ "concentric ring set the natal chart uses. Set by the in-iframe layout toggle, never by " +
1330
+ "the model.",
1331
+ },
1324
1332
  },
1325
1333
  required: ["datetime"],
1326
1334
  },
@@ -1363,9 +1371,10 @@ registerTool({
1363
1371
  // dark MCP hosts — mirror the natal path's explicit dark default. The
1364
1372
  // iframe re-calls this tool with theme once it detects the host theme.
1365
1373
  const theme = args.theme === "light" ? "light" : "dark";
1374
+ const layout = args.layout === "mandala" ? "mandala" : undefined;
1366
1375
  if (bundleAvailable) {
1367
1376
  body.include_visual = true;
1368
- body.visual_config = { theme };
1377
+ body.visual_config = { theme, ...(layout ? { layout } : {}) };
1369
1378
  }
1370
1379
  const resp = await client.request("POST", "/human-design/transit-chart", { data: body });
1371
1380
  const transit = (resp?.transit ?? {});
@@ -1443,6 +1452,14 @@ registerTool({
1443
1452
  description: "Visual theme for the overlay bodygraph. Set automatically by the embedded app to match " +
1444
1453
  "the host; defaults to dark. You normally never need to pass this.",
1445
1454
  },
1455
+ layout: {
1456
+ type: "string",
1457
+ enum: ["graph", "mandala"],
1458
+ description: "Overlay composition. 'graph' (default) is the classic bodygraph rectangle with connected " +
1459
+ "channels classified by connection type; 'mandala' nests the same overlay inside the " +
1460
+ "concentric ring set the natal chart uses. Set by the in-iframe layout toggle, never by " +
1461
+ "the model.",
1462
+ },
1446
1463
  },
1447
1464
  required: ["person_a", "person_b"],
1448
1465
  },
@@ -1472,9 +1489,10 @@ registerTool({
1472
1489
  const bundleAvailable = Boolean(getBodygraphBundle());
1473
1490
  // Mirror the transit tool: explicit dark default, host-theme refetch.
1474
1491
  const theme = args.theme === "light" ? "light" : "dark";
1492
+ const layout = args.layout === "mandala" ? "mandala" : undefined;
1475
1493
  if (bundleAvailable) {
1476
1494
  body.include_visual = true;
1477
- body.visual_config = { theme };
1495
+ body.visual_config = { theme, ...(layout ? { layout } : {}) };
1478
1496
  }
1479
1497
  const resp = await client.request("POST", "/human-design/composite", { data: body });
1480
1498
  const connections = Array.isArray(resp?.connections) ? resp.connections : [];
@@ -27,19 +27,30 @@ export declare function hasZoneSuffix(value: string): boolean;
27
27
  /** The canonical description for a birth / chart-moment datetime parameter. */
28
28
  export declare const DATETIME_DESC: string;
29
29
  /** The canonical description for the companion `timezone` parameter. */
30
- export declare const TIMEZONE_DESC: string;
30
+ export declare const TIMEZONE_DESC = "IANA timezone (e.g. `America/Chicago`); required when the datetime is naive, ignored otherwise.";
31
31
  /** The canonical description for a search-window date parameter (date or datetime). */
32
- export declare const WINDOW_DATE_DESC: string;
32
+ export declare const WINDOW_DATE_DESC = "ISO 8601 date or zoned datetime; a naive clock time is rejected. Date-only resolves to 12:00 UTC.";
33
33
  /** The `timezone` property to spread into a tool's inputSchema. */
34
34
  export declare const TIMEZONE_PROPERTY: {
35
35
  readonly type: "string";
36
- readonly description: string;
36
+ readonly description: "IANA timezone (e.g. `America/Chicago`); required when the datetime is naive, ignored otherwise.";
37
37
  };
38
38
  /** Build a `<prefix>_timezone` property with a tailored example. */
39
39
  export declare function timezoneProperty(label: string, example?: string): {
40
40
  readonly type: "string";
41
- readonly description: string;
41
+ readonly description: `IANA timezone for ${string} (e.g. \`${string}\`); required when the datetime is naive.`;
42
42
  };
43
+ /**
44
+ * The full statement of the datetime contract, for the server `instructions`
45
+ * field. Every datetime-accepting tool's parameter description points here.
46
+ *
47
+ * Setting this on the server (both stdio and HTTP transports) surfaces the
48
+ * contract to the model once, up front, instead of re-sending it on every
49
+ * tool description. Clients that fail to propagate `instructions` still get
50
+ * the rule enforced by the 400 rejection, whose detail rewrites the caller's
51
+ * own value into each remedy — the model learns from the first violation.
52
+ */
53
+ export declare const DATETIME_CONTRACT_INSTRUCTIONS: string;
43
54
  /**
44
55
  * The rejection message. Names both remedies, rewriting the caller's own value
45
56
  * into each — the commonest failure is not realising the value was ambiguous.
@@ -53,6 +64,39 @@ export declare function ambiguousDatetimeMessage(field: string, value: string, t
53
64
  * resolved server-side (or by `localToUtcIso` for the `*_utc` endpoints).
54
65
  */
55
66
  export declare function assertZonedDatetime(field: string, value: unknown, timezone?: unknown, timezoneField?: string): void;
67
+ /**
68
+ * Body shape for a `DateTimeInput`-typed request field (`date_time`,
69
+ * `birth_datetime`, `target_datetime`, `transit_datetime`, …). The Go type
70
+ * carries an optional `timezone` companion, so a naive local wall-clock time
71
+ * plus its IANA name is a legitimate value the server resolves itself.
72
+ */
73
+ export type DateTimeInputBody = {
74
+ iso: string;
75
+ timezone?: {
76
+ iana_name: string;
77
+ };
78
+ };
79
+ /**
80
+ * Build a `DateTimeInput` body that PRESERVES zone provenance.
81
+ *
82
+ * Use this in place of `localToUtcIso` for any endpoint whose body field is a
83
+ * `DateTimeInput` (i.e. `{ iso, timezone?, components?, julian_day? }`). Those
84
+ * endpoints already resolve a naive datetime plus an inner `timezone.iana_name`
85
+ * server-side and stamp `datetime_zone_source: "timezone"` on the response.
86
+ * Pre-converting to UTC on the client makes the server believe the caller
87
+ * supplied a Z-suffixed offset — the metadata then reports
88
+ * `datetime_zone: "UTC", datetime_zone_source: "offset"` for a call that
89
+ * actually named "America/Chicago", and `house_cusps` and `natal_chart`
90
+ * disagree about the same fact (NEW-8).
91
+ *
92
+ * - Zone-suffixed input passes through (with `±HHMM` normalised to `±HH:MM`);
93
+ * server records `source: "offset"`.
94
+ * - Naive input + `tz` returns `{ iso, timezone: { iana_name: tz } }`; server
95
+ * records `source: "timezone", zone: tz`.
96
+ * - Naive input with no `tz` throws (same behaviour as `localToUtcIso`).
97
+ * - Date-only or unrecognised strings pass through untouched.
98
+ */
99
+ export declare function toDateTimeInputBody(field: string, dt: string, tz?: string, timezoneField?: string): DateTimeInputBody;
56
100
  /**
57
101
  * Convert a datetime to a UTC ISO 8601 string for the endpoints whose field is
58
102
  * named `*_utc` and whose Go type is a strict RFC 3339 `time.Time`.
@@ -61,5 +105,10 @@ export declare function assertZonedDatetime(field: string, value: unknown, timez
61
105
  * appends a bare "Z" to a zone-less value — it throws instead. Appending Z
62
106
  * asserts the input was UTC, which is exactly the silent assumption that made
63
107
  * the original defect invisible.
108
+ *
109
+ * For endpoints that take a `DateTimeInput` body field (not `*_utc`), prefer
110
+ * `toDateTimeInputBody` — pre-converting to UTC erases the zone name the
111
+ * caller supplied, and the response metadata then lies about how the moment
112
+ * was named.
64
113
  */
65
114
  export declare function localToUtcIso(field: string, dt: string, tz?: string, timezoneField?: string): string;
@@ -34,26 +34,26 @@ export function hasZoneSuffix(value) {
34
34
  return ZONE_SUFFIX.test((value ?? "").trim());
35
35
  }
36
36
  // ─── Canonical parameter documentation ───────────────────────────────────────
37
- // Every tool that accepts a datetime uses these strings verbatim, so the contract
38
- // reads identically everywhere. `test/datetime-contract.test.ts` fails if a tool
39
- // description grows an ISO example that violates it.
37
+ // Every datetime-accepting tool uses these constants verbatim, so the contract
38
+ // reads identically everywhere and `test/datetime-contract.test.ts` can gate
39
+ // on the exact string. They deliberately point at the server `instructions`
40
+ // field (spelled out in DATETIME_CONTRACT_INSTRUCTIONS below) rather than
41
+ // restating the contract inline: the previous ~130-token paragraph was repeated
42
+ // on every datetime tool, so each character cost ~40x across the surface and
43
+ // was re-sent on every model pass. NEW-4 in the Phase-0 v4.1 audit asked for
44
+ // it back.
45
+ //
46
+ // The full rule still lives somewhere the model can see it: `instructions`
47
+ // carries the expanded text, and the 400 rejection rewrites the caller's own
48
+ // value into each remedy — the model learns from the first violation even
49
+ // when a host fails to propagate `instructions`.
40
50
  /** The canonical description for a birth / chart-moment datetime parameter. */
41
- // Kept deliberately terse: this string is repeated on every datetime-accepting
42
- // tool, so each character costs ~40x across the surface and is re-sent on every
43
- // model pass. Every RULE stays; the rationale for the rule lives in the module
44
- // docstring above, which the model never sees.
45
- export const DATETIME_DESC = "ISO 8601 datetime that states its zone. Either put the zone on the value " +
46
- "('1987-07-15T09:01:00-05:00', or '...T14:01:00Z' for UTC), or pass local " +
47
- "wall-clock time plus the `timezone` argument. A zone-less time is REJECTED. " +
48
- "A date with no time ('1987-07-15') resolves to 12:00 UTC.";
51
+ export const DATETIME_DESC = "ISO 8601 datetime; zone required — a `Z`/±HH:MM suffix or the `timezone` argument. " +
52
+ "Date-only resolves to 12:00 UTC. Full rule: server `instructions`.";
49
53
  /** The canonical description for the companion `timezone` parameter. */
50
- export const TIMEZONE_DESC = "IANA timezone name for the birth/observation location, e.g. 'America/Chicago'. " +
51
- "Required when the datetime has no 'Z' or ±HH:MM offset; ignored when it does. " +
52
- "Historical DST is resolved correctly.";
54
+ export const TIMEZONE_DESC = "IANA timezone (e.g. `America/Chicago`); required when the datetime is naive, ignored otherwise.";
53
55
  /** The canonical description for a search-window date parameter (date or datetime). */
54
- export const WINDOW_DATE_DESC = "ISO 8601 date ('2026-01-01') or a zoned datetime ('2026-01-01T00:00:00Z'). " +
55
- "A date with no time resolves to 12:00 UTC. A time without a 'Z' or ±HH:MM offset " +
56
- "is rejected as ambiguous.";
56
+ export const WINDOW_DATE_DESC = "ISO 8601 date or zoned datetime; a naive clock time is rejected. Date-only resolves to 12:00 UTC.";
57
57
  /** The `timezone` property to spread into a tool's inputSchema. */
58
58
  export const TIMEZONE_PROPERTY = {
59
59
  type: "string",
@@ -63,10 +63,25 @@ export const TIMEZONE_PROPERTY = {
63
63
  export function timezoneProperty(label, example = "America/Chicago") {
64
64
  return {
65
65
  type: "string",
66
- description: `IANA timezone name for ${label}, e.g. '${example}'. ` +
67
- "Required when that datetime has no 'Z' or ±HH:MM offset; ignored when it does.",
66
+ description: `IANA timezone for ${label} (e.g. \`${example}\`); required when the datetime is naive.`,
68
67
  };
69
68
  }
69
+ /**
70
+ * The full statement of the datetime contract, for the server `instructions`
71
+ * field. Every datetime-accepting tool's parameter description points here.
72
+ *
73
+ * Setting this on the server (both stdio and HTTP transports) surfaces the
74
+ * contract to the model once, up front, instead of re-sending it on every
75
+ * tool description. Clients that fail to propagate `instructions` still get
76
+ * the rule enforced by the 400 rejection, whose detail rewrites the caller's
77
+ * own value into each remedy — the model learns from the first violation.
78
+ */
79
+ export const DATETIME_CONTRACT_INSTRUCTIONS = "Datetime contract: every clock time needs a zone. Either put the zone on the value " +
80
+ "(`1987-07-15T09:01:00-05:00` for a local time, or `...T14:01:00Z` for UTC), or pass " +
81
+ "the naive local time and name the zone in the sibling `timezone` argument (IANA name, " +
82
+ "e.g. `America/Chicago`). A zone-less clock time is a hard 400 — the engine never " +
83
+ "guesses UTC, because an unstated zone shifts the Ascendant by ~15° per hour and moves " +
84
+ "every house placement. A bare date (no clock time) resolves to 12:00 UTC.";
70
85
  // ─── Enforcement ─────────────────────────────────────────────────────────────
71
86
  /**
72
87
  * The rejection message. Names both remedies, rewriting the caller's own value
@@ -98,6 +113,39 @@ export function assertZonedDatetime(field, value, timezone, timezoneField = "tim
98
113
  return;
99
114
  throw new Error(ambiguousDatetimeMessage(field, value, timezoneField));
100
115
  }
116
+ /**
117
+ * Build a `DateTimeInput` body that PRESERVES zone provenance.
118
+ *
119
+ * Use this in place of `localToUtcIso` for any endpoint whose body field is a
120
+ * `DateTimeInput` (i.e. `{ iso, timezone?, components?, julian_day? }`). Those
121
+ * endpoints already resolve a naive datetime plus an inner `timezone.iana_name`
122
+ * server-side and stamp `datetime_zone_source: "timezone"` on the response.
123
+ * Pre-converting to UTC on the client makes the server believe the caller
124
+ * supplied a Z-suffixed offset — the metadata then reports
125
+ * `datetime_zone: "UTC", datetime_zone_source: "offset"` for a call that
126
+ * actually named "America/Chicago", and `house_cusps` and `natal_chart`
127
+ * disagree about the same fact (NEW-8).
128
+ *
129
+ * - Zone-suffixed input passes through (with `±HHMM` normalised to `±HH:MM`);
130
+ * server records `source: "offset"`.
131
+ * - Naive input + `tz` returns `{ iso, timezone: { iana_name: tz } }`; server
132
+ * records `source: "timezone", zone: tz`.
133
+ * - Naive input with no `tz` throws (same behaviour as `localToUtcIso`).
134
+ * - Date-only or unrecognised strings pass through untouched.
135
+ */
136
+ export function toDateTimeInputBody(field, dt, tz, timezoneField = "timezone") {
137
+ const value = (dt ?? "").trim();
138
+ if (hasZoneSuffix(value)) {
139
+ return { iso: value.replace(/([+-]\d{2})(\d{2})$/, "$1:$2") };
140
+ }
141
+ if (!isNaiveClockTime(value)) {
142
+ return { iso: value };
143
+ }
144
+ if (!tz || tz.trim() === "") {
145
+ throw new Error(ambiguousDatetimeMessage(field, value, timezoneField));
146
+ }
147
+ return { iso: value, timezone: { iana_name: tz } };
148
+ }
101
149
  /**
102
150
  * Convert a datetime to a UTC ISO 8601 string for the endpoints whose field is
103
151
  * named `*_utc` and whose Go type is a strict RFC 3339 `time.Time`.
@@ -106,6 +154,11 @@ export function assertZonedDatetime(field, value, timezone, timezoneField = "tim
106
154
  * appends a bare "Z" to a zone-less value — it throws instead. Appending Z
107
155
  * asserts the input was UTC, which is exactly the silent assumption that made
108
156
  * the original defect invisible.
157
+ *
158
+ * For endpoints that take a `DateTimeInput` body field (not `*_utc`), prefer
159
+ * `toDateTimeInputBody` — pre-converting to UTC erases the zone name the
160
+ * caller supplied, and the response metadata then lies about how the moment
161
+ * was named.
109
162
  */
110
163
  export function localToUtcIso(field, dt, tz, timezoneField = "timezone") {
111
164
  const value = (dt ?? "").trim();
@@ -1,7 +1,7 @@
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, assertZonedDatetime, localToUtcIso, timezoneProperty } from "../datetime.js";
4
+ import { DATETIME_DESC, assertZonedDatetime, toDateTimeInputBody, timezoneProperty } from "../datetime.js";
5
5
  /** Reject a zone-less person datetime here, before a credit is spent on it. */
6
6
  function assertPersonZoned(prefix, args) {
7
7
  assertZonedDatetime(`${prefix}_datetime`, args[`${prefix}_datetime`], args[`${prefix}_timezone`], `${prefix}_timezone`);
@@ -203,12 +203,10 @@ registerTool({
203
203
  subject: buildSubject("Natal", args.natal_datetime, args.natal_latitude, args.natal_longitude, args.natal_timezone),
204
204
  };
205
205
  if (args.transit_datetime) {
206
- // transit_datetime is a DateTimeInput on the backend, and unlike the
207
- // subject it has no timezone companion — so , which
208
- // was declared but never read, is resolved here.
209
- body.transit_datetime = {
210
- iso: localToUtcIso("transit_datetime", args.transit_datetime, args.transit_timezone, "transit_timezone"),
211
- };
206
+ // transit_datetime is a DateTimeInput — pass the caller's zone name
207
+ // through so the response's datetime_zone_source reads "timezone",
208
+ // not "offset" (NEW-8).
209
+ body.transit_datetime = toDateTimeInputBody("transit_datetime", args.transit_datetime, args.transit_timezone, "transit_timezone");
212
210
  }
213
211
  const query = {};
214
212
  if (args.format)
@@ -1,7 +1,7 @@
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, localToUtcIso } from "../datetime.js";
4
+ import { DATETIME_DESC, TIMEZONE_PROPERTY, toDateTimeInputBody } from "../datetime.js";
5
5
  // POST /ephemeris/planet-position — OE-016
6
6
  registerTool({
7
7
  name: "ephemeris_planet_position",
@@ -45,7 +45,7 @@ registerTool({
45
45
  validateRequired(args, ["planet_id", "datetime"]);
46
46
  const body = {
47
47
  planet_id: args.planet_id,
48
- date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
48
+ date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
49
49
  };
50
50
  if (args.latitude != null)
51
51
  body.latitude = args.latitude;
@@ -93,7 +93,7 @@ registerTool({
93
93
  handler: async (args) => {
94
94
  validateRequired(args, ["datetime", "latitude", "longitude"]);
95
95
  const body = {
96
- date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
96
+ date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
97
97
  latitude: args.latitude,
98
98
  longitude: args.longitude,
99
99
  };
@@ -1,7 +1,7 @@
1
1
  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
- import { DATETIME_DESC, TIMEZONE_PROPERTY, assertZonedDatetime, localToUtcIso } from "../datetime.js";
4
+ import { DATETIME_DESC, TIMEZONE_PROPERTY, assertZonedDatetime, toDateTimeInputBody } from "../datetime.js";
5
5
  function buildSubject(name, datetime, lat, lon) {
6
6
  return {
7
7
  name,
@@ -78,7 +78,7 @@ registerTool({
78
78
  handler: async (args) => {
79
79
  validateRequired(args, ["datetime"]);
80
80
  return await getActiveClient().post("/ephemeris/dignities", {
81
- date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
81
+ date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
82
82
  });
83
83
  },
84
84
  });
@@ -88,19 +88,27 @@ registerTool({
88
88
  // the documented "all planets" behaviour works correctly.
89
89
  registerTool({
90
90
  name: "ephemeris_retrograde_status",
91
- description: "Get retrograde/direct status and speed for all planets at a given date/time. " +
92
- "Returns is_retrograde flag, longitude speed, and station proximity for every planet.\n\n" +
93
- "✅ Answers 'is X retrograde?' at ONE instant. For WHEN a planet turns retrograde or " +
94
- "direct, or whether it stations anywhere in a date range, use electional_station_tracker.\n\n" +
95
- "CREDIT COST: for a single planet — the common case — pass planet_id (0-9, e.g. 2 for " +
96
- "Mercury): 1 credit. Omitting planet_id runs the all-planets sweep: 10 credits (the " +
97
- "backend bills one credit per body and this fans out to 10).",
91
+ description: "Get retrograde/direct status and speed for ONE planet — or, if you ask for it, all " +
92
+ "ten — at a given date/time. Returns is_retrograde flag, longitude speed, and station " +
93
+ "proximity.\n\n" +
94
+ "✅ Answers 'is X retrograde?' at ONE instant. Pass planet_id for the named planet; " +
95
+ "'is Mercury retrograde?' is planet_id=2, not a whole-sky sweep. For WHEN a planet turns " +
96
+ "retrograde or direct, or whether it stations anywhere in a date range, use " +
97
+ "electional_station_tracker.\n\n" +
98
+ "CREDIT COST: 1 credit for a single planet (pass planet_id). Omitting planet_id runs the " +
99
+ "all-planets sweep and costs 10 credits — the backend bills one credit per body and this " +
100
+ "fans out to 10. Only omit it when the question really is about every planet.",
98
101
  inputSchema: {
99
102
  type: "object",
100
103
  properties: {
101
104
  datetime: { type: "string", description: DATETIME_DESC },
102
105
  timezone: TIMEZONE_PROPERTY,
103
- planet_id: { type: "integer", description: "Optional single planet ID (0-9). Omit for all planets." },
106
+ planet_id: {
107
+ type: "integer",
108
+ description: "Single planet ID (0=Sun, 1=Moon, 2=Mercury, 3=Venus, 4=Mars, 5=Jupiter, " +
109
+ "6=Saturn, 7=Uranus, 8=Neptune, 9=Pluto). Pass this whenever the question " +
110
+ "names a planet — 1 credit. Omit only to sweep all ten — 10 credits.",
111
+ },
104
112
  },
105
113
  required: ["datetime"],
106
114
  additionalProperties: false,
@@ -113,14 +121,14 @@ registerTool({
113
121
  // If a specific planet is requested, delegate directly.
114
122
  if (args.planet_id != null) {
115
123
  return await client.post("/ephemeris/retrograde-status", {
116
- date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
124
+ date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
117
125
  planet_id: args.planet_id,
118
126
  });
119
127
  }
120
128
  // Fan-out: the backend only handles one planet per call; query all 10 in parallel.
121
129
  const PLANET_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
122
130
  const results = await Promise.all(PLANET_IDS.map((pid) => client.post("/ephemeris/retrograde-status", {
123
- date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
131
+ date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
124
132
  planet_id: pid,
125
133
  }).catch(() => null)));
126
134
  // Merge into a keyed object: { planet_name: {...status} }
@@ -161,7 +169,7 @@ registerTool({
161
169
  handler: async (args) => {
162
170
  validateRequired(args, ["datetime", "latitude", "longitude"]);
163
171
  return await getActiveClient().post("/ephemeris/midpoints", {
164
- date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
172
+ date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
165
173
  latitude: args.latitude,
166
174
  longitude: args.longitude,
167
175
  });
@@ -210,7 +218,7 @@ registerTool({
210
218
  // the exact failure this tool exists to avoid — reject it instead.
211
219
  validateCoordinates(args, "latitude", "longitude");
212
220
  const body = {
213
- date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
221
+ date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
214
222
  };
215
223
  if (args.latitude != null)
216
224
  body.latitude = args.latitude;
@@ -250,7 +258,7 @@ registerTool({
250
258
  handler: async (args) => {
251
259
  validateRequired(args, ["datetime", "latitude", "longitude"]);
252
260
  return await getActiveClient().post("/ephemeris/hermetic-lots", {
253
- date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
261
+ date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
254
262
  latitude: args.latitude,
255
263
  longitude: args.longitude,
256
264
  });
@@ -278,7 +286,7 @@ registerTool({
278
286
  handler: async (args) => {
279
287
  validateRequired(args, ["datetime", "latitude", "longitude"]);
280
288
  return await getActiveClient().post("/ephemeris/angles-points", {
281
- date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
289
+ date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
282
290
  latitude: args.latitude,
283
291
  longitude: args.longitude,
284
292
  });