@openephemeris/mcp-server 4.8.1 → 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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,57 @@ 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
+
10
61
  ## [4.8.1] — 2026-08-02
11
62
 
12
63
  ### Fixed
@@ -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
@@ -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,7 +152,7 @@ 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"],
@@ -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.
@@ -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
  }
@@ -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
+ };
@@ -2,7 +2,7 @@ import { registerTool } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_IMAGE_AND_JSON } from "../output-schemas.js";
4
4
  export function parseBaziArgs(args, datetimeField = "datetime") {
5
- let { year, month, day, hour } = args;
5
+ let { year, month, day, hour, minute } = args;
6
6
  const datetime = args[datetimeField];
7
7
  if (datetime && (!year || !month || !day)) {
8
8
  const parts = baziLocalComponents(datetime, args.timezone, datetimeField);
@@ -11,6 +11,8 @@ export function parseBaziArgs(args, datetimeField = "datetime") {
11
11
  day = parts.day;
12
12
  if (hour == null)
13
13
  hour = parts.hour;
14
+ if (minute == null)
15
+ minute = parts.minute;
14
16
  }
15
17
  if (!year || !month || !day) {
16
18
  throw new Error("Provide year/month/day fields, or a datetime ISO string. " +
@@ -19,8 +21,48 @@ export function parseBaziArgs(args, datetimeField = "datetime") {
19
21
  const out = { year, month, day };
20
22
  if (hour != null)
21
23
  out.hour = hour;
24
+ if (minute != null)
25
+ out.minute = minute;
22
26
  return out;
23
27
  }
28
+ /**
29
+ * Build the optional BaZi convention fields (year_boundary, day_boundary,
30
+ * true_solar_time, latitude, longitude, timezone) to spread into a request
31
+ * body. `timezone` is wrapped to the API's `TimezoneInput` object shape.
32
+ *
33
+ * Kept separate from `parseBaziArgs`/`BaziComponents`: these fields frame
34
+ * which convention resolves the pillars rather than naming the birth moment
35
+ * itself, and bazi_compatibility needs the same set built twice under a
36
+ * `chart_a_`/`chart_b_` prefix.
37
+ */
38
+ export function buildBaziConventionFields(args) {
39
+ const fields = {};
40
+ if (args.year_boundary != null)
41
+ fields.year_boundary = args.year_boundary;
42
+ if (args.day_boundary != null)
43
+ fields.day_boundary = args.day_boundary;
44
+ if (args.true_solar_time != null)
45
+ fields.true_solar_time = args.true_solar_time;
46
+ if (args.latitude != null)
47
+ fields.latitude = args.latitude;
48
+ if (args.longitude != null)
49
+ fields.longitude = args.longitude;
50
+ if (typeof args.timezone === "string" && args.timezone.trim() !== "") {
51
+ fields.timezone = { iana_name: args.timezone };
52
+ }
53
+ return fields;
54
+ }
55
+ /** `buildBaziConventionFields`, reading `<prefix>_<field>` args — used by bazi_compatibility. */
56
+ export function buildBaziConventionFieldsPrefixed(args, prefix) {
57
+ return buildBaziConventionFields({
58
+ year_boundary: args[`${prefix}_year_boundary`],
59
+ day_boundary: args[`${prefix}_day_boundary`],
60
+ true_solar_time: args[`${prefix}_true_solar_time`],
61
+ latitude: args[`${prefix}_latitude`],
62
+ longitude: args[`${prefix}_longitude`],
63
+ timezone: args[`${prefix}_timezone`],
64
+ });
65
+ }
24
66
  /**
25
67
  * Extract the LOCAL calendar/clock components a BaZi chart is built from.
26
68
  *
@@ -50,7 +92,7 @@ function baziLocalComponents(datetime, timezone, field) {
50
92
  hour: "2-digit", minute: "2-digit",
51
93
  }).formatToParts(new Date(value));
52
94
  const get = (t) => Number(parts.find((p) => p.type === t)?.value);
53
- return { year: get("year"), month: get("month"), day: get("day"), hour: get("hour") % 24 };
95
+ return { year: get("year"), month: get("month"), day: get("day"), hour: get("hour") % 24, minute: get("minute") };
54
96
  }
55
97
  const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/.exec(value);
56
98
  if (!m) {
@@ -62,6 +104,7 @@ function baziLocalComponents(datetime, timezone, field) {
62
104
  month: Number(m[2]),
63
105
  day: Number(m[3]),
64
106
  hour: m[4] != null ? Number(m[4]) : undefined,
107
+ minute: m[5] != null ? Number(m[5]) : undefined,
65
108
  };
66
109
  }
67
110
  // Shared datetime input schema fragment — used across all BaZiRequest tools.
@@ -83,6 +126,10 @@ const DATETIME_PROPERTIES = {
83
126
  description: "Birth hour (0–23). Optional, defaults to 12 (noon). " +
84
127
  "Chinese shí hours are 2-hour blocks — precision within a 2-hour window is sufficient.",
85
128
  },
129
+ minute: {
130
+ type: "integer",
131
+ description: "Birth minute (0–59). Optional, defaults to 0. Only matters under true_solar_time or near a boundary.",
132
+ },
86
133
  datetime: {
87
134
  type: "string",
88
135
  description: "Alternative to year/month/day: ISO 8601 datetime read as LOCAL wall-clock time at the " +
@@ -93,11 +140,49 @@ const DATETIME_PROPERTIES = {
93
140
  },
94
141
  timezone: {
95
142
  type: "string",
96
- description: "IANA timezone name for the birth place, e.g. 'Asia/Shanghai'. Only needed when " +
97
- "`datetime` carries a 'Z' or ±HH:MM offset, to convert that instant back to the local " +
98
- "clock the pillars are built from.",
143
+ description: "IANA timezone name for the birth place, e.g. 'Asia/Shanghai'. Required to convert a " +
144
+ "zoned `datetime` back to local clock; also passed to the API to frame the year/month " +
145
+ "solar-term boundary at the true birth instant instead of naive local clock.",
146
+ },
147
+ };
148
+ // Optional convention fields, shared across every BaZiRequest-shaped tool —
149
+ // see BaZiRequest in the Go API. Kept terse: core tool-surface budget is
150
+ // near its ceiling (test/tool-surface-budget.test.ts).
151
+ export const CONVENTION_PROPERTIES = {
152
+ year_boundary: {
153
+ type: "string",
154
+ enum: ["lichun", "cny"],
155
+ description: "Year-pillar cutover convention. Default lichun.",
156
+ },
157
+ day_boundary: {
158
+ type: "string",
159
+ enum: ["zi_hour", "midnight"],
160
+ description: "Day-pillar cutover convention. Default zi_hour.",
161
+ },
162
+ true_solar_time: {
163
+ type: "boolean",
164
+ description: "Apply true solar time correction (needs latitude+longitude).",
165
+ },
166
+ latitude: {
167
+ type: "number",
168
+ description: "Birth latitude, decimal degrees (north+).",
169
+ },
170
+ longitude: {
171
+ type: "number",
172
+ description: "Birth longitude, decimal degrees (east+).",
99
173
  },
100
174
  };
175
+ /** CONVENTION_PROPERTIES (+ minute) under a `chart_a_`/`chart_b_` prefix — used by bazi_compatibility. */
176
+ function chartConventionProperties(label) {
177
+ return {
178
+ [`chart_${label.toLowerCase()}_minute`]: { type: "integer", description: `Chart ${label} minute (0–59).` },
179
+ [`chart_${label.toLowerCase()}_year_boundary`]: { type: "string", enum: ["lichun", "cny"], description: `Chart ${label} year-pillar cutover.` },
180
+ [`chart_${label.toLowerCase()}_day_boundary`]: { type: "string", enum: ["zi_hour", "midnight"], description: `Chart ${label} day-pillar cutover.` },
181
+ [`chart_${label.toLowerCase()}_true_solar_time`]: { type: "boolean", description: `Chart ${label}: true solar time correction.` },
182
+ [`chart_${label.toLowerCase()}_latitude`]: { type: "number", description: `Chart ${label} latitude (decimal degrees, north+).` },
183
+ [`chart_${label.toLowerCase()}_longitude`]: { type: "number", description: `Chart ${label} longitude (decimal degrees, east+).` },
184
+ };
185
+ }
101
186
  // Shared visual input schema fragment — mirrors natal.ts pattern.
102
187
  const VISUAL_PROPERTIES = {
103
188
  include_visual: {
@@ -153,6 +238,7 @@ registerTool({
153
238
  type: "object",
154
239
  properties: {
155
240
  ...DATETIME_PROPERTIES,
241
+ ...CONVENTION_PROPERTIES,
156
242
  ...VISUAL_PROPERTIES,
157
243
  },
158
244
  additionalProperties: false,
@@ -160,7 +246,7 @@ registerTool({
160
246
  outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
161
247
  annotations: { title: "BaZi Four Pillars Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
162
248
  handler: async (args) => {
163
- const body = { ...parseBaziArgs(args) };
249
+ const body = { ...parseBaziArgs(args), ...buildBaziConventionFields(args) };
164
250
  applyVisualConfig(body, args);
165
251
  return await getActiveClient().request("POST", "/chinese/bazi", { data: body });
166
252
  },
@@ -190,6 +276,7 @@ registerTool({
190
276
  type: "object",
191
277
  properties: {
192
278
  ...DATETIME_PROPERTIES,
279
+ ...CONVENTION_PROPERTIES,
193
280
  ...VISUAL_PROPERTIES,
194
281
  },
195
282
  additionalProperties: false,
@@ -197,7 +284,7 @@ registerTool({
197
284
  outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
198
285
  annotations: { title: "BaZi Ten Gods", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
199
286
  handler: async (args) => {
200
- const body = { ...parseBaziArgs(args) };
287
+ const body = { ...parseBaziArgs(args), ...buildBaziConventionFields(args) };
201
288
  applyVisualConfig(body, args);
202
289
  return await getActiveClient().request("POST", "/chinese/bazi/ten-gods", { data: body });
203
290
  },
@@ -225,6 +312,7 @@ registerTool({
225
312
  type: "object",
226
313
  properties: {
227
314
  ...DATETIME_PROPERTIES,
315
+ ...CONVENTION_PROPERTIES,
228
316
  ...VISUAL_PROPERTIES,
229
317
  },
230
318
  additionalProperties: false,
@@ -232,7 +320,7 @@ registerTool({
232
320
  outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
233
321
  annotations: { title: "BaZi Element Balance", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
234
322
  handler: async (args) => {
235
- const body = { ...parseBaziArgs(args) };
323
+ const body = { ...parseBaziArgs(args), ...buildBaziConventionFields(args) };
236
324
  applyVisualConfig(body, args);
237
325
  return await getActiveClient().request("POST", "/chinese/bazi/element-balance", { data: body });
238
326
  },
@@ -261,6 +349,7 @@ registerTool({
261
349
  type: "object",
262
350
  properties: {
263
351
  ...DATETIME_PROPERTIES,
352
+ ...CONVENTION_PROPERTIES,
264
353
  gender: {
265
354
  type: "string",
266
355
  enum: ["male", "female"],
@@ -279,10 +368,15 @@ registerTool({
279
368
  throw new Error("gender is required for luck pillar calculation. " +
280
369
  "Provide 'male' or 'female' — the direction of Da Yun depends on gender and year polarity.");
281
370
  }
282
- const { year, month, day, hour } = parseBaziArgs(args);
283
- const body = { year, month, day, gender: args.gender };
371
+ const { year, month, day, hour, minute } = parseBaziArgs(args);
372
+ const body = {
373
+ year, month, day, gender: args.gender,
374
+ ...buildBaziConventionFields(args),
375
+ };
284
376
  if (hour != null)
285
377
  body.hour = hour;
378
+ if (minute != null)
379
+ body.minute = minute;
286
380
  return await getActiveClient().request("POST", "/chinese/bazi/luck-pillars", { data: body });
287
381
  },
288
382
  });
@@ -362,9 +456,10 @@ registerTool({
362
456
  },
363
457
  chart_a_timezone: {
364
458
  type: "string",
365
- description: "IANA timezone for Chart A's birth place, e.g. 'Asia/Shanghai'. Only needed when " +
366
- "chart_a_datetime carries a 'Z' or ±HH:MM offset.",
459
+ description: "IANA timezone for Chart A's birth place, e.g. 'Asia/Shanghai'. Needed when " +
460
+ "chart_a_datetime carries a 'Z' or ±HH:MM offset; also frames Chart A's year/month boundary.",
367
461
  },
462
+ ...chartConventionProperties("A"),
368
463
  // Chart B
369
464
  chart_b_year: { type: "integer", description: "Chart B birth year." },
370
465
  chart_b_month: { type: "integer", description: "Chart B birth month (1–12)." },
@@ -378,9 +473,10 @@ registerTool({
378
473
  },
379
474
  chart_b_timezone: {
380
475
  type: "string",
381
- description: "IANA timezone for Chart B's birth place, e.g. 'Asia/Shanghai'. Only needed when " +
382
- "chart_b_datetime carries a 'Z' or ±HH:MM offset.",
476
+ description: "IANA timezone for Chart B's birth place, e.g. 'Asia/Shanghai'. Needed when " +
477
+ "chart_b_datetime carries a 'Z' or ±HH:MM offset; also frames Chart B's year/month boundary.",
383
478
  },
479
+ ...chartConventionProperties("B"),
384
480
  },
385
481
  additionalProperties: false,
386
482
  },
@@ -392,6 +488,7 @@ registerTool({
392
488
  month: args.chart_a_month,
393
489
  day: args.chart_a_day,
394
490
  hour: args.chart_a_hour,
491
+ minute: args.chart_a_minute,
395
492
  datetime: args.chart_a_datetime,
396
493
  timezone: args.chart_a_timezone,
397
494
  });
@@ -400,13 +497,14 @@ registerTool({
400
497
  month: args.chart_b_month,
401
498
  day: args.chart_b_day,
402
499
  hour: args.chart_b_hour,
500
+ minute: args.chart_b_minute,
403
501
  datetime: args.chart_b_datetime,
404
502
  timezone: args.chart_b_timezone,
405
503
  });
406
504
  return await getActiveClient().request("POST", "/chinese/bazi/compatibility", {
407
505
  data: {
408
- chart_a: chartAComponents,
409
- chart_b: chartBComponents,
506
+ chart_a: { ...chartAComponents, ...buildBaziConventionFieldsPrefixed(args, "chart_a") },
507
+ chart_b: { ...chartBComponents, ...buildBaziConventionFieldsPrefixed(args, "chart_b") },
410
508
  },
411
509
  });
412
510
  },
@@ -434,6 +532,7 @@ registerTool({
434
532
  type: "object",
435
533
  properties: {
436
534
  ...DATETIME_PROPERTIES,
535
+ ...CONVENTION_PROPERTIES,
437
536
  theme: {
438
537
  type: "string",
439
538
  enum: ["light", "dark", "mono"],
@@ -449,7 +548,7 @@ registerTool({
449
548
  outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
450
549
  annotations: { title: "BaZi Chart (Visual)", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
451
550
  handler: async (args) => {
452
- const { year, month, day, hour } = parseBaziArgs(args);
551
+ const { year, month, day, hour, minute } = parseBaziArgs(args);
453
552
  const body = {
454
553
  year, month, day,
455
554
  visual_config: {
@@ -457,9 +556,12 @@ registerTool({
457
556
  theme: args.theme ?? "light",
458
557
  size: args.size ?? 800,
459
558
  },
559
+ ...buildBaziConventionFields(args),
460
560
  };
461
561
  if (hour != null)
462
562
  body.hour = hour;
563
+ if (minute != null)
564
+ body.minute = minute;
463
565
  return await getActiveClient().request("POST", "/chinese/bazi/chart", { data: body });
464
566
  },
465
567
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openephemeris/mcp-server",
3
- "version": "4.8.1",
3
+ "version": "4.10.0",
4
4
  "description": "Model Context Protocol server for the Open Ephemeris astronomical computation API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",