@openephemeris/mcp-server 4.11.0 → 4.11.2

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,55 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [4.11.2] — 2026-08-13
11
+
12
+ ### Fixed
13
+ - **Location resolver rejected `"City, Country"` as ambiguous.** The
14
+ ambiguity qualifier matched a region name, a US state abbreviation, or a
15
+ two-letter ISO country *code* — but never a country *name*. So
16
+ `location: "Paris, France"` lost the qualification check against its US
17
+ namesakes and threw, while the far rarer `"Paris, fr"` resolved fine.
18
+ Live, `"Paris, France"` matches 8 same-named places and `"London, UK"`
19
+ matches 6, so the most natural phrasing for a foreign birthplace failed
20
+ outright. Country names now qualify via `Intl.DisplayNames` (the runtime's
21
+ own code→name table, so there is no list to maintain), plus colloquial
22
+ aliases Intl doesn't emit (`uk` → GB). Completes the class of fix started
23
+ for US state abbreviations in 4.11.1.
24
+
25
+ ### Changed
26
+ - **Location resolver errors now carry a stable `code`.** `mcp_tool_error`
27
+ telemetry records no message and no stack, so every resolver rejection
28
+ arrived as `code: "none"` / `error_kind: "local"` — indistinguishable from
29
+ a genuine server-side crash. Throws now carry `location_ambiguous`,
30
+ `location_not_found`, `location_missing_coordinates`, `location_empty`, or
31
+ `coords_partial` on the property the event already ships. The codes are
32
+ constants; no user input is added to telemetry.
33
+
34
+ ---
35
+
36
+ ## [4.11.1] — 2026-08-12
37
+
38
+ ### Fixed
39
+ - **Location resolver didn't recognize US state abbreviations.**
40
+ `location: "San Francisco, CA"` was rejected as ambiguous while the
41
+ equivalent `"San Francisco, California"` resolved fine — the API returns
42
+ the full state name in `region`, and the ambiguity qualifier only matched
43
+ full-name substrings or ISO country-code tokens. Added a USPS abbreviation
44
+ lookup (50 states + DC + territories) so `"City, ST"` qualifies the same
45
+ as `"City, State Name"`.
46
+ - **`bazi_recalculate` double-charged for theme-only re-renders.** Its only
47
+ caller is the BaZi app's automatic host-theme reconciliation — never a
48
+ birth-data change — but it billed the full 3 credits as if it were a fresh
49
+ chart, on top of the identical charge already paid via `explore_bazi_chart`
50
+ moments earlier. Theme-only reconcile now skips the 2-credit visual-render
51
+ reservation.
52
+ - **Datetime tool descriptions could invite a silent 1-hour-off chart.**
53
+ Extended the shared datetime contract instructions to explicitly tell
54
+ callers to pass local wall-clock time + IANA `timezone` rather than
55
+ converting to UTC themselves — a calling model's own conversion mistake
56
+ (e.g. treating July San Francisco as PST instead of PDT) previously sailed
57
+ through with zero server-side errors and a confidently wrong chart.
58
+
10
59
  ## [4.11.0] — 2026-08-07
11
60
 
12
61
  ### Fixed
@@ -10,7 +10,7 @@ export interface ResolvedLocation {
10
10
  *
11
11
  * Ambiguity is detected the same way `location_search` reports it — a
12
12
  * count of *rivals* for the top short_name, unless the query itself
13
- * qualifies the place (e.g. "portland uk", "dallas texas").
13
+ * qualifies the place (e.g. "portland uk", "dallas texas", "dallas, tx").
14
14
  */
15
15
  export declare function resolveLocationOrThrow(location: string): Promise<ResolvedLocation>;
16
16
  /**
@@ -15,24 +15,68 @@
15
15
  // produces a chart nothing downstream can detect as wrong. Same failure
16
16
  // class as the naive-UTC ASC and the unlabelled cusp longitudes.
17
17
  import { getActiveClient } from "../../backend/client.js";
18
+ // The API returns the full state name in `region` ("Oregon", not "OR"), but
19
+ // most callers naturally type the USPS abbreviation ("Portland, OR"). Without
20
+ // this table, "San Francisco, CA" was flagged ambiguous while the equivalent
21
+ // "San Francisco, California" resolved fine — same query, arbitrarily
22
+ // different outcome depending on which form the caller happened to type.
23
+ const US_STATE_ABBREVIATIONS = {
24
+ al: "alabama", ak: "alaska", az: "arizona", ar: "arkansas", ca: "california",
25
+ co: "colorado", ct: "connecticut", de: "delaware", fl: "florida", ga: "georgia",
26
+ hi: "hawaii", id: "idaho", il: "illinois", in: "indiana", ia: "iowa",
27
+ ks: "kansas", ky: "kentucky", la: "louisiana", me: "maine", md: "maryland",
28
+ ma: "massachusetts", mi: "michigan", mn: "minnesota", ms: "mississippi", mo: "missouri",
29
+ mt: "montana", ne: "nebraska", nv: "nevada", nh: "new hampshire", nj: "new jersey",
30
+ nm: "new mexico", ny: "new york", nc: "north carolina", nd: "north dakota", oh: "ohio",
31
+ ok: "oklahoma", or: "oregon", pa: "pennsylvania", ri: "rhode island", sc: "south carolina",
32
+ sd: "south dakota", tn: "tennessee", tx: "texas", ut: "utah", vt: "vermont",
33
+ va: "virginia", wa: "washington", wv: "west virginia", wi: "wisconsin", wy: "wyoming",
34
+ dc: "district of columbia", pr: "puerto rico", vi: "virgin islands", gu: "guam",
35
+ as: "american samoa", mp: "northern mariana islands",
36
+ };
37
+ // The API reports the country as an ISO code ("FR"), but callers type the
38
+ // country *name* — "Paris, France" is the phrasing a model reaches for when
39
+ // it has a foreign birthplace. Matching only the code meant that query lost
40
+ // the qualification check against its US namesake (Paris, TX) and threw
41
+ // ambiguous, while the far rarer "Paris, fr" resolved fine. Intl.DisplayNames
42
+ // is the runtime's own code→name table, so there is no list to keep in sync.
43
+ const REGION_NAMES = new Intl.DisplayNames(["en"], { type: "region" });
44
+ function countryNameFor(code) {
45
+ try {
46
+ return String(REGION_NAMES.of(code.toUpperCase()) ?? "").toLowerCase();
47
+ }
48
+ catch {
49
+ return ""; // not a valid region subtag — fall through to the other checks
50
+ }
51
+ }
52
+ // Colloquial forms Intl does not produce: it renders GB as "United Kingdom",
53
+ // so a bare "uk" token would otherwise miss.
54
+ const COUNTRY_ALIASES = { uk: "gb", usa: "us", uae: "ae" };
55
+ // `code` rides along to PostHog via the existing `code` property on
56
+ // mcp_tool_error. The event deliberately records no message or stack, so
57
+ // without a code every one of these lands as code:"none" / error_kind:"local"
58
+ // and is indistinguishable from a genuine server-side crash.
59
+ function codedError(code, message) {
60
+ return Object.assign(new Error(message), { code });
61
+ }
18
62
  /**
19
63
  * Resolve a place name to lat/lon/tz. Throws if ambiguous or unresolvable.
20
64
  *
21
65
  * Ambiguity is detected the same way `location_search` reports it — a
22
66
  * count of *rivals* for the top short_name, unless the query itself
23
- * qualifies the place (e.g. "portland uk", "dallas texas").
67
+ * qualifies the place (e.g. "portland uk", "dallas texas", "dallas, tx").
24
68
  */
25
69
  export async function resolveLocationOrThrow(location) {
26
70
  const query = location.trim();
27
71
  if (query === "") {
28
- throw new Error("location is empty");
72
+ throw codedError("location_empty", "location is empty");
29
73
  }
30
74
  const raw = (await getActiveClient().request("GET", "/location/autocomplete", {
31
75
  params: { query },
32
76
  }));
33
77
  const list = Array.isArray(raw?.suggestions) ? raw.suggestions : [];
34
78
  if (list.length === 0) {
35
- throw new Error(`location "${query}" did not match any known place. Call location_search directly to inspect suggestions, ` +
79
+ throw codedError("location_not_found", `location "${query}" did not match any known place. Call location_search directly to inspect suggestions, ` +
36
80
  `or supply latitude/longitude explicitly.`);
37
81
  }
38
82
  const norm = (v) => String(v ?? "").trim().toLowerCase();
@@ -40,18 +84,24 @@ export async function resolveLocationOrThrow(location) {
40
84
  const rivals = list.filter((s) => norm(s.short_name) === norm(top.short_name));
41
85
  const q = norm(query);
42
86
  const qTokens = new Set(q.split(/[^a-z0-9]+/).filter(Boolean));
43
- const qualified = (norm(top.region) !== "" && q.includes(norm(top.region))) ||
44
- (norm(top.country_code) !== "" && qTokens.has(norm(top.country_code)));
87
+ const topRegion = norm(top.region);
88
+ const topCountry = norm(top.country_code);
89
+ const topCountryName = topCountry === "" ? "" : countryNameFor(topCountry);
90
+ const qualified = (topRegion !== "" && q.includes(topRegion)) ||
91
+ (topRegion !== "" && [...qTokens].some((t) => US_STATE_ABBREVIATIONS[t] === topRegion)) ||
92
+ (topCountry !== "" && qTokens.has(topCountry)) ||
93
+ (topCountryName !== "" && q.includes(topCountryName)) ||
94
+ (topCountry !== "" && [...qTokens].some((t) => COUNTRY_ALIASES[t] === topCountry));
45
95
  const ambiguous = rivals.length > 1 && !qualified;
46
96
  if (ambiguous) {
47
97
  const options = rivals
48
98
  .slice(0, 4)
49
99
  .map((s) => `${s.display_name ?? s.short_name} (${s.country_code ?? "??"})`)
50
100
  .join("; ");
51
- throw new Error(`location "${query}" is ambiguous — matches ${rivals.length} places (${options}${rivals.length > 4 ? ", …" : ""}). Call location_search directly and pass the disambiguated place, or supply latitude/longitude explicitly.`);
101
+ throw codedError("location_ambiguous", `location "${query}" is ambiguous — matches ${rivals.length} places (${options}${rivals.length > 4 ? ", …" : ""}). Call location_search directly and pass the disambiguated place, or supply latitude/longitude explicitly.`);
52
102
  }
53
103
  if (top.latitude == null || top.longitude == null) {
54
- throw new Error(`location "${query}" matched "${top.display_name ?? top.short_name}" but the record is missing coordinates. ` +
104
+ throw codedError("location_missing_coordinates", `location "${query}" matched "${top.display_name ?? top.short_name}" but the record is missing coordinates. ` +
55
105
  `Supply latitude/longitude explicitly.`);
56
106
  }
57
107
  return {
@@ -80,7 +130,7 @@ export async function coordsFromArgsOrLocation(args) {
80
130
  return { latitude: lat, longitude: lon, timezone: tz, location: loc, resolvedFromLocation: false };
81
131
  }
82
132
  if (latSet !== lonSet) {
83
- throw new Error("latitude and longitude must both be supplied (or both omitted with a `location` name).");
133
+ throw codedError("coords_partial", "latitude and longitude must both be supplied (or both omitted with a `location` name).");
84
134
  }
85
135
  if (!loc || String(loc).trim() === "") {
86
136
  // Neither coords nor location — leave undefined; the tool's own
@@ -66,13 +66,16 @@ 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", conventionFields = {}) {
69
+ async function fetchBaziChart(components, theme = "dark", conventionFields = {}, reconcile = false) {
70
70
  const client = getActiveClient();
71
71
  const body = {
72
72
  ...components,
73
73
  ...conventionFields,
74
74
  include_visual: true,
75
75
  visual_config: { format: "svg", theme, size: 800 },
76
+ // Set only by bazi_recalculate's theme-reconcile path below — waives the
77
+ // visual surcharge server-side for a same-birth-data re-render (#551).
78
+ ...(reconcile ? { _visual_reconcile: true } : {}),
76
79
  };
77
80
  return await client.request("POST", "/chinese/bazi", { data: body });
78
81
  }
@@ -194,7 +197,11 @@ registerTool({
194
197
  // ── Tool: bazi_recalculate [app-only] ────────────────────────────────────────
195
198
  registerTool({
196
199
  name: "bazi_recalculate",
197
- description: "Recalculates a BaZi Four Pillars chart with new birth data or theme.",
200
+ description: "Recalculates a BaZi Four Pillars chart with new birth data or theme. " +
201
+ "App-only: called by the embedded chart itself to reconcile the initial " +
202
+ "server-rendered SVG to the host's actual light/dark theme. Billed at 1 " +
203
+ "credit (base only) — the visual-render surcharge is waived because this " +
204
+ "re-renders already-computed data, not a new chart.",
198
205
  inputSchema: {
199
206
  type: "object",
200
207
  properties: {
@@ -216,7 +223,7 @@ registerTool({
216
223
  handler: async (args) => {
217
224
  const components = parseBaziArgs(args);
218
225
  const theme = args.theme === "light" ? "light" : "dark";
219
- const data = await fetchBaziChart(components, theme);
226
+ const data = await fetchBaziChart(components, theme, {}, true);
220
227
  const payload = buildModelPayload(data, components, theme);
221
228
  return {
222
229
  content: [{ type: "text", text: JSON.stringify({ ...payload, server_version: SERVER_VERSION }) }],
@@ -81,7 +81,16 @@ export const DATETIME_CONTRACT_INSTRUCTIONS = "Datetime contract: every clock ti
81
81
  "the naive local time and name the zone in the sibling `timezone` argument (IANA name, " +
82
82
  "e.g. `America/Chicago`). A zone-less clock time is a hard 400 — the engine never " +
83
83
  "guesses UTC, because an unstated zone shifts the Ascendant by ~15° per hour and moves " +
84
- "every house placement. A bare date (no clock time) resolves to 12:00 UTC.";
84
+ "every house placement. A bare date (no clock time) resolves to 12:00 UTC.\n\n" +
85
+ "PREFER the local-time + `timezone` form over converting to UTC yourself. The server " +
86
+ "resolves the IANA zone against the actual date, including historical DST rules — that " +
87
+ "arithmetic (which offset applied on that specific day, in that specific year) is easy " +
88
+ "to get wrong by hand and the server will not catch it: a value that already carries a " +
89
+ "`Z`/±HH:MM suffix is trusted as the stated instant, so a self-computed offset that is " +
90
+ "off by an hour (e.g. daylight vs. standard time) produces a chart that is confidently " +
91
+ "wrong with no error raised. If the user states a local clock time (\"2:30 PM in San " +
92
+ "Francisco\"), pass that wall-clock string verbatim with `timezone` — do not do the " +
93
+ "UTC conversion mentally first.";
85
94
  // ─── Enforcement ─────────────────────────────────────────────────────────────
86
95
  /**
87
96
  * The rejection message. Names both remedies, rewriting the caller's own value
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openephemeris/mcp-server",
3
- "version": "4.11.0",
3
+ "version": "4.11.2",
4
4
  "description": "Model Context Protocol server for the Open Ephemeris astronomical computation API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",