@openephemeris/mcp-server 3.24.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/LICENSE +21 -21
  3. package/README.md +40 -2
  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/server-sse.js +114 -14
  11. package/dist/tools/apps/bazi-app.js +12 -26
  12. package/dist/tools/apps/bi-wheel-app.js +2 -2
  13. package/dist/tools/apps/bodygraph-app.d.ts +5 -5
  14. package/dist/tools/apps/bodygraph-app.js +157 -210
  15. package/dist/tools/apps/chart-wheel-app.js +5 -3
  16. package/dist/tools/apps/location-tools.js +167 -18
  17. package/dist/tools/apps/moon-phase-app.js +10 -3
  18. package/dist/tools/apps/transit-timeline-app.js +6 -4
  19. package/dist/tools/apps/vedic-chart-app.js +15 -49
  20. package/dist/tools/datetime.d.ts +65 -0
  21. package/dist/tools/datetime.js +153 -0
  22. package/dist/tools/index.d.ts +45 -2
  23. package/dist/tools/index.js +78 -2
  24. package/dist/tools/specialized/account.d.ts +1 -0
  25. package/dist/tools/specialized/account.js +100 -0
  26. package/dist/tools/specialized/acg.js +16 -14
  27. package/dist/tools/specialized/bazi.d.ts +7 -1
  28. package/dist/tools/specialized/bazi.js +86 -19
  29. package/dist/tools/specialized/bi_wheel.js +5 -4
  30. package/dist/tools/specialized/chart_wheel.js +5 -8
  31. package/dist/tools/specialized/comparative.js +13 -5
  32. package/dist/tools/specialized/electional.js +7 -7
  33. package/dist/tools/specialized/ephemeris_core.js +13 -8
  34. package/dist/tools/specialized/ephemeris_extended.js +27 -17
  35. package/dist/tools/specialized/hd_bodygraph.js +8 -10
  36. package/dist/tools/specialized/hd_cycles.js +7 -14
  37. package/dist/tools/specialized/hd_group.js +20 -13
  38. package/dist/tools/specialized/human_design.js +11 -17
  39. package/dist/tools/specialized/moon.js +8 -2
  40. package/dist/tools/specialized/natal.js +7 -9
  41. package/dist/tools/specialized/progressed.js +12 -8
  42. package/dist/tools/specialized/relocation.js +9 -3
  43. package/dist/tools/specialized/returns.js +23 -11
  44. package/dist/tools/specialized/synastry.js +17 -6
  45. package/dist/tools/specialized/transits.js +9 -5
  46. package/dist/tools/specialized/vedic.js +5 -3
  47. package/dist/tools/specialized/venus_star_points.js +14 -9
  48. package/dist/ui/bazi.html +1063 -1049
  49. package/dist/ui/bi-wheel.html +4188 -4128
  50. package/dist/ui/bodygraph.html +3673 -3616
  51. package/dist/ui/chart-wheel.html +3769 -3713
  52. package/dist/ui/moon-phase.html +3219 -3153
  53. package/dist/ui/transit-timeline.html +199 -170
  54. package/dist/ui/vedic-chart.html +1116 -1098
  55. package/package.json +3 -2
  56. 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.
@@ -132,7 +183,7 @@ registerTool({
132
183
  " RESOURCE: Indirect Resource (偏印), Direct Resource (正印)\n\n" +
133
184
  "The Day Pillar itself has no Ten God (it IS the Day Master). " +
134
185
  "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" +
186
+ "CREDIT COST: 3 credits (5 credits when include_visual=true).\n\n" +
136
187
  "Set include_visual=true to receive an SVG Four Pillars chart alongside the Ten Gods data.\n\n" +
137
188
  "EXAMPLE: Ten Gods for someone born July 15, 1987 at 2 PM:\n" +
138
189
  " year=1987, month=7, day=15, hour=14",
@@ -167,7 +218,7 @@ registerTool({
167
218
  "Also returns:\n" +
168
219
  " • Day Master strength: 'strong' (旺, ≥50% own+resource elements) or 'weak' (弱)\n" +
169
220
  " • 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" +
221
+ "CREDIT COST: 3 credits (5 credits when include_visual=true).\n\n" +
171
222
  "Set include_visual=true to receive an SVG Four Pillars chart alongside the element data.\n\n" +
172
223
  "EXAMPLE: Element balance for 1987-07-15 at 14:00:\n" +
173
224
  " year=1987, month=7, day=15, hour=14",
@@ -204,7 +255,7 @@ registerTool({
204
255
  "gender is REQUIRED — the direction of luck pillars is gender-dependent.\n\n" +
205
256
  "Returns: starting_age, direction, direction_reason, and 8 pillars each with:\n" +
206
257
  " stem, branch, Chinese characters, element, start_age, end_age\n\n" +
207
- "CREDIT COST: 1 credit per call.\n\n" +
258
+ "CREDIT COST: 3 credits per call.\n\n" +
208
259
  "EXAMPLE: Luck pillars for a female born July 15, 1987 at 2 PM:\n" +
209
260
  " year=1987, month=7, day=15, hour=14, gender='female'",
210
261
  inputSchema: {
@@ -251,7 +302,7 @@ registerTool({
251
302
  " • Identify the energetic quality of any given year\n" +
252
303
  " • Determine a person's birth year pillar for compatibility context\n" +
253
304
  " • Find the NaYin element for year or day interpretations\n\n" +
254
- "CREDIT COST: 1 credit per call.\n\n" +
305
+ "CREDIT COST: 3 credits per call.\n\n" +
255
306
  "EXAMPLE: Year pillar for 2025:\n" +
256
307
  " year=2025",
257
308
  inputSchema: {
@@ -292,7 +343,7 @@ registerTool({
292
343
  " -6 Three Penalties (三刑 Sān Xíng) — branch penalty formations\n\n" +
293
344
  "Assessment grades:\n" +
294
345
  " 90–100: excellent | 70–89: good | 50–69: moderate | below 50: challenging\n\n" +
295
- "CREDIT COST: 2 credits per call.\n\n" +
346
+ "CREDIT COST: 3 credits per call.\n\n" +
296
347
  "EXAMPLE: Compatibility between two people:\n" +
297
348
  " chart_a_year=1987, chart_a_month=7, chart_a_day=15, chart_a_hour=14\n" +
298
349
  " chart_b_year=1990, chart_b_month=3, chart_b_day=22, chart_b_hour=8",
@@ -306,7 +357,14 @@ registerTool({
306
357
  chart_a_hour: { type: "integer", description: "Chart A birth hour (0–23). Optional." },
307
358
  chart_a_datetime: {
308
359
  type: "string",
309
- description: "Chart A alternative: ISO 8601 datetime. Extracts year/month/day/hour automatically.",
360
+ description: "Chart A alternative: ISO 8601 datetime read as LOCAL wall-clock time at the birth " +
361
+ "place — a zone-less value is correct here and is not converted to UTC. If it carries " +
362
+ "a 'Z' or offset, also pass chart_a_timezone. Extracts year/month/day/hour automatically.",
363
+ },
364
+ chart_a_timezone: {
365
+ type: "string",
366
+ description: "IANA timezone for Chart A's birth place, e.g. 'Asia/Shanghai'. Only needed when " +
367
+ "chart_a_datetime carries a 'Z' or ±HH:MM offset.",
310
368
  },
311
369
  // Chart B
312
370
  chart_b_year: { type: "integer", description: "Chart B birth year." },
@@ -315,7 +373,14 @@ registerTool({
315
373
  chart_b_hour: { type: "integer", description: "Chart B birth hour (0–23). Optional." },
316
374
  chart_b_datetime: {
317
375
  type: "string",
318
- description: "Chart B alternative: ISO 8601 datetime. Extracts year/month/day/hour automatically.",
376
+ description: "Chart B alternative: ISO 8601 datetime read as LOCAL wall-clock time at the birth " +
377
+ "place — a zone-less value is correct here and is not converted to UTC. If it carries " +
378
+ "a 'Z' or offset, also pass chart_b_timezone. Extracts year/month/day/hour automatically.",
379
+ },
380
+ chart_b_timezone: {
381
+ type: "string",
382
+ description: "IANA timezone for Chart B's birth place, e.g. 'Asia/Shanghai'. Only needed when " +
383
+ "chart_b_datetime carries a 'Z' or ±HH:MM offset.",
319
384
  },
320
385
  },
321
386
  additionalProperties: false,
@@ -329,6 +394,7 @@ registerTool({
329
394
  day: args.chart_a_day,
330
395
  hour: args.chart_a_hour,
331
396
  datetime: args.chart_a_datetime,
397
+ timezone: args.chart_a_timezone,
332
398
  });
333
399
  const chartBComponents = parseBaziArgs({
334
400
  year: args.chart_b_year,
@@ -336,6 +402,7 @@ registerTool({
336
402
  day: args.chart_b_day,
337
403
  hour: args.chart_b_hour,
338
404
  datetime: args.chart_b_datetime,
405
+ timezone: args.chart_b_timezone,
339
406
  });
340
407
  return await getActiveClient().request("POST", "/chinese/bazi/compatibility", {
341
408
  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,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 { localToUtcIso } from "../datetime.js";
4
5
  function buildSubject(name, datetime, lat, lon, timezone) {
5
6
  const subj = {
6
7
  name,
@@ -22,8 +23,10 @@ registerTool({
22
23
  "uses midpoints of each planet pair to derive a single relationship chart.\n\n" +
23
24
  "CREDIT COST: 3 credits per call.\n\n" +
24
25
  "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",
26
+ " person_a_datetime='1990-04-15T14:30:00', person_a_timezone='America/Chicago',\n" +
27
+ " person_a_latitude=41.88, person_a_longitude=-87.63,\n" +
28
+ " person_b_datetime='1988-09-22T08:15:00', person_b_timezone='America/Los_Angeles',\n" +
29
+ " person_b_latitude=34.05, person_b_longitude=-118.24",
27
30
  inputSchema: {
28
31
  type: "object",
29
32
  properties: {
@@ -163,7 +166,8 @@ registerTool({
163
166
  "are aspecting natal positions. Essential for predictive astrology.\n\n" +
164
167
  "CREDIT COST: 3 credits per call.\n\n" +
165
168
  "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",
169
+ " natal_datetime='1990-04-15T14:30:00', natal_timezone='America/Chicago',\n" +
170
+ " natal_latitude=41.88, natal_longitude=-87.63",
167
171
  inputSchema: {
168
172
  type: "object",
169
173
  properties: {
@@ -188,8 +192,12 @@ registerTool({
188
192
  subject: buildSubject("Natal", args.natal_datetime, args.natal_latitude, args.natal_longitude, args.natal_timezone),
189
193
  };
190
194
  if (args.transit_datetime) {
191
- // transit_datetime is a DateTimeInput on the backend
192
- body.transit_datetime = { iso: args.transit_datetime };
195
+ // transit_datetime is a DateTimeInput on the backend, and unlike the
196
+ // subject it has no timezone companion — so , which
197
+ // was declared but never read, is resolved here.
198
+ body.transit_datetime = {
199
+ iso: localToUtcIso("transit_datetime", args.transit_datetime, args.transit_timezone, "transit_timezone"),
200
+ };
193
201
  }
194
202
  const query = {};
195
203
  if (args.format)
@@ -25,7 +25,7 @@ registerTool({
25
25
  },
26
26
  latitude: {
27
27
  type: "number",
28
- description: "Latitude of location in decimal degrees (positive = North).",
28
+ description: "Latitude of location in decimal degrees (positive = North). Resolve from a place name with location_search; never recall coordinates from memory.",
29
29
  },
30
30
  longitude: {
31
31
  type: "number",
@@ -89,15 +89,15 @@ registerTool({
89
89
  description: "Analyze the astrological quality of a specific moment: planet positions, aspects, " +
90
90
  "void of course status, lunar phase, day ruler, and an overall electional score (0-100). " +
91
91
  "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" +
92
+ "CREDIT COST: 5 credits per call.\n\n" +
93
93
  "EXAMPLE: Analyze March 21, 2026 at noon:\n" +
94
- " date='2026-03-21T12:00:00'",
94
+ " date='2026-03-21T12:00:00Z'",
95
95
  inputSchema: {
96
96
  type: "object",
97
97
  properties: {
98
98
  date: {
99
99
  type: "string",
100
- description: "ISO 8601 datetime to analyze (e.g., '2026-03-21T12:00:00'). Defaults to now.",
100
+ 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
101
  },
102
102
  format: {
103
103
  type: "string",
@@ -129,7 +129,7 @@ registerTool({
129
129
  "USE THIS TOOL FOR: 'When does Mercury go retrograde?', 'Is Venus retrograde this year?', " +
130
130
  "'What planets station this month?', 'When does Mars go direct?'\n\n" +
131
131
  "All required fields have smart defaults (searches the next 90 days from today).\n\n" +
132
- "CREDIT COST: 3 credits per call.\n\n" +
132
+ "CREDIT COST: 5 credits per call.\n\n" +
133
133
  "EXAMPLE: Mercury and Venus stations in the next 3 months (all defaults):\n" +
134
134
  " (no args required, will auto-scan next 90 days for all inner planets)\n\n" +
135
135
  "EXAMPLE: Outer planet stations in 2026:\n" +
@@ -198,9 +198,9 @@ registerTool({
198
198
  description: "Find all active aspects between planets at a specific moment. Returns aspect type, " +
199
199
  "orb, quality score, and whether it's applying or separating. Great for checking " +
200
200
  "the 'weather' of a given day.\n\n" +
201
- "CREDIT COST: 2 credits per call.\n\n" +
201
+ "CREDIT COST: 5 credits per call.\n\n" +
202
202
  "EXAMPLE: What aspects are active on March 21, 2026?\n" +
203
- " date='2026-03-21T12:00:00'",
203
+ " date='2026-03-21T12:00:00Z'",
204
204
  inputSchema: {
205
205
  type: "object",
206
206
  properties: {
@@ -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
  };
@@ -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 { DATETIME_DESC, TIMEZONE_PROPERTY, assertZonedDatetime, localToUtcIso } from "../datetime.js";
4
5
  function buildSubject(name, datetime, lat, lon) {
5
6
  return {
6
7
  name,
@@ -21,8 +22,8 @@ registerTool({
21
22
  "(100 subjects = 100 credits). Confirm with the user before large batches.\n\n" +
22
23
  "EXAMPLE: Two people batch:\n" +
23
24
  " subjects: [\n" +
24
- " { name: 'Alice', datetime: '1990-04-15T14:30:00', latitude: 41.88, longitude: -87.63 },\n" +
25
- " { name: 'Bob', datetime: '1988-09-22T08:15:00', latitude: 34.05, longitude: -118.24 }\n" +
25
+ " { name: 'Alice', datetime: '1990-04-15T14:30:00', timezone: 'America/Chicago', latitude: 41.88, longitude: -87.63 },\n" +
26
+ " { name: 'Bob', datetime: '1988-09-22T15:15:00Z', latitude: 34.05, longitude: -118.24 }\n" +
26
27
  " ]",
27
28
  inputSchema: {
28
29
  type: "object",
@@ -33,7 +34,8 @@ registerTool({
33
34
  type: "object",
34
35
  properties: {
35
36
  name: { type: "string", description: "Subject name." },
36
- datetime: { type: "string", description: "Birth datetime (ISO 8601)." },
37
+ datetime: { type: "string", description: DATETIME_DESC },
38
+ timezone: TIMEZONE_PROPERTY,
37
39
  latitude: { type: "number", description: "Birth latitude." },
38
40
  longitude: { type: "number", description: "Birth longitude." },
39
41
  },
@@ -49,6 +51,7 @@ registerTool({
49
51
  annotations: { title: "Natal Batch Charts", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
50
52
  handler: async (args) => {
51
53
  validateRequired(args, ["subjects"]);
54
+ args.subjects.forEach((s, i) => assertZonedDatetime(`subjects[${i}].datetime`, s?.datetime, s?.timezone, `subjects[${i}].timezone`));
52
55
  const items = args.subjects.map((s) => ({
53
56
  subject: buildSubject(s.name, s.datetime, s.latitude, s.longitude),
54
57
  }));
@@ -64,7 +67,8 @@ registerTool({
64
67
  inputSchema: {
65
68
  type: "object",
66
69
  properties: {
67
- datetime: { type: "string", description: "ISO 8601 date/time." },
70
+ datetime: { type: "string", description: DATETIME_DESC },
71
+ timezone: TIMEZONE_PROPERTY,
68
72
  },
69
73
  required: ["datetime"],
70
74
  additionalProperties: false,
@@ -74,7 +78,7 @@ registerTool({
74
78
  handler: async (args) => {
75
79
  validateRequired(args, ["datetime"]);
76
80
  return await getActiveClient().post("/ephemeris/dignities", {
77
- date_time: { iso: args.datetime },
81
+ date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
78
82
  });
79
83
  },
80
84
  });
@@ -86,12 +90,14 @@ registerTool({
86
90
  name: "ephemeris_retrograde_status",
87
91
  description: "Get retrograde/direct status and speed for all planets at a given date/time. " +
88
92
  "Returns is_retrograde flag, longitude speed, and station proximity for every planet.\n\n" +
89
- "CREDIT COST: 1 credit per call.\n\n" +
93
+ "CREDIT COST: 10 credits for the all-planets sweep (the backend bills one " +
94
+ "credit per body and this fans out to 10), or 1 credit when planet_id is given.\n\n" +
90
95
  "Optionally pass planet_id (0-9) to query a single planet.",
91
96
  inputSchema: {
92
97
  type: "object",
93
98
  properties: {
94
- datetime: { type: "string", description: "ISO 8601 date/time." },
99
+ datetime: { type: "string", description: DATETIME_DESC },
100
+ timezone: TIMEZONE_PROPERTY,
95
101
  planet_id: { type: "integer", description: "Optional single planet ID (0-9). Omit for all planets." },
96
102
  },
97
103
  required: ["datetime"],
@@ -105,14 +111,14 @@ registerTool({
105
111
  // If a specific planet is requested, delegate directly.
106
112
  if (args.planet_id != null) {
107
113
  return await client.post("/ephemeris/retrograde-status", {
108
- date_time: { iso: args.datetime },
114
+ date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
109
115
  planet_id: args.planet_id,
110
116
  });
111
117
  }
112
118
  // Fan-out: the backend only handles one planet per call; query all 10 in parallel.
113
119
  const PLANET_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
114
120
  const results = await Promise.all(PLANET_IDS.map((pid) => client.post("/ephemeris/retrograde-status", {
115
- date_time: { iso: args.datetime },
121
+ date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
116
122
  planet_id: pid,
117
123
  }).catch(() => null)));
118
124
  // Merge into a keyed object: { planet_name: {...status} }
@@ -140,7 +146,8 @@ registerTool({
140
146
  inputSchema: {
141
147
  type: "object",
142
148
  properties: {
143
- datetime: { type: "string", description: "ISO 8601 date/time." },
149
+ datetime: { type: "string", description: DATETIME_DESC },
150
+ timezone: TIMEZONE_PROPERTY,
144
151
  latitude: { type: "number", description: "Observer latitude." },
145
152
  longitude: { type: "number", description: "Observer longitude." },
146
153
  },
@@ -152,7 +159,7 @@ registerTool({
152
159
  handler: async (args) => {
153
160
  validateRequired(args, ["datetime", "latitude", "longitude"]);
154
161
  return await getActiveClient().post("/ephemeris/midpoints", {
155
- date_time: { iso: args.datetime },
162
+ date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
156
163
  latitude: args.latitude,
157
164
  longitude: args.longitude,
158
165
  });
@@ -167,7 +174,8 @@ registerTool({
167
174
  inputSchema: {
168
175
  type: "object",
169
176
  properties: {
170
- datetime: { type: "string", description: "ISO 8601 date/time." },
177
+ datetime: { type: "string", description: DATETIME_DESC },
178
+ timezone: TIMEZONE_PROPERTY,
171
179
  star_names: {
172
180
  type: "array",
173
181
  items: { type: "string" },
@@ -183,7 +191,7 @@ registerTool({
183
191
  handler: async (args) => {
184
192
  validateRequired(args, ["datetime"]);
185
193
  const body = {
186
- date_time: { iso: args.datetime },
194
+ date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
187
195
  };
188
196
  if (args.star_names)
189
197
  body.star_names = args.star_names;
@@ -232,7 +240,8 @@ registerTool({
232
240
  inputSchema: {
233
241
  type: "object",
234
242
  properties: {
235
- datetime: { type: "string", description: "ISO 8601 date/time." },
243
+ datetime: { type: "string", description: DATETIME_DESC },
244
+ timezone: TIMEZONE_PROPERTY,
236
245
  latitude: { type: "number", description: "Observer latitude." },
237
246
  longitude: { type: "number", description: "Observer longitude." },
238
247
  },
@@ -244,7 +253,7 @@ registerTool({
244
253
  handler: async (args) => {
245
254
  validateRequired(args, ["datetime", "latitude", "longitude"]);
246
255
  return await getActiveClient().post("/ephemeris/hermetic-lots", {
247
- date_time: { iso: args.datetime },
256
+ date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
248
257
  latitude: args.latitude,
249
258
  longitude: args.longitude,
250
259
  });
@@ -259,7 +268,8 @@ registerTool({
259
268
  inputSchema: {
260
269
  type: "object",
261
270
  properties: {
262
- datetime: { type: "string", description: "ISO 8601 date/time." },
271
+ datetime: { type: "string", description: DATETIME_DESC },
272
+ timezone: TIMEZONE_PROPERTY,
263
273
  latitude: { type: "number", description: "Observer latitude." },
264
274
  longitude: { type: "number", description: "Observer longitude." },
265
275
  },
@@ -271,7 +281,7 @@ registerTool({
271
281
  handler: async (args) => {
272
282
  validateRequired(args, ["datetime", "latitude", "longitude"]);
273
283
  return await getActiveClient().post("/ephemeris/angles-points", {
274
- date_time: { iso: args.datetime },
284
+ date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
275
285
  latitude: args.latitude,
276
286
  longitude: args.longitude,
277
287
  });
@@ -1,12 +1,7 @@
1
1
  import { registerTool, validateRequired, pickEnum } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
- /** Append 'Z' if no timezone offset is present in the datetime string. */
5
- function ensureTimezone(dt) {
6
- if (/[Zz]$/.test(dt) || /[+-]\d{2}:\d{2}$/.test(dt))
7
- return dt;
8
- return dt + "Z";
9
- }
4
+ import { DATETIME_DESC, TIMEZONE_PROPERTY, localToUtcIso } from "../datetime.js";
10
5
  registerTool({
11
6
  name: "human_design_bodygraph",
12
7
  description: "Generate a Human Design Bodygraph image (SVG) from a birth datetime (UTC). " +
@@ -16,16 +11,19 @@ registerTool({
16
11
  "Returns a native SVG that Claude displays inline. Use format='png' to opt into raster output (requires server-side rasterizer).\n\n" +
17
12
  "For a user-facing interactive bodygraph explorer, use explore_human_design instead.\n\n" +
18
13
  "CREDIT COST: 2 credits per call.\n\n" +
19
- "EXAMPLE: Generate a bodygraph for someone born April 15, 1990 at 19:30 UTC:\n" +
14
+ "EXAMPLE (local birth time + zone): born 15 April 1990 at 14:30 in Chicago:\n" +
15
+ " datetime='1990-04-15T14:30:00', timezone='America/Chicago'\n" +
16
+ "EXAMPLE (already in UTC):\n" +
20
17
  " datetime='1990-04-15T19:30:00Z'",
21
18
  inputSchema: {
22
19
  type: "object",
23
20
  properties: {
24
21
  datetime: {
25
22
  type: "string",
26
- description: "ISO 8601 birth datetime in UTC, e.g. '1990-04-15T19:30:00Z'. " +
27
- "Human Design chart calculation is time-sensitive — accuracy to the minute matters.",
23
+ description: DATETIME_DESC +
24
+ " Human Design is time-sensitive — accuracy to the minute matters.",
28
25
  },
26
+ timezone: TIMEZONE_PROPERTY,
29
27
  style: {
30
28
  type: "string",
31
29
  enum: ["light", "dark", "mono"],
@@ -46,7 +44,7 @@ registerTool({
46
44
  handler: async (args) => {
47
45
  validateRequired(args, ["datetime"]);
48
46
  const body = {
49
- birth_datetime_utc: ensureTimezone(args.datetime),
47
+ birth_datetime_utc: localToUtcIso("datetime", args.datetime, args.timezone),
50
48
  };
51
49
  const format = pickEnum(args.format, ["svg", "png"]) || "svg";
52
50
  const style = pickEnum(args.style, ["light", "dark", "mono"]);