@openephemeris/mcp-server 4.6.0 → 4.8.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,75 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [4.8.0] — 2026-08-02
11
+
12
+ ### Added
13
+ - **Toolsets by tradition.** Tool definitions are re-sent to the model on every
14
+ message, so callers who work in one tradition can now advertise just that
15
+ tradition instead of the general-purpose default: `OPENEPHEMERIS_TOOLS=hd`
16
+ (stdio) or `?profile=hd,bazi` (hosted). Eight toolsets — `astrology`, `moon`,
17
+ `hd`, `bazi`, `vedic`, `acg`, `electional`, `venus` — each including
18
+ geocoding, account usage, and the API escape hatch. A Human Design session
19
+ drops from ~19,100 to ~7,800 advertised tokens (−59%); Vedic to ~3,400
20
+ (−82%). `astrology,moon` matches the default's tool count at ~1,700 fewer
21
+ tokens while covering the long tail (dignities, midpoints, hermetic lots,
22
+ fixed stars, composites) the curated set omits. As with `core`/`full`, this
23
+ filters `tools/list` only — every tool stays callable by name. Also exposed
24
+ as the `toolSurface` option in the Smithery config.
25
+
26
+ ### Fixed
27
+ - `explore_human_design` now states its real credit cost: 4 where the bodygraph
28
+ renders (2 chart + 2 visual), 2 in text-only hosts — it previously claimed a
29
+ flat 2. `ephemeris_moon_phase` (2, not 1) and `ephemeris_next_lunar_phase`
30
+ (1–2 per occurrence) corrected the same way.
31
+ - `explore_bi_wheel` in synastry mode now requires Person 2's coordinates (or a
32
+ resolvable `person2_location`) instead of silently casting Person 2's chart
33
+ for 0°N 0°E.
34
+ - Chart-wheel widget escapes birth parameters before rendering them into the
35
+ info chips.
36
+ - OAuth token endpoint binds authorization codes to their `redirect_uri` and
37
+ `client_id`; both checks were previously skippable by omitting the parameter.
38
+
39
+ ---
40
+
41
+ ## [4.7.0] — 2026-08-02
42
+
43
+ Historically correct birth-time conversion. IANA timezone databases — including
44
+ the one inside every JavaScript runtime — are only authoritative from 1970.
45
+ Before the US Uniform Time Act took effect in 1967, daylight saving was state
46
+ and municipal law, and a zone like `America/Chicago` models Chicago alone. A
47
+ 1961 Minnesota birth converted with standard timezone math lands an hour off,
48
+ which flips the Ascendant sign and the Human Design design-Moon gate.
49
+
50
+ ### Added
51
+
52
+ - **Pre-1970 births now resolve through the API's historical correction
53
+ overlay.** Any tool given a naive local birth time with a timezone and
54
+ coordinates — natal, Human Design (chart, composite, penta, cycles, groups),
55
+ Vedic, BaZi apps, ACG, moon tools, and the embedded chart apps — routes the
56
+ local→UTC conversion through the API's `/timezone/offset` `datetime_local`
57
+ mode, which applies primary-source-cited state and local law (with a
58
+ `tz_confidence` grade and rule citation) instead of assuming the reference
59
+ city's rules. Post-1970 conversions stay on the local, zero-latency path,
60
+ which is exactly as authoritative as the server. If the server is
61
+ unreachable, tools fall back to the previous local conversion rather than
62
+ failing the chart call.
63
+ - **`timezone_resolve`** consults the same historical path for pre-1970 dates
64
+ and reports `tzConfidence`, `tzRuleSource`, and `datetimeStatus` (ambiguous
65
+ DST-fold and nonexistent-gap times are called out explicitly).
66
+ - **`location_search`** tags each suggestion's historical offset with a
67
+ confidence grade and a note when a pre-1970 date may be affected by
68
+ state/local law divergence.
69
+
70
+ ### Fixed
71
+
72
+ - Three skills and one prompt still instructed the model to convert local
73
+ birth times to UTC itself with generic timezone math — the exact manual
74
+ conversion the historical overlay exists to prevent. They now direct all
75
+ conversion through the API.
76
+
77
+ ---
78
+
10
79
  ## [4.6.0] — 2026-07-30
11
80
 
12
81
  Fixes the tool that could not answer the question it is named for, closes the
package/README.md CHANGED
@@ -358,6 +358,38 @@ On the remote HTTP server, append `?profile=full` to the connector URL (or send
358
358
  https://mcp.openephemeris.com/mcp?profile=full
359
359
  ```
360
360
 
361
+ ### Toolsets by tradition
362
+
363
+ If you work in one tradition, ask for it by name instead of taking the general-purpose default. You get that tradition in full — including the long-tail tools the core set leaves out — for a fraction of the context.
364
+
365
+ ```bash
366
+ OPENEPHEMERIS_TOOLS=hd npx -y @openephemeris/mcp-server # Human Design
367
+ OPENEPHEMERIS_TOOLS=astrology,moon npx -y @openephemeris/mcp-server
368
+ ```
369
+
370
+ ```
371
+ https://mcp.openephemeris.com/mcp?profile=hd,bazi
372
+ ```
373
+
374
+ | Toolset | What it covers | Tools | Approx. tokens |
375
+ |---|---|---:|---:|
376
+ | `astrology` | Natal, transits, synastry, progressions, returns, relocation, dignities, midpoints, lots, fixed stars, composites | 32 | 15,800 |
377
+ | `hd` | Human Design charts, transits, connection charts, penta, bodygraph | 14 | 7,800 |
378
+ | `bazi` | Four Pillars, Ten Gods, element balance, luck pillars, compatibility | 13 | 7,200 |
379
+ | `electional` | Timing windows, angle crossings, stations, moment analysis | 10 | 4,600 |
380
+ | `moon` | Phases, void-of-course, eclipses | 9 | 4,100 |
381
+ | `venus` | Star points, phases, elongations, stations | 11 | 3,700 |
382
+ | `acg` | Astrocartography lines and hits | 7 | 3,700 |
383
+ | `vedic` | Jyotish Rashi chart | 7 | 3,400 |
384
+ | — | *core (default)* | 36 | 19,100 |
385
+ | — | *full* | 70 | 34,600 |
386
+
387
+ Every selection also includes geocoding (`location_search`, `timezone_resolve`), `account_usage`, and the allowlist-gated proxy — so a birthplace is always resolvable and nothing is stranded.
388
+
389
+ Combine with commas; unknown names are ignored rather than rejected, so a typo degrades to a smaller surface instead of a dead connector. As with `core`/`full`, this only filters `tools/list` — every tool remains callable by name.
390
+
391
+ Why it matters: tool definitions are re-sent to the model on **every** pass. `astrology,moon` advertises the same number of tools as the default but costs ~1,700 fewer tokens per message and covers more of the tradition.
392
+
361
393
  The surface is fixed when the session initializes — this server does not advertise `tools.listChanged`, so switching requires reconnecting. `dev_list_allowed` enumerates every operation reachable through the generic proxy regardless of surface.
362
394
 
363
395
  ## Contributing & Support
@@ -443,8 +475,8 @@ Generated by `npm run sync:readme` from `config/dev-allowlist.json` and the live
443
475
  - Allowlisted operations: **31**
444
476
  - Methods: `GET=5`, `POST=26`, `PUT=0`, `PATCH=0`, `DELETE=0`
445
477
  - Registered tools (`OPENEPHEMERIS_PROFILE=dev`): **92**
446
- - Typed tools: `account_usage`, `acg_hits`, `acg_power_lines`, `auth_login`, `auth_logout`, `auth_status`, `bazi_annual_pillar`, `bazi_chart`, `bazi_compatibility`, `bazi_element_balance`, `bazi_luck_pillars`, `bazi_recalculate`, `bazi_ten_gods`, `bi_wheel_on_cross_aspect_click`, `bi_wheel_on_house_click`, `bi_wheel_on_planet_click`, `bi_wheel_recalculate`, `bi_wheel_synopsis`, `bodygraph_recalculate`, `chart_wheel_on_aspect_click`, `chart_wheel_on_house_click`, `chart_wheel_on_planet_click`, `chart_wheel_recalculate`, `chinese_bazi`, `dev_list_allowed`, `dev_read_api`, `dev_write_api`, `electional_angle_crossings`, `electional_aspect_search`, `electional_moment_analysis`, `electional_station_tracker`, `ephemeris_angles_points`, `ephemeris_aspect_check`, `ephemeris_bi_wheel`, `ephemeris_chart_wheel`, `ephemeris_composite`, `ephemeris_composite_midpoint`, `ephemeris_dignities`, `ephemeris_electional`, `ephemeris_fixed_stars`, `ephemeris_hermetic_lots`, `ephemeris_house_cusps`, `ephemeris_lunar_return`, `ephemeris_midpoints`, `ephemeris_moon_phase`, `ephemeris_natal_batch`, `ephemeris_natal_chart`, `ephemeris_natal_transits`, `ephemeris_next_eclipse`, `ephemeris_next_lunar_phase`, `ephemeris_overlay`, `ephemeris_planet_position`, `ephemeris_planetary_return`, `ephemeris_progressed_chart`, `ephemeris_relocation`, `ephemeris_retrograde_status`, `ephemeris_solar_return`, `ephemeris_synastry`, `ephemeris_transits`, `explore_bazi_chart`, `explore_bi_wheel`, `explore_human_design`, `explore_human_design_connection`, `explore_human_design_transit`, `explore_moon_phase`, `explore_natal_chart`, `explore_transit_timeline`, `explore_vedic_chart`, `hd_on_center_click`, `hd_on_channel_click`, `hd_on_connection_channel_click`, `hd_on_gate_click`, `hd_on_planet_click`, `hd_on_transit_channel_click`, `hd_on_variable_click`, `hd_opposition`, `hd_planetary_return`, `human_design_bodygraph`, `human_design_chart`, `human_design_composite`, `human_design_penta`, `location_search`, `moon_phase_recalculate`, `timezone_resolve`, `vedic_chart`, `vedic_chart_recalculate`, `venus_eight_year_star`, `venus_elongations`, `venus_phase`, `venus_star_points`, `venus_star_points_conjunctions`, `venus_stations`
447
- - Generic tools:
478
+ - Typed tools: `account_usage`, `acg_hits`, `acg_power_lines`, `auth_login`, `auth_logout`, `auth_status`, `bazi_annual_pillar`, `bazi_chart`, `bazi_compatibility`, `bazi_element_balance`, `bazi_luck_pillars`, `bazi_recalculate`, `bazi_ten_gods`, `bi_wheel_on_cross_aspect_click`, `bi_wheel_on_house_click`, `bi_wheel_on_planet_click`, `bi_wheel_recalculate`, `bi_wheel_synopsis`, `bodygraph_recalculate`, `chart_wheel_on_aspect_click`, `chart_wheel_on_house_click`, `chart_wheel_on_planet_click`, `chart_wheel_recalculate`, `chinese_bazi`, `electional_angle_crossings`, `electional_aspect_search`, `electional_moment_analysis`, `electional_station_tracker`, `ephemeris_angles_points`, `ephemeris_aspect_check`, `ephemeris_bi_wheel`, `ephemeris_chart_wheel`, `ephemeris_composite`, `ephemeris_composite_midpoint`, `ephemeris_dignities`, `ephemeris_electional`, `ephemeris_fixed_stars`, `ephemeris_hermetic_lots`, `ephemeris_house_cusps`, `ephemeris_lunar_return`, `ephemeris_midpoints`, `ephemeris_moon_phase`, `ephemeris_natal_batch`, `ephemeris_natal_chart`, `ephemeris_natal_transits`, `ephemeris_next_eclipse`, `ephemeris_next_lunar_phase`, `ephemeris_overlay`, `ephemeris_planet_position`, `ephemeris_planetary_return`, `ephemeris_progressed_chart`, `ephemeris_relocation`, `ephemeris_retrograde_status`, `ephemeris_solar_return`, `ephemeris_synastry`, `ephemeris_transits`, `explore_bazi_chart`, `explore_bi_wheel`, `explore_human_design`, `explore_human_design_connection`, `explore_human_design_transit`, `explore_moon_phase`, `explore_natal_chart`, `explore_transit_timeline`, `explore_vedic_chart`, `hd_on_center_click`, `hd_on_channel_click`, `hd_on_connection_channel_click`, `hd_on_gate_click`, `hd_on_planet_click`, `hd_on_transit_channel_click`, `hd_on_variable_click`, `hd_opposition`, `hd_planetary_return`, `human_design_bodygraph`, `human_design_chart`, `human_design_composite`, `human_design_penta`, `location_search`, `moon_phase_recalculate`, `timezone_resolve`, `vedic_chart`, `vedic_chart_recalculate`, `venus_eight_year_star`, `venus_elongations`, `venus_phase`, `venus_star_points`, `venus_star_points_conjunctions`, `venus_stations`
479
+ - Generic tools: `dev_list_allowed`, `dev_read_api`, `dev_write_api`
448
480
 
449
481
  ### Allowlist Families
450
482
 
@@ -466,5 +498,5 @@ Most LLMs (like Claude and ChatGPT) struggle heavily with astronomical calculati
466
498
 
467
499
  By pairing LLMs with the OpenEphemeris MCP server, your agents can instantly access:
468
500
  - **Zero-hallucination coordinates**: Direct, sub-arcsecond NASA JPL DE440 calculations spanning 1,100 years of astronomical data.
469
- - **LLM-optimized tokens (`format=llm`)**: We compress standard 25,000 token JSON chart responses into minimal text blocks, cutting your inference costs by 50%.
501
+ - **LLM-optimized tokens (`format=llm`)**: We compress standard 25,000 token JSON chart responses into minimal text blocks, cutting your inference costs by 50–73% depending on endpoint.
470
502
  - **Ready-to-use astrology layers**: Built-in support for Astrocartography geoJSON lines, Hermetic Lots, Fixed Stars, and complex Human Design matrix generation.
package/dist/index.js CHANGED
@@ -73,9 +73,12 @@ const server = new Server({
73
73
  "See the 'welcome_to_open_ephemeris' prompt for orientation.\n\n" +
74
74
  DATETIME_CONTRACT_INSTRUCTIONS,
75
75
  });
76
- // Which slice of the registry this process advertises. Tools outside the core
76
+ // Which slice of the registry this process advertises. Tools outside the
77
77
  // surface stay callable by name — this only controls what tools/list returns.
78
- // Set OPENEPHEMERIS_TOOLS=full to advertise everything.
78
+ // OPENEPHEMERIS_TOOLS=full advertise everything
79
+ // OPENEPHEMERIS_TOOLS=hd,bazi advertise those traditions (+ geocoding,
80
+ // account and the escape hatch)
81
+ // unset the curated core set
79
82
  const STDIO_SURFACE = parseToolSurface(process.env.OPENEPHEMERIS_TOOLS);
80
83
  /**
81
84
  * Stable pseudonymous id for this install. Derived from the configured
@@ -106,11 +106,38 @@ async function handleAuthorizationCode(req, res, store) {
106
106
  });
107
107
  return;
108
108
  }
109
- // Verify redirect_uri exact match
110
- if (redirect_uri && !timingSafeStringEqual(redirect_uri, stored.redirect_uri)) {
109
+ // Verify redirect_uri exact match.
110
+ //
111
+ // This used to read `if (redirect_uri && ...)`, so a client that simply omitted
112
+ // the parameter skipped the check entirely. RFC 6749 §4.1.3 requires the value
113
+ // to be present AND identical whenever the authorization request carried one,
114
+ // precisely so a dropped parameter cannot be the bypass. PKCE still gates the
115
+ // exchange, but that is the last line, not the only one.
116
+ if (stored.redirect_uri) {
117
+ if (!redirect_uri) {
118
+ res.status(400).json({
119
+ error: "invalid_request",
120
+ error_description: "Missing redirect_uri. It is required because the authorization request included one.",
121
+ });
122
+ return;
123
+ }
124
+ if (!timingSafeStringEqual(redirect_uri, stored.redirect_uri)) {
125
+ res.status(400).json({
126
+ error: "invalid_grant",
127
+ error_description: "redirect_uri does not match the value used during authorization.",
128
+ });
129
+ return;
130
+ }
131
+ }
132
+ // Bind the code to the client it was issued to. `client_id` was read off the
133
+ // request but never compared, so any client holding the verifier could redeem
134
+ // another client's code. DCR client_ids are deterministic and non-secret
135
+ // (see oauth/dcr.ts), which makes this binding the only thing distinguishing
136
+ // one registered client from another.
137
+ if (stored.client_id && client_id && !timingSafeStringEqual(client_id, stored.client_id)) {
111
138
  res.status(400).json({
112
139
  error: "invalid_grant",
113
- error_description: "redirect_uri does not match the value used during authorization.",
140
+ error_description: "client_id does not match the client the authorization code was issued to.",
114
141
  });
115
142
  return;
116
143
  }
package/dist/prompts.js CHANGED
@@ -377,8 +377,9 @@ export const PROMPTS = [
377
377
  "even 10–15 minutes can shift the Type, Authority, or Profile. If truly unknown, " +
378
378
  "proceed with solar noon but flag that results may be imprecise.\n" +
379
379
  "4. **Birth city and country** — needed for timezone conversion\n\n" +
380
- "## Step 2 — CRITICAL: Convert to UTC\n" +
381
- "Human Design requires **UTC datetime**. The tool rejects local times without a UTC offset.\n\n" +
380
+ "## Step 2 — Resolve Coordinates and Timezone\n" +
381
+ "Human Design needs the exact birth instant; the tool resolves it from local time + " +
382
+ "timezone + coordinates automatically (historically correct even pre-1970).\n\n" +
382
383
  "**Resolve the birth place with `location_search`** — pass `query: 'Birth City, Country'` " +
383
384
  "and `date` set to the birth date (`YYYY-MM-DD`). One call returns decimal lat/lon, the " +
384
385
  "IANA timezone, and `utcOffsetAtDate` — the offset that actually applied at that place on " +
@@ -23,7 +23,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
23
23
  import { InMemoryEventStore } from "./event-store.js";
24
24
  import { captureEvent, distinctIdFor } from "./analytics.js";
25
25
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
26
- import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools, parseToolSurface } from "./tools/index.js";
26
+ import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools, parseToolSurface, describeSurface } from "./tools/index.js";
27
27
  import { DATETIME_CONTRACT_INSTRUCTIONS } from "./tools/datetime.js";
28
28
  import { BackendClient, runWithClient } from "./backend/client.js";
29
29
  import { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
@@ -215,7 +215,10 @@ function transportProps(server, surface) {
215
215
  const info = server.getClientVersion();
216
216
  return {
217
217
  transport: "http",
218
- surface,
218
+ // describeSurface, not the raw value: a toolset selection is an object and
219
+ // would land in PostHog as "[object Object]", making the property useless
220
+ // for exactly the question it exists to answer — which surfaces get used.
221
+ surface: describeSurface(surface),
219
222
  client_name: info?.name ?? "unknown",
220
223
  client_version: info?.version ?? "unknown",
221
224
  server_version: version,
@@ -848,6 +851,8 @@ export async function createSseApp() {
848
851
  // `tools.listChanged`, so a host has no obligation to re-fetch the list.
849
852
  // Opt into the full surface with `?profile=full` on the connector URL, or
850
853
  // an `X-OE-Tool-Surface: full` header where the host allows custom headers.
854
+ // A comma list of traditions works the same way — `?profile=hd,bazi` —
855
+ // and advertises just those plus geocoding, account and the escape hatch.
851
856
  const surface = parseToolSurface(req.query.profile ?? req.headers["x-oe-tool-surface"]);
852
857
  const server = createMcpServer(analyticsId, surface);
853
858
  // Fire session_init after the handshake so getClientVersion() is populated
@@ -115,9 +115,9 @@ registerTool({
115
115
  "(Year = ancestry/early life, Month = parents/career, Day = self/spouse, Hour = children/later life).\n\n" +
116
116
  "CREDIT COST: 3 credits per call (1 base + 2 visual render).\n\n" +
117
117
  "Use this for a rich, interactive BaZi experience in MCP Apps-capable hosts (Claude Desktop). " +
118
- "Falls back to a text summary in other hosts. For non-visual data lookups, use chinese_bazi " +
119
- "instead; deeper derivations (Ten Gods, element balance, luck pillars) have dedicated tools " +
120
- "on the full tool surface (`?profile=full`).",
118
+ "Falls back to a text summary in other hosts, so it is also the right call without a " +
119
+ "renderer. Deeper derivations (Ten Gods, element balance, luck pillars) and a plain " +
120
+ "non-visual pillars lookup have dedicated tools on the full surface (`?profile=full`).",
121
121
  inputSchema: {
122
122
  type: "object",
123
123
  properties: {
@@ -104,13 +104,21 @@ const HOUSE_SYSTEM_MAP = {
104
104
  campanus: "C", regiomontanus: "R",
105
105
  };
106
106
  function buildNatalBody(datetime, lat, lon, timezone, houseSystem = "placidus") {
107
+ // A natal chart with no birthplace is never meaningful — houses and angles are
108
+ // derived entirely from it. This used to read `lat ?? 0`, which cast the chart
109
+ // for 0°N 0°E and returned it as though nothing were wrong. Callers that can
110
+ // legitimately omit coords resolve them first (see coordsFromArgsOrLocation) or
111
+ // fall back to Person 1's; anything reaching here without them is a bug.
112
+ if (lat == null || lon == null) {
113
+ throw new Error("Internal: buildNatalBody requires latitude and longitude — refusing to cast a chart for 0°N 0°E.");
114
+ }
107
115
  const body = {
108
116
  subject: {
109
117
  name: "MCP Request",
110
118
  birth_datetime: { iso: datetime },
111
119
  birth_location: {
112
- latitude: { decimal: lat ?? 0 },
113
- longitude: { decimal: lon ?? 0 },
120
+ latitude: { decimal: lat },
121
+ longitude: { decimal: lon },
114
122
  },
115
123
  },
116
124
  configuration: {
@@ -413,16 +421,15 @@ registerTool({
413
421
  "CREDIT COST: 2 credits per call for most modes (1 per wheel computed); " +
414
422
  "solar_return/lunar_return modes cost 6 (5 for the return + 1 for the natal wheel).\n\n" +
415
423
  "The inner wheel is always Person 1's natal chart. The outer ring depends on mode:\n" +
416
- "• synastry — Person 2's natal chart (two-person compatibility). 1 credit.\n" +
417
- "• transit — Transiting planets for a given date. 1 credit.\n" +
418
- "• progressed — Secondary progressed positions (person2_datetime = target date). 1 credit.\n" +
419
- "• solar_return — Nearest solar return chart (person2_datetime = year to return for). 5 credits.\n" +
420
- "• lunar_return — Nearest lunar return chart (person2_datetime = target month). 5 credits.\n" +
421
- "• solar_arc — Solar arc directed positions (person2_datetime = target date). 1 credit.\n" +
422
- "Cross-aspects between both wheels are computed and displayed as coloured dashed lines. " +
423
- "Click any planet, aspect line, or house cusp for interpretation. " +
424
- "Prefer this whenever the user should SEE the comparison — it renders interactively in " +
425
- "MCP Apps-capable hosts (Claude Desktop) and falls back to static SVG in other hosts. " +
424
+ "• synastry — Person 2's natal chart (needs Person 2's own birthplace).\n" +
425
+ "• transit — Transiting planets for a given date.\n" +
426
+ "• progressed — Secondary progressed positions (person2_datetime = target date).\n" +
427
+ "• solar_return — Nearest solar return chart (person2_datetime = year to return for).\n" +
428
+ "• lunar_return — Nearest lunar return chart (person2_datetime = target month).\n" +
429
+ "• solar_arc — Solar arc directed positions (person2_datetime = target date).\n" +
430
+ "Cross-aspects are drawn as coloured dashed lines; click any planet, aspect line, or " +
431
+ "house cusp for interpretation. Prefer this whenever the user should SEE the comparison " +
432
+ "— it renders interactively in MCP Apps hosts and falls back to static SVG elsewhere. " +
426
433
  "For raw synastry data without a visual, use ephemeris_synastry.",
427
434
  inputSchema: {
428
435
  type: "object",
@@ -444,8 +451,8 @@ registerTool({
444
451
  "synastry = Person 2 birth datetime; transit/progressed/solar_arc = target date; " +
445
452
  "solar_return/lunar_return = any date within the target year/month. " + DATETIME_DESC,
446
453
  },
447
- person2_latitude: { type: "number", description: "Birth latitude for Person 2 (synastry) or return/transit location. Defaults to Person 1 coords for single-person modes." },
448
- person2_longitude: { type: "number", description: "Birth longitude for Person 2 (synastry) or return/transit location. Defaults to Person 1 coords for single-person modes." },
454
+ person2_latitude: { type: "number", description: "Birth latitude for Person 2. REQUIRED in synastry unless `person2_location` is given — never inferred from Person 1. Other modes default to Person 1's coords." },
455
+ person2_longitude: { type: "number", description: "Birth longitude for Person 2. REQUIRED in synastry unless `person2_location` is given. Other modes default to Person 1's coords." },
449
456
  person2_timezone: timezoneProperty("Person 2 / the outer chart", "America/Chicago"),
450
457
  person2_name: {
451
458
  type: "string",
@@ -453,7 +460,11 @@ registerTool({
453
460
  },
454
461
  location: {
455
462
  type: "string",
456
- description: "Person 1 / Natal location. Prefer a plain place name like 'New York, NY' — the server resolves it via the same lookup `location_search` uses (unambiguous names → coords + timezone; ambiguous names throw with a disambiguation hint). Supplied person1_latitude/person1_longitude always win.",
463
+ description: "Person 1 / Natal location. Prefer a plain place name like 'New York, NY' — resolved via the same lookup `location_search` uses; ambiguous names throw with a disambiguation hint. Supplied person1_latitude/person1_longitude win.",
464
+ },
465
+ person2_location: {
466
+ type: "string",
467
+ description: "Person 2 / outer-chart place name, resolved like `location`. person2_latitude/person2_longitude win.",
457
468
  },
458
469
  mode: {
459
470
  type: "string",
@@ -493,10 +504,26 @@ registerTool({
493
504
  const lat1 = resolved1.latitude;
494
505
  const lon1 = resolved1.longitude;
495
506
  const tz1 = args.person1_timezone ?? resolved1.timezone;
507
+ const resolved2 = await coordsFromArgsOrLocation({
508
+ latitude: args.person2_latitude,
509
+ longitude: args.person2_longitude,
510
+ timezone: args.person2_timezone,
511
+ location: args.person2_location,
512
+ });
513
+ // Only synastry casts a standalone chart from Person 2's own birthplace. The
514
+ // other modes either fall back to Person 1's coords (transit, *_return) or
515
+ // ignore lat2/lon2 entirely (progressed, solar_arc), so an unresolved Person 2
516
+ // is a hard error there alone. Without this the coords fell through to
517
+ // `lat ?? 0` in buildNatalBody and silently produced a chart cast for
518
+ // 0°N 0°E — no error, no warning, wrong houses and angles.
519
+ if (mode === "synastry" && (resolved2.latitude == null || resolved2.longitude == null)) {
520
+ throw new Error("explore_bi_wheel in synastry mode requires either `person2_latitude` + `person2_longitude` " +
521
+ "or a resolvable `person2_location` name. Person 2's birthplace is never inferred from Person 1's.");
522
+ }
496
523
  const dt2 = String(args.person2_datetime);
497
- const lat2 = args.person2_latitude;
498
- const lon2 = args.person2_longitude;
499
- const tz2 = args.person2_timezone;
524
+ const lat2 = resolved2.latitude;
525
+ const lon2 = resolved2.longitude;
526
+ const tz2 = args.person2_timezone ?? resolved2.timezone;
500
527
  const loc1 = String(args.person1_name ?? resolved1.location ?? args.location ?? `${lat1},${lon1}`);
501
528
  const loc2 = MODE_META[mode].outerLabel(dt2, args.person2_name);
502
529
  // Fetch inner (natal) and outer (mode-dependent) charts in parallel where possible
@@ -575,6 +602,13 @@ registerTool({
575
602
  const lat2 = args.person2_latitude;
576
603
  const lon2 = args.person2_longitude;
577
604
  const tz2 = args.person2_timezone;
605
+ // Same 0°N 0°E trap as explore_bi_wheel: person1 coords are required by the
606
+ // schema but person2's cannot be (they are only needed in synastry mode), so
607
+ // the mode-specific requirement is enforced here. The widget always supplies
608
+ // them; a hand-rolled call need not.
609
+ if (mode === "synastry" && (lat2 == null || lon2 == null)) {
610
+ throw new Error("bi_wheel_recalculate in synastry mode requires `person2_latitude` and `person2_longitude`.");
611
+ }
578
612
  const loc1 = String(args.person1_name ?? args.location ?? `${lat1 ?? "?"},${lon1 ?? "?"}`);
579
613
  const loc2 = MODE_META[mode].outerLabel(dt2, args.person2_name);
580
614
  const [innerData, outerData] = await Promise.all([
@@ -25,7 +25,7 @@ import { fileURLToPath } from "node:url";
25
25
  import { registerTool, SERVER_VERSION } from "../index.js";
26
26
  import { getActiveClient } from "../../backend/client.js";
27
27
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
28
- import { localToUtcIso } from "../datetime.js";
28
+ import { localToUtcIsoHistorical } from "../datetime-historical.js";
29
29
  import { coordsFromArgsOrLocation } from "./_location-resolver.js";
30
30
  // ── Constants ─────────────────────────────────────────────────────────────
31
31
  export const BODYGRAPH_RESOURCE_URI = "ui://openephemeris/bodygraph";
@@ -306,7 +306,10 @@ function buildHdSummary(payload, location) {
306
306
  registerTool({
307
307
  name: "explore_human_design",
308
308
  description: "Generate an interactive Human Design Bodygraph with clickable centers, gates, and channels.\n\n" +
309
- "CREDIT COST: 2 credits per call.\n\n" +
309
+ "CREDIT COST: 4 credits where the bodygraph renders (2 for the chart + 2 for the " +
310
+ "visual), 2 in text-only hosts that skip the render. For chart data alone at 2 " +
311
+ "credits — including the activations, design, personality, strategy and variables " +
312
+ "this tool does not return — use human_design_chart.\n\n" +
310
313
  "Returns an embedded visual bodygraph explorer that lets you click any center or gate " +
311
314
  "for instant Human Design interpretation. " +
312
315
  "Shows defined/undefined centers, active gates, channels, Type, Profile, Authority, " +
@@ -372,9 +375,9 @@ registerTool({
372
375
  location: args.location,
373
376
  });
374
377
  const timezone = args.timezone ?? resolved.timezone;
375
- const datetime = localToUtcIso("datetime", String(args.datetime), timezone);
376
378
  const lat = resolved.latitude;
377
379
  const lon = resolved.longitude;
380
+ const datetime = await localToUtcIsoHistorical("datetime", String(args.datetime), timezone, { latitude: lat, longitude: lon });
378
381
  if (lat == null || lon == null) {
379
382
  throw new Error("explore_human_design requires either `latitude` + `longitude` or a resolvable `location` name. " +
380
383
  "Human Design is sensitive to birth location; a chart at 0°N 0°E is silently wrong.");
@@ -473,10 +476,11 @@ registerTool({
473
476
  handler: async (args) => {
474
477
  const client = getActiveClient();
475
478
  const timezone = args.timezone;
476
- // Convert local-time input → UTC using IANA timezone when provided
477
- const datetime = localToUtcIso("datetime", String(args.datetime), timezone);
478
479
  const lat = args.latitude;
479
480
  const lon = args.longitude;
481
+ // Convert local-time input → UTC using IANA timezone when provided;
482
+ // pre-1970 births route through the API's historical correction overlay.
483
+ const datetime = await localToUtcIsoHistorical("datetime", String(args.datetime), timezone, { latitude: lat, longitude: lon });
480
484
  const theme = args.theme === "light" ? "light" : "dark";
481
485
  const layout = args.layout === "mandala" ? "mandala" : undefined;
482
486
  const rings = typeof args.rings === "string" ? args.rings : undefined;
@@ -1365,9 +1369,9 @@ registerTool({
1365
1369
  location: args.location,
1366
1370
  });
1367
1371
  const timezone = args.timezone ?? resolved.timezone;
1368
- const natalIso = localToUtcIso("datetime", String(args.datetime), timezone);
1369
1372
  const lat = resolved.latitude;
1370
1373
  const lon = resolved.longitude;
1374
+ const natalIso = await localToUtcIsoHistorical("datetime", String(args.datetime), timezone, { latitude: lat, longitude: lon });
1371
1375
  if (lat == null || lon == null) {
1372
1376
  throw new Error("explore_human_design_transit requires either `latitude` + `longitude` or a resolvable `location` name. " +
1373
1377
  "Human Design is sensitive to birth location; a chart at 0°N 0°E is silently wrong.");
@@ -1389,7 +1393,7 @@ registerTool({
1389
1393
  };
1390
1394
  if (args.transit_datetime) {
1391
1395
  body.transit_datetime = {
1392
- iso: localToUtcIso("transit_datetime", String(args.transit_datetime), args.timezone),
1396
+ iso: await localToUtcIsoHistorical("transit_datetime", String(args.transit_datetime), args.timezone, { latitude: lat, longitude: lon }),
1393
1397
  };
1394
1398
  }
1395
1399
  const bundleAvailable = Boolean(getBodygraphBundle());
@@ -1520,7 +1524,7 @@ registerTool({
1520
1524
  "Human Design is sensitive to birth location; a chart at 0°N 0°E is silently wrong.");
1521
1525
  }
1522
1526
  return {
1523
- birth_datetime_utc: localToUtcIso(`${label}.datetime`, String(p?.datetime), tz, `${label}.timezone`),
1527
+ birth_datetime_utc: await localToUtcIsoHistorical(`${label}.datetime`, String(p?.datetime), tz, { latitude: lat, longitude: lon }, `${label}.timezone`),
1524
1528
  latitude: lat,
1525
1529
  longitude: lon,
1526
1530
  };
@@ -1,6 +1,15 @@
1
1
  import { registerTool, validateRequired } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
+ import { TZDATA_AUTHORITATIVE_FROM_YEAR } from "../datetime-historical.js";
5
+ /** Render minutes east of UTC as `±HH:MM`. */
6
+ function formatOffsetMinutes(offsetMinutes) {
7
+ const sign = offsetMinutes < 0 ? "-" : "+";
8
+ const abs = Math.abs(offsetMinutes);
9
+ const hh = String(Math.floor(abs / 60)).padStart(2, "0");
10
+ const mm = String(Math.round(abs % 60)).padStart(2, "0");
11
+ return `${sign}${hh}:${mm}`;
12
+ }
4
13
  /**
5
14
  * The UTC offset of an IANA zone is a function of place AND date: 1987 US DST
6
15
  * rules are not today's rules, and America/Chicago ran on CDT through the whole
@@ -143,6 +152,8 @@ registerTool({
143
152
  // Map the API's snake_case response to the camelCase shape the app UI consumes.
144
153
  const list = Array.isArray(raw?.suggestions) ? raw.suggestions : [];
145
154
  const date = args.date != null && String(args.date).trim() !== "" ? String(args.date).trim() : null;
155
+ const dateYear = date ? Number(date.slice(0, 4)) : null;
156
+ const preTzdataEra = dateYear != null && Number.isFinite(dateYear) && dateYear < TZDATA_AUTHORITATIVE_FROM_YEAR;
146
157
  const suggestions = list.map((s) => {
147
158
  const mapped = {
148
159
  displayName: s.display_name,
@@ -154,8 +165,17 @@ registerTool({
154
165
  longitude: s.longitude,
155
166
  timezone: s.timezone,
156
167
  };
157
- if (date && s.timezone)
158
- Object.assign(mapped, offsetAtLocalNoon(s.timezone, date) ?? {});
168
+ if (date && s.timezone) {
169
+ const offset = offsetAtLocalNoon(s.timezone, date);
170
+ if (offset) {
171
+ // These offsets come from local tzdata, which is only
172
+ // authoritative from 1970 — before that it models the
173
+ // zone's reference city, not state/local law.
174
+ Object.assign(mapped, offset, {
175
+ tzConfidence: preTzdataEra ? "historical_estimate" : "authoritative",
176
+ });
177
+ }
178
+ }
159
179
  return mapped;
160
180
  });
161
181
  // Ambiguity signal. "portland" really does match Oregon, Maine, Texas,
@@ -180,6 +200,11 @@ registerTool({
180
200
  return {
181
201
  suggestions,
182
202
  matchCount: suggestions.length,
203
+ ...(preTzdataEra
204
+ ? {
205
+ historicalNote: "Pre-1970 date: tzdata offsets are reference-city estimates. For the chosen place, call timezone_resolve with the same date (or submit the naive local time + timezone + coordinates to the chart endpoint) to apply the API's historical correction overlay.",
206
+ }
207
+ : {}),
183
208
  ambiguous,
184
209
  ...(ambiguous
185
210
  ? {
@@ -203,7 +228,7 @@ registerTool({
203
228
  longitude: { type: "number" },
204
229
  date: {
205
230
  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.",
231
+ description: "Optional birth/event date 'YYYY-MM-DD'. Adds utcOffsetAtDate / utcOffsetMinutes / isDst / tzConfidence. Post-1970 resolves locally (free); pre-1970 consults the API's historical correction overlay (1 extra credit).",
207
232
  },
208
233
  },
209
234
  required: ["latitude", "longitude"],
@@ -228,9 +253,46 @@ registerTool({
228
253
  const date = args.date != null && String(args.date).trim() !== "" ? String(args.date).trim() : null;
229
254
  const tz = typeof result?.timezone === "string" ? result.timezone : null;
230
255
  if (date && tz) {
256
+ // Pre-1970 dates: tzdata (Node's and Go's alike) models the zone's
257
+ // reference city only, so ask the API — its historical correction
258
+ // overlay knows where state law diverged (e.g. Minnesota 1959-66).
259
+ // Costs one extra credit, only for pre-1970 dates.
260
+ const year = Number(date.slice(0, 4));
261
+ if (Number.isFinite(year) && year < TZDATA_AUTHORITATIVE_FROM_YEAR) {
262
+ try {
263
+ const off = (await getActiveClient().request("POST", "/timezone/offset", {
264
+ data: { lat: args.latitude, lon: args.longitude, datetime_local: `${date.slice(0, 10)}T12:00:00` },
265
+ }));
266
+ if (typeof off?.offset_seconds === "number") {
267
+ const minutes = off.offset_seconds / 60;
268
+ return {
269
+ ...result,
270
+ utcOffsetAtDate: formatOffsetMinutes(minutes),
271
+ utcOffsetMinutes: minutes,
272
+ isDst: Boolean(off.is_dst),
273
+ date,
274
+ 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 } : {}),
277
+ };
278
+ }
279
+ }
280
+ catch {
281
+ // Server path unavailable — fall through to the local
282
+ // tzdata estimate below, flagged as such.
283
+ }
284
+ }
231
285
  const offset = offsetAtLocalNoon(tz, date);
232
- if (offset)
233
- return { ...result, ...offset, date };
286
+ if (offset) {
287
+ return {
288
+ ...result,
289
+ ...offset,
290
+ date,
291
+ tzConfidence: Number.isFinite(year) && year < TZDATA_AUTHORITATIVE_FROM_YEAR
292
+ ? "historical_estimate"
293
+ : "authoritative",
294
+ };
295
+ }
234
296
  }
235
297
  return result;
236
298
  },
@@ -17,7 +17,8 @@ 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
+ import { TIMEZONE_PROPERTY } from "../datetime.js";
21
+ import { localToUtcIsoHistorical } from "../datetime-historical.js";
21
22
  // ── Constants ─────────────────────────────────────────────────────────────────
22
23
  export const MOON_PHASE_RESOURCE_URI = "ui://openephemeris/moon-phase";
23
24
  export const MOON_PHASE_MIME_TYPE = "text/html;profile=mcp-app";
@@ -98,7 +99,7 @@ async function computeMoonData(args) {
98
99
  const client = getActiveClient();
99
100
  const params = {};
100
101
  params.datetime = args.datetime
101
- ? localToUtcIso("datetime", String(args.datetime), args.timezone)
102
+ ? await localToUtcIsoHistorical("datetime", String(args.datetime), args.timezone, { latitude: args.latitude, longitude: args.longitude })
102
103
  : new Date().toISOString();
103
104
  if (args.latitude != null)
104
105
  params.latitude = args.latitude;
@@ -23,7 +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
+ import { localToUtcIsoHistorical } from "../datetime-historical.js";
27
27
  import { coordsFromArgsOrLocation } from "./_location-resolver.js";
28
28
  // ── Constants ─────────────────────────────────────────────────────────────────
29
29
  export const VEDIC_CHART_RESOURCE_URI = "ui://openephemeris/vedic-chart";
@@ -165,9 +165,9 @@ registerTool({
165
165
  validateCoordinates(argsWithResolved, "latitude", "longitude");
166
166
  const client = getActiveClient();
167
167
  const timezone = args.timezone ?? resolved.timezone;
168
- const datetime = localToUtcIso("datetime", String(args.datetime), timezone);
169
168
  const lat = Number(resolved.latitude);
170
169
  const lon = Number(resolved.longitude);
170
+ const datetime = await localToUtcIsoHistorical("datetime", String(args.datetime), timezone, { latitude: lat, longitude: lon });
171
171
  const ayanamsa = args.ayanamsa;
172
172
  const location = String(resolved.location ?? args.location ?? `${lat}, ${lon}`).slice(0, 120);
173
173
  const bundleAvailable = Boolean(getVedicChartBundle());
@@ -244,9 +244,9 @@ registerTool({
244
244
  _meta: { ui: { resourceUri: VEDIC_CHART_RESOURCE_URI, visibility: ["app"] } },
245
245
  handler: async (args) => {
246
246
  const client = getActiveClient();
247
- const datetime = localToUtcIso("datetime", String(args.datetime), args.timezone);
248
247
  const lat = args.latitude != null ? Number(args.latitude) : undefined;
249
248
  const lon = args.longitude != null ? Number(args.longitude) : undefined;
249
+ const datetime = await localToUtcIsoHistorical("datetime", String(args.datetime), args.timezone, { latitude: lat, longitude: lon });
250
250
  const ayanamsa = args.ayanamsa;
251
251
  const theme = args.theme === "light" ? "light" : "dark";
252
252
  const body = {