@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
@@ -21,6 +21,7 @@ import { fileURLToPath } from "node:url";
21
21
  import { registerTool, SERVER_VERSION } from "../index.js";
22
22
  import { getActiveClient } from "../../backend/client.js";
23
23
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
24
+ import { DATETIME_DESC, TIMEZONE_PROPERTY } from "../datetime.js";
24
25
  // ── Constants ─────────────────────────────────────────────────────────────
25
26
  export const CHART_WHEEL_RESOURCE_URI = "ui://openephemeris/chart-wheel";
26
27
  export const CHART_WHEEL_MIME_TYPE = "text/html;profile=mcp-app";
@@ -81,6 +82,10 @@ const HOUSE_SYSTEM_MAP = {
81
82
  placidus: "P", whole_sign: "W", equal: "E", koch: "K",
82
83
  campanus: "C", regiomontanus: "R",
83
84
  };
85
+ /** "whole_sign" → "Whole Sign" for display in text summaries. */
86
+ function prettyHouseSystem(hs) {
87
+ return hs.split("_").map(capitalize).join(" ");
88
+ }
84
89
  /** Build the nested request body expected by /ephemeris/natal-chart (POST). */
85
90
  function buildNatalBody(datetime, lat, lon, houseSystem, timezone) {
86
91
  const body = {
@@ -111,23 +116,16 @@ registerTool({
111
116
  "CREDIT COST: 1 credit per call.\n\n" +
112
117
  "Supports house system switching (Placidus, Whole Sign, Equal, Koch). " +
113
118
  "The chart is computed using NASA JPL DE440 ephemerides for sub-arcsecond precision. " +
114
- "Use this instead of ephemeris_chart_wheel for a richer, interactive experience in " +
115
- "MCP Apps-capable hosts (Claude Desktop). Falls back to static SVG in other hosts.",
119
+ "Renders interactively in MCP Apps-capable hosts (Claude Desktop) and falls back to " +
120
+ "static SVG in other hosts.",
116
121
  inputSchema: {
117
122
  type: "object",
118
123
  properties: {
119
- datetime: {
120
- type: "string",
121
- description: "ISO 8601 datetime string, e.g. '1990-04-15T14:30:00'. " +
122
- "Include timezone offset if known, e.g. '1990-04-15T14:30:00-05:00'.",
123
- },
124
- timezone: {
125
- type: "string",
126
- description: "IANA timezone name (e.g. 'America/New_York'). Used if datetime has no UTC offset.",
127
- },
124
+ datetime: { type: "string", description: DATETIME_DESC },
125
+ timezone: TIMEZONE_PROPERTY,
128
126
  latitude: {
129
127
  type: "number",
130
- description: "Birth latitude in decimal degrees (positive = North).",
128
+ description: "Birth latitude in decimal degrees (positive = North). Resolve from a place name with location_search; never recall coordinates from memory.",
131
129
  },
132
130
  longitude: {
133
131
  type: "number",
@@ -135,7 +133,7 @@ registerTool({
135
133
  },
136
134
  location: {
137
135
  type: "string",
138
- description: "Location name for display only (e.g. 'New York, NY').",
136
+ description: "Location name for display only (e.g. 'New York, NY'). This is a caption, NOT a geocoder — it does not set the chart location. Supply latitude/longitude too (resolve them with location_search), or the chart is computed at 0N 0E.",
139
137
  },
140
138
  house_system: {
141
139
  type: "string",
@@ -190,7 +188,7 @@ registerTool({
190
188
  // source of the ~90 second blocking delay.
191
189
  const chartData = await client.post("/ephemeris/natal-chart", natalBody);
192
190
  // Build human-readable summary for the LLM context
193
- const summary = buildChartSummary(chartData, String(args.location ?? `${lat}, ${lon}`));
191
+ const summary = buildChartSummary(chartData, String(args.location ?? `${lat}, ${lon}`), houseSystem);
194
192
  // Build the UI payload (planets normalised to array, server aspects mapped)
195
193
  const modelPayload = buildModelPayload(chartData, {
196
194
  datetime,
@@ -343,8 +341,8 @@ registerTool({
343
341
  inputSchema: {
344
342
  type: "object",
345
343
  properties: {
346
- datetime: { type: "string", description: "ISO 8601 natal datetime string" },
347
- timezone: { type: "string", description: "IANA timezone name (e.g. 'America/New_York')" },
344
+ datetime: { type: "string", description: DATETIME_DESC },
345
+ timezone: TIMEZONE_PROPERTY,
348
346
  latitude: { type: "number", description: "Birth latitude in decimal degrees (positive = North)" },
349
347
  longitude: { type: "number", description: "Birth longitude in decimal degrees (positive = East)" },
350
348
  location: { type: "string", description: "Location name for display only (e.g. 'New York, NY')" },
@@ -360,7 +358,7 @@ registerTool({
360
358
  },
361
359
  target_datetime: {
362
360
  type: "string",
363
- description: "ISO 8601 target datetime string. Reguired for solar_return or progressed calculations."
361
+ description: "Target datetime. Required for solar_return or progressed calculations. " + DATETIME_DESC
364
362
  }
365
363
  },
366
364
  required: ["datetime", "latitude", "longitude", "house_system"],
@@ -383,7 +381,9 @@ registerTool({
383
381
  requestBody.configuration.target_datetime = { iso: args.target_datetime ?? datetime };
384
382
  }
385
383
  else if (chartType === "progressed") {
386
- endpoint = "/predictive/progressed";
384
+ // openapi.json exposes /ephemeris/progressed — /predictive/progressed
385
+ // does not exist and 404'd every progressed recalculate in the iframe.
386
+ endpoint = "/ephemeris/progressed";
387
387
  requestBody.configuration.target_datetime = { iso: args.target_datetime ?? datetime };
388
388
  requestBody.configuration.method = "secondary";
389
389
  }
@@ -560,7 +560,7 @@ function buildModelPayload(chartData, birthParams, houseSystem, svgBase, bodyFil
560
560
  house_system: houseSystem,
561
561
  };
562
562
  }
563
- function buildChartSummary(data, location) {
563
+ function buildChartSummary(data, location, houseSystem) {
564
564
  // The natal API returns planets as a keyed object { sun: {...}, moon: {...} }.
565
565
  // Normalise to an array before processing.
566
566
  const raw = data.planets ?? {};
@@ -621,7 +621,8 @@ function buildChartSummary(data, location) {
621
621
  const strongestNote = strongest
622
622
  ? `Closest aspect: ${capitalize(strongest.planet1)} ${strongest.type} ${capitalize(strongest.planet2)} (${strongest.orb.toFixed(1)}° orb)`
623
623
  : "";
624
- const enrichment = [sectNote, domElement ? `Dominant element: ${domElement[0]} (${domElement[1]})` : "", strongestNote]
624
+ const housesNote = houseSystem ? `Houses: ${prettyHouseSystem(houseSystem)}` : "";
625
+ const enrichment = [housesNote, sectNote, domElement ? `Dominant element: ${domElement[0]} (${domElement[1]})` : "", strongestNote]
625
626
  .filter(Boolean).join(" · ");
626
627
  return (`**Natal Chart — ${location}**\n\n` +
627
628
  (enrichment ? `${enrichment}\n\n` : "") +
@@ -1,9 +1,95 @@
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
+ * The UTC offset of an IANA zone is a function of place AND date: 1987 US DST
6
+ * rules are not today's rules, and America/Chicago ran on CDT through the whole
7
+ * of January 1974. A bare zone name is therefore not enough to turn a local
8
+ * birth time into an instant — the caller needs the offset *on the birth date*.
9
+ *
10
+ * We resolve it here from Node's bundled IANA tzdata rather than by calling
11
+ * `/timezone/offset`, for two reasons:
12
+ * 1. Credits meter per URL path, so resolving N suggestions server-side would
13
+ * cost N extra credits per search. Local resolution costs nothing.
14
+ * 2. Node's ICU tzdata and the Go sidecar's tzdata agree on every historical
15
+ * discriminator we test (see test/location-tools.test.ts), so there is no
16
+ * accuracy argument for the round trip.
17
+ *
18
+ * The offset is evaluated at 12:00 **local** time on the given date. DST
19
+ * transitions happen around 02:00 local, so local noon is unambiguous for every
20
+ * real zone — this avoids both the "which side of the boundary" problem and the
21
+ * date-rollover problem that evaluating at 12:00 UTC would create for zones at
22
+ * extreme offsets (e.g. Pacific/Kiritimati at +14).
23
+ */
24
+ function offsetAtLocalNoon(timezone, date) {
25
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(date).trim());
26
+ if (!m || !timezone)
27
+ return null;
28
+ const [year, month, day] = [Number(m[1]), Number(m[2]), Number(m[3])];
29
+ try {
30
+ // Offset (in ms) that must be ADDED to a UTC instant to get local wall time.
31
+ const offsetMsAt = (utcMs) => {
32
+ const parts = new Intl.DateTimeFormat("en-US", {
33
+ timeZone: timezone,
34
+ year: "numeric", month: "2-digit", day: "2-digit",
35
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
36
+ hour12: false,
37
+ }).formatToParts(new Date(utcMs));
38
+ const get = (t) => Number(parts.find((p) => p.type === t)?.value ?? 0);
39
+ const wallMs = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
40
+ return wallMs - utcMs;
41
+ };
42
+ // Find the UTC instant corresponding to 12:00 local on `date`. Two passes
43
+ // settle the fixed point (the first guess can land on the wrong side of a
44
+ // transition; noon never sits close enough to one for a third to matter).
45
+ const wantWallMs = Date.UTC(year, month - 1, day, 12, 0, 0);
46
+ let utcMs = wantWallMs - offsetMsAt(wantWallMs);
47
+ utcMs = wantWallMs - offsetMsAt(utcMs);
48
+ const offsetMinutes = offsetMsAt(utcMs) / 60000;
49
+ if (!Number.isFinite(offsetMinutes))
50
+ return null;
51
+ // DST flag. The long zone name is tzdata's own answer and is the only
52
+ // reliable source: comparing against a January/July baseline gets 1974
53
+ // wrong, because the US observed DST for the WHOLE of that year, so
54
+ // both bracket months are daylight time and the comparison reports
55
+ // "not DST" for a date that genuinely was. Locale is pinned to en-US so
56
+ // the wording is stable.
57
+ let isDst;
58
+ const longName = new Intl.DateTimeFormat("en-US", { timeZone: timezone, timeZoneName: "long" })
59
+ .formatToParts(new Date(utcMs))
60
+ .find((p) => p.type === "timeZoneName")?.value ?? "";
61
+ if (/daylight|summer/i.test(longName)) {
62
+ isDst = true;
63
+ }
64
+ else if (/standard/i.test(longName)) {
65
+ isDst = false;
66
+ }
67
+ else {
68
+ // Zone has no localized name (Intl fell back to "GMT+11"). Bracket the
69
+ // year instead: the smaller of the January and July offsets is standard
70
+ // time in either hemisphere.
71
+ const janOffset = offsetMsAt(Date.UTC(year, 0, 15, 12, 0, 0)) / 60000;
72
+ const julOffset = offsetMsAt(Date.UTC(year, 6, 15, 12, 0, 0)) / 60000;
73
+ isDst = offsetMinutes > Math.min(janOffset, julOffset);
74
+ }
75
+ const sign = offsetMinutes < 0 ? "-" : "+";
76
+ const abs = Math.abs(offsetMinutes);
77
+ const hh = String(Math.floor(abs / 60)).padStart(2, "0");
78
+ const mm = String(Math.round(abs % 60)).padStart(2, "0");
79
+ return {
80
+ utcOffsetAtDate: `${sign}${hh}:${mm}`,
81
+ utcOffsetMinutes: offsetMinutes,
82
+ isDst,
83
+ };
84
+ }
85
+ catch {
86
+ // Unknown/invalid zone — the caller still gets the IANA name back.
87
+ return null;
88
+ }
89
+ }
4
90
  registerTool({
5
91
  name: "location_search",
6
- description: "App-only tool for getting location autocomplete suggestions. CREDIT COST: 1 credit per call. Returns display name, region (state/province), latitude, longitude, and IANA timezone. Optional bias params (country/region/near) improve ranking; a trailing \"City, ST\" qualifier in the query is also honored.",
92
+ description: "Resolve a place name to coordinates and IANA timezone — use this first whenever a user gives a birth city rather than latitude/longitude. NEVER recall coordinates from memory; always resolve them here. CREDIT COST: 1 credit per call. Returns display name, region (state/province), latitude, longitude, and IANA timezone. Pass the birth date as `date` to also get `utcOffsetAtDate`, the historically-correct UTC offset for that place on that date (1987 DST rules differ from today's) — this costs no extra credits. When the result is `ambiguous` (several places share the name, e.g. \"portland\"), ASK the user which one they mean rather than assuming the first. Optional bias params (country/region/near) improve ranking; a trailing \"City, ST\" qualifier in the query is also honored.",
7
93
  inputSchema: {
8
94
  type: "object",
9
95
  properties: {
@@ -27,14 +113,22 @@ registerTool({
27
113
  type: "string",
28
114
  description: "Optional 'lat,lon' proximity hint (e.g. '37.77,-122.42') to bias ranking toward nearby places.",
29
115
  },
116
+ date: {
117
+ type: "string",
118
+ description: "Optional birth/event date as 'YYYY-MM-DD'. When given, each suggestion also carries utcOffsetAtDate / utcOffsetMinutes / isDst — the UTC offset that actually applied at that place on that date, using historical DST rules. Free: adds no API call and no credits.",
119
+ },
30
120
  },
31
121
  required: ["query"],
32
122
  additionalProperties: false,
33
123
  },
34
124
  outputSchema: OUTPUT_SCHEMA_JSON,
125
+ annotations: { title: "Location Search", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
126
+ // Model-visible as well as app-visible: without this the model had no
127
+ // geocoding tool and every prompt had to route through
128
+ // `dev_read_api /location/autocomplete` instead.
35
129
  _meta: {
36
130
  ui: {
37
- visibility: ["app"],
131
+ visibility: ["model", "app"],
38
132
  },
39
133
  },
40
134
  handler: async (args) => {
@@ -48,41 +142,96 @@ registerTool({
48
142
  const raw = (await getActiveClient().request("GET", "/location/autocomplete", { params }));
49
143
  // Map the API's snake_case response to the camelCase shape the app UI consumes.
50
144
  const list = Array.isArray(raw?.suggestions) ? raw.suggestions : [];
51
- const suggestions = list.map((s) => ({
52
- displayName: s.display_name,
53
- shortName: s.short_name,
54
- region: s.region,
55
- countryCode: s.country_code,
56
- placeId: s.place_id,
57
- latitude: s.latitude,
58
- longitude: s.longitude,
59
- timezone: s.timezone,
60
- }));
61
- return { suggestions };
145
+ const date = args.date != null && String(args.date).trim() !== "" ? String(args.date).trim() : null;
146
+ const suggestions = list.map((s) => {
147
+ const mapped = {
148
+ displayName: s.display_name,
149
+ shortName: s.short_name,
150
+ region: s.region,
151
+ countryCode: s.country_code,
152
+ placeId: s.place_id,
153
+ latitude: s.latitude,
154
+ longitude: s.longitude,
155
+ timezone: s.timezone,
156
+ };
157
+ if (date && s.timezone)
158
+ Object.assign(mapped, offsetAtLocalNoon(s.timezone, date) ?? {});
159
+ return mapped;
160
+ });
161
+ // Ambiguity signal. "portland" really does match Oregon, Maine, Texas,
162
+ // England, ... — an agent that silently takes suggestions[0] produces a
163
+ // chart nothing downstream can detect as wrong.
164
+ //
165
+ // What matters is *rivals for the top hit*, not the row count. The
166
+ // endpoint pads toward its limit of 8, so "dallas texas" comes back with
167
+ // eight rows (Dallas GA, Dallas OR, ...) even though the query is already
168
+ // unambiguous — counting rows would flag essentially every search.
169
+ const norm = (v) => String(v ?? "").trim().toLowerCase();
170
+ const top = suggestions[0];
171
+ const rivals = top ? suggestions.filter((s) => norm(s.shortName) === norm(top.shortName)) : [];
172
+ // If the caller already pinned the place — "dallas texas", "portland uk" —
173
+ // the top hit is what they asked for and there is nothing to ask about.
174
+ const q = norm(args.query);
175
+ const qTokens = new Set(q.split(/[^a-z0-9]+/).filter(Boolean));
176
+ const qualified = !!top &&
177
+ ((norm(top.region) !== "" && q.includes(norm(top.region))) ||
178
+ (norm(top.countryCode) !== "" && qTokens.has(norm(top.countryCode))));
179
+ const ambiguous = rivals.length > 1 && !qualified;
180
+ return {
181
+ suggestions,
182
+ matchCount: suggestions.length,
183
+ ambiguous,
184
+ ...(ambiguous
185
+ ? {
186
+ disambiguationHint: `"${args.query}" matches ${rivals.length} places of that name (${rivals
187
+ .slice(0, 4)
188
+ .map((s) => s.region ?? s.countryCode)
189
+ .filter(Boolean)
190
+ .join(", ")}${rivals.length > 4 ? ", ..." : ""}). Confirm which one the user means before building a chart — do not assume the first result.`,
191
+ }
192
+ : {}),
193
+ };
62
194
  },
63
195
  });
64
196
  registerTool({
65
197
  name: "timezone_resolve",
66
- description: "App-only tool for resolving IANA timezone by latitude and longitude. CREDIT COST: 1 credit per call.",
198
+ description: "Resolve the IANA timezone for a latitude/longitude pair. Use when you have coordinates but need the timezone to interpret a local birth time. Pass `date` to also get the historically-correct UTC offset that applied on that date. CREDIT COST: 1 credit per call. If you are starting from a place name rather than coordinates, use location_search instead — it returns the timezone too, in the same single call.",
67
199
  inputSchema: {
68
200
  type: "object",
69
201
  properties: {
70
202
  latitude: { type: "number" },
71
203
  longitude: { type: "number" },
204
+ date: {
205
+ type: "string",
206
+ description: "Optional birth/event date as 'YYYY-MM-DD'. When given, the result also carries utcOffsetAtDate / utcOffsetMinutes / isDst for that date under that zone's historical DST rules. Free: adds no API call and no credits.",
207
+ },
72
208
  },
73
209
  required: ["latitude", "longitude"],
74
210
  additionalProperties: false,
75
211
  },
76
212
  outputSchema: OUTPUT_SCHEMA_JSON,
213
+ annotations: { title: "Timezone Resolve", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
77
214
  _meta: {
78
215
  ui: {
79
- visibility: ["app"],
216
+ visibility: ["model", "app"],
80
217
  },
81
218
  },
82
219
  handler: async (args) => {
83
220
  validateRequired(args, ["latitude", "longitude"]);
84
- return await getActiveClient().request("GET", "/timezone/lookup", {
85
- params: { lat: args.latitude.toString(), lon: args.longitude.toString() },
86
- });
221
+ // /timezone/lookup is registered POST-only (main.go via api.gen.go) and
222
+ // takes latitude/longitude in the BODY. This tool used to issue
223
+ // `GET /timezone/lookup?lat=&lon=`, which the API answered with 405 —
224
+ // i.e. the tool failed 100% of the time in production.
225
+ const result = (await getActiveClient().request("POST", "/timezone/lookup", {
226
+ data: { latitude: args.latitude, longitude: args.longitude },
227
+ }));
228
+ const date = args.date != null && String(args.date).trim() !== "" ? String(args.date).trim() : null;
229
+ const tz = typeof result?.timezone === "string" ? result.timezone : null;
230
+ if (date && tz) {
231
+ const offset = offsetAtLocalNoon(tz, date);
232
+ if (offset)
233
+ return { ...result, ...offset, date };
234
+ }
235
+ return result;
87
236
  },
88
237
  });
@@ -17,6 +17,7 @@ import { fileURLToPath } from "node:url";
17
17
  import { registerTool, validateCoordinates, SERVER_VERSION } from "../index.js";
18
18
  import { getActiveClient } from "../../backend/client.js";
19
19
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
20
+ import { TIMEZONE_PROPERTY, localToUtcIso } from "../datetime.js";
20
21
  // ── Constants ─────────────────────────────────────────────────────────────────
21
22
  export const MOON_PHASE_RESOURCE_URI = "ui://openephemeris/moon-phase";
22
23
  export const MOON_PHASE_MIME_TYPE = "text/html;profile=mcp-app";
@@ -96,7 +97,9 @@ async function computeMoonData(args) {
96
97
  validateCoordinates(args, "latitude", "longitude");
97
98
  const client = getActiveClient();
98
99
  const params = {};
99
- params.datetime = args.datetime ?? new Date().toISOString();
100
+ params.datetime = args.datetime
101
+ ? localToUtcIso("datetime", String(args.datetime), args.timezone)
102
+ : new Date().toISOString();
100
103
  if (args.latitude != null)
101
104
  params.latitude = args.latitude;
102
105
  if (args.longitude != null)
@@ -206,8 +209,10 @@ registerTool({
206
209
  properties: {
207
210
  datetime: {
208
211
  type: "string",
209
- description: "ISO 8601 datetime to query. If omitted, returns the current live moon phase (UTC now).",
212
+ description: "ISO 8601 datetime to query, stating its zone ('2026-03-20T12:00:00Z'), or a local " +
213
+ "time together with timezone. If omitted, returns the current live moon phase (UTC now).",
210
214
  },
215
+ timezone: TIMEZONE_PROPERTY,
211
216
  latitude: {
212
217
  type: "number",
213
218
  description: "Observer latitude (optional, used for local void-of-course calculations).",
@@ -272,8 +277,10 @@ registerTool({
272
277
  properties: {
273
278
  datetime: {
274
279
  type: "string",
275
- description: "ISO 8601 datetime to query. If omitted, uses the current live moon phase (UTC now).",
280
+ description: "ISO 8601 datetime to query, stating its zone ('2026-03-20T12:00:00Z'), or a local " +
281
+ "time together with timezone. If omitted, uses the current live moon phase (UTC now).",
276
282
  },
283
+ timezone: TIMEZONE_PROPERTY,
277
284
  latitude: {
278
285
  type: "number",
279
286
  description: "Observer latitude (optional, used for local void-of-course calculations).",
@@ -18,6 +18,7 @@ import { fileURLToPath } from "node:url";
18
18
  import { registerTool, validateRequired, SERVER_VERSION } from "../index.js";
19
19
  import { getActiveClient } from "../../backend/client.js";
20
20
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
21
+ import { DATETIME_DESC, WINDOW_DATE_DESC, timezoneProperty } from "../datetime.js";
21
22
  // ── Constants ─────────────────────────────────────────────────────────────────
22
23
  export const TRANSIT_TIMELINE_RESOURCE_URI = "ui://openephemeris/transit-timeline";
23
24
  export const TRANSIT_TIMELINE_MIME_TYPE = "text/html;profile=mcp-app";
@@ -109,11 +110,12 @@ registerTool({
109
110
  inputSchema: {
110
111
  type: "object",
111
112
  properties: {
112
- natal_datetime: { type: "string", description: "ISO 8601 birth datetime for the natal chart." },
113
- natal_latitude: { type: "number", description: "Latitude of birth location in decimal degrees." },
113
+ natal_datetime: { type: "string", description: DATETIME_DESC },
114
+ natal_timezone: timezoneProperty("the natal birth location"),
115
+ natal_latitude: { type: "number", description: "Latitude of birth location in decimal degrees. Resolve from a place name with location_search; never recall coordinates from memory." },
114
116
  natal_longitude: { type: "number", description: "Longitude of birth location in decimal degrees." },
115
- start_date: { type: "string", description: "Start of the transit search window, ISO 8601 date or datetime (e.g. '2026-01-01')." },
116
- end_date: { type: "string", description: "End of the transit search window, ISO 8601 date or datetime (e.g. '2026-12-31')." },
117
+ start_date: { type: "string", description: "Start of the transit search window. " + WINDOW_DATE_DESC },
118
+ end_date: { type: "string", description: "End of the transit search window. " + WINDOW_DATE_DESC },
117
119
  transiting_planets: {
118
120
  type: "array", items: { type: "string" },
119
121
  description: "Transiting planet IDs to search, e.g. ['saturn','jupiter','uranus','pluto','chiron']. Omit to search all outer planets.",
@@ -23,6 +23,7 @@ import { fileURLToPath } from "node:url";
23
23
  import { registerTool, validateRequired, validateCoordinates, SERVER_VERSION } from "../index.js";
24
24
  import { getActiveClient } from "../../backend/client.js";
25
25
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
26
+ import { localToUtcIso } from "../datetime.js";
26
27
  // ── Constants ─────────────────────────────────────────────────────────────────
27
28
  export const VEDIC_CHART_RESOURCE_URI = "ui://openephemeris/vedic-chart";
28
29
  export const VEDIC_CHART_MIME_TYPE = "text/html;profile=mcp-app";
@@ -57,50 +58,6 @@ export function getVedicChartBundle() {
57
58
  export function clearVedicChartBundleCache() {
58
59
  cachedBundle = null;
59
60
  }
60
- // ── Helpers ──────────────────────────────────────────────────────────────────
61
- /** Ensure datetime has a UTC offset for Go time.Time parsing. */
62
- function ensureTimezone(dt) {
63
- if (!dt)
64
- return dt;
65
- if (/[Zz]$/.test(dt) || /[+-]\d{2}:\d{2}$/.test(dt))
66
- return dt;
67
- return dt + "Z";
68
- }
69
- /**
70
- * Convert a local datetime string (no offset) to a UTC ISO 8601 string using
71
- * the IANA timezone. Mirrors bodygraph-app.ts's localToUtcIso.
72
- */
73
- function localToUtcIso(dt, tz) {
74
- if (!dt || /[Zz]$/.test(dt) || /[+-]\d{2}:\d{2}$/.test(dt))
75
- return ensureTimezone(dt);
76
- if (!tz)
77
- return dt + "Z";
78
- try {
79
- const [datePart, timePart = "00:00:00"] = dt.split("T");
80
- const [year, month, day] = datePart.split("-").map(Number);
81
- const [hour, min, sec = 0] = timePart.split(":").map(Number);
82
- const candidateUtcMs = Date.UTC(year, month - 1, day, hour, min, sec);
83
- const getOffsetMs = (utcMs) => {
84
- const fmtParts = new Intl.DateTimeFormat("en-US", {
85
- timeZone: tz,
86
- year: "numeric", month: "2-digit", day: "2-digit",
87
- hour: "2-digit", minute: "2-digit", second: "2-digit",
88
- hour12: false,
89
- }).formatToParts(new Date(utcMs));
90
- const get = (t) => Number(fmtParts.find((p) => p.type === t)?.value ?? 0);
91
- const localizedUtcMs = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
92
- return utcMs - localizedUtcMs;
93
- };
94
- const offsetMs1 = getOffsetMs(candidateUtcMs);
95
- const correctedUtcMs1 = candidateUtcMs + offsetMs1;
96
- const offsetMs2 = getOffsetMs(correctedUtcMs1);
97
- const finalUtcMs = candidateUtcMs + offsetMs2;
98
- return new Date(finalUtcMs).toISOString();
99
- }
100
- catch {
101
- return dt + "Z";
102
- }
103
- }
104
61
  /**
105
62
  * Build the compact iframe model from the raw Go /vedic/chart response.
106
63
  * The Go response nests ayanamsha/lagna under `metadata` (radians→degrees
@@ -155,7 +112,7 @@ registerTool({
155
112
  },
156
113
  latitude: {
157
114
  type: "number",
158
- description: "Birth latitude in decimal degrees (positive = North).",
115
+ description: "Birth latitude in decimal degrees (positive = North). Resolve from a place name with location_search; never recall coordinates from memory.",
159
116
  },
160
117
  longitude: {
161
118
  type: "number",
@@ -163,7 +120,7 @@ registerTool({
163
120
  },
164
121
  location: {
165
122
  type: "string",
166
- description: "Location name for display only (e.g. 'Mumbai, India').",
123
+ description: "Location name for display only (e.g. 'Mumbai, India'). This is a caption, NOT a geocoder — it does not set the chart location. Supply latitude/longitude too (resolve them with location_search), or the chart is computed at 0N 0E.",
167
124
  },
168
125
  timezone: {
169
126
  type: "string",
@@ -197,7 +154,7 @@ registerTool({
197
154
  validateCoordinates(args, "latitude", "longitude");
198
155
  const client = getActiveClient();
199
156
  const timezone = args.timezone;
200
- const datetime = localToUtcIso(String(args.datetime), timezone);
157
+ const datetime = localToUtcIso("datetime", String(args.datetime), timezone);
201
158
  const lat = Number(args.latitude);
202
159
  const lon = Number(args.longitude);
203
160
  const ayanamsa = args.ayanamsa;
@@ -250,7 +207,16 @@ registerTool({
250
207
  inputSchema: {
251
208
  type: "object",
252
209
  properties: {
253
- datetime: { type: "string" },
210
+ datetime: {
211
+ type: "string",
212
+ description: "ISO 8601 birth datetime stating its zone, or a local time together with timezone. " +
213
+ "Must match the value used by explore_vedic_chart or the re-render will show a different chart.",
214
+ },
215
+ timezone: {
216
+ type: "string",
217
+ description: "IANA timezone name for the birth location (e.g. 'Asia/Kolkata'). Required when datetime " +
218
+ "has no 'Z' or ±HH:MM offset — the same value passed to explore_vedic_chart.",
219
+ },
254
220
  latitude: { type: "number" },
255
221
  longitude: { type: "number" },
256
222
  ayanamsa: { type: "string" },
@@ -267,7 +233,7 @@ registerTool({
267
233
  _meta: { ui: { resourceUri: VEDIC_CHART_RESOURCE_URI, visibility: ["app"] } },
268
234
  handler: async (args) => {
269
235
  const client = getActiveClient();
270
- const datetime = ensureTimezone(String(args.datetime));
236
+ const datetime = localToUtcIso("datetime", String(args.datetime), args.timezone);
271
237
  const lat = args.latitude != null ? Number(args.latitude) : undefined;
272
238
  const lon = args.longitude != null ? Number(args.longitude) : undefined;
273
239
  const ayanamsa = args.ayanamsa;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The datetime contract, stated once for the whole tool surface.
3
+ *
4
+ * A datetime that names a clock time but not a zone does not name an instant.
5
+ * The API used to read such a value as UTC, so a birth time of 09:01 in Chicago
6
+ * was computed as 09:01 UTC — five hours early. Every planet keeps its sign at
7
+ * that error scale, so the answer looked right while the Ascendant was two signs
8
+ * off and every house placement was wrong.
9
+ *
10
+ * The rules, identical here and in the Go engine
11
+ * (apps/api/go-sidecar/internal/api/handlers/datetime_contract.go):
12
+ *
13
+ * 1. A datetime WITH a clock time must carry its zone — either a `Z`/`±HH:MM`
14
+ * suffix on the string, or a companion `timezone` argument.
15
+ * 2. A date-only value ("1987-07-15") has no clock time to be ambiguous about
16
+ * and resolves to 12:00 UTC.
17
+ * 3. Nothing in this layer ever appends a `Z` to a zone-less value. Doing so
18
+ * asserts a fact the caller never stated.
19
+ *
20
+ * Violations are rejected here rather than at the API so the model gets the
21
+ * correction immediately and no credit is spent on a chart that is wrong.
22
+ */
23
+ /** True when `value` states a clock time but not the zone that clock is in. */
24
+ export declare function isNaiveClockTime(value: string): boolean;
25
+ /** True when `value` already carries its own zone. */
26
+ export declare function hasZoneSuffix(value: string): boolean;
27
+ /** The canonical description for a birth / chart-moment datetime parameter. */
28
+ export declare const DATETIME_DESC: string;
29
+ /** The canonical description for the companion `timezone` parameter. */
30
+ export declare const TIMEZONE_DESC: string;
31
+ /** The canonical description for a search-window date parameter (date or datetime). */
32
+ export declare const WINDOW_DATE_DESC: string;
33
+ /** The `timezone` property to spread into a tool's inputSchema. */
34
+ export declare const TIMEZONE_PROPERTY: {
35
+ readonly type: "string";
36
+ readonly description: string;
37
+ };
38
+ /** Build a `<prefix>_timezone` property with a tailored example. */
39
+ export declare function timezoneProperty(label: string, example?: string): {
40
+ readonly type: "string";
41
+ readonly description: string;
42
+ };
43
+ /**
44
+ * The rejection message. Names both remedies, rewriting the caller's own value
45
+ * into each — the commonest failure is not realising the value was ambiguous.
46
+ */
47
+ export declare function ambiguousDatetimeMessage(field: string, value: string, timezoneField?: string): string;
48
+ /**
49
+ * Throws unless `value` names an unambiguous instant.
50
+ *
51
+ * Call this in every tool handler before building a request body. Passing a
52
+ * `timezone` satisfies the contract for a zone-less value; the zone itself is
53
+ * resolved server-side (or by `localToUtcIso` for the `*_utc` endpoints).
54
+ */
55
+ export declare function assertZonedDatetime(field: string, value: unknown, timezone?: unknown, timezoneField?: string): void;
56
+ /**
57
+ * Convert a datetime to a UTC ISO 8601 string for the endpoints whose field is
58
+ * named `*_utc` and whose Go type is a strict RFC 3339 `time.Time`.
59
+ *
60
+ * Unlike the four ad-hoc `ensureTimezone` helpers this replaces, it never
61
+ * appends a bare "Z" to a zone-less value — it throws instead. Appending Z
62
+ * asserts the input was UTC, which is exactly the silent assumption that made
63
+ * the original defect invisible.
64
+ */
65
+ export declare function localToUtcIso(field: string, dt: string, tz?: string, timezoneField?: string): string;