@openephemeris/mcp-server 4.8.0 → 4.10.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 (35) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/tools/apps/bazi-app.js +14 -7
  3. package/dist/tools/apps/bi-wheel-app.js +5 -5
  4. package/dist/tools/apps/bodygraph-app.js +8 -8
  5. package/dist/tools/apps/chart-wheel-app.js +4 -4
  6. package/dist/tools/apps/location-tools.js +68 -6
  7. package/dist/tools/apps/moon-phase-app.js +1 -1
  8. package/dist/tools/apps/vedic-chart-app.js +1 -1
  9. package/dist/tools/auth.js +8 -0
  10. package/dist/tools/dev.js +15 -5
  11. package/dist/tools/specialized/account.js +5 -1
  12. package/dist/tools/specialized/acg.js +2 -2
  13. package/dist/tools/specialized/bazi.d.ts +38 -0
  14. package/dist/tools/specialized/bazi.js +126 -24
  15. package/dist/tools/specialized/bi_wheel.js +1 -1
  16. package/dist/tools/specialized/chart_wheel.js +1 -1
  17. package/dist/tools/specialized/comparative.js +4 -4
  18. package/dist/tools/specialized/eclipse.js +1 -1
  19. package/dist/tools/specialized/electional.js +5 -5
  20. package/dist/tools/specialized/ephemeris_core.js +2 -2
  21. package/dist/tools/specialized/ephemeris_extended.js +8 -8
  22. package/dist/tools/specialized/hd_bodygraph.js +1 -1
  23. package/dist/tools/specialized/hd_cycles.js +2 -2
  24. package/dist/tools/specialized/hd_group.js +2 -2
  25. package/dist/tools/specialized/human_design.js +1 -1
  26. package/dist/tools/specialized/moon.js +2 -2
  27. package/dist/tools/specialized/natal.js +1 -1
  28. package/dist/tools/specialized/progressed.js +1 -1
  29. package/dist/tools/specialized/relocation.js +1 -1
  30. package/dist/tools/specialized/returns.js +3 -3
  31. package/dist/tools/specialized/synastry.js +1 -1
  32. package/dist/tools/specialized/transits.js +1 -1
  33. package/dist/tools/specialized/vedic.js +1 -1
  34. package/dist/tools/specialized/venus_star_points.js +6 -6
  35. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,69 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [4.10.0] — 2026-08-05
11
+
12
+ ### Fixed
13
+ - **BaZi tools never forwarded `timezone` to the API.** `parseBaziArgs` used it
14
+ only to read a zoned `datetime` back into local calendar components, then
15
+ dropped it — every BaZi request from MCP (`chinese_bazi`, `bazi_ten_gods`,
16
+ `bazi_element_balance`, `bazi_luck_pillars`, `bazi_chart`,
17
+ `explore_bazi_chart`, `bazi_compatibility`) landed on the API with no zone,
18
+ which resolves the year/month solar-term boundary (Li Chun, the Jié) against
19
+ the naive local clock instead of the true birth instant — exactly the defect
20
+ [#532](https://github.com/openephemeris/openephemeris/pull/532) fixes at the
21
+ API. A birth within roughly the birthplace's UTC offset of a boundary could
22
+ land on the wrong side and get the wrong year or month pillar. `timezone` is
23
+ now forwarded on every BaZi tool.
24
+
25
+ ### Added
26
+ - **BaZi convention parameters.** `minute`, `year_boundary`
27
+ (`lichun`/`cny`), `day_boundary` (`zi_hour`/`midnight`), `true_solar_time`,
28
+ and `latitude`/`longitude` are now request parameters on every BaZi tool
29
+ that accepts them at the API — `bazi_compatibility` takes them per chart
30
+ (`chart_a_*`/`chart_b_*`), since partners are often born under different
31
+ conventions or in different timezones.
32
+
33
+ ## [4.9.0] — 2026-08-03
34
+
35
+ ### Fixed
36
+ - **`location_search` gave pre-1970 birthplaces the wrong UTC offset.** With a
37
+ `date` before 1970 it answered from local tzdata alone — and tzdata models
38
+ only a zone's *reference city* before 1970, which is exactly the error the
39
+ API's historical correction overlay exists to remove. Dallas on 1952-07-15
40
+ came back `-05:00` (Chicago observed daylight time that summer; Texas did
41
+ not) instead of the correct `-06:00`. Because this tool is the documented
42
+ one-call alternative to `timezone_resolve`, an agent that took the top match
43
+ and built a zone-suffixed datetime from it produced a chart an hour off with
44
+ nothing downstream able to detect it. The top match is now corrected through
45
+ the API (1 extra credit, pre-1970 only); remaining matches keep their tzdata
46
+ estimate and stay labelled `historical_estimate`. Post-1970 is unchanged and
47
+ still free.
48
+
49
+ ### Added
50
+ - **Historical-correction provenance now reaches MCP consumers.**
51
+ `timezone_resolve` and `location_search` forwarded only `tzRuleSource` out of
52
+ the five fields the API publishes. They now also return `tzOverlayVersion`,
53
+ `tzOverlayHash`, `tzRuleCitation` and `serverVersion`. The first two are the
54
+ change signal — without them a consumer cannot tell that the correction
55
+ dataset moved underneath charts it already computed. `tzRuleCitation` carries
56
+ the actual primary source behind a correction (a statute reference, an agency
57
+ order, a dated almanac page), so a corrected time can show its work instead of
58
+ asking to be trusted. Keys are omitted rather than emitted as nulls when the
59
+ API sends none.
60
+
61
+ ## [4.8.1] — 2026-08-02
62
+
63
+ ### Fixed
64
+ - Every tool now declares all four MCP annotation hints explicitly
65
+ (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`).
66
+ Notably `dev_write_api` is `destructiveHint: false` — every allowlisted
67
+ write is stateless chart computation — so strict clients no longer gate it
68
+ behind a confirmation, and `openWorldHint` is uniformly `false` (the server
69
+ reaches only the fixed OpenEphemeris API).
70
+ - `account_usage` reports a real authentication failure as a tool error
71
+ (`isError: true`) instead of a success-shaped message.
72
+
10
73
  ## [4.8.0] — 2026-08-02
11
74
 
12
75
  ### Added
@@ -25,7 +25,7 @@ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
25
25
  // One BaZi component parser, shared with the specialized tools. The local copy
26
26
  // this replaces used `new Date(naive)`, which resolves in the host process's
27
27
  // timezone and silently shifted the hour pillar on any non-UTC server.
28
- import { parseBaziArgs } from "../specialized/bazi.js";
28
+ import { parseBaziArgs, buildBaziConventionFields, CONVENTION_PROPERTIES } from "../specialized/bazi.js";
29
29
  // ── Constants ─────────────────────────────────────────────────────────────────
30
30
  export const BAZI_RESOURCE_URI = "ui://openephemeris/bazi";
31
31
  export const BAZI_MIME_TYPE = "text/html;profile=mcp-app";
@@ -66,10 +66,11 @@ export function clearBaziBundleCache() {
66
66
  * Go-rendered SVG (bazi.RenderBaziChartSVG), same shape the strict
67
67
  * /chinese/bazi handler returns with a `visual` key attached.
68
68
  */
69
- async function fetchBaziChart(components, theme = "dark") {
69
+ async function fetchBaziChart(components, theme = "dark", conventionFields = {}) {
70
70
  const client = getActiveClient();
71
71
  const body = {
72
72
  ...components,
73
+ ...conventionFields,
73
74
  include_visual: true,
74
75
  visual_config: { format: "svg", theme, size: 800 },
75
76
  };
@@ -129,6 +130,10 @@ registerTool({
129
130
  description: "Birth hour (0–23). Optional, defaults to 12 (noon). " +
130
131
  "Chinese shí hours are 2-hour blocks — precision within a 2-hour window is sufficient.",
131
132
  },
133
+ minute: {
134
+ type: "integer",
135
+ description: "Birth minute (0–59). Optional, defaults to 0. Only matters under true_solar_time or near a boundary.",
136
+ },
132
137
  datetime: {
133
138
  type: "string",
134
139
  description: "Alternative to year/month/day: ISO 8601 datetime read as LOCAL wall-clock time at the " +
@@ -137,9 +142,10 @@ registerTool({
137
142
  },
138
143
  timezone: {
139
144
  type: "string",
140
- description: "IANA timezone name for the birth place, e.g. 'Asia/Shanghai'. Only needed when " +
141
- "datetime carries a 'Z' or ±HH:MM offset.",
145
+ description: "IANA timezone name for the birth place, e.g. 'Asia/Shanghai'. Required to convert a zoned " +
146
+ "datetime back to local clock; also frames the year/month solar-term boundary.",
142
147
  },
148
+ ...CONVENTION_PROPERTIES,
143
149
  },
144
150
  additionalProperties: false,
145
151
  },
@@ -159,17 +165,18 @@ registerTool({
159
165
  },
160
166
  handler: async (args) => {
161
167
  const components = parseBaziArgs(args);
168
+ const conventionFields = buildBaziConventionFields(args);
162
169
  const bundleAvailable = Boolean(getBaziBundle());
163
170
  const theme = "dark";
164
171
  if (!bundleAvailable) {
165
172
  // Text-fallback hosts have no use for the SVG — skip the visual surcharge.
166
173
  const data = await getActiveClient().request("POST", "/chinese/bazi", {
167
- data: components,
174
+ data: { ...components, ...conventionFields },
168
175
  });
169
176
  const payload = buildModelPayload(data, components, theme);
170
177
  return { content: [{ type: "text", text: buildSummary(payload) }] };
171
178
  }
172
- const data = await fetchBaziChart(components, theme);
179
+ const data = await fetchBaziChart(components, theme, conventionFields);
173
180
  const payload = buildModelPayload(data, components, theme);
174
181
  const summary = buildSummary(payload);
175
182
  // MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
@@ -204,7 +211,7 @@ registerTool({
204
211
  required: ["year", "month", "day"],
205
212
  },
206
213
  outputSchema: OUTPUT_SCHEMA_JSON,
207
- annotations: { title: "Recalculate BaZi Chart", readOnlyHint: true, openWorldHint: false },
214
+ annotations: { title: "Recalculate BaZi Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
208
215
  _meta: { ui: { resourceUri: BAZI_RESOURCE_URI, visibility: ["app"] } },
209
216
  handler: async (args) => {
210
217
  const components = parseBaziArgs(args);
@@ -588,7 +588,7 @@ registerTool({
588
588
  required: ["mode", "person1_datetime", "person1_latitude", "person1_longitude", "person2_datetime"],
589
589
  },
590
590
  outputSchema: OUTPUT_SCHEMA_JSON,
591
- annotations: { title: "Recalculate Bi-Wheel", readOnlyHint: true, openWorldHint: false },
591
+ annotations: { title: "Recalculate Bi-Wheel", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
592
592
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
593
593
  handler: async (args) => {
594
594
  const client = getActiveClient();
@@ -644,7 +644,7 @@ registerTool({
644
644
  required: ["planet1", "planet2", "aspect_type"],
645
645
  },
646
646
  outputSchema: OUTPUT_SCHEMA_JSON,
647
- annotations: { title: "Cross-Aspect Interpretation", readOnlyHint: true, openWorldHint: false },
647
+ annotations: { title: "Cross-Aspect Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
648
648
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
649
649
  handler: async (args) => {
650
650
  const p1 = capitalize(String(args.planet1));
@@ -683,7 +683,7 @@ registerTool({
683
683
  required: ["planet", "wheel", "longitude"],
684
684
  },
685
685
  outputSchema: OUTPUT_SCHEMA_JSON,
686
- annotations: { title: "Bi-Wheel Planet Interpretation", readOnlyHint: true, openWorldHint: false },
686
+ annotations: { title: "Bi-Wheel Planet Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
687
687
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
688
688
  handler: async (args) => {
689
689
  const planet = String(args.planet);
@@ -884,7 +884,7 @@ registerTool({
884
884
  required: ["house_number"],
885
885
  },
886
886
  outputSchema: OUTPUT_SCHEMA_JSON,
887
- annotations: { title: "Bi-Wheel House Interpretation", readOnlyHint: true, openWorldHint: false },
887
+ annotations: { title: "Bi-Wheel House Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
888
888
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
889
889
  handler: async (args) => {
890
890
  const num = Number(args.house_number);
@@ -944,7 +944,7 @@ registerTool({
944
944
  required: ["mode"],
945
945
  },
946
946
  outputSchema: OUTPUT_SCHEMA_JSON,
947
- annotations: { title: "Bi-Wheel Synopsis", readOnlyHint: true, openWorldHint: false },
947
+ annotations: { title: "Bi-Wheel Synopsis", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
948
948
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app", "model"] } },
949
949
  handler: async (args) => {
950
950
  const mode = args.mode ?? "synastry";
@@ -471,7 +471,7 @@ registerTool({
471
471
  required: ["datetime"],
472
472
  },
473
473
  outputSchema: OUTPUT_SCHEMA_JSON,
474
- annotations: { title: "Recalculate Bodygraph", readOnlyHint: true, openWorldHint: false },
474
+ annotations: { title: "Recalculate Bodygraph", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
475
475
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
476
476
  handler: async (args) => {
477
477
  const client = getActiveClient();
@@ -536,7 +536,7 @@ registerTool({
536
536
  required: ["center", "defined"],
537
537
  },
538
538
  outputSchema: OUTPUT_SCHEMA_JSON,
539
- annotations: { title: "Center Interpretation", readOnlyHint: true, openWorldHint: false },
539
+ annotations: { title: "Center Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
540
540
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
541
541
  handler: async (args) => {
542
542
  const center = String(args.center);
@@ -594,7 +594,7 @@ registerTool({
594
594
  required: ["gate"],
595
595
  },
596
596
  outputSchema: OUTPUT_SCHEMA_JSON,
597
- annotations: { title: "Gate Interpretation", readOnlyHint: true, openWorldHint: false },
597
+ annotations: { title: "Gate Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
598
598
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
599
599
  handler: async (args) => {
600
600
  const gate = Number(args.gate);
@@ -649,7 +649,7 @@ registerTool({
649
649
  required: ["channel"],
650
650
  },
651
651
  outputSchema: OUTPUT_SCHEMA_JSON,
652
- annotations: { title: "Channel Interpretation", readOnlyHint: true, openWorldHint: false },
652
+ annotations: { title: "Channel Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
653
653
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
654
654
  handler: async (args) => {
655
655
  const channelStr = String(args.channel);
@@ -751,7 +751,7 @@ registerTool({
751
751
  required: ["channel"],
752
752
  },
753
753
  outputSchema: OUTPUT_SCHEMA_JSON,
754
- annotations: { title: "Connection Channel Interpretation", readOnlyHint: true, openWorldHint: false },
754
+ annotations: { title: "Connection Channel Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
755
755
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
756
756
  handler: async (args) => {
757
757
  const channelStr = String(args.channel ?? "");
@@ -807,7 +807,7 @@ registerTool({
807
807
  required: ["channel"],
808
808
  },
809
809
  outputSchema: OUTPUT_SCHEMA_JSON,
810
- annotations: { title: "Transit Channel Interpretation", readOnlyHint: true, openWorldHint: false },
810
+ annotations: { title: "Transit Channel Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
811
811
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
812
812
  handler: async (args) => {
813
813
  const channelStr = String(args.channel ?? "");
@@ -1135,7 +1135,7 @@ registerTool({
1135
1135
  required: ["planet", "column"],
1136
1136
  },
1137
1137
  outputSchema: OUTPUT_SCHEMA_JSON,
1138
- annotations: { title: "Planet Sidebar Interpretation", readOnlyHint: true, openWorldHint: false },
1138
+ annotations: { title: "Planet Sidebar Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1139
1139
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
1140
1140
  handler: async (args) => {
1141
1141
  const planet = String(args.planet);
@@ -1204,7 +1204,7 @@ registerTool({
1204
1204
  required: ["variable", "direction"],
1205
1205
  },
1206
1206
  outputSchema: OUTPUT_SCHEMA_JSON,
1207
- annotations: { title: "Variable Arrow Interpretation", readOnlyHint: true, openWorldHint: false },
1207
+ annotations: { title: "Variable Arrow Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1208
1208
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
1209
1209
  handler: async (args) => {
1210
1210
  const variable = String(args.variable).toLowerCase();
@@ -259,7 +259,7 @@ registerTool({
259
259
  required: ["planet", "longitude"],
260
260
  },
261
261
  outputSchema: OUTPUT_SCHEMA_JSON,
262
- annotations: { title: "Planet Interpretation", readOnlyHint: true, openWorldHint: false },
262
+ annotations: { title: "Planet Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
263
263
  _meta: { ui: { resourceUri: CHART_WHEEL_RESOURCE_URI, visibility: ["app"] } },
264
264
  handler: async (args) => {
265
265
  const planet = String(args.planet);
@@ -294,7 +294,7 @@ registerTool({
294
294
  required: ["house_number"],
295
295
  },
296
296
  outputSchema: OUTPUT_SCHEMA_JSON,
297
- annotations: { title: "House Interpretation", readOnlyHint: true, openWorldHint: false },
297
+ annotations: { title: "House Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
298
298
  _meta: { ui: { resourceUri: CHART_WHEEL_RESOURCE_URI, visibility: ["app"] } },
299
299
  handler: async (args) => {
300
300
  const num = Number(args.house_number);
@@ -330,7 +330,7 @@ registerTool({
330
330
  required: ["planet1", "planet2"],
331
331
  },
332
332
  outputSchema: OUTPUT_SCHEMA_JSON,
333
- annotations: { title: "Aspect Interpretation", readOnlyHint: true, openWorldHint: false },
333
+ annotations: { title: "Aspect Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
334
334
  _meta: { ui: { resourceUri: CHART_WHEEL_RESOURCE_URI, visibility: ["app"] } },
335
335
  handler: async (args) => {
336
336
  const p1 = capitalize(String(args.planet1));
@@ -379,7 +379,7 @@ registerTool({
379
379
  required: ["datetime", "latitude", "longitude", "house_system"],
380
380
  },
381
381
  outputSchema: OUTPUT_SCHEMA_JSON,
382
- annotations: { title: "Recalculate Chart", readOnlyHint: true, openWorldHint: false },
382
+ annotations: { title: "Recalculate Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
383
383
  _meta: { ui: { resourceUri: CHART_WHEEL_RESOURCE_URI, visibility: ["app"] } },
384
384
  handler: async (args) => {
385
385
  const client = getActiveClient();
@@ -2,6 +2,34 @@ 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
4
  import { TZDATA_AUTHORITATIVE_FROM_YEAR } from "../datetime-historical.js";
5
+ /**
6
+ * Lift the API's historical-correction provenance onto an MCP result.
7
+ *
8
+ * The server publishes five fields that say *why* a pre-1970 offset is what it
9
+ * is, and until now this server forwarded exactly one of them. That left an
10
+ * agent unable to tell a cited statutory correction from a tzdata guess, and
11
+ * left every MCP consumer blind to the overlay dataset changing underneath
12
+ * previously-computed charts — which is the whole reason the version signal
13
+ * exists.
14
+ *
15
+ * Placement mirrors the REST contract: `tz_overlay_version`/`tz_overlay_hash`
16
+ * sit at the top level of the /timezone/offset response alongside
17
+ * `tz_rule_source`; `tz_rule_citation` and `server_version` live in
18
+ * `calculation_metadata`.
19
+ */
20
+ function historicalProvenance(off) {
21
+ const meta = (off?.calculation_metadata ?? {});
22
+ const citation = meta.tz_rule_citation;
23
+ const serverVersion = meta.server_version;
24
+ return {
25
+ ...(typeof off.tz_rule_source === "string" ? { tzRuleSource: off.tz_rule_source } : {}),
26
+ ...(typeof off.datetime_status === "string" ? { datetimeStatus: off.datetime_status } : {}),
27
+ ...(typeof off.tz_overlay_version === "number" ? { tzOverlayVersion: off.tz_overlay_version } : {}),
28
+ ...(typeof off.tz_overlay_hash === "string" ? { tzOverlayHash: off.tz_overlay_hash } : {}),
29
+ ...(typeof citation === "string" && citation !== "" ? { tzRuleCitation: citation } : {}),
30
+ ...(typeof serverVersion === "string" && serverVersion !== "" ? { serverVersion } : {}),
31
+ };
32
+ }
5
33
  /** Render minutes east of UTC as `±HH:MM`. */
6
34
  function formatOffsetMinutes(offsetMinutes) {
7
35
  const sign = offsetMinutes < 0 ? "-" : "+";
@@ -98,7 +126,7 @@ function offsetAtLocalNoon(timezone, date) {
98
126
  }
99
127
  registerTool({
100
128
  name: "location_search",
101
- 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.",
129
+ 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). Post-1970 dates resolve locally and cost no extra credits; a pre-1970 date consults the API's historical correction overlay for the top match (1 extra credit) and returns its provenance — tzRuleSource, tzRuleCitation, tzOverlayVersion. 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.",
102
130
  inputSchema: {
103
131
  type: "object",
104
132
  properties: {
@@ -124,14 +152,14 @@ registerTool({
124
152
  },
125
153
  date: {
126
154
  type: "string",
127
- 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.",
155
+ 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. Post-1970 is free (local resolution, no API call). Pre-1970, the top match is additionally corrected through the API's historical overlay (1 extra credit) because tzdata models only the zone's reference city before 1970; the remaining suggestions keep their tzdata estimate and are labelled historical_estimate.",
128
156
  },
129
157
  },
130
158
  required: ["query"],
131
159
  additionalProperties: false,
132
160
  },
133
161
  outputSchema: OUTPUT_SCHEMA_JSON,
134
- annotations: { title: "Location Search", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
162
+ annotations: { title: "Location Search", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
135
163
  // Model-visible as well as app-visible: without this the model had no
136
164
  // geocoding tool and every prompt had to route through
137
165
  // `dev_read_api /location/autocomplete` instead.
@@ -178,6 +206,41 @@ registerTool({
178
206
  }
179
207
  return mapped;
180
208
  });
209
+ // Pre-1970, the tzdata offsets just attached are the very answers the
210
+ // historical-correction overlay exists to replace: a 1961 Robbinsdale
211
+ // date gets -05:00 from tzdata where state law says -06:00. This tool
212
+ // is documented as the one-call alternative to timezone_resolve, so an
213
+ // agent that takes the top hit and builds a zone-suffixed datetime from
214
+ // it would produce a chart an hour off — and nothing downstream could
215
+ // detect it.
216
+ //
217
+ // Correct the TOP suggestion through the server (one extra credit, the
218
+ // same cost timezone_resolve documents). Correcting all eight would
219
+ // multiply that for rows the caller almost never uses; the remaining
220
+ // rows keep their tzdata offsets and their historical_estimate label.
221
+ const topHit = suggestions[0];
222
+ if (preTzdataEra && date && topHit && typeof topHit.latitude === "number" && typeof topHit.longitude === "number") {
223
+ try {
224
+ const off = (await getActiveClient().request("POST", "/timezone/offset", {
225
+ data: { lat: topHit.latitude, lon: topHit.longitude, datetime_local: `${date.slice(0, 10)}T12:00:00` },
226
+ }));
227
+ if (typeof off?.offset_seconds === "number") {
228
+ const minutes = off.offset_seconds / 60;
229
+ Object.assign(topHit, {
230
+ utcOffsetAtDate: formatOffsetMinutes(minutes),
231
+ utcOffsetMinutes: minutes,
232
+ isDst: Boolean(off.is_dst),
233
+ tzConfidence: typeof off.tz_confidence === "string" ? off.tz_confidence : "historical_estimate",
234
+ ...historicalProvenance(off),
235
+ });
236
+ }
237
+ }
238
+ catch {
239
+ // Server path unavailable — the tzdata estimate stands, still
240
+ // labelled historical_estimate. Degrading to a labelled
241
+ // estimate is acceptable; failing the whole search is not.
242
+ }
243
+ }
181
244
  // Ambiguity signal. "portland" really does match Oregon, Maine, Texas,
182
245
  // England, ... — an agent that silently takes suggestions[0] produces a
183
246
  // chart nothing downstream can detect as wrong.
@@ -235,7 +298,7 @@ registerTool({
235
298
  additionalProperties: false,
236
299
  },
237
300
  outputSchema: OUTPUT_SCHEMA_JSON,
238
- annotations: { title: "Timezone Resolve", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
301
+ annotations: { title: "Timezone Resolve", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
239
302
  _meta: {
240
303
  ui: {
241
304
  visibility: ["model", "app"],
@@ -272,8 +335,7 @@ registerTool({
272
335
  isDst: Boolean(off.is_dst),
273
336
  date,
274
337
  tzConfidence: typeof off.tz_confidence === "string" ? off.tz_confidence : "historical_estimate",
275
- ...(typeof off.tz_rule_source === "string" ? { tzRuleSource: off.tz_rule_source } : {}),
276
- ...(typeof off.datetime_status === "string" ? { datetimeStatus: off.datetime_status } : {}),
338
+ ...historicalProvenance(off),
277
339
  };
278
340
  }
279
341
  }
@@ -295,7 +295,7 @@ registerTool({
295
295
  additionalProperties: false,
296
296
  },
297
297
  outputSchema: OUTPUT_SCHEMA_JSON,
298
- annotations: { title: "Recalculate Moon Phase", readOnlyHint: true, openWorldHint: false },
298
+ annotations: { title: "Recalculate Moon Phase", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
299
299
  _meta: { ui: { resourceUri: MOON_PHASE_RESOURCE_URI, visibility: ["app"] } },
300
300
  handler: async (args) => {
301
301
  const { summary, uiPayload } = await computeMoonData(args);
@@ -240,7 +240,7 @@ registerTool({
240
240
  required: ["datetime"],
241
241
  },
242
242
  outputSchema: OUTPUT_SCHEMA_JSON,
243
- annotations: { title: "Recalculate Vedic Chart", readOnlyHint: true, openWorldHint: false },
243
+ annotations: { title: "Recalculate Vedic Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
244
244
  _meta: { ui: { resourceUri: VEDIC_CHART_RESOURCE_URI, visibility: ["app"] } },
245
245
  handler: async (args) => {
246
246
  const client = getActiveClient();
@@ -24,6 +24,10 @@ registerTool({
24
24
  title: "Connect Account",
25
25
  readOnlyHint: false,
26
26
  destructiveHint: false,
27
+ // Each call starts a fresh device-auth flow with a new code — repeating
28
+ // it is harmless but not a no-op.
29
+ idempotentHint: false,
30
+ openWorldHint: false,
27
31
  },
28
32
  stdioOnly: true,
29
33
  handler: async () => {
@@ -105,6 +109,8 @@ registerTool({
105
109
  title: "Auth Status",
106
110
  readOnlyHint: true,
107
111
  destructiveHint: false,
112
+ idempotentHint: true,
113
+ openWorldHint: false,
108
114
  },
109
115
  stdioOnly: true,
110
116
  handler: async () => {
@@ -191,6 +197,8 @@ registerTool({
191
197
  title: "Disconnect Account",
192
198
  readOnlyHint: false,
193
199
  destructiveHint: true,
200
+ idempotentHint: true,
201
+ openWorldHint: false,
194
202
  },
195
203
  stdioOnly: true,
196
204
  handler: async () => {
package/dist/tools/dev.js CHANGED
@@ -196,8 +196,10 @@ registerTool({
196
196
  DEV_API_REFERENCE,
197
197
  inputSchema: makeProxyInputSchema(READ_METHODS),
198
198
  outputSchema: OUTPUT_SCHEMA_JSON,
199
- // GET-only: never mutates server state.
200
- annotations: { title: "API Read", readOnlyHint: true, openWorldHint: true },
199
+ // GET-only: never mutates server state. openWorldHint false — the proxy
200
+ // reaches only the fixed, allowlisted OpenEphemeris API (a closed world),
201
+ // same as every typed tool.
202
+ annotations: { title: "API Read", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
201
203
  handler: makeProxyHandler(READ_METHODS, "GET"),
202
204
  });
203
205
  registerTool({
@@ -209,8 +211,16 @@ registerTool({
209
211
  DEV_API_REFERENCE,
210
212
  inputSchema: makeProxyInputSchema(WRITE_METHODS),
211
213
  outputSchema: OUTPUT_SCHEMA_JSON,
212
- // Issues POST/PUT/PATCH/DELETE — must not be advertised as read-only.
213
- annotations: { title: "API Write", readOnlyHint: false, openWorldHint: true },
214
+ // Issues POST/PUT/PATCH/DELETE — must not be advertised as read-only. But
215
+ // destructiveHint is explicitly FALSE: every allowlisted write operation is
216
+ // stateless chart computation (natal, synastry, ACG, returns…) that debits
217
+ // credits and returns a result — nothing on the allowlist mutates or deletes
218
+ // user data, and the deny rules block /auth, /billing, /account, /api-keys.
219
+ // Left unset, strict clients default destructiveHint to true and gate every
220
+ // call behind a confirmation. idempotentHint true for the same reason: the
221
+ // same body yields the same chart. openWorldHint false — fixed allowlisted
222
+ // API only, a closed world.
223
+ annotations: { title: "API Write", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
214
224
  handler: makeProxyHandler(WRITE_METHODS, "POST"),
215
225
  });
216
226
  registerTool({
@@ -226,7 +236,7 @@ registerTool({
226
236
  additionalProperties: false,
227
237
  },
228
238
  outputSchema: OUTPUT_SCHEMA_JSON,
229
- annotations: { title: "List Allowed API Operations", readOnlyHint: true, openWorldHint: false },
239
+ annotations: { title: "List Allowed API Operations", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
230
240
  handler: async () => {
231
241
  const allowlist = loadAllowlist();
232
242
  return {
@@ -27,7 +27,7 @@ registerTool({
27
27
  additionalProperties: false,
28
28
  },
29
29
  outputSchema: OUTPUT_SCHEMA_JSON,
30
- annotations: { title: "Account Usage", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
30
+ annotations: { title: "Account Usage", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
31
31
  handler: async (args) => {
32
32
  const params = {};
33
33
  if (args?.month)
@@ -39,7 +39,11 @@ registerTool({
39
39
  }
40
40
  catch (error) {
41
41
  if (error instanceof BackendError && error.status === 401) {
42
+ // A real auth failure must carry isError — a success-shaped response
43
+ // teaches the model the call worked and it stops trying to fix auth.
44
+ // The guidance text is kept so hosts still show the user a way forward.
42
45
  return {
46
+ isError: true,
43
47
  content: [{
44
48
  type: "text",
45
49
  text: `**Not signed in.** To check usage, connect an OpenEphemeris account first.\n\n` +
@@ -60,7 +60,7 @@ registerTool({
60
60
  additionalProperties: false,
61
61
  },
62
62
  outputSchema: OUTPUT_SCHEMA_JSON,
63
- annotations: { title: "Astrocartography Lines", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
63
+ annotations: { title: "Astrocartography Lines", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
64
64
  handler: async (args) => {
65
65
  validateRequired(args, ["birth_datetime", "birth_latitude", "birth_longitude"]);
66
66
  const body = {
@@ -157,7 +157,7 @@ registerTool({
157
157
  additionalProperties: false,
158
158
  },
159
159
  outputSchema: OUTPUT_SCHEMA_JSON,
160
- annotations: { title: "Astrocartography Location Hits", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
160
+ annotations: { title: "Astrocartography Location Hits", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
161
161
  handler: async (args) => {
162
162
  validateRequired(args, ["birth_datetime", "birth_latitude", "birth_longitude", "query_latitude", "query_longitude"]);
163
163
  const body = {
@@ -3,5 +3,43 @@ export interface BaziComponents {
3
3
  month: number;
4
4
  day: number;
5
5
  hour?: number;
6
+ minute?: number;
6
7
  }
7
8
  export declare function parseBaziArgs(args: any, datetimeField?: string): BaziComponents;
9
+ /**
10
+ * Build the optional BaZi convention fields (year_boundary, day_boundary,
11
+ * true_solar_time, latitude, longitude, timezone) to spread into a request
12
+ * body. `timezone` is wrapped to the API's `TimezoneInput` object shape.
13
+ *
14
+ * Kept separate from `parseBaziArgs`/`BaziComponents`: these fields frame
15
+ * which convention resolves the pillars rather than naming the birth moment
16
+ * itself, and bazi_compatibility needs the same set built twice under a
17
+ * `chart_a_`/`chart_b_` prefix.
18
+ */
19
+ export declare function buildBaziConventionFields(args: any): Record<string, unknown>;
20
+ /** `buildBaziConventionFields`, reading `<prefix>_<field>` args — used by bazi_compatibility. */
21
+ export declare function buildBaziConventionFieldsPrefixed(args: any, prefix: string): Record<string, unknown>;
22
+ export declare const CONVENTION_PROPERTIES: {
23
+ readonly year_boundary: {
24
+ readonly type: "string";
25
+ readonly enum: readonly ["lichun", "cny"];
26
+ readonly description: "Year-pillar cutover convention. Default lichun.";
27
+ };
28
+ readonly day_boundary: {
29
+ readonly type: "string";
30
+ readonly enum: readonly ["zi_hour", "midnight"];
31
+ readonly description: "Day-pillar cutover convention. Default zi_hour.";
32
+ };
33
+ readonly true_solar_time: {
34
+ readonly type: "boolean";
35
+ readonly description: "Apply true solar time correction (needs latitude+longitude).";
36
+ };
37
+ readonly latitude: {
38
+ readonly type: "number";
39
+ readonly description: "Birth latitude, decimal degrees (north+).";
40
+ };
41
+ readonly longitude: {
42
+ readonly type: "number";
43
+ readonly description: "Birth longitude, decimal degrees (east+).";
44
+ };
45
+ };