@openephemeris/mcp-server 3.24.0 → 4.1.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/LICENSE +21 -21
  3. package/README.md +75 -3
  4. package/dist/analytics.js +37 -5
  5. package/dist/backend/client.d.ts +7 -0
  6. package/dist/backend/client.js +39 -38
  7. package/dist/index.js +64 -2
  8. package/dist/oauth/session-utils.d.ts +42 -18
  9. package/dist/oauth/session-utils.js +79 -0
  10. package/dist/prompts.js +55 -42
  11. package/dist/server-sse.js +114 -14
  12. package/dist/tools/apps/bazi-app.js +15 -28
  13. package/dist/tools/apps/bi-wheel-app.js +14 -11
  14. package/dist/tools/apps/bodygraph-app.d.ts +5 -5
  15. package/dist/tools/apps/bodygraph-app.js +159 -212
  16. package/dist/tools/apps/chart-wheel-app.js +21 -20
  17. package/dist/tools/apps/location-tools.js +167 -18
  18. package/dist/tools/apps/moon-phase-app.js +10 -3
  19. package/dist/tools/apps/transit-timeline-app.js +6 -4
  20. package/dist/tools/apps/vedic-chart-app.js +15 -49
  21. package/dist/tools/datetime.d.ts +65 -0
  22. package/dist/tools/datetime.js +153 -0
  23. package/dist/tools/dev.js +4 -3
  24. package/dist/tools/index.d.ts +45 -2
  25. package/dist/tools/index.js +81 -2
  26. package/dist/tools/specialized/account.d.ts +1 -0
  27. package/dist/tools/specialized/account.js +100 -0
  28. package/dist/tools/specialized/acg.js +16 -14
  29. package/dist/tools/specialized/bazi.d.ts +7 -1
  30. package/dist/tools/specialized/bazi.js +89 -23
  31. package/dist/tools/specialized/bi_wheel.js +5 -4
  32. package/dist/tools/specialized/chart_wheel.js +5 -8
  33. package/dist/tools/specialized/comparative.js +40 -21
  34. package/dist/tools/specialized/electional.js +13 -10
  35. package/dist/tools/specialized/ephemeris_core.js +13 -8
  36. package/dist/tools/specialized/ephemeris_extended.js +60 -53
  37. package/dist/tools/specialized/hd_bodygraph.js +8 -10
  38. package/dist/tools/specialized/hd_cycles.js +7 -14
  39. package/dist/tools/specialized/hd_group.js +20 -13
  40. package/dist/tools/specialized/human_design.js +11 -17
  41. package/dist/tools/specialized/moon.js +14 -6
  42. package/dist/tools/specialized/natal.js +7 -9
  43. package/dist/tools/specialized/progressed.js +12 -8
  44. package/dist/tools/specialized/relocation.js +9 -3
  45. package/dist/tools/specialized/returns.js +23 -11
  46. package/dist/tools/specialized/synastry.js +17 -6
  47. package/dist/tools/specialized/transits.js +9 -5
  48. package/dist/tools/specialized/vedic.js +5 -3
  49. package/dist/tools/specialized/venus_star_points.js +14 -9
  50. package/dist/ui/bazi.html +1063 -1049
  51. package/dist/ui/bi-wheel.html +4188 -4128
  52. package/dist/ui/bodygraph.html +3673 -3616
  53. package/dist/ui/chart-wheel.html +3769 -3713
  54. package/dist/ui/moon-phase.html +3219 -3153
  55. package/dist/ui/transit-timeline.html +199 -170
  56. package/dist/ui/vedic-chart.html +1116 -1098
  57. package/package.json +3 -2
  58. package/smithery.yaml +1 -1
@@ -1,17 +1,16 @@
1
1
  import { registerTool } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_IMAGE_AND_JSON } from "../output-schemas.js";
4
- function parseBaziArgs(args) {
4
+ export function parseBaziArgs(args, datetimeField = "datetime") {
5
5
  let { year, month, day, hour } = args;
6
- if (args.datetime && (!year || !month || !day)) {
7
- const dt = new Date(args.datetime);
8
- if (!isNaN(dt.getTime())) {
9
- year = dt.getUTCFullYear();
10
- month = dt.getUTCMonth() + 1;
11
- day = dt.getUTCDate();
12
- if (hour == null)
13
- hour = dt.getUTCHours();
14
- }
6
+ const datetime = args[datetimeField];
7
+ if (datetime && (!year || !month || !day)) {
8
+ const parts = baziLocalComponents(datetime, args.timezone, datetimeField);
9
+ year = parts.year;
10
+ month = parts.month;
11
+ day = parts.day;
12
+ if (hour == null)
13
+ hour = parts.hour;
15
14
  }
16
15
  if (!year || !month || !day) {
17
16
  throw new Error("Provide year/month/day fields, or a datetime ISO string. " +
@@ -22,6 +21,49 @@ function parseBaziArgs(args) {
22
21
  out.hour = hour;
23
22
  return out;
24
23
  }
24
+ /**
25
+ * Extract the LOCAL calendar/clock components a BaZi chart is built from.
26
+ *
27
+ * BaZi pillars are a function of local wall-clock time at the birth place — the
28
+ * hour pillar is a two-hour shí block on the local clock — so unlike a western
29
+ * chart there is no UTC instant to resolve to, and a zone-less datetime is
30
+ * exactly right here. What was wrong was HOW those components were read: the
31
+ * previous implementation did `new Date("1987-07-15T14:00:00")`, which JS parses
32
+ * in the *host process's* timezone, then read `.getUTCHours()`. On any server
33
+ * not running in UTC that silently shifted the hour pillar, and near midnight
34
+ * the day pillar too. Reading the components lexically removes the dependency on
35
+ * where the server happens to be.
36
+ *
37
+ * A datetime that carries its own zone (`Z` / `±HH:MM`) is not local wall-clock
38
+ * time. It is converted into `timezone` when one is supplied; without one, its
39
+ * own stated zone is taken as the local clock, which is the long-standing
40
+ * behaviour and the only reading available.
41
+ */
42
+ function baziLocalComponents(datetime, timezone, field) {
43
+ const value = String(datetime).trim();
44
+ const zoned = /([Zz]|[+-]\d{2}:?\d{2})$/.test(value);
45
+ if (zoned && timezone) {
46
+ const parts = new Intl.DateTimeFormat("en-US", {
47
+ timeZone: timezone,
48
+ hour12: false,
49
+ year: "numeric", month: "2-digit", day: "2-digit",
50
+ hour: "2-digit", minute: "2-digit",
51
+ }).formatToParts(new Date(value));
52
+ const get = (t) => Number(parts.find((p) => p.type === t)?.value);
53
+ return { year: get("year"), month: get("month"), day: get("day"), hour: get("hour") % 24 };
54
+ }
55
+ const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/.exec(value);
56
+ if (!m) {
57
+ throw new Error(`\`${field}\` is "${value}", which is not an ISO 8601 date or datetime. ` +
58
+ "Example: '1987-07-15T14:00:00' (local time at the birth place).");
59
+ }
60
+ return {
61
+ year: Number(m[1]),
62
+ month: Number(m[2]),
63
+ day: Number(m[3]),
64
+ hour: m[4] != null ? Number(m[4]) : undefined,
65
+ };
66
+ }
25
67
  // Shared datetime input schema fragment — used across all BaZiRequest tools.
26
68
  const DATETIME_PROPERTIES = {
27
69
  year: {
@@ -43,8 +85,17 @@ const DATETIME_PROPERTIES = {
43
85
  },
44
86
  datetime: {
45
87
  type: "string",
46
- description: "Alternative to year/month/day: ISO 8601 datetime (e.g. '1987-07-15T14:00:00'). " +
47
- "year/month/day/hour are extracted automatically. Use this OR the individual fields.",
88
+ description: "Alternative to year/month/day: ISO 8601 datetime read as LOCAL wall-clock time at the " +
89
+ "birth place — BaZi pillars are local by definition, so a zone-less value like " +
90
+ "'1987-07-15T14:00:00' is correct here and is NOT converted to UTC. " +
91
+ "If you pass an instant instead ('1987-07-15T19:00:00Z' or with an offset), also pass " +
92
+ "`timezone` so the local hour can be derived. Use this OR the individual fields.",
93
+ },
94
+ timezone: {
95
+ type: "string",
96
+ description: "IANA timezone name for the birth place, e.g. 'Asia/Shanghai'. Only needed when " +
97
+ "`datetime` carries a 'Z' or ±HH:MM offset, to convert that instant back to the local " +
98
+ "clock the pillars are built from.",
48
99
  },
49
100
  };
50
101
  // Shared visual input schema fragment — mirrors natal.ts pattern.
@@ -93,10 +144,9 @@ registerTool({
93
144
  "Includes the Day Master element and basic metadata.\n\n" +
94
145
  "CREDIT COST: 1 credit (3 credits when include_visual=true).\n\n" +
95
146
  "Set include_visual=true to receive a rendered SVG chart alongside the text data.\n\n" +
96
- "For deep analysis, follow up with:\n" +
97
- " • bazi_ten_gods() — Ten Gods (十神) per pillar including hidden stems\n" +
98
- " • bazi_element_balance() — Weighted Wu Xing (五行) element scores + Yong Shen\n" +
99
- " • bazi_luck_pillars() — 8 Da Yun 10-year luck cycles\n\n" +
147
+ "For deep analysis — Ten Gods (十神) per pillar, weighted Wu Xing (五行) element balance, " +
148
+ "Da Yun 10-year luck cycles — dedicated tools are available on the full tool surface " +
149
+ "(`?profile=full` on HTTP, OPENEPHEMERIS_TOOLS=full on stdio).\n\n" +
100
150
  "EXAMPLE: BaZi chart for someone born July 15, 1987 at 2 PM:\n" +
101
151
  " year=1987, month=7, day=15, hour=14",
102
152
  inputSchema: {
@@ -132,7 +182,7 @@ registerTool({
132
182
  " RESOURCE: Indirect Resource (偏印), Direct Resource (正印)\n\n" +
133
183
  "The Day Pillar itself has no Ten God (it IS the Day Master). " +
134
184
  "Hidden stems in the Day Branch still receive Ten God labels.\n\n" +
135
- "CREDIT COST: 1 credit (3 credits when include_visual=true).\n\n" +
185
+ "CREDIT COST: 3 credits (5 credits when include_visual=true).\n\n" +
136
186
  "Set include_visual=true to receive an SVG Four Pillars chart alongside the Ten Gods data.\n\n" +
137
187
  "EXAMPLE: Ten Gods for someone born July 15, 1987 at 2 PM:\n" +
138
188
  " year=1987, month=7, day=15, hour=14",
@@ -167,7 +217,7 @@ registerTool({
167
217
  "Also returns:\n" +
168
218
  " • Day Master strength: 'strong' (旺, ≥50% own+resource elements) or 'weak' (弱)\n" +
169
219
  " • Yong Shen (用神): the favorable element — what the chart needs most\n\n" +
170
- "CREDIT COST: 1 credit (3 credits when include_visual=true).\n\n" +
220
+ "CREDIT COST: 3 credits (5 credits when include_visual=true).\n\n" +
171
221
  "Set include_visual=true to receive an SVG Four Pillars chart alongside the element data.\n\n" +
172
222
  "EXAMPLE: Element balance for 1987-07-15 at 14:00:\n" +
173
223
  " year=1987, month=7, day=15, hour=14",
@@ -204,7 +254,7 @@ registerTool({
204
254
  "gender is REQUIRED — the direction of luck pillars is gender-dependent.\n\n" +
205
255
  "Returns: starting_age, direction, direction_reason, and 8 pillars each with:\n" +
206
256
  " stem, branch, Chinese characters, element, start_age, end_age\n\n" +
207
- "CREDIT COST: 1 credit per call.\n\n" +
257
+ "CREDIT COST: 3 credits per call.\n\n" +
208
258
  "EXAMPLE: Luck pillars for a female born July 15, 1987 at 2 PM:\n" +
209
259
  " year=1987, month=7, day=15, hour=14, gender='female'",
210
260
  inputSchema: {
@@ -251,7 +301,7 @@ registerTool({
251
301
  " • Identify the energetic quality of any given year\n" +
252
302
  " • Determine a person's birth year pillar for compatibility context\n" +
253
303
  " • Find the NaYin element for year or day interpretations\n\n" +
254
- "CREDIT COST: 1 credit per call.\n\n" +
304
+ "CREDIT COST: 3 credits per call.\n\n" +
255
305
  "EXAMPLE: Year pillar for 2025:\n" +
256
306
  " year=2025",
257
307
  inputSchema: {
@@ -292,7 +342,7 @@ registerTool({
292
342
  " -6 Three Penalties (三刑 Sān Xíng) — branch penalty formations\n\n" +
293
343
  "Assessment grades:\n" +
294
344
  " 90–100: excellent | 70–89: good | 50–69: moderate | below 50: challenging\n\n" +
295
- "CREDIT COST: 2 credits per call.\n\n" +
345
+ "CREDIT COST: 3 credits per call.\n\n" +
296
346
  "EXAMPLE: Compatibility between two people:\n" +
297
347
  " chart_a_year=1987, chart_a_month=7, chart_a_day=15, chart_a_hour=14\n" +
298
348
  " chart_b_year=1990, chart_b_month=3, chart_b_day=22, chart_b_hour=8",
@@ -306,7 +356,14 @@ registerTool({
306
356
  chart_a_hour: { type: "integer", description: "Chart A birth hour (0–23). Optional." },
307
357
  chart_a_datetime: {
308
358
  type: "string",
309
- description: "Chart A alternative: ISO 8601 datetime. Extracts year/month/day/hour automatically.",
359
+ description: "Chart A alternative: ISO 8601 datetime read as LOCAL wall-clock time at the birth " +
360
+ "place — a zone-less value is correct here and is not converted to UTC. If it carries " +
361
+ "a 'Z' or offset, also pass chart_a_timezone. Extracts year/month/day/hour automatically.",
362
+ },
363
+ chart_a_timezone: {
364
+ type: "string",
365
+ description: "IANA timezone for Chart A's birth place, e.g. 'Asia/Shanghai'. Only needed when " +
366
+ "chart_a_datetime carries a 'Z' or ±HH:MM offset.",
310
367
  },
311
368
  // Chart B
312
369
  chart_b_year: { type: "integer", description: "Chart B birth year." },
@@ -315,7 +372,14 @@ registerTool({
315
372
  chart_b_hour: { type: "integer", description: "Chart B birth hour (0–23). Optional." },
316
373
  chart_b_datetime: {
317
374
  type: "string",
318
- description: "Chart B alternative: ISO 8601 datetime. Extracts year/month/day/hour automatically.",
375
+ description: "Chart B alternative: ISO 8601 datetime read as LOCAL wall-clock time at the birth " +
376
+ "place — a zone-less value is correct here and is not converted to UTC. If it carries " +
377
+ "a 'Z' or offset, also pass chart_b_timezone. Extracts year/month/day/hour automatically.",
378
+ },
379
+ chart_b_timezone: {
380
+ type: "string",
381
+ description: "IANA timezone for Chart B's birth place, e.g. 'Asia/Shanghai'. Only needed when " +
382
+ "chart_b_datetime carries a 'Z' or ±HH:MM offset.",
319
383
  },
320
384
  },
321
385
  additionalProperties: false,
@@ -329,6 +393,7 @@ registerTool({
329
393
  day: args.chart_a_day,
330
394
  hour: args.chart_a_hour,
331
395
  datetime: args.chart_a_datetime,
396
+ timezone: args.chart_a_timezone,
332
397
  });
333
398
  const chartBComponents = parseBaziArgs({
334
399
  year: args.chart_b_year,
@@ -336,6 +401,7 @@ registerTool({
336
401
  day: args.chart_b_day,
337
402
  hour: args.chart_b_hour,
338
403
  datetime: args.chart_b_datetime,
404
+ timezone: args.chart_b_timezone,
339
405
  });
340
406
  return await getActiveClient().request("POST", "/chinese/bazi/compatibility", {
341
407
  data: {
@@ -1,8 +1,7 @@
1
1
  import { registerTool, validateRequired, pickEnum } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_IMAGE } from "../output-schemas.js";
4
- const DATETIME_DESC = "ISO 8601 datetime string, e.g. '1990-04-15T14:30:00' (local time at birth location). " +
5
- "Include timezone offset if known, e.g. '1990-04-15T14:30:00-05:00'.";
4
+ import { DATETIME_DESC, assertZonedDatetime, timezoneProperty } from "../datetime.js";
6
5
  const HOUSE_SYSTEM_MAP = {
7
6
  placidus: "P", whole_sign: "W", equal: "E", koch: "K",
8
7
  campanus: "C", regiomontanus: "R", porphyry: "O",
@@ -19,11 +18,11 @@ registerTool({
19
18
  type: "object",
20
19
  properties: {
21
20
  datetime_a: { type: "string", description: DATETIME_DESC },
22
- timezone_a: { type: "string", description: "IANA timezone name for subject A, e.g. 'America/Denver'." },
21
+ timezone_a: timezoneProperty("subject A's location", "America/Denver"),
23
22
  latitude_a: { type: "number", description: "Latitude for subject A" },
24
23
  longitude_a: { type: "number", description: "Longitude for subject A" },
25
24
  datetime_b: { type: "string", description: DATETIME_DESC },
26
- timezone_b: { type: "string", description: "IANA timezone name for subject B, e.g. 'Europe/London'." },
25
+ timezone_b: timezoneProperty("subject B's location", "Europe/London"),
27
26
  latitude_b: { type: "number", description: "Latitude for subject B" },
28
27
  longitude_b: { type: "number", description: "Longitude for subject B" },
29
28
  house_system: {
@@ -44,6 +43,8 @@ registerTool({
44
43
  annotations: { title: "Bi-Wheel Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
45
44
  handler: async (args) => {
46
45
  validateRequired(args, ["datetime_a", "latitude_a", "longitude_a", "datetime_b", "latitude_b", "longitude_b"]);
46
+ assertZonedDatetime("datetime_a", args.datetime_a, args.timezone_a, "timezone_a");
47
+ assertZonedDatetime("datetime_b", args.datetime_b, args.timezone_b, "timezone_b");
47
48
  const body = {
48
49
  subject_a: {
49
50
  name: "Subject A",
@@ -1,8 +1,7 @@
1
1
  import { registerTool, validateRequired, pickEnum } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_IMAGE } from "../output-schemas.js";
4
- const DATETIME_DESC = "ISO 8601 datetime string, e.g. '1990-04-15T14:30:00' (local time at birth location). " +
5
- "Include timezone offset if known, e.g. '1990-04-15T14:30:00-05:00'.";
4
+ import { DATETIME_DESC, TIMEZONE_PROPERTY, assertZonedDatetime } from "../datetime.js";
6
5
  const HOUSE_SYSTEM_MAP = {
7
6
  placidus: "P", whole_sign: "W", equal: "E", koch: "K",
8
7
  campanus: "C", regiomontanus: "R", porphyry: "O",
@@ -13,8 +12,8 @@ registerTool({
13
12
  description: "Generate a classic astrological Chart Wheel image (SVG) for a person/event. This draws a standard circular chart wheel with planets, aspects, and house cusps. The tool returns a native SVG image that Claude displays inline in the conversation — no external tools needed.\n\n" +
14
13
  "For a user-facing interactive chart wheel, use explore_natal_chart instead.\n\n" +
15
14
  "CREDIT COST: 2 credits per call.\n\n" +
16
- "EXAMPLE: Generate a natal chart wheel for someone born April 15, 1990 in Chicago:\n" +
17
- " datetime='1990-04-15T14:30:00', latitude=41.8781, longitude=-87.6298",
15
+ "EXAMPLE: born 15 April 1990 at 2:30 PM local time in Chicago:\n" +
16
+ " datetime='1990-04-15T14:30:00', timezone='America/Chicago', latitude=41.8781, longitude=-87.6298",
18
17
  inputSchema: {
19
18
  type: "object",
20
19
  properties: {
@@ -22,10 +21,7 @@ registerTool({
22
21
  type: "string",
23
22
  description: DATETIME_DESC,
24
23
  },
25
- timezone: {
26
- type: "string",
27
- description: "IANA timezone name, e.g. 'America/Denver'. Use this if passing local time without a UTC offset in the datetime string.",
28
- },
24
+ timezone: TIMEZONE_PROPERTY,
29
25
  latitude: {
30
26
  type: "number",
31
27
  description: "Geographic latitude of birth location in decimal degrees (positive = North).",
@@ -52,6 +48,7 @@ registerTool({
52
48
  annotations: { title: "Chart Wheel SVG", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
53
49
  handler: async (args) => {
54
50
  validateRequired(args, ["datetime", "latitude", "longitude"]);
51
+ assertZonedDatetime("datetime", args.datetime, args.timezone);
55
52
  const body = {
56
53
  subject: {
57
54
  name: "Chart Wheel Request",
@@ -1,6 +1,11 @@
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";
5
+ /** Reject a zone-less person datetime here, before a credit is spent on it. */
6
+ function assertPersonZoned(prefix, args) {
7
+ assertZonedDatetime(`${prefix}_datetime`, args[`${prefix}_datetime`], args[`${prefix}_timezone`], `${prefix}_timezone`);
8
+ }
4
9
  function buildSubject(name, datetime, lat, lon, timezone) {
5
10
  const subj = {
6
11
  name,
@@ -22,17 +27,19 @@ registerTool({
22
27
  "uses midpoints of each planet pair to derive a single relationship chart.\n\n" +
23
28
  "CREDIT COST: 3 credits per call.\n\n" +
24
29
  "EXAMPLE: Composite for two people:\n" +
25
- " person_a_datetime='1990-04-15T14:30:00', person_a_latitude=41.88, person_a_longitude=-87.63,\n" +
26
- " person_b_datetime='1988-09-22T08:15:00', person_b_latitude=34.05, person_b_longitude=-118.24",
30
+ " person_a_datetime='1990-04-15T14:30:00', person_a_timezone='America/Chicago',\n" +
31
+ " person_a_latitude=41.88, person_a_longitude=-87.63,\n" +
32
+ " person_b_datetime='1988-09-22T08:15:00', person_b_timezone='America/Los_Angeles',\n" +
33
+ " person_b_latitude=34.05, person_b_longitude=-118.24",
27
34
  inputSchema: {
28
35
  type: "object",
29
36
  properties: {
30
- person_a_datetime: { type: "string", description: "Person A birth datetime (ISO 8601)." },
31
- person_a_timezone: { type: "string", description: "IANA timezone name for Person A, e.g. 'America/Denver'." },
37
+ person_a_datetime: { type: "string", description: DATETIME_DESC },
38
+ person_a_timezone: timezoneProperty("Person A's birth location", "America/Denver"),
32
39
  person_a_latitude: { type: "number", description: "Person A birth latitude." },
33
40
  person_a_longitude: { type: "number", description: "Person A birth longitude." },
34
- person_b_datetime: { type: "string", description: "Person B birth datetime (ISO 8601)." },
35
- person_b_timezone: { type: "string", description: "IANA timezone name for Person B, e.g. 'Europe/London'." },
41
+ person_b_datetime: { type: "string", description: DATETIME_DESC },
42
+ person_b_timezone: timezoneProperty("Person B's birth location", "Europe/London"),
36
43
  person_b_latitude: { type: "number", description: "Person B birth latitude." },
37
44
  person_b_longitude: { type: "number", description: "Person B birth longitude." },
38
45
  format: { type: "string", enum: ["json", "llm"], description: "Use 'llm' for token-efficient LLM projection." },
@@ -50,6 +57,8 @@ registerTool({
50
57
  "person_a_datetime", "person_a_latitude", "person_a_longitude",
51
58
  "person_b_datetime", "person_b_latitude", "person_b_longitude",
52
59
  ]);
60
+ assertPersonZoned("person_a", args);
61
+ assertPersonZoned("person_b", args);
53
62
  const query = {};
54
63
  if (args.format)
55
64
  query.format = args.format;
@@ -73,12 +82,12 @@ registerTool({
73
82
  inputSchema: {
74
83
  type: "object",
75
84
  properties: {
76
- person_a_datetime: { type: "string", description: "Person A birth datetime (ISO 8601)." },
77
- person_a_timezone: { type: "string", description: "IANA timezone name for Person A, e.g. 'America/Denver'." },
85
+ person_a_datetime: { type: "string", description: DATETIME_DESC },
86
+ person_a_timezone: timezoneProperty("Person A's birth location", "America/Denver"),
78
87
  person_a_latitude: { type: "number", description: "Person A birth latitude." },
79
88
  person_a_longitude: { type: "number", description: "Person A birth longitude." },
80
- person_b_datetime: { type: "string", description: "Person B birth datetime (ISO 8601)." },
81
- person_b_timezone: { type: "string", description: "IANA timezone name for Person B, e.g. 'Europe/London'." },
89
+ person_b_datetime: { type: "string", description: DATETIME_DESC },
90
+ person_b_timezone: timezoneProperty("Person B's birth location", "Europe/London"),
82
91
  person_b_latitude: { type: "number", description: "Person B birth latitude." },
83
92
  person_b_longitude: { type: "number", description: "Person B birth longitude." },
84
93
  format: { type: "string", enum: ["json", "llm"], description: "Use 'llm' for token-efficient LLM projection." },
@@ -96,6 +105,8 @@ registerTool({
96
105
  "person_a_datetime", "person_a_latitude", "person_a_longitude",
97
106
  "person_b_datetime", "person_b_latitude", "person_b_longitude",
98
107
  ]);
108
+ assertPersonZoned("person_a", args);
109
+ assertPersonZoned("person_b", args);
99
110
  const query = {};
100
111
  if (args.format)
101
112
  query.format = args.format;
@@ -119,12 +130,12 @@ registerTool({
119
130
  inputSchema: {
120
131
  type: "object",
121
132
  properties: {
122
- person_a_datetime: { type: "string", description: "Person A birth datetime (ISO 8601)." },
123
- person_a_timezone: { type: "string", description: "IANA timezone name for Person A, e.g. 'America/Denver'." },
133
+ person_a_datetime: { type: "string", description: DATETIME_DESC },
134
+ person_a_timezone: timezoneProperty("Person A's birth location", "America/Denver"),
124
135
  person_a_latitude: { type: "number", description: "Person A birth latitude." },
125
136
  person_a_longitude: { type: "number", description: "Person A birth longitude." },
126
- person_b_datetime: { type: "string", description: "Person B birth datetime (ISO 8601)." },
127
- person_b_timezone: { type: "string", description: "IANA timezone name for Person B, e.g. 'Europe/London'." },
137
+ person_b_datetime: { type: "string", description: DATETIME_DESC },
138
+ person_b_timezone: timezoneProperty("Person B's birth location", "Europe/London"),
128
139
  person_b_latitude: { type: "number", description: "Person B birth latitude." },
129
140
  person_b_longitude: { type: "number", description: "Person B birth longitude." },
130
141
  format: { type: "string", enum: ["json", "llm"], description: "Use 'llm' for token-efficient LLM projection." },
@@ -142,6 +153,8 @@ registerTool({
142
153
  "person_a_datetime", "person_a_latitude", "person_a_longitude",
143
154
  "person_b_datetime", "person_b_latitude", "person_b_longitude",
144
155
  ]);
156
+ assertPersonZoned("person_a", args);
157
+ assertPersonZoned("person_b", args);
145
158
  const query = {};
146
159
  if (args.format)
147
160
  query.format = args.format;
@@ -163,16 +176,17 @@ registerTool({
163
176
  "are aspecting natal positions. Essential for predictive astrology.\n\n" +
164
177
  "CREDIT COST: 3 credits per call.\n\n" +
165
178
  "EXAMPLE: Current transits to someone born 1990-04-15:\n" +
166
- " natal_datetime='1990-04-15T14:30:00', natal_latitude=41.88, natal_longitude=-87.63",
179
+ " natal_datetime='1990-04-15T14:30:00', natal_timezone='America/Chicago',\n" +
180
+ " natal_latitude=41.88, natal_longitude=-87.63",
167
181
  inputSchema: {
168
182
  type: "object",
169
183
  properties: {
170
- natal_datetime: { type: "string", description: "Natal birth datetime (ISO 8601)." },
171
- natal_timezone: { type: "string", description: "IANA timezone name for Natal, e.g. 'America/Denver'." },
184
+ natal_datetime: { type: "string", description: DATETIME_DESC },
185
+ natal_timezone: timezoneProperty("the natal birth location", "America/Denver"),
172
186
  natal_latitude: { type: "number", description: "Natal birth latitude." },
173
187
  natal_longitude: { type: "number", description: "Natal birth longitude." },
174
- transit_datetime: { type: "string", description: "Transit moment (ISO 8601). Defaults to now." },
175
- transit_timezone: { type: "string", description: "IANA timezone name for Transit, e.g. 'America/Denver'. Only applicable if transit_datetime is provided without offset." },
188
+ transit_datetime: { type: "string", description: "Transit moment. Defaults to now. " + DATETIME_DESC },
189
+ transit_timezone: timezoneProperty("the transit moment", "America/Denver"),
176
190
  format: { type: "string", enum: ["json", "llm"], description: "Use 'llm' for token-efficient LLM projection." },
177
191
  },
178
192
  required: ["natal_datetime", "natal_latitude", "natal_longitude"],
@@ -182,14 +196,19 @@ registerTool({
182
196
  annotations: { title: "Transits to Natal Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
183
197
  handler: async (args) => {
184
198
  validateRequired(args, ["natal_datetime", "natal_latitude", "natal_longitude"]);
199
+ assertPersonZoned("natal", args);
185
200
  // Backend expects: { subject: {...}, transit_datetime?: {...} }
186
201
  // NOT a subjects[] array — natal-transits is a single-subject endpoint
187
202
  const body = {
188
203
  subject: buildSubject("Natal", args.natal_datetime, args.natal_latitude, args.natal_longitude, args.natal_timezone),
189
204
  };
190
205
  if (args.transit_datetime) {
191
- // transit_datetime is a DateTimeInput on the backend
192
- body.transit_datetime = { iso: 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
+ };
193
212
  }
194
213
  const query = {};
195
214
  if (args.format)
@@ -1,6 +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 { WINDOW_DATE_DESC } from "../datetime.js";
4
5
  // All electional endpoints are GET endpoints with query params.
5
6
  // GET /electional/find-window — already existed, keeping it
6
7
  registerTool({
@@ -17,15 +18,15 @@ registerTool({
17
18
  properties: {
18
19
  start_date: {
19
20
  type: "string",
20
- description: "ISO 8601 start date or datetime for the search window (e.g., 2026-03-01).",
21
+ description: "Start of the search window, e.g. '2026-03-01'. " + WINDOW_DATE_DESC,
21
22
  },
22
23
  end_date: {
23
24
  type: "string",
24
- description: "ISO 8601 end date or datetime for the search window.",
25
+ description: "End of the search window. " + WINDOW_DATE_DESC,
25
26
  },
26
27
  latitude: {
27
28
  type: "number",
28
- description: "Latitude of location in decimal degrees (positive = North).",
29
+ description: "Latitude of location in decimal degrees (positive = North). Resolve from a place name with location_search; never recall coordinates from memory.",
29
30
  },
30
31
  longitude: {
31
32
  type: "number",
@@ -89,15 +90,15 @@ registerTool({
89
90
  description: "Analyze the astrological quality of a specific moment: planet positions, aspects, " +
90
91
  "void of course status, lunar phase, day ruler, and an overall electional score (0-100). " +
91
92
  "Perfect for evaluating whether 'right now' or a specific date/time is good for action.\n\n" +
92
- "CREDIT COST: 2 credits per call.\n\n" +
93
+ "CREDIT COST: 5 credits per call.\n\n" +
93
94
  "EXAMPLE: Analyze March 21, 2026 at noon:\n" +
94
- " date='2026-03-21T12:00:00'",
95
+ " date='2026-03-21T12:00:00Z'",
95
96
  inputSchema: {
96
97
  type: "object",
97
98
  properties: {
98
99
  date: {
99
100
  type: "string",
100
- description: "ISO 8601 datetime to analyze (e.g., '2026-03-21T12:00:00'). Defaults to now.",
101
+ description: "ISO 8601 datetime to analyze, with a zone (e.g., '2026-03-21T12:00:00Z' or '2026-03-21T08:00:00-04:00'). Defaults to now.",
101
102
  },
102
103
  format: {
103
104
  type: "string",
@@ -128,8 +129,10 @@ registerTool({
128
129
  "Returns exact station times, longitudes, and signs.\n\n" +
129
130
  "USE THIS TOOL FOR: 'When does Mercury go retrograde?', 'Is Venus retrograde this year?', " +
130
131
  "'What planets station this month?', 'When does Mars go direct?'\n\n" +
132
+ "❌ NOT FOR: 'Is Mercury retrograde right now?' — that is the state at a single instant, " +
133
+ "so use ephemeris_retrograde_status (1 credit for one planet, vs 5 here).\n\n" +
131
134
  "All required fields have smart defaults (searches the next 90 days from today).\n\n" +
132
- "CREDIT COST: 3 credits per call.\n\n" +
135
+ "CREDIT COST: 5 credits per call.\n\n" +
133
136
  "EXAMPLE: Mercury and Venus stations in the next 3 months (all defaults):\n" +
134
137
  " (no args required, will auto-scan next 90 days for all inner planets)\n\n" +
135
138
  "EXAMPLE: Outer planet stations in 2026:\n" +
@@ -198,15 +201,15 @@ registerTool({
198
201
  description: "Find all active aspects between planets at a specific moment. Returns aspect type, " +
199
202
  "orb, quality score, and whether it's applying or separating. Great for checking " +
200
203
  "the 'weather' of a given day.\n\n" +
201
- "CREDIT COST: 2 credits per call.\n\n" +
204
+ "CREDIT COST: 5 credits per call.\n\n" +
202
205
  "EXAMPLE: What aspects are active on March 21, 2026?\n" +
203
- " date='2026-03-21T12:00:00'",
206
+ " date='2026-03-21T12:00:00Z'",
204
207
  inputSchema: {
205
208
  type: "object",
206
209
  properties: {
207
210
  date: {
208
211
  type: "string",
209
- description: "ISO 8601 datetime to check. Defaults to now.",
212
+ description: "Datetime to check. Defaults to now. " + WINDOW_DATE_DESC,
210
213
  },
211
214
  max_orb: {
212
215
  type: "number",
@@ -1,14 +1,17 @@
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
5
  // POST /ephemeris/planet-position — OE-016
5
6
  registerTool({
6
7
  name: "ephemeris_planet_position",
7
8
  description: "Get the precise ecliptic longitude, latitude, distance, speed, and retrograde status " +
8
9
  "for a single planet/body at a given date and time. " +
9
10
  "Planet IDs: 0=Sun, 1=Moon, 2=Mercury, 3=Venus, 4=Mars, 5=Jupiter, 6=Saturn, " +
10
- "7=Uranus, 8=Neptune, 9=Pluto, 10=North Node, 11=South Node, 12=Lilith, " +
11
- "15=Chiron, 17=Ceres, 18=Pallas, 19=Juno, 20=Vesta.\n\n" +
11
+ "7=Uranus, 8=Neptune, 9=Pluto, 10=North Node (Mean), 11=North Node (True), " +
12
+ "12=Lilith (Mean), 15=Chiron, 17=Ceres, 18=Pallas, 19=Juno, 20=Vesta.\n\n" +
13
+ "SOUTH NODE: there is no South Node id. The South Node is always exactly " +
14
+ "opposite the North Node — request id 10 or 11 and add 180° (mod 360).\n\n" +
12
15
  "CREDIT COST: 1 credit per call.\n\n" +
13
16
  "EXAMPLE: Where is Mars on 2026-03-20 at noon UTC?\n" +
14
17
  " planet_id=4, datetime='2026-03-20T12:00:00Z'",
@@ -17,12 +20,13 @@ registerTool({
17
20
  properties: {
18
21
  planet_id: {
19
22
  type: "integer",
20
- description: "Planet/body ID (0=Sun, 1=Moon, 2=Mercury, 3=Venus, 4=Mars, 5=Jupiter, 6=Saturn, 7=Uranus, 8=Neptune, 9=Pluto, 10=MeanNode, 11=TrueNode, 12=Lilith, 15=Chiron, 17=Ceres, 18=Pallas, 19=Juno, 20=Vesta).",
23
+ description: "Planet/body ID (0=Sun, 1=Moon, 2=Mercury, 3=Venus, 4=Mars, 5=Jupiter, 6=Saturn, 7=Uranus, 8=Neptune, 9=Pluto, 10=North Node (Mean), 11=North Node (True), 12=Lilith (Mean), 15=Chiron, 17=Ceres, 18=Pallas, 19=Juno, 20=Vesta). There is no South Node id — take the North Node and add 180°.",
21
24
  },
22
25
  datetime: {
23
26
  type: "string",
24
- description: "ISO 8601 date/time in UTC or with offset (e.g. '2026-03-20T12:00:00Z' or '2026-03-20T12:00:00-05:00').",
27
+ description: DATETIME_DESC,
25
28
  },
29
+ timezone: TIMEZONE_PROPERTY,
26
30
  latitude: {
27
31
  type: "number",
28
32
  description: "Observer latitude for topocentric position (optional). Omit for geocentric.",
@@ -41,7 +45,7 @@ registerTool({
41
45
  validateRequired(args, ["planet_id", "datetime"]);
42
46
  const body = {
43
47
  planet_id: args.planet_id,
44
- date_time: { iso: args.datetime },
48
+ date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
45
49
  };
46
50
  if (args.latitude != null)
47
51
  body.latitude = args.latitude;
@@ -64,11 +68,12 @@ registerTool({
64
68
  properties: {
65
69
  datetime: {
66
70
  type: "string",
67
- description: "ISO 8601 date/time in UTC or with offset (e.g. '2026-03-20T12:00:00Z').",
71
+ description: DATETIME_DESC,
68
72
  },
73
+ timezone: TIMEZONE_PROPERTY,
69
74
  latitude: {
70
75
  type: "number",
71
- description: "Observer latitude in decimal degrees.",
76
+ description: "Observer latitude in decimal degrees. Resolve from a place name with location_search; never recall coordinates from memory.",
72
77
  },
73
78
  longitude: {
74
79
  type: "number",
@@ -88,7 +93,7 @@ registerTool({
88
93
  handler: async (args) => {
89
94
  validateRequired(args, ["datetime", "latitude", "longitude"]);
90
95
  const body = {
91
- date_time: { iso: args.datetime },
96
+ date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
92
97
  latitude: args.latitude,
93
98
  longitude: args.longitude,
94
99
  };