@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,6 +1,7 @@
1
- import { registerTool, validateRequired } from "../index.js";
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
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,16 @@ 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" +
90
- "Optionally pass planet_id (0-9) to query a single planet.",
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
98
  inputSchema: {
92
99
  type: "object",
93
100
  properties: {
94
- datetime: { type: "string", description: "ISO 8601 date/time." },
101
+ datetime: { type: "string", description: DATETIME_DESC },
102
+ timezone: TIMEZONE_PROPERTY,
95
103
  planet_id: { type: "integer", description: "Optional single planet ID (0-9). Omit for all planets." },
96
104
  },
97
105
  required: ["datetime"],
@@ -105,14 +113,14 @@ registerTool({
105
113
  // If a specific planet is requested, delegate directly.
106
114
  if (args.planet_id != null) {
107
115
  return await client.post("/ephemeris/retrograde-status", {
108
- date_time: { iso: args.datetime },
116
+ date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
109
117
  planet_id: args.planet_id,
110
118
  });
111
119
  }
112
120
  // Fan-out: the backend only handles one planet per call; query all 10 in parallel.
113
121
  const PLANET_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
114
122
  const results = await Promise.all(PLANET_IDS.map((pid) => client.post("/ephemeris/retrograde-status", {
115
- date_time: { iso: args.datetime },
123
+ date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
116
124
  planet_id: pid,
117
125
  }).catch(() => null)));
118
126
  // Merge into a keyed object: { planet_name: {...status} }
@@ -140,7 +148,8 @@ registerTool({
140
148
  inputSchema: {
141
149
  type: "object",
142
150
  properties: {
143
- datetime: { type: "string", description: "ISO 8601 date/time." },
151
+ datetime: { type: "string", description: DATETIME_DESC },
152
+ timezone: TIMEZONE_PROPERTY,
144
153
  latitude: { type: "number", description: "Observer latitude." },
145
154
  longitude: { type: "number", description: "Observer longitude." },
146
155
  },
@@ -152,7 +161,7 @@ registerTool({
152
161
  handler: async (args) => {
153
162
  validateRequired(args, ["datetime", "latitude", "longitude"]);
154
163
  return await getActiveClient().post("/ephemeris/midpoints", {
155
- date_time: { iso: args.datetime },
164
+ date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
156
165
  latitude: args.latitude,
157
166
  longitude: args.longitude,
158
167
  });
@@ -161,13 +170,28 @@ registerTool({
161
170
  // POST /ephemeris/fixed-stars
162
171
  registerTool({
163
172
  name: "ephemeris_fixed_stars",
164
- description: "Calculate positions of fixed stars and conjunctions to natal planets. " +
165
- "Returns star longitude, magnitude, and any planets within orb.\n\n" +
173
+ description: "Fixed star positions (ecliptic longitude, magnitude) and the chart points conjunct them. " +
174
+ "Always scans the ten traditional planets.\n\n" +
175
+ "⚠️ ANGLES REQUIRE A LOCATION: 'what stars are on my Ascendant?' is only answerable with " +
176
+ "latitude AND longitude — ASC/MC/DSC/IC depend on place, not just time. Supply both and the " +
177
+ "four angles join the scan; omit them and the result covers PLANETS ONLY. Each conjunction " +
178
+ "is tagged point_type 'planet' or 'angle', and the angle longitudes used come back in " +
179
+ "`angles`, so never report a planetary hit as an Ascendant hit.\n\n" +
166
180
  "CREDIT COST: 1 credit per call.",
167
181
  inputSchema: {
168
182
  type: "object",
169
183
  properties: {
170
- datetime: { type: "string", description: "ISO 8601 date/time." },
184
+ datetime: { type: "string", description: DATETIME_DESC },
185
+ timezone: TIMEZONE_PROPERTY,
186
+ latitude: {
187
+ type: "number",
188
+ description: "Observer latitude in decimal degrees. Required (with longitude) to scan the angles. " +
189
+ "Resolve from a place name with location_search; never recall coordinates from memory.",
190
+ },
191
+ longitude: {
192
+ type: "number",
193
+ description: "Observer longitude in decimal degrees. Required (with latitude) to scan the angles.",
194
+ },
171
195
  star_names: {
172
196
  type: "array",
173
197
  items: { type: "string" },
@@ -182,45 +206,26 @@ registerTool({
182
206
  annotations: { title: "Fixed Stars", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
183
207
  handler: async (args) => {
184
208
  validateRequired(args, ["datetime"]);
209
+ // A lone latitude would silently drop the angles from the scan, which is
210
+ // the exact failure this tool exists to avoid — reject it instead.
211
+ validateCoordinates(args, "latitude", "longitude");
185
212
  const body = {
186
- date_time: { iso: args.datetime },
213
+ date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
187
214
  };
215
+ if (args.latitude != null)
216
+ body.latitude = args.latitude;
217
+ if (args.longitude != null)
218
+ body.longitude = args.longitude;
188
219
  if (args.star_names)
189
220
  body.star_names = args.star_names;
190
221
  if (args.orb != null)
191
222
  body.orb = args.orb;
192
- const raw = await getActiveClient().post("/ephemeris/fixed-stars", body);
193
- // Bug 6 workaround: backend returns longitude as degrees-within-sign (0–30),
194
- // not full ecliptic longitude (0–360). Compute full_longitude from sign name.
195
- const SIGN_ORDER = {
196
- Aries: 0, Taurus: 1, Gemini: 2, Cancer: 3, Leo: 4, Virgo: 5,
197
- Libra: 6, Scorpio: 7, Sagittarius: 8, Capricorn: 9, Aquarius: 10, Pisces: 11,
198
- };
199
- function enrichStar(star) {
200
- if (!star || typeof star !== "object")
201
- return star;
202
- const signLon = typeof star.longitude === "number" ? star.longitude : 0;
203
- const signName = star.sign ?? star.sign_name ?? "";
204
- const signIndex = SIGN_ORDER[signName] ?? -1;
205
- const fullLon = signIndex >= 0 ? signIndex * 30 + signLon : signLon;
206
- return {
207
- ...star,
208
- longitude: fullLon, // ecliptic longitude 0–360
209
- sign_longitude: signLon, // original within-sign degrees preserved
210
- sign: signName || undefined,
211
- };
212
- }
213
- // Normalise the response — backend may return array or { stars: [...] }
214
- if (Array.isArray(raw)) {
215
- return raw.map(enrichStar);
216
- }
217
- if (raw && typeof raw === "object") {
218
- const starsKey = ["stars", "data", "fixed_stars", "results"].find((k) => Array.isArray(raw[k]));
219
- if (starsKey) {
220
- return { ...raw, [starsKey]: raw[starsKey].map(enrichStar) };
221
- }
222
- }
223
- return raw;
223
+ // No response rewriting: /ephemeris/fixed-stars returns full ecliptic
224
+ // longitude (0–360) directly. It previously returned degrees-within-sign,
225
+ // and the workaround here reconstructed the full value from a `sign`
226
+ // field the endpoint never actually sent — so it was inert. Both the
227
+ // backend bug and the dead workaround are gone.
228
+ return await getActiveClient().post("/ephemeris/fixed-stars", body);
224
229
  },
225
230
  });
226
231
  // POST /ephemeris/hermetic-lots
@@ -232,7 +237,8 @@ registerTool({
232
237
  inputSchema: {
233
238
  type: "object",
234
239
  properties: {
235
- datetime: { type: "string", description: "ISO 8601 date/time." },
240
+ datetime: { type: "string", description: DATETIME_DESC },
241
+ timezone: TIMEZONE_PROPERTY,
236
242
  latitude: { type: "number", description: "Observer latitude." },
237
243
  longitude: { type: "number", description: "Observer longitude." },
238
244
  },
@@ -244,7 +250,7 @@ registerTool({
244
250
  handler: async (args) => {
245
251
  validateRequired(args, ["datetime", "latitude", "longitude"]);
246
252
  return await getActiveClient().post("/ephemeris/hermetic-lots", {
247
- date_time: { iso: args.datetime },
253
+ date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
248
254
  latitude: args.latitude,
249
255
  longitude: args.longitude,
250
256
  });
@@ -259,7 +265,8 @@ registerTool({
259
265
  inputSchema: {
260
266
  type: "object",
261
267
  properties: {
262
- datetime: { type: "string", description: "ISO 8601 date/time." },
268
+ datetime: { type: "string", description: DATETIME_DESC },
269
+ timezone: TIMEZONE_PROPERTY,
263
270
  latitude: { type: "number", description: "Observer latitude." },
264
271
  longitude: { type: "number", description: "Observer longitude." },
265
272
  },
@@ -271,7 +278,7 @@ registerTool({
271
278
  handler: async (args) => {
272
279
  validateRequired(args, ["datetime", "latitude", "longitude"]);
273
280
  return await getActiveClient().post("/ephemeris/angles-points", {
274
- date_time: { iso: args.datetime },
281
+ date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
275
282
  latitude: args.latitude,
276
283
  longitude: args.longitude,
277
284
  });
@@ -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"]);
@@ -1,16 +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
- /**
5
- * Ensure a datetime string has a timezone offset for Go's time.Time parsing.
6
- */
7
- function ensureTimezone(dt) {
8
- if (!dt)
9
- return dt;
10
- if (/[Zz]$/.test(dt) || /[+-]\d{2}:\d{2}$/.test(dt) || /[+-]\d{4}$/.test(dt))
11
- return dt;
12
- return dt + "Z";
13
- }
4
+ import { DATETIME_DESC, TIMEZONE_PROPERTY, localToUtcIso } from "../datetime.js";
14
5
  registerTool({
15
6
  name: "hd_planetary_return",
16
7
  description: "Calculate a Human Design planetary return chart — full HD chart " +
@@ -35,8 +26,9 @@ registerTool({
35
26
  },
36
27
  datetime: {
37
28
  type: "string",
38
- description: "ISO 8601 birth datetime in UTC, e.g. '1990-04-15T19:30:00Z'.",
29
+ description: DATETIME_DESC,
39
30
  },
31
+ timezone: TIMEZONE_PROPERTY,
40
32
  return_year: {
41
33
  type: "integer",
42
34
  description: "Year to find the return near (e.g. 2020 for a Saturn return at ~age 29).",
@@ -57,7 +49,7 @@ registerTool({
57
49
  return await getActiveClient().request("POST", "/human-design/cycles/return", {
58
50
  data: {
59
51
  planet: args.planet,
60
- birth_datetime_utc: ensureTimezone(args.datetime),
52
+ birth_datetime_utc: localToUtcIso("datetime", args.datetime, args.timezone),
61
53
  return_year: args.return_year,
62
54
  format: args.format,
63
55
  include_chiron: args.include_chiron,
@@ -87,8 +79,9 @@ registerTool({
87
79
  },
88
80
  datetime: {
89
81
  type: "string",
90
- description: "ISO 8601 birth datetime in UTC, e.g. '1983-07-15T12:00:00Z'.",
82
+ description: DATETIME_DESC,
91
83
  },
84
+ timezone: TIMEZONE_PROPERTY,
92
85
  target_year: {
93
86
  type: "integer",
94
87
  description: "Year to search for the opposition near.",
@@ -109,7 +102,7 @@ registerTool({
109
102
  return await getActiveClient().request("POST", "/human-design/cycles/opposition", {
110
103
  data: {
111
104
  planet: args.planet,
112
- birth_datetime_utc: ensureTimezone(args.datetime),
105
+ birth_datetime_utc: localToUtcIso("datetime", args.datetime, args.timezone),
113
106
  target_year: args.target_year,
114
107
  format: args.format,
115
108
  include_chiron: args.include_chiron,
@@ -1,25 +1,31 @@
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, localToUtcIso, timezoneProperty } from "../datetime.js";
4
5
  // POST /human-design/composite — OE-027
5
6
  registerTool({
6
7
  name: "human_design_composite",
7
8
  description: "Calculate a Human Design composite chart for two people. Merges both bodygraphs " +
8
9
  "to show shared channels, authority dynamics, and relationship type.\n\n" +
9
10
  "CREDIT COST: 4 credits per call.\n\n" +
10
- "EXAMPLE:\n" +
11
- " person_a_datetime='1990-04-15T14:30:00Z', person_b_datetime='1988-09-22T08:15:00Z'",
11
+ "EXAMPLE (local times + zones):\n" +
12
+ " person_a_datetime='1990-04-15T14:30:00', person_a_timezone='America/Chicago',\n" +
13
+ " person_b_datetime='1988-09-22T08:15:00', person_b_timezone='America/Los_Angeles'\n" +
14
+ "EXAMPLE (already in UTC):\n" +
15
+ " person_a_datetime='1990-04-15T19:30:00Z', person_b_datetime='1988-09-22T15:15:00Z'",
12
16
  inputSchema: {
13
17
  type: "object",
14
18
  properties: {
15
19
  person_a_datetime: {
16
20
  type: "string",
17
- description: "Person A birth datetime as ISO 8601 UTC (e.g. '1990-04-15T14:30:00Z').",
21
+ description: DATETIME_DESC,
18
22
  },
23
+ person_a_timezone: timezoneProperty("Person A's birth location"),
19
24
  person_b_datetime: {
20
25
  type: "string",
21
- description: "Person B birth datetime as ISO 8601 UTC.",
26
+ description: DATETIME_DESC,
22
27
  },
28
+ person_b_timezone: timezoneProperty("Person B's birth location", "America/Los_Angeles"),
23
29
  format: {
24
30
  type: "string",
25
31
  enum: ["json", "llm"],
@@ -35,10 +41,10 @@ registerTool({
35
41
  validateRequired(args, ["person_a_datetime", "person_b_datetime"]);
36
42
  const body = {
37
43
  subject_1: {
38
- birth_datetime_utc: args.person_a_datetime,
44
+ birth_datetime_utc: localToUtcIso("person_a_datetime", args.person_a_datetime, args.person_a_timezone, "person_a_timezone"),
39
45
  },
40
46
  subject_2: {
41
- birth_datetime_utc: args.person_b_datetime,
47
+ birth_datetime_utc: localToUtcIso("person_b_datetime", args.person_b_datetime, args.person_b_timezone, "person_b_timezone"),
42
48
  },
43
49
  };
44
50
  if (args.format)
@@ -52,12 +58,12 @@ registerTool({
52
58
  description: "Calculate a Human Design Penta (group) chart for 3-5 people. Shows functional " +
53
59
  "attributes, leadership dynamics, channels, redundancies, and a group stability score.\n\n" +
54
60
  "CREDIT COST: 6 credits per call.\n\n" +
55
- "EXAMPLE (3 people):\n" +
61
+ "EXAMPLE (3 people — each datetime states its zone):\n" +
56
62
  " group_name='Team Alpha',\n" +
57
63
  " members=[\n" +
58
- " {id: 'alice', name: 'Alice', datetime: '1990-04-15T14:30:00Z'},\n" +
59
- " {id: 'bob', name: 'Bob', datetime: '1988-09-22T08:15:00Z'},\n" +
60
- " {id: 'carol', name: 'Carol', datetime: '1995-01-10T11:00:00Z'}\n" +
64
+ " {id: 'alice', name: 'Alice', datetime: '1990-04-15T14:30:00', timezone: 'America/Chicago'},\n" +
65
+ " {id: 'bob', name: 'Bob', datetime: '1988-09-22T15:15:00Z'},\n" +
66
+ " {id: 'carol', name: 'Carol', datetime: '1995-01-10T11:00:00+00:00'}\n" +
61
67
  " ]",
62
68
  inputSchema: {
63
69
  type: "object",
@@ -73,7 +79,8 @@ registerTool({
73
79
  properties: {
74
80
  id: { type: "string", description: "Unique member ID." },
75
81
  name: { type: "string", description: "Member's name." },
76
- datetime: { type: "string", description: "Birth datetime as ISO 8601 UTC." },
82
+ datetime: { type: "string", description: DATETIME_DESC },
83
+ timezone: timezoneProperty("this member's birth location"),
77
84
  },
78
85
  required: ["id", "name", "datetime"],
79
86
  },
@@ -94,11 +101,11 @@ registerTool({
94
101
  annotations: { title: "HD Penta Group Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
95
102
  handler: async (args) => {
96
103
  validateRequired(args, ["group_name", "members"]);
97
- const members = args.members.map((m) => ({
104
+ const members = args.members.map((m, i) => ({
98
105
  id: m.id,
99
106
  name: m.name,
100
107
  birth_data: {
101
- birth_datetime_utc: m.datetime,
108
+ birth_datetime_utc: localToUtcIso(`members[${i}].datetime`, m.datetime, m.timezone, `members[${i}].timezone`),
102
109
  },
103
110
  }));
104
111
  const body = {
@@ -1,18 +1,7 @@
1
1
  import { registerTool, validateRequired } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_IMAGE_AND_JSON } from "../output-schemas.js";
4
- /**
5
- * Ensure a datetime string has a timezone offset for Go's time.Time parsing.
6
- * Go's encoding/json only accepts RFC 3339 (must have Z or +HH:MM offset).
7
- */
8
- function ensureTimezone(dt) {
9
- if (!dt)
10
- return dt;
11
- // Already has timezone indicator
12
- if (/[Zz]$/.test(dt) || /[+-]\d{2}:\d{2}$/.test(dt) || /[+-]\d{4}$/.test(dt))
13
- return dt;
14
- return dt + "Z";
15
- }
4
+ import { DATETIME_DESC, TIMEZONE_PROPERTY, localToUtcIso } from "../datetime.js";
16
5
  registerTool({
17
6
  name: "human_design_chart",
18
7
  description: "Calculate a full Human Design I Ching hexagram chart from birth data. Returns the person's Type " +
@@ -23,19 +12,24 @@ registerTool({
23
12
  "CREDIT COST: 2 credits per call.\n\n" +
24
13
  "Human Design uses two calculation moments: the birth time (Personality) and ~88° of Sun motion " +
25
14
  "before birth (~3 months prior, the Design calculation). The API handles this automatically.\n\n" +
26
- "IMPORTANT: The datetime should be in UTC. If you have local birth time, convert to UTC first.\n\n" +
27
- "EXAMPLE: Get the Human Design chart for someone born April 15, 1990 at 7:30 PM UTC:\n" +
15
+ "The datetime must state its zone — pass an offset/'Z', or pass local time plus timezone. " +
16
+ "A zone-less datetime is rejected rather than assumed to be UTC: Human Design is minute-sensitive, " +
17
+ "and an hour of error changes the Profile and the Design Sun line.\n\n" +
18
+ "EXAMPLE (local birth time + zone): someone born 15 April 1990 at 14:30 in Chicago:\n" +
19
+ " datetime='1990-04-15T14:30:00', timezone='America/Chicago', latitude=41.8781, longitude=-87.6298\n" +
20
+ "EXAMPLE (already in UTC):\n" +
28
21
  " datetime='1990-04-15T19:30:00Z', latitude=41.8781, longitude=-87.6298",
29
22
  inputSchema: {
30
23
  type: "object",
31
24
  properties: {
32
25
  datetime: {
33
26
  type: "string",
34
- description: "ISO 8601 birth datetime in UTC, e.g. '1990-04-15T19:30:00Z'. Must include 'Z' or timezone offset.",
27
+ description: DATETIME_DESC,
35
28
  },
29
+ timezone: TIMEZONE_PROPERTY,
36
30
  latitude: {
37
31
  type: "number",
38
- description: "Latitude of birth location in decimal degrees (positive = North).",
32
+ description: "Latitude of birth location in decimal degrees (positive = North). Resolve from a place name with location_search; never recall coordinates from memory.",
39
33
  },
40
34
  longitude: {
41
35
  type: "number",
@@ -71,7 +65,7 @@ registerTool({
71
65
  handler: async (args) => {
72
66
  validateRequired(args, ["datetime"]);
73
67
  const body = {
74
- birth_datetime_utc: ensureTimezone(args.datetime),
68
+ birth_datetime_utc: localToUtcIso("datetime", args.datetime, args.timezone),
75
69
  };
76
70
  if (args.latitude != null)
77
71
  body.latitude = args.latitude;
@@ -1,12 +1,15 @@
1
1
  import { registerTool, validateCoordinates } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
+ import { TIMEZONE_PROPERTY, localToUtcIso } from "../datetime.js";
4
5
  registerTool({
5
6
  name: "ephemeris_moon_phase",
6
- description: "Get the Moon's current phase angle, illumination, and void-of-course status AT a specific " +
7
- "point in time. Returns phase name (New, Waxing Crescent, First Quarter, etc.), exact angle, " +
8
- "illumination %, and next void-of-course period.\n\n" +
9
- "⚠️ THIS TOOL ANSWERS: 'What phase is the moon in right now (or at a given datetime)?'\n" +
7
+ description: "Get the Moon's current phase angle, illumination, sign and void-of-course status AT a " +
8
+ "specific point in time. Returns phase name (New, Waxing Crescent, First Quarter, etc.), exact " +
9
+ "angle, illumination %, the zodiac sign and degree the Moon occupies, and next void-of-course " +
10
+ "period.\n\n" +
11
+ "⚠️ THIS TOOL ANSWERS: 'What phase is the moon in right now (or at a given datetime)?' and " +
12
+ "'What sign is the moon in?'\n" +
10
13
  "❌ THIS TOOL DOES NOT ANSWER: 'When is the next new moon / full moon?'\n" +
11
14
  "→ For upcoming phase DATES use ephemeris_next_lunar_phase instead.\n\n" +
12
15
  "For a user-facing interactive moon-phase dial, use explore_moon_phase instead.\n\n" +
@@ -21,8 +24,11 @@ registerTool({
21
24
  properties: {
22
25
  datetime: {
23
26
  type: "string",
24
- description: "ISO 8601 datetime to query. If omitted, returns the current live moon phase (UTC now).",
27
+ description: "ISO 8601 datetime to query, stating its zone ('2026-03-20T12:00:00Z' or " +
28
+ "'2026-03-20T08:00:00-04:00'), or a local time together with `timezone`. " +
29
+ "If omitted, returns the current live moon phase (UTC now).",
25
30
  },
31
+ timezone: TIMEZONE_PROPERTY,
26
32
  latitude: {
27
33
  type: "number",
28
34
  description: "Observer latitude (optional, used for local void-of-course calculations).",
@@ -42,7 +48,9 @@ registerTool({
42
48
  const params = {};
43
49
  // Backend requires datetime even though the schema marks it optional.
44
50
  // Default to current UTC time when caller omits it.
45
- params.datetime = args.datetime ?? new Date().toISOString();
51
+ params.datetime = args.datetime
52
+ ? localToUtcIso("datetime", args.datetime, args.timezone)
53
+ : new Date().toISOString();
46
54
  if (args.latitude != null)
47
55
  params.latitude = args.latitude;
48
56
  if (args.longitude != null)
@@ -1,8 +1,7 @@
1
1
  import { registerTool, validateRequired } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_IMAGE_AND_JSON } 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
  /** Map human-readable house system names to standard single-letter codes */
7
6
  const HOUSE_SYSTEM_MAP = {
8
7
  placidus: "P", whole_sign: "W", equal: "E", koch: "K",
@@ -17,8 +16,9 @@ registerTool({
17
16
  "essential dignities, retrograde status, house system data, and major aspect grid. Asteroids are automatically included. " +
18
17
  "Returns raw JSON. For a user-facing visual chart, use explore_natal_chart instead.\n\n" +
19
18
  "CREDIT COST: 1 credit per call.\n\n" +
20
- "EXAMPLE: Calculate the natal chart for someone born April 15, 1990 at 2:30 PM in Chicago:\n" +
21
- " datetime='1990-04-15T14:30:00', latitude=41.8781, longitude=-87.6298",
19
+ "EXAMPLE: born 15 April 1990 at 2:30 PM local time in Chicago — pass the local time and name the zone:\n" +
20
+ " datetime='1990-04-15T14:30:00', timezone='America/Chicago', latitude=41.8781, longitude=-87.6298\n" +
21
+ "Equivalently, put the zone on the datetime: datetime='1990-04-15T14:30:00-05:00' (or the UTC instant '1990-04-15T19:30:00Z').",
22
22
  inputSchema: {
23
23
  type: "object",
24
24
  properties: {
@@ -26,13 +26,10 @@ registerTool({
26
26
  type: "string",
27
27
  description: DATETIME_DESC,
28
28
  },
29
- timezone: {
30
- type: "string",
31
- description: "IANA timezone name, e.g. 'America/Denver'. Use this if passing local time without a UTC offset in the datetime string.",
32
- },
29
+ timezone: TIMEZONE_PROPERTY,
33
30
  latitude: {
34
31
  type: "number",
35
- description: "Geographic latitude of birth location in decimal degrees (positive = North).",
32
+ description: "Geographic latitude of birth location in decimal degrees (positive = North). Resolve from a place name with location_search; never recall coordinates from memory.",
36
33
  },
37
34
  longitude: {
38
35
  type: "number",
@@ -79,6 +76,7 @@ registerTool({
79
76
  annotations: { title: "Natal Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
80
77
  handler: async (args) => {
81
78
  validateRequired(args, ["datetime", "latitude", "longitude"]);
79
+ assertZonedDatetime("datetime", args.datetime, args.timezone);
82
80
  const body = {
83
81
  subject: {
84
82
  name: "MCP Request",
@@ -1,27 +1,27 @@
1
1
  import { registerTool, validateRequired } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_IMAGE_AND_JSON } from "../output-schemas.js";
4
+ import { DATETIME_DESC, TIMEZONE_PROPERTY, WINDOW_DATE_DESC, assertZonedDatetime } from "../datetime.js";
4
5
  registerTool({
5
6
  name: "ephemeris_progressed_chart",
6
7
  description: "Calculate a Secondary Progressed (or Solar Arc / Tertiary) chart. " +
7
8
  "Advances the natal chart symbolically — 1 day = 1 year (secondary), or using solar arc motion. " +
8
9
  "Returns progressed planet positions, house cusps, aspects, and retrograde status.\n\n" +
9
10
  "CREDIT COST: 1 credit per call.\n\n" +
10
- "EXAMPLE: Secondary progressions for someone born 1985-06-21, progressed to 2026-01-01:\n" +
11
- " birth_datetime='1985-06-21T14:00:00', birth_latitude=51.5, birth_longitude=-0.12,\n" +
12
- " target_datetime='2026-01-01', method='secondary'",
11
+ "EXAMPLE: Secondary progressions for someone born 1985-06-21 at 2:00 PM in London, progressed to 2026-01-01:\n" +
12
+ " birth_datetime='1985-06-21T14:00:00', timezone='Europe/London',\n" +
13
+ " birth_latitude=51.5, birth_longitude=-0.12, target_datetime='2026-01-01', method='secondary'",
13
14
  inputSchema: {
14
15
  type: "object",
15
16
  properties: {
16
17
  birth_datetime: {
17
18
  type: "string",
18
- description: "ISO 8601 natal birth date/time. MUST include a UTC offset (or 'Z'), " +
19
- "e.g. '1990-05-15T14:30:00-05:00'. A naive datetime (no offset) is " +
20
- "interpreted as UTC and will shift the progressed positions.",
19
+ description: DATETIME_DESC,
21
20
  },
21
+ timezone: TIMEZONE_PROPERTY,
22
22
  birth_latitude: {
23
23
  type: "number",
24
- description: "Birth latitude in decimal degrees.",
24
+ description: "Birth latitude in decimal degrees. Resolve from a place name with location_search; never recall coordinates from memory.",
25
25
  },
26
26
  birth_longitude: {
27
27
  type: "number",
@@ -29,7 +29,7 @@ registerTool({
29
29
  },
30
30
  target_datetime: {
31
31
  type: "string",
32
- description: "Target date to progress the chart to (ISO 8601).",
32
+ description: "Date to progress the chart to. " + WINDOW_DATE_DESC,
33
33
  },
34
34
  method: {
35
35
  type: "string",
@@ -63,6 +63,8 @@ registerTool({
63
63
  annotations: { title: "Progressed Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
64
64
  handler: async (args) => {
65
65
  validateRequired(args, ["birth_datetime", "birth_latitude", "birth_longitude", "target_datetime"]);
66
+ assertZonedDatetime("birth_datetime", args.birth_datetime, args.timezone);
67
+ assertZonedDatetime("target_datetime", args.target_datetime);
66
68
  const body = {
67
69
  subject: {
68
70
  name: "Progressed Subject",
@@ -70,6 +72,8 @@ registerTool({
70
72
  birth_location: {
71
73
  latitude: { decimal: args.birth_latitude },
72
74
  longitude: { decimal: args.birth_longitude },
75
+ // The server resolves the IANA zone, so historical DST stays correct.
76
+ ...(args.timezone ? { timezone: { iana_name: args.timezone } } : {}),
73
77
  },
74
78
  },
75
79
  target_datetime: { iso: args.target_datetime },