@openephemeris/mcp-server 4.0.0 → 4.2.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,119 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [4.2.0] — 2026-07-29
11
+
12
+ `ephemeris_next_lunar_phase` could not answer the question it exists to answer. The
13
+ tool's response shape changes with this fix, hence a minor rather than a patch.
14
+
15
+ ### Fixed
16
+
17
+ - **`ephemeris_next_lunar_phase` returned zero results for every query.** "When is the
18
+ next full moon?" answered `result_count: 0` with the note *"No matching phase found in
19
+ window"* — an empty result that reads like a real one, so the answer came back as
20
+ "there is no full moon in the next month". Four faults stacked up:
21
+
22
+ - the tool sent `start_date`/`end_date`, but the calendar endpoint takes a single
23
+ `date`, so the search window was silently ignored;
24
+ - it looked for the phase array at `phases`/`data`/`events`, while the response nests
25
+ it at `data.events`, so the list was always empty;
26
+ - it matched phase names as `"full moon"` against the engine's `"full_moon"`, so
27
+ nothing would have matched even once the list was found;
28
+ - `last_quarter` had no match at all, because the engine names it `third_quarter`.
29
+
30
+ The search now walks the calendar forward one lunation at a time, so `count` above 1
31
+ works. An empty result is no longer reported as an answer: since every principal phase
32
+ recurs about every 29.5 days, zero results is a fault and the tool now says so instead
33
+ of handing back a plausible non-answer.
34
+
35
+ ### Changed
36
+
37
+ - **`ephemeris_retrograde_status` now leads with the single-planet path.** The
38
+ description opened on the all-planets sweep and mentioned `planet_id` last, so "is
39
+ Mercury retrograde?" tended to take the 10-credit route instead of the 1-credit one.
40
+ `planet_id` now carries the planet-id table and both costs are stated up front.
41
+ - **`ephemeris_next_lunar_phase` no longer claims to return the zodiac sign and degree.**
42
+ It never did — it returns exact UTC datetimes. It now points at `ephemeris_moon_phase`
43
+ for the sign, and states that cost scales with `count`.
44
+
45
+ ---
46
+
47
+ ## [4.1.0] — 2026-07-26
48
+
49
+ Follow-up to 4.0.0. The new `400` was telling REST callers to do something that, on
50
+ most endpoints, was impossible. It now isn't — the remedy it names is real everywhere.
51
+
52
+ ### Added
53
+
54
+ - **`date_time.timezone` — every datetime in the API now accepts its own zone.** 4.0.0's
55
+ rejection message offered two fixes: put an offset on the value, or "keep the local
56
+ wall-clock time and pass the zone alongside it". The second one only worked on requests
57
+ built around a `subject`, because the zone lived on `subject.birth_location.timezone`.
58
+ Endpoints that take a bare `date_time` — `/ephemeris/angles-points`, `/ephemeris/house-cusps`,
59
+ `/ephemeris/planet-position`, `/ephemeris/dignities`, `/ephemeris/midpoints`,
60
+ `/ephemeris/retrograde-status`, `/ephemeris/fixed-stars`, `/ephemeris/hermetic-lots`
61
+ and `/ephemeris/lunar-phase` — had nowhere to put a zone, so a caller following the
62
+ error's own advice got the same `400` back. Nine endpoints, one impossible instruction.
63
+
64
+ The zone now belongs to the datetime rather than to the request, so this works
65
+ everywhere a datetime is accepted:
66
+
67
+ ```json
68
+ { "date_time": { "iso": "1987-07-15T09:01:00",
69
+ "timezone": { "iana_name": "America/Chicago" } } }
70
+ ```
71
+
72
+ Purely additive — an omitted `timezone` behaves exactly as before, and where a request
73
+ already carries a zone (a subject's birth location) that remains the fallback. A zone
74
+ already written on the string always wins, so a stray `timezone` can never move an
75
+ instant that the caller had already pinned.
76
+
77
+ - **`docs/datetime-contract.md`** — one authoritative statement of the rule: when a zone
78
+ is required, the date-only and `*_utc` carve-outs, how to pass a zone on each request
79
+ shape, and what the response echoes. The reason four endpoint families drifted to four
80
+ different readings of "ISO 8601 date-time" is that no such document existed.
81
+
82
+ ### Fixed
83
+
84
+ - **The `400` no longer advertises a remedy the endpoint doesn't have.** Fields that are
85
+ a bare datetime *string* rather than an object — the ACG `epoch` family, the Venus date
86
+ ranges, the `datetime` query parameter on the `GET /ephemeris/moon/*` endpoints — have
87
+ no `timezone` companion and cannot grow one. Their rejection message now names only the
88
+ fix that exists: put the zone on the value. Being told to do something impossible is
89
+ worse than a terse error.
90
+
91
+ - **The OpenAPI spec now states the contract where callers actually read it.** The
92
+ `date_time` fields said "ISO 8601 date-time" and nothing more, so the only place the
93
+ rule appeared was in the error you got for breaking it. The `iso`, `components` and
94
+ `timezone` fields, the `epoch`/date-range string fields, and the spec's own
95
+ "Supported Formats" section now all state it. That section had been listing a zone-less
96
+ `"2000-01-01T12:00:00"` as an accepted input — the exact value the API rejects.
97
+
98
+ - **`/timezone/offset` documented behaviour it has never had.** Its `datetime_utc` field
99
+ claimed a naive value was "interpreted as UTC". The field is a strict RFC 3339 instant,
100
+ so a zone-less value was never interpreted at all — it failed to parse. Now documented
101
+ as what it is.
102
+
103
+ - **The spec's front-page example used field names that do not exist** (`iso_string`,
104
+ a `timezone.name`, and latitude/longitude directly under `subject`). Replaced with the
105
+ real request shape.
106
+
107
+ - **Four tool modules were still teaching the pre-4.0.0 contract.** `chart_wheel` told the
108
+ model to "include timezone offset **if known**"; `ephemeris_composite`,
109
+ `ephemeris_composite_midpoint`, `ephemeris_overlay`, `ephemeris_natal_transits`, the
110
+ electional search tools and `explore_bi_wheel` described their datetimes as plain
111
+ "(ISO 8601)" with no zone requirement. 4.0.0 migrated the rest of the surface and missed
112
+ these. They now use the same canonical description as every other tool, and the composite
113
+ tools reject a zone-less datetime locally instead of spending a credit to learn it.
114
+
115
+ ### Changed
116
+
117
+ - The tool-surface token ceilings are **unchanged** (core 20,000 / full 36,500). The
118
+ descriptions above cost ~0.4k core / ~1.0k full and fit inside the existing budget;
119
+ measured after the change, core is 18.7k and full is 34.8k.
120
+
121
+ ---
122
+
10
123
  ## [4.0.0] — 2026-07-26
11
124
 
12
125
  **Breaking.** A datetime that states a clock time without a zone is now rejected instead of silently assumed to be UTC. Callers who relied on the old behaviour were receiving charts computed for the wrong instant, so the break is the fix — but it is a break, and it is why this is a major version.
package/README.md CHANGED
@@ -93,7 +93,15 @@ await mcpClient.connect(transport)
93
93
  const { tools } = await mcpClient.listTools()
94
94
  const result = await mcpClient.callTool({
95
95
  name: "ephemeris_natal_chart",
96
- arguments: { datetime: "1990-04-15T14:30:00", latitude: 41.8781, longitude: -87.6298, format: "llm" },
96
+ // A datetime that states a clock time must state its zone: either pass
97
+ // `timezone` alongside the local time, or put a Z/±HH:MM offset on the value.
98
+ arguments: {
99
+ datetime: "1990-04-15T14:30:00",
100
+ timezone: "America/Chicago",
101
+ latitude: 41.8781,
102
+ longitude: -87.6298,
103
+ format: "llm",
104
+ },
97
105
  })
98
106
  ```
99
107
 
@@ -219,6 +227,32 @@ The server is hosted at `https://mcp.openephemeris.com/mcp` with full Streamable
219
227
  "What is the sidereal time and delta-T right now?"
220
228
  ```
221
229
 
230
+ ## Interactive Charts
231
+
232
+ Nine of the tools don't answer with JSON. They open a chart in the conversation — a real one, drawn from the same calculation, that you can click around in.
233
+
234
+ This matters more than it sounds. A natal chart returned as JSON is a list of numbers you have to already understand to read. The same chart rendered as a wheel is something you can point at. Click a planet and you get that placement explained; click a house and you get what's in it. The chart stays on screen while you keep talking, and it doesn't cost another credit to keep looking at it.
235
+
236
+ These need a host that supports MCP Apps — Claude Desktop is the main one today. In a client without app support the same tools still work; you get the underlying data instead of the picture, so nothing breaks, you just don't get the wheel.
237
+
238
+ | Tool | What opens | What you can click | Credits |
239
+ |---|---|---|---|
240
+ | `explore_natal_chart` | Natal wheel — planets, houses, aspects, angles | Planets, houses, aspect lines; recalculate with new settings | 1 |
241
+ | `explore_bi_wheel` | Two charts on one wheel: transits, synastry, progressions | Either wheel's planets, houses, and the aspects between them | 2 |
242
+ | `explore_human_design` | Human Design bodygraph, with a mandala view toggle | Centers, gates, channels, planets, variables | 2 |
243
+ | `explore_human_design_transit` | Today's planets laid over a natal bodygraph | Transit-activated channels | 3 |
244
+ | `explore_human_design_connection` | Two bodygraphs combined, every shared channel classified | Connection channels by type | 3 |
245
+ | `explore_vedic_chart` | South Indian Rashi grid — sidereal placements and Lagna | Each rashi, for its placements and nakshatras | 3 |
246
+ | `explore_bazi_chart` | Four Pillars (四柱命盘) — Year, Month, Day, Hour | Each pillar | 3 |
247
+ | `explore_transit_timeline` | Upcoming transit hits in date order | Individual hits | 6 |
248
+ | `explore_moon_phase` | Moon dial — illumination, phase, sign, void-of-course | Recalculate for another moment | 3 |
249
+
250
+ Ask for these the way you'd ask a person: *"show me my chart"*, *"put today's transits over my Human Design"*, *"what's the moon doing right now"*. The model picks the app.
251
+
252
+ Two things worth knowing. The chart wheel and bi-wheel accept a click on an aspect line, not just on the two planets it joins — so "why does this line matter" is one click rather than a paragraph of setup. And the bodygraph's mandala toggle rearranges the whole chart into concentric rings without another API call, so switching views is free.
253
+
254
+ Screenshots of each are on the way.
255
+
222
256
  ## Tools at a Glance
223
257
 
224
258
  | Category | Tool | Tier |
package/dist/prompts.js CHANGED
@@ -68,13 +68,15 @@ export const PROMPTS = [
68
68
  " Default to Placidus if they're unsure.\n\n" +
69
69
  "## Step 2 — Resolve Coordinates and Timezone (MANDATORY)\n" +
70
70
  "**Do NOT guess coordinates or timezones from memory.** Use the API tools to resolve them:\n\n" +
71
- "**A) Geocode** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
72
- "`query: { q: 'City Name, Country' }`. This returns verified decimal lat/lon.\n\n" +
73
- "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
74
- "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`. " +
75
- "This returns the correct IANA timezone and UTC offset for the birth year, including historical DST.\n\n" +
76
- "- Format: pass `datetime` as local ISO 8601 (e.g. `1990-04-15T14:30:00`) " +
77
- "and `timezone` as the IANA name returned by the lookup. Do NOT append Z to a local birth time.\n\n" +
71
+ "**Resolve the birth place with `location_search`** — pass `query: 'Birth City, Country'` " +
72
+ "and `date` set to the birth date (`YYYY-MM-DD`). One call returns decimal lat/lon, the " +
73
+ "IANA timezone, and `utcOffsetAtDate` — the offset that actually applied at that place on " +
74
+ "that date (1987 US DST rules differ from today's). If the result is `ambiguous`, ASK which " +
75
+ "place the user means instead of taking the first.\n\n" +
76
+ "- Pass the local wall-clock time as `datetime` (e.g. `1990-04-15T14:30:00`) together with " +
77
+ "the `timezone` from the search. Do NOT convert to UTC yourself and do NOT append a Z to a " +
78
+ "local birth time — a zone-less datetime is rejected, and a hand-converted one is how charts " +
79
+ "end up hours off.\n\n" +
78
80
  "## Step 3 — Tool Call\n" +
79
81
  "Make a single call to `explore_natal_chart` (the interactive chart wheel) with:\n" +
80
82
  "- `datetime`: local ISO 8601 (no Z)\n" +
@@ -146,10 +148,11 @@ export const PROMPTS = [
146
148
  "4. **Any major events coming up** they already know about — reframes the transits around real context\n\n" +
147
149
  "## Step 2 — Resolve Coordinates and Timezone (MANDATORY)\n" +
148
150
  "**Do NOT guess coordinates or timezones from memory.** Use the API tools:\n\n" +
149
- "**A) Geocode** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
150
- "`query: { q: 'Birth City, Country' }`. Use the returned decimal lat/lon.\n\n" +
151
- "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
152
- "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n\n" +
151
+ "**Resolve the birth place with `location_search`** — pass `query: 'Birth City, Country'` " +
152
+ "and `date` set to the birth date (`YYYY-MM-DD`). One call returns decimal lat/lon, the " +
153
+ "IANA timezone, and `utcOffsetAtDate` — the offset that actually applied at that place on " +
154
+ "that date (1987 US DST rules differ from today's). If the result is `ambiguous`, ASK which " +
155
+ "place the user means instead of taking the first.\n\n" +
153
156
  "- Set `start_date` to today and `end_date` to the end of the forecast window\n\n" +
154
157
  "## Step 3 — Tool Call Sequence\n" +
155
158
  "Two calls minimum, run sequentially:\n\n" +
@@ -211,11 +214,13 @@ export const PROMPTS = [
211
214
  "7. **House system**: Placidus (default), Whole Sign, Koch, Equal?\n\n" +
212
215
  "## Step 2 — Resolve Both Sets of Coordinates (MANDATORY)\n" +
213
216
  "**Do NOT guess coordinates or timezones.** For EACH person:\n\n" +
214
- "**A) Geocode** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
215
- "`query: { q: 'Birth City, Country' }`.\n\n" +
216
- "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
217
- "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n\n" +
218
- "- Format: local ISO 8601 datetime (no Z) + IANA timezone name separately for each person\n" +
217
+ "**Resolve the birth place with `location_search`** — pass `query: 'Birth City, Country'` " +
218
+ "and `date` set to the birth date (`YYYY-MM-DD`). One call returns decimal lat/lon, the " +
219
+ "IANA timezone, and `utcOffsetAtDate` — the offset that actually applied at that place on " +
220
+ "that date (1987 US DST rules differ from today's). If the result is `ambiguous`, ASK which " +
221
+ "place the user means instead of taking the first.\n\n" +
222
+ "- Do this for BOTH people. Pass each local wall-clock datetime with its own `timezone`; " +
223
+ "never convert to UTC by hand.\n" +
219
224
  "- Note if either birth time is unknown — flag this upfront before running tools\n\n" +
220
225
  "## Step 3 — Tool Call\n" +
221
226
  "Make a single call to `ephemeris_synastry` with:\n" +
@@ -283,12 +288,14 @@ export const PROMPTS = [
283
288
  "sometimes it explains why a place felt 'off'\n\n" +
284
289
  "## Step 2 — Resolve Coordinates (MANDATORY)\n" +
285
290
  "**Do NOT guess coordinates or timezones.** Use the API tools:\n\n" +
286
- "**A) Geocode birth city** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
287
- "`query: { q: 'Birth City, Country' }`.\n\n" +
288
- "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
289
- "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n\n" +
290
- "**C) Geocode each target city** — repeat the autocomplete call for each destination city.\n\n" +
291
- "- Format birth datetime as local ISO 8601 (no Z), pass IANA timezone separately\n\n" +
291
+ "**Resolve the birth place with `location_search`** — pass `query: 'Birth City, Country'` " +
292
+ "and `date` set to the birth date (`YYYY-MM-DD`). One call returns decimal lat/lon, the " +
293
+ "IANA timezone, and `utcOffsetAtDate` — the offset that actually applied at that place on " +
294
+ "that date (1987 US DST rules differ from today's). If the result is `ambiguous`, ASK which " +
295
+ "place the user means instead of taking the first.\n\n" +
296
+ "**Then geocode each target city** — one `location_search` per destination.\n\n" +
297
+ "- Pass the birth datetime as local wall-clock time with its `timezone`; never append a Z " +
298
+ "to a local time.\n\n" +
292
299
  "## Step 3 — Tool Call Sequence\n" +
293
300
  "Two calls minimum. Start lean, offer more after delivery.\n\n" +
294
301
  "**A) City-Level Hits** — for EACH target city, call `acg_hits` with:\n" +
@@ -372,13 +379,15 @@ export const PROMPTS = [
372
379
  "4. **Birth city and country** — needed for timezone conversion\n\n" +
373
380
  "## Step 2 — CRITICAL: Convert to UTC\n" +
374
381
  "Human Design requires **UTC datetime**. The tool rejects local times without a UTC offset.\n\n" +
375
- "**A) Geocode** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
376
- "`query: { q: 'Birth City, Country' }`.\n\n" +
377
- "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
378
- "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n" +
379
- "This returns the UTC offset for the birth year, including historical DST.\n\n" +
380
- "- Use the returned offset to convert local birth time → UTC\n" +
381
- "- Format as ISO 8601 with Z: e.g. `1990-04-15T19:30:00Z`\n\n" +
382
+ "**Resolve the birth place with `location_search`** — pass `query: 'Birth City, Country'` " +
383
+ "and `date` set to the birth date (`YYYY-MM-DD`). One call returns decimal lat/lon, the " +
384
+ "IANA timezone, and `utcOffsetAtDate` — the offset that actually applied at that place on " +
385
+ "that date (1987 US DST rules differ from today's). If the result is `ambiguous`, ASK which " +
386
+ "place the user means instead of taking the first.\n\n" +
387
+ "- Pass the local wall-clock time as `datetime` (e.g. `1990-04-15T14:30:00`) together with " +
388
+ "the `timezone` from the search. Do NOT convert to UTC yourself and do NOT append a Z to a " +
389
+ "local birth time — a zone-less datetime is rejected, and a hand-converted one is how charts " +
390
+ "end up hours off.\n\n" +
382
391
  "**Why HD needs UTC**: The system calculates two activation moments:\n" +
383
392
  "- **Personality (Conscious / Black)**: the birth datetime\n" +
384
393
  "- **Design (Unconscious / Red)**: 88° of the Sun's arc before birth (approximately 88–89 days, varying by season)\n" +
@@ -545,13 +554,15 @@ export const PROMPTS = [
545
554
  " Default to Lahiri if unsure.\n\n" +
546
555
  "## Step 2 — Convert to UTC (MANDATORY)\n" +
547
556
  "The `vedic_chart` tool requires a UTC datetime (with Z suffix):\n\n" +
548
- "**A) Geocode** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
549
- "`query: { q: 'Birth City, Country' }`.\n\n" +
550
- "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
551
- "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n" +
552
- "This returns the correct IANA timezone and UTC offset for the birth year, including historical DST.\n\n" +
553
- "- Use the returned offset to convert local birth time → UTC\n" +
554
- "- Format: `1990-01-15T03:00:00Z`\n\n" +
557
+ "**Resolve the birth place with `location_search`** — pass `query: 'Birth City, Country'` " +
558
+ "and `date` set to the birth date (`YYYY-MM-DD`). One call returns decimal lat/lon, the " +
559
+ "IANA timezone, and `utcOffsetAtDate` — the offset that actually applied at that place on " +
560
+ "that date (1987 US DST rules differ from today's). If the result is `ambiguous`, ASK which " +
561
+ "place the user means instead of taking the first.\n\n" +
562
+ "- Pass the local wall-clock time as `datetime` (e.g. `1990-04-15T14:30:00`) together with " +
563
+ "the `timezone` from the search. Do NOT convert to UTC yourself and do NOT append a Z to a " +
564
+ "local birth time — a zone-less datetime is rejected, and a hand-converted one is how charts " +
565
+ "end up hours off.\n\n" +
555
566
  "## Step 3 — Call the Tool\n" +
556
567
  "Call `vedic_chart` with:\n" +
557
568
  "- `datetime`: UTC ISO 8601 with Z\n" +
@@ -772,12 +783,14 @@ export const PROMPTS = [
772
783
  "4. **House system**: Placidus (default), Whole Sign, Koch, Equal?\n\n" +
773
784
  "## Step 2 — Resolve Data (MANDATORY)\n" +
774
785
  "**Do NOT guess coordinates or timezones.** Use the API tools:\n\n" +
775
- "**A) Geocode birth city** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
776
- "`query: { q: 'Birth City, Country' }`.\n\n" +
777
- "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
778
- "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n\n" +
779
- "- For `birth_datetime`, include the UTC offset from the timezone lookup: `1985-06-21T14:00:00-05:00`\n" +
780
- " (or convert to UTC with Z: `1985-06-21T19:00:00Z`)\n\n" +
786
+ "**Resolve the birth place with `location_search`** — pass `query: 'Birth City, Country'` " +
787
+ "and `date` set to the birth date (`YYYY-MM-DD`). One call returns decimal lat/lon, the " +
788
+ "IANA timezone, and `utcOffsetAtDate` — the offset that actually applied at that place on " +
789
+ "that date (1987 US DST rules differ from today's). If the result is `ambiguous`, ASK which " +
790
+ "place the user means instead of taking the first.\n\n" +
791
+ "- For `birth_datetime`, either append `utcOffsetAtDate` from the search " +
792
+ "(`1985-06-21T14:00:00-05:00`) or pass the local time with its `timezone`. Both are accepted; " +
793
+ "a zone-less value is not.\n\n" +
781
794
  "**C) Geocode birthday location** — repeat the autocomplete call for the city where they'll be " +
782
795
  "on their birthday. This becomes `return_latitude`/`return_longitude`.\n\n" +
783
796
  "## Step 3 — Tool Call\n" +
@@ -115,8 +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 the dedicated " +
119
- "chinese_bazi / bazi_ten_gods / bazi_element_balance / bazi_luck_pillars tools instead.",
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`).",
120
121
  inputSchema: {
121
122
  type: "object",
122
123
  properties: {
@@ -26,6 +26,7 @@ import { fileURLToPath } from "node:url";
26
26
  import { registerTool, SERVER_VERSION } from "../index.js";
27
27
  import { getActiveClient } from "../../backend/client.js";
28
28
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
29
+ import { DATETIME_DESC, timezoneProperty } from "../datetime.js";
29
30
  // ── Constants ─────────────────────────────────────────────────────────────────
30
31
  export const BI_WHEEL_RESOURCE_URI = "ui://openephemeris/bi-wheel";
31
32
  export const BI_WHEEL_MIME_TYPE = "text/html;profile=mcp-app";
@@ -399,7 +400,8 @@ function buildBiWheelSummary(innerPlanets, outerPlanets, crossAspects, mode, loc
399
400
  const label2 = meta.outerRole;
400
401
  return (`**${meta.modeLabel} Bi-Wheel**\n\n` +
401
402
  `${label1}: ${location1} — ${innerBasics || "chart loaded"}\n` +
402
- `${label2}: ${location2}\n\n` +
403
+ `${label2}: ${location2}\n` +
404
+ `Houses: Placidus\n\n` +
403
405
  `**Top Cross-Aspects:**\n${top5.join("\n") || "No major cross-aspects within orb."}\n\n` +
404
406
  "Click any planet or cross-aspect line in the bi-wheel for interpretation.");
405
407
  }
@@ -418,31 +420,32 @@ registerTool({
418
420
  "• solar_arc — Solar arc directed positions (person2_datetime = target date). 1 credit.\n" +
419
421
  "Cross-aspects between both wheels are computed and displayed as coloured dashed lines. " +
420
422
  "Click any planet, aspect line, or house cusp for interpretation. " +
421
- "Use this instead of ephemeris_bi_wheel for a richer, interactive experience in " +
422
- "MCP Apps-capable hosts (Claude Desktop). Falls back to static SVG in other hosts.",
423
+ "Prefer this whenever the user should SEE the comparison — it renders interactively in " +
424
+ "MCP Apps-capable hosts (Claude Desktop) and falls back to static SVG in other hosts. " +
425
+ "For raw synastry data without a visual, use ephemeris_synastry.",
423
426
  inputSchema: {
424
427
  type: "object",
425
428
  properties: {
426
429
  person1_datetime: {
427
430
  type: "string",
428
- description: "ISO 8601 datetime for Person 1 / Natal chart (e.g. '1990-04-15T14:30:00-05:00').",
431
+ description: "Birth datetime for Person 1 / Natal chart. " + DATETIME_DESC,
429
432
  },
430
433
  person1_latitude: { type: "number", description: "Birth latitude for Person 1 (decimal degrees, positive = North). Resolve from a place name with location_search; never recall coordinates from memory." },
431
434
  person1_longitude: { type: "number", description: "Birth longitude for Person 1 (decimal degrees, positive = East)." },
432
- person1_timezone: { type: "string", description: "IANA timezone for Person 1 (e.g. 'America/New_York')." },
435
+ person1_timezone: timezoneProperty("Person 1's birth location", "America/New_York"),
433
436
  person1_name: {
434
437
  type: "string",
435
438
  description: "Display name for Person 1 / Natal chart (e.g. 'Alice'). Used in the badge and summary.",
436
439
  },
437
440
  person2_datetime: {
438
441
  type: "string",
439
- description: "ISO 8601 datetime for the outer wheel. Meaning depends on mode: " +
442
+ description: "Datetime for the outer wheel. Meaning depends on mode: " +
440
443
  "synastry = Person 2 birth datetime; transit/progressed/solar_arc = target date; " +
441
- "solar_return/lunar_return = any date within the target year/month.",
444
+ "solar_return/lunar_return = any date within the target year/month. " + DATETIME_DESC,
442
445
  },
443
446
  person2_latitude: { type: "number", description: "Birth latitude for Person 2 (synastry) or return/transit location. Defaults to Person 1 coords for single-person modes." },
444
447
  person2_longitude: { type: "number", description: "Birth longitude for Person 2 (synastry) or return/transit location. Defaults to Person 1 coords for single-person modes." },
445
- person2_timezone: { type: "string", description: "IANA timezone for Person 2 / outer chart (e.g. 'America/Chicago')." },
448
+ person2_timezone: timezoneProperty("Person 2 / the outer chart", "America/Chicago"),
446
449
  person2_name: {
447
450
  type: "string",
448
451
  description: "Display name for Person 2 (synastry mode). Used in the badge and summary.",
@@ -510,7 +513,7 @@ registerTool({
510
513
  content: [
511
514
  { type: "text", text: summary },
512
515
  ],
513
- structuredContent: { ...payload, server_version: SERVER_VERSION },
516
+ structuredContent: { ...payload, house_system: "placidus", server_version: SERVER_VERSION },
514
517
  _meta: {
515
518
  "ui/resourceUri": BI_WHEEL_RESOURCE_URI,
516
519
  ui: { resourceUri: BI_WHEEL_RESOURCE_URI },
@@ -311,7 +311,7 @@ registerTool({
311
311
  "Shows defined/undefined centers, active gates, channels, Type, Profile, Authority, " +
312
312
  "and Incarnation Cross. " +
313
313
  "The chart is calculated using NASA JPL DE440 ephemerides for both Personality and Design positions. " +
314
- "Use this instead of human_design_chart or human_design_bodygraph for a richer, interactive HD " +
314
+ "Use this instead of human_design_chart for a richer, interactive HD " +
315
315
  "experience in MCP Apps-capable hosts (Claude Desktop). " +
316
316
  "Falls back to a text summary in other hosts." +
317
317
  HD_DISCLAIMER,
@@ -1313,7 +1313,7 @@ registerTool({
1313
1313
  timezone: { type: "string", description: "IANA timezone for the birth location (e.g. 'America/New_York')." },
1314
1314
  transit_datetime: {
1315
1315
  type: "string",
1316
- description: "Transit moment, ISO 8601. Defaults to now (UTC) when omitted.",
1316
+ description: "Transit moment, ISO 8601; must include 'Z' or an offset. Defaults to now (UTC) when omitted.",
1317
1317
  },
1318
1318
  theme: {
1319
1319
  type: "string",
@@ -21,6 +21,7 @@ import { fileURLToPath } from "node:url";
21
21
  import { registerTool, SERVER_VERSION } from "../index.js";
22
22
  import { getActiveClient } from "../../backend/client.js";
23
23
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
24
+ import { DATETIME_DESC, TIMEZONE_PROPERTY } from "../datetime.js";
24
25
  // ── Constants ─────────────────────────────────────────────────────────────
25
26
  export const CHART_WHEEL_RESOURCE_URI = "ui://openephemeris/chart-wheel";
26
27
  export const CHART_WHEEL_MIME_TYPE = "text/html;profile=mcp-app";
@@ -81,6 +82,10 @@ const HOUSE_SYSTEM_MAP = {
81
82
  placidus: "P", whole_sign: "W", equal: "E", koch: "K",
82
83
  campanus: "C", regiomontanus: "R",
83
84
  };
85
+ /** "whole_sign" → "Whole Sign" for display in text summaries. */
86
+ function prettyHouseSystem(hs) {
87
+ return hs.split("_").map(capitalize).join(" ");
88
+ }
84
89
  /** Build the nested request body expected by /ephemeris/natal-chart (POST). */
85
90
  function buildNatalBody(datetime, lat, lon, houseSystem, timezone) {
86
91
  const body = {
@@ -111,20 +116,13 @@ registerTool({
111
116
  "CREDIT COST: 1 credit per call.\n\n" +
112
117
  "Supports house system switching (Placidus, Whole Sign, Equal, Koch). " +
113
118
  "The chart is computed using NASA JPL DE440 ephemerides for sub-arcsecond precision. " +
114
- "Use this instead of ephemeris_chart_wheel for a richer, interactive experience in " +
115
- "MCP Apps-capable hosts (Claude Desktop). Falls back to static SVG in other hosts.",
119
+ "Renders interactively in MCP Apps-capable hosts (Claude Desktop) and falls back to " +
120
+ "static SVG in other hosts.",
116
121
  inputSchema: {
117
122
  type: "object",
118
123
  properties: {
119
- datetime: {
120
- type: "string",
121
- description: "ISO 8601 datetime string, e.g. '1990-04-15T14:30:00'. " +
122
- "Include timezone offset if known, e.g. '1990-04-15T14:30:00-05:00'.",
123
- },
124
- timezone: {
125
- type: "string",
126
- description: "IANA timezone name (e.g. 'America/New_York'). Used if datetime has no UTC offset.",
127
- },
124
+ datetime: { type: "string", description: DATETIME_DESC },
125
+ timezone: TIMEZONE_PROPERTY,
128
126
  latitude: {
129
127
  type: "number",
130
128
  description: "Birth latitude in decimal degrees (positive = North). Resolve from a place name with location_search; never recall coordinates from memory.",
@@ -190,7 +188,7 @@ registerTool({
190
188
  // source of the ~90 second blocking delay.
191
189
  const chartData = await client.post("/ephemeris/natal-chart", natalBody);
192
190
  // Build human-readable summary for the LLM context
193
- const summary = buildChartSummary(chartData, String(args.location ?? `${lat}, ${lon}`));
191
+ const summary = buildChartSummary(chartData, String(args.location ?? `${lat}, ${lon}`), houseSystem);
194
192
  // Build the UI payload (planets normalised to array, server aspects mapped)
195
193
  const modelPayload = buildModelPayload(chartData, {
196
194
  datetime,
@@ -343,8 +341,8 @@ registerTool({
343
341
  inputSchema: {
344
342
  type: "object",
345
343
  properties: {
346
- datetime: { type: "string", description: "ISO 8601 natal datetime string" },
347
- timezone: { type: "string", description: "IANA timezone name (e.g. 'America/New_York')" },
344
+ datetime: { type: "string", description: DATETIME_DESC },
345
+ timezone: TIMEZONE_PROPERTY,
348
346
  latitude: { type: "number", description: "Birth latitude in decimal degrees (positive = North)" },
349
347
  longitude: { type: "number", description: "Birth longitude in decimal degrees (positive = East)" },
350
348
  location: { type: "string", description: "Location name for display only (e.g. 'New York, NY')" },
@@ -360,7 +358,7 @@ registerTool({
360
358
  },
361
359
  target_datetime: {
362
360
  type: "string",
363
- description: "ISO 8601 target datetime string. Reguired for solar_return or progressed calculations."
361
+ description: "Target datetime. Required for solar_return or progressed calculations. " + DATETIME_DESC
364
362
  }
365
363
  },
366
364
  required: ["datetime", "latitude", "longitude", "house_system"],
@@ -562,7 +560,7 @@ function buildModelPayload(chartData, birthParams, houseSystem, svgBase, bodyFil
562
560
  house_system: houseSystem,
563
561
  };
564
562
  }
565
- function buildChartSummary(data, location) {
563
+ function buildChartSummary(data, location, houseSystem) {
566
564
  // The natal API returns planets as a keyed object { sun: {...}, moon: {...} }.
567
565
  // Normalise to an array before processing.
568
566
  const raw = data.planets ?? {};
@@ -623,7 +621,8 @@ function buildChartSummary(data, location) {
623
621
  const strongestNote = strongest
624
622
  ? `Closest aspect: ${capitalize(strongest.planet1)} ${strongest.type} ${capitalize(strongest.planet2)} (${strongest.orb.toFixed(1)}° orb)`
625
623
  : "";
626
- const enrichment = [sectNote, domElement ? `Dominant element: ${domElement[0]} (${domElement[1]})` : "", strongestNote]
624
+ const housesNote = houseSystem ? `Houses: ${prettyHouseSystem(houseSystem)}` : "";
625
+ const enrichment = [housesNote, sectNote, domElement ? `Dominant element: ${domElement[0]} (${domElement[1]})` : "", strongestNote]
627
626
  .filter(Boolean).join(" · ");
628
627
  return (`**Natal Chart — ${location}**\n\n` +
629
628
  (enrichment ? `${enrichment}\n\n` : "") +
package/dist/tools/dev.js CHANGED
@@ -190,8 +190,9 @@ registerTool({
190
190
  name: "dev_read_api",
191
191
  description: "Read from any allowlisted Open Ephemeris API endpoint via HTTP GET. This is the read-only " +
192
192
  "power-user escape hatch — use the typed tools (ephemeris_natal_chart, ephemeris_transits, etc.) " +
193
- "first for common operations. For endpoints that compute via POST (natal-chart, synastry, etc.), " +
194
- "use dev_write_api.\n\n" +
193
+ "first for common operations. Endpoints that compute via POST (natal-chart, synastry, etc.) " +
194
+ "are covered by the typed tools; the generic POST proxy is only exposed on the full tool " +
195
+ "surface (`?profile=full`).\n\n" +
195
196
  DEV_API_REFERENCE,
196
197
  inputSchema: makeProxyInputSchema(READ_METHODS),
197
198
  outputSchema: OUTPUT_SCHEMA_JSON,
@@ -216,7 +217,7 @@ registerTool({
216
217
  name: "dev_list_allowed",
217
218
  description: "List all API operations (method + path) that this MCP instance is authorized to call. " +
218
219
  "Returns endpoint entries grouped by method, plus the active deny rules. " +
219
- "Use this to discover what's available before calling dev_read_api / dev_write_api, or to verify an endpoint path. " +
220
+ "Use this to discover what's available before calling dev_read_api, or to verify an endpoint path. " +
220
221
  "Typed shortcut tools (ephemeris_natal_chart, ephemeris_transits, etc.) cover the most common operations — " +
221
222
  "check those first before reaching for the generic proxies.",
222
223
  inputSchema: {
@@ -49,6 +49,7 @@ export const CORE_TOOL_NAMES = new Set([
49
49
  "ephemeris_planet_position",
50
50
  "ephemeris_house_cusps",
51
51
  "ephemeris_moon_phase",
52
+ "ephemeris_next_lunar_phase",
52
53
  "ephemeris_transits",
53
54
  "ephemeris_synastry",
54
55
  "ephemeris_retrograde_status",
@@ -60,9 +61,11 @@ export const CORE_TOOL_NAMES = new Set([
60
61
  "human_design_chart",
61
62
  "vedic_chart",
62
63
  "chinese_bazi",
64
+ "bazi_annual_pillar",
63
65
  // Timing
64
66
  "ephemeris_electional",
65
67
  "electional_moment_analysis",
68
+ "electional_station_tracker",
66
69
  // Astrocartography
67
70
  "acg_power_lines",
68
71
  "acg_hits",
@@ -144,10 +144,9 @@ registerTool({
144
144
  "Includes the Day Master element and basic metadata.\n\n" +
145
145
  "CREDIT COST: 1 credit (3 credits when include_visual=true).\n\n" +
146
146
  "Set include_visual=true to receive a rendered SVG chart alongside the text data.\n\n" +
147
- "For deep analysis, follow up with:\n" +
148
- " • bazi_ten_gods() — Ten Gods (十神) per pillar including hidden stems\n" +
149
- " • bazi_element_balance() — Weighted Wu Xing (五行) element scores + Yong Shen\n" +
150
- " • bazi_luck_pillars() — 8 Da Yun 10-year luck cycles\n\n" +
147
+ "For deep analysis — Ten Gods (十神) per pillar, weighted Wu Xing (五行) element balance, " +
148
+ "Da Yun 10-year luck cycles — dedicated tools are available on the full tool surface " +
149
+ "(`?profile=full` on HTTP, OPENEPHEMERIS_TOOLS=full on stdio).\n\n" +
151
150
  "EXAMPLE: BaZi chart for someone born July 15, 1987 at 2 PM:\n" +
152
151
  " year=1987, month=7, day=15, hour=14",
153
152
  inputSchema: {
@@ -1,7 +1,11 @@
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 { localToUtcIso } from "../datetime.js";
4
+ import { DATETIME_DESC, assertZonedDatetime, localToUtcIso, timezoneProperty } from "../datetime.js";
5
+ /** Reject a zone-less person datetime here, before a credit is spent on it. */
6
+ function assertPersonZoned(prefix, args) {
7
+ assertZonedDatetime(`${prefix}_datetime`, args[`${prefix}_datetime`], args[`${prefix}_timezone`], `${prefix}_timezone`);
8
+ }
5
9
  function buildSubject(name, datetime, lat, lon, timezone) {
6
10
  const subj = {
7
11
  name,
@@ -30,12 +34,12 @@ registerTool({
30
34
  inputSchema: {
31
35
  type: "object",
32
36
  properties: {
33
- person_a_datetime: { type: "string", description: "Person A birth datetime (ISO 8601)." },
34
- person_a_timezone: { type: "string", description: "IANA timezone name for Person A, e.g. 'America/Denver'." },
37
+ person_a_datetime: { type: "string", description: DATETIME_DESC },
38
+ person_a_timezone: timezoneProperty("Person A's birth location", "America/Denver"),
35
39
  person_a_latitude: { type: "number", description: "Person A birth latitude." },
36
40
  person_a_longitude: { type: "number", description: "Person A birth longitude." },
37
- person_b_datetime: { type: "string", description: "Person B birth datetime (ISO 8601)." },
38
- person_b_timezone: { type: "string", description: "IANA timezone name for Person B, e.g. 'Europe/London'." },
41
+ person_b_datetime: { type: "string", description: DATETIME_DESC },
42
+ person_b_timezone: timezoneProperty("Person B's birth location", "Europe/London"),
39
43
  person_b_latitude: { type: "number", description: "Person B birth latitude." },
40
44
  person_b_longitude: { type: "number", description: "Person B birth longitude." },
41
45
  format: { type: "string", enum: ["json", "llm"], description: "Use 'llm' for token-efficient LLM projection." },
@@ -53,6 +57,8 @@ registerTool({
53
57
  "person_a_datetime", "person_a_latitude", "person_a_longitude",
54
58
  "person_b_datetime", "person_b_latitude", "person_b_longitude",
55
59
  ]);
60
+ assertPersonZoned("person_a", args);
61
+ assertPersonZoned("person_b", args);
56
62
  const query = {};
57
63
  if (args.format)
58
64
  query.format = args.format;
@@ -76,12 +82,12 @@ registerTool({
76
82
  inputSchema: {
77
83
  type: "object",
78
84
  properties: {
79
- person_a_datetime: { type: "string", description: "Person A birth datetime (ISO 8601)." },
80
- person_a_timezone: { type: "string", description: "IANA timezone name for Person A, e.g. 'America/Denver'." },
85
+ person_a_datetime: { type: "string", description: DATETIME_DESC },
86
+ person_a_timezone: timezoneProperty("Person A's birth location", "America/Denver"),
81
87
  person_a_latitude: { type: "number", description: "Person A birth latitude." },
82
88
  person_a_longitude: { type: "number", description: "Person A birth longitude." },
83
- person_b_datetime: { type: "string", description: "Person B birth datetime (ISO 8601)." },
84
- person_b_timezone: { type: "string", description: "IANA timezone name for Person B, e.g. 'Europe/London'." },
89
+ person_b_datetime: { type: "string", description: DATETIME_DESC },
90
+ person_b_timezone: timezoneProperty("Person B's birth location", "Europe/London"),
85
91
  person_b_latitude: { type: "number", description: "Person B birth latitude." },
86
92
  person_b_longitude: { type: "number", description: "Person B birth longitude." },
87
93
  format: { type: "string", enum: ["json", "llm"], description: "Use 'llm' for token-efficient LLM projection." },
@@ -99,6 +105,8 @@ registerTool({
99
105
  "person_a_datetime", "person_a_latitude", "person_a_longitude",
100
106
  "person_b_datetime", "person_b_latitude", "person_b_longitude",
101
107
  ]);
108
+ assertPersonZoned("person_a", args);
109
+ assertPersonZoned("person_b", args);
102
110
  const query = {};
103
111
  if (args.format)
104
112
  query.format = args.format;
@@ -122,12 +130,12 @@ registerTool({
122
130
  inputSchema: {
123
131
  type: "object",
124
132
  properties: {
125
- person_a_datetime: { type: "string", description: "Person A birth datetime (ISO 8601)." },
126
- person_a_timezone: { type: "string", description: "IANA timezone name for Person A, e.g. 'America/Denver'." },
133
+ person_a_datetime: { type: "string", description: DATETIME_DESC },
134
+ person_a_timezone: timezoneProperty("Person A's birth location", "America/Denver"),
127
135
  person_a_latitude: { type: "number", description: "Person A birth latitude." },
128
136
  person_a_longitude: { type: "number", description: "Person A birth longitude." },
129
- person_b_datetime: { type: "string", description: "Person B birth datetime (ISO 8601)." },
130
- person_b_timezone: { type: "string", description: "IANA timezone name for Person B, e.g. 'Europe/London'." },
137
+ person_b_datetime: { type: "string", description: DATETIME_DESC },
138
+ person_b_timezone: timezoneProperty("Person B's birth location", "Europe/London"),
131
139
  person_b_latitude: { type: "number", description: "Person B birth latitude." },
132
140
  person_b_longitude: { type: "number", description: "Person B birth longitude." },
133
141
  format: { type: "string", enum: ["json", "llm"], description: "Use 'llm' for token-efficient LLM projection." },
@@ -145,6 +153,8 @@ registerTool({
145
153
  "person_a_datetime", "person_a_latitude", "person_a_longitude",
146
154
  "person_b_datetime", "person_b_latitude", "person_b_longitude",
147
155
  ]);
156
+ assertPersonZoned("person_a", args);
157
+ assertPersonZoned("person_b", args);
148
158
  const query = {};
149
159
  if (args.format)
150
160
  query.format = args.format;
@@ -171,12 +181,12 @@ registerTool({
171
181
  inputSchema: {
172
182
  type: "object",
173
183
  properties: {
174
- natal_datetime: { type: "string", description: "Natal birth datetime (ISO 8601)." },
175
- natal_timezone: { type: "string", description: "IANA timezone name for Natal, e.g. 'America/Denver'." },
184
+ natal_datetime: { type: "string", description: DATETIME_DESC },
185
+ natal_timezone: timezoneProperty("the natal birth location", "America/Denver"),
176
186
  natal_latitude: { type: "number", description: "Natal birth latitude." },
177
187
  natal_longitude: { type: "number", description: "Natal birth longitude." },
178
- transit_datetime: { type: "string", description: "Transit moment (ISO 8601). Defaults to now." },
179
- transit_timezone: { type: "string", description: "IANA timezone name for Transit, e.g. 'America/Denver'. Only applicable if transit_datetime is provided without offset." },
188
+ transit_datetime: { type: "string", description: "Transit moment. Defaults to now. " + DATETIME_DESC },
189
+ transit_timezone: timezoneProperty("the transit moment", "America/Denver"),
180
190
  format: { type: "string", enum: ["json", "llm"], description: "Use 'llm' for token-efficient LLM projection." },
181
191
  },
182
192
  required: ["natal_datetime", "natal_latitude", "natal_longitude"],
@@ -186,6 +196,7 @@ registerTool({
186
196
  annotations: { title: "Transits to Natal Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
187
197
  handler: async (args) => {
188
198
  validateRequired(args, ["natal_datetime", "natal_latitude", "natal_longitude"]);
199
+ assertPersonZoned("natal", args);
189
200
  // Backend expects: { subject: {...}, transit_datetime?: {...} }
190
201
  // NOT a subjects[] array — natal-transits is a single-subject endpoint
191
202
  const body = {
@@ -1,6 +1,7 @@
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 { WINDOW_DATE_DESC } from "../datetime.js";
4
5
  // All electional endpoints are GET endpoints with query params.
5
6
  // GET /electional/find-window — already existed, keeping it
6
7
  registerTool({
@@ -17,11 +18,11 @@ registerTool({
17
18
  properties: {
18
19
  start_date: {
19
20
  type: "string",
20
- description: "ISO 8601 start date or datetime for the search window (e.g., 2026-03-01).",
21
+ description: "Start of the search window, e.g. '2026-03-01'. " + WINDOW_DATE_DESC,
21
22
  },
22
23
  end_date: {
23
24
  type: "string",
24
- description: "ISO 8601 end date or datetime for the search window.",
25
+ description: "End of the search window. " + WINDOW_DATE_DESC,
25
26
  },
26
27
  latitude: {
27
28
  type: "number",
@@ -128,6 +129,8 @@ registerTool({
128
129
  "Returns exact station times, longitudes, and signs.\n\n" +
129
130
  "USE THIS TOOL FOR: 'When does Mercury go retrograde?', 'Is Venus retrograde this year?', " +
130
131
  "'What planets station this month?', 'When does Mars go direct?'\n\n" +
132
+ "❌ NOT FOR: 'Is Mercury retrograde right now?' — that is the state at a single instant, " +
133
+ "so use ephemeris_retrograde_status (1 credit for one planet, vs 5 here).\n\n" +
131
134
  "All required fields have smart defaults (searches the next 90 days from today).\n\n" +
132
135
  "CREDIT COST: 5 credits per call.\n\n" +
133
136
  "EXAMPLE: Mercury and Venus stations in the next 3 months (all defaults):\n" +
@@ -206,7 +209,7 @@ registerTool({
206
209
  properties: {
207
210
  date: {
208
211
  type: "string",
209
- description: "ISO 8601 datetime to check. Defaults to now.",
212
+ description: "Datetime to check. Defaults to now. " + WINDOW_DATE_DESC,
210
213
  },
211
214
  max_orb: {
212
215
  type: "number",
@@ -1,4 +1,4 @@
1
- import { registerTool, validateRequired } from "../index.js";
1
+ import { registerTool, validateRequired, validateCoordinates } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
4
  import { DATETIME_DESC, TIMEZONE_PROPERTY, assertZonedDatetime, localToUtcIso } from "../datetime.js";
@@ -88,17 +88,27 @@ registerTool({
88
88
  // the documented "all planets" behaviour works correctly.
89
89
  registerTool({
90
90
  name: "ephemeris_retrograde_status",
91
- description: "Get retrograde/direct status and speed for all planets at a given date/time. " +
92
- "Returns is_retrograde flag, longitude speed, and station proximity for every planet.\n\n" +
93
- "CREDIT COST: 10 credits for the all-planets sweep (the backend bills one " +
94
- "credit per body and this fans out to 10), or 1 credit when planet_id is given.\n\n" +
95
- "Optionally pass planet_id (0-9) to query a single planet.",
91
+ description: "Get retrograde/direct status and speed for ONE planet — or, if you ask for it, all " +
92
+ "ten — at a given date/time. Returns is_retrograde flag, longitude speed, and station " +
93
+ "proximity.\n\n" +
94
+ "✅ Answers 'is X retrograde?' at ONE instant. Pass planet_id for the named planet; " +
95
+ "'is Mercury retrograde?' is planet_id=2, not a whole-sky sweep. For WHEN a planet turns " +
96
+ "retrograde or direct, or whether it stations anywhere in a date range, use " +
97
+ "electional_station_tracker.\n\n" +
98
+ "CREDIT COST: 1 credit for a single planet (pass planet_id). Omitting planet_id runs the " +
99
+ "all-planets sweep and costs 10 credits — the backend bills one credit per body and this " +
100
+ "fans out to 10. Only omit it when the question really is about every planet.",
96
101
  inputSchema: {
97
102
  type: "object",
98
103
  properties: {
99
104
  datetime: { type: "string", description: DATETIME_DESC },
100
105
  timezone: TIMEZONE_PROPERTY,
101
- planet_id: { type: "integer", description: "Optional single planet ID (0-9). Omit for all planets." },
106
+ planet_id: {
107
+ type: "integer",
108
+ description: "Single planet ID (0=Sun, 1=Moon, 2=Mercury, 3=Venus, 4=Mars, 5=Jupiter, " +
109
+ "6=Saturn, 7=Uranus, 8=Neptune, 9=Pluto). Pass this whenever the question " +
110
+ "names a planet — 1 credit. Omit only to sweep all ten — 10 credits.",
111
+ },
102
112
  },
103
113
  required: ["datetime"],
104
114
  additionalProperties: false,
@@ -168,14 +178,28 @@ registerTool({
168
178
  // POST /ephemeris/fixed-stars
169
179
  registerTool({
170
180
  name: "ephemeris_fixed_stars",
171
- description: "Calculate positions of fixed stars and conjunctions to natal planets. " +
172
- "Returns star longitude, magnitude, and any planets within orb.\n\n" +
181
+ description: "Fixed star positions (ecliptic longitude, magnitude) and the chart points conjunct them. " +
182
+ "Always scans the ten traditional planets.\n\n" +
183
+ "⚠️ ANGLES REQUIRE A LOCATION: 'what stars are on my Ascendant?' is only answerable with " +
184
+ "latitude AND longitude — ASC/MC/DSC/IC depend on place, not just time. Supply both and the " +
185
+ "four angles join the scan; omit them and the result covers PLANETS ONLY. Each conjunction " +
186
+ "is tagged point_type 'planet' or 'angle', and the angle longitudes used come back in " +
187
+ "`angles`, so never report a planetary hit as an Ascendant hit.\n\n" +
173
188
  "CREDIT COST: 1 credit per call.",
174
189
  inputSchema: {
175
190
  type: "object",
176
191
  properties: {
177
192
  datetime: { type: "string", description: DATETIME_DESC },
178
193
  timezone: TIMEZONE_PROPERTY,
194
+ latitude: {
195
+ type: "number",
196
+ description: "Observer latitude in decimal degrees. Required (with longitude) to scan the angles. " +
197
+ "Resolve from a place name with location_search; never recall coordinates from memory.",
198
+ },
199
+ longitude: {
200
+ type: "number",
201
+ description: "Observer longitude in decimal degrees. Required (with latitude) to scan the angles.",
202
+ },
179
203
  star_names: {
180
204
  type: "array",
181
205
  items: { type: "string" },
@@ -190,45 +214,26 @@ registerTool({
190
214
  annotations: { title: "Fixed Stars", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
191
215
  handler: async (args) => {
192
216
  validateRequired(args, ["datetime"]);
217
+ // A lone latitude would silently drop the angles from the scan, which is
218
+ // the exact failure this tool exists to avoid — reject it instead.
219
+ validateCoordinates(args, "latitude", "longitude");
193
220
  const body = {
194
221
  date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
195
222
  };
223
+ if (args.latitude != null)
224
+ body.latitude = args.latitude;
225
+ if (args.longitude != null)
226
+ body.longitude = args.longitude;
196
227
  if (args.star_names)
197
228
  body.star_names = args.star_names;
198
229
  if (args.orb != null)
199
230
  body.orb = args.orb;
200
- const raw = await getActiveClient().post("/ephemeris/fixed-stars", body);
201
- // Bug 6 workaround: backend returns longitude as degrees-within-sign (0–30),
202
- // not full ecliptic longitude (0–360). Compute full_longitude from sign name.
203
- const SIGN_ORDER = {
204
- Aries: 0, Taurus: 1, Gemini: 2, Cancer: 3, Leo: 4, Virgo: 5,
205
- Libra: 6, Scorpio: 7, Sagittarius: 8, Capricorn: 9, Aquarius: 10, Pisces: 11,
206
- };
207
- function enrichStar(star) {
208
- if (!star || typeof star !== "object")
209
- return star;
210
- const signLon = typeof star.longitude === "number" ? star.longitude : 0;
211
- const signName = star.sign ?? star.sign_name ?? "";
212
- const signIndex = SIGN_ORDER[signName] ?? -1;
213
- const fullLon = signIndex >= 0 ? signIndex * 30 + signLon : signLon;
214
- return {
215
- ...star,
216
- longitude: fullLon, // ecliptic longitude 0–360
217
- sign_longitude: signLon, // original within-sign degrees preserved
218
- sign: signName || undefined,
219
- };
220
- }
221
- // Normalise the response — backend may return array or { stars: [...] }
222
- if (Array.isArray(raw)) {
223
- return raw.map(enrichStar);
224
- }
225
- if (raw && typeof raw === "object") {
226
- const starsKey = ["stars", "data", "fixed_stars", "results"].find((k) => Array.isArray(raw[k]));
227
- if (starsKey) {
228
- return { ...raw, [starsKey]: raw[starsKey].map(enrichStar) };
229
- }
230
- }
231
- return raw;
231
+ // No response rewriting: /ephemeris/fixed-stars returns full ecliptic
232
+ // longitude (0–360) directly. It previously returned degrees-within-sign,
233
+ // and the workaround here reconstructed the full value from a `sign`
234
+ // field the endpoint never actually sent — so it was inert. Both the
235
+ // backend bug and the dead workaround are gone.
236
+ return await getActiveClient().post("/ephemeris/fixed-stars", body);
232
237
  },
233
238
  });
234
239
  // POST /ephemeris/hermetic-lots
@@ -4,10 +4,12 @@ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
4
  import { TIMEZONE_PROPERTY, localToUtcIso } from "../datetime.js";
5
5
  registerTool({
6
6
  name: "ephemeris_moon_phase",
7
- description: "Get the Moon's current phase angle, illumination, and void-of-course status AT a specific " +
8
- "point in time. Returns phase name (New, Waxing Crescent, First Quarter, etc.), exact angle, " +
9
- "illumination %, and next void-of-course period.\n\n" +
10
- "⚠️ THIS TOOL ANSWERS: 'What phase is the moon in right now (or at a given datetime)?'\n" +
7
+ description: "Get the Moon's current phase angle, illumination, sign and void-of-course status AT a " +
8
+ "specific point in time. Returns phase name (New, Waxing Crescent, First Quarter, etc.), exact " +
9
+ "angle, illumination %, the zodiac sign and degree the Moon occupies, and next void-of-course " +
10
+ "period.\n\n" +
11
+ "⚠️ THIS TOOL ANSWERS: 'What phase is the moon in right now (or at a given datetime)?' and " +
12
+ "'What sign is the moon in?'\n" +
11
13
  "❌ THIS TOOL DOES NOT ANSWER: 'When is the next new moon / full moon?'\n" +
12
14
  "→ For upcoming phase DATES use ephemeris_next_lunar_phase instead.\n\n" +
13
15
  "For a user-facing interactive moon-phase dial, use explore_moon_phase instead.\n\n" +
@@ -66,10 +68,13 @@ registerTool({
66
68
  registerTool({
67
69
  name: "ephemeris_next_lunar_phase",
68
70
  description: "Find the next occurrence of a specific Moon phase after a given date. " +
69
- "Returns the exact UTC datetime, zodiac sign, and degree.\n\n" +
71
+ "Returns the exact UTC datetime of each occurrence.\n\n" +
70
72
  "✅ USE THIS TOOL FOR: 'When is the next new moon?', 'When is the next full moon?', " +
71
- "'What date is the next quarter moon?', or any question about UPCOMING phase dates.\n\n" +
72
- "CREDIT COST: 1 credit per call.\n\n" +
73
+ "'What date is the next quarter moon?', or any question about UPCOMING phase dates.\n" +
74
+ "❌ NOT FOR: 'What phase is the moon in right now?' or 'What sign is the moon in?'\n" +
75
+ "→ For the phase, sign and degree AT a moment, use ephemeris_moon_phase.\n\n" +
76
+ "CREDIT COST: about 1 credit per occurrence returned — count=1 costs 1 credit, " +
77
+ "count=3 costs 3.\n\n" +
73
78
  "EXAMPLE: Find the next new moon:\n" +
74
79
  " phase='new_moon'\n\n" +
75
80
  "EXAMPLE: Find the next full moon after a specific date:\n" +
@@ -103,64 +108,87 @@ registerTool({
103
108
  outputSchema: OUTPUT_SCHEMA_JSON,
104
109
  annotations: { title: "Next Lunar Phase", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
105
110
  handler: async (args) => {
106
- // Map our clean enum to display names the calendar API returns
107
- const phaseNameMap = {
111
+ const PHASE_LABELS = {
108
112
  new_moon: "New Moon",
109
113
  full_moon: "Full Moon",
110
114
  first_quarter: "First Quarter",
111
115
  last_quarter: "Last Quarter",
112
116
  };
113
- const phaseLabel = phaseNameMap[args.phase] ?? args.phase;
117
+ // The engine names the waning quarter `third_quarter`; accept both spellings.
118
+ const PHASE_IDS = {
119
+ new_moon: ["new_moon"],
120
+ full_moon: ["full_moon"],
121
+ first_quarter: ["first_quarter"],
122
+ last_quarter: ["last_quarter", "third_quarter"],
123
+ };
124
+ const phaseLabel = PHASE_LABELS[args.phase];
125
+ const wanted = PHASE_IDS[args.phase];
126
+ if (!wanted) {
127
+ throw new Error(`Unknown phase '${args.phase}'. Use one of: ${Object.keys(PHASE_IDS).join(", ")}.`);
128
+ }
114
129
  const count = Math.max(1, Math.min(12, args.count ?? 1));
115
- // Build a search window wide enough to capture `count` occurrences.
116
- // Lunar cycle is ~29.5 days, so count * 35 days guarantees coverage.
117
- const startDate = args.after_date
118
- ? args.after_date.substring(0, 10)
119
- : new Date().toISOString().substring(0, 10);
120
- const endMs = new Date(startDate + "T00:00:00Z").getTime() + count * 35 * 24 * 60 * 60 * 1000;
121
- const endDate = new Date(endMs).toISOString().substring(0, 10);
122
- const result = await getActiveClient().request("GET", "/calendar/astrology/moon-phases", {
123
- params: { start_date: startDate, end_date: endDate },
124
- });
125
- // Normalise response shape — defensively handle every known API envelope shape.
126
- // Crash "allPhases.filter is not a function" occurs when a property like
127
- // result.data is truthy but is an object, not an array.
128
- function extractArray(val) {
129
- if (Array.isArray(val))
130
- return val;
131
- if (val && typeof val === "object") {
132
- // one level deeper: { phases: [...] }, { data: [...] }, etc.
133
- for (const key of ["phases", "data", "events", "moon_phases", "results", "items"]) {
134
- const nested = val[key];
135
- if (Array.isArray(nested))
136
- return nested;
137
- }
130
+ const startDate = (args.after_date ?? new Date().toISOString()).substring(0, 10);
131
+ const startMs = Date.parse(startDate + "T00:00:00Z");
132
+ if (Number.isNaN(startMs)) {
133
+ throw new Error(`after_date must be an ISO date like '2026-06-01', got '${args.after_date}'.`);
134
+ }
135
+ const HALF_DAY = 12 * 60 * 60 * 1000;
136
+ // The calendar endpoint takes a single `date` (it searches forward from noon UTC
137
+ // that day) and returns the first occurrence of each principal phase within the
138
+ // following ~30 days. So one request yields at most one hit for the phase we want.
139
+ async function phaseTimesFrom(probeDay) {
140
+ const result = await getActiveClient().request("GET", "/calendar/astrology/moon-phases", { params: { date: probeDay } });
141
+ const events = Array.isArray(result?.data?.events) ? result.data.events : [];
142
+ // The endpoint prepends the Moon's *current* 8-phase bucket, stamped with the
143
+ // query instant (noon UTC of `date`) rather than an event time. Left in, it
144
+ // reads as a real phase occurring at exactly 12:00:00. Computed moments always
145
+ // carry sub-second precision, so an exact hit on noon is that placeholder.
146
+ const probeNoonMs = Date.parse(probeDay + "T12:00:00Z");
147
+ return events
148
+ .filter((e) => wanted.includes(String(e?.phase ?? e?.phase_name ?? "").toLowerCase()))
149
+ .map((e) => Date.parse(e?.time ?? e?.iso_time ?? ""))
150
+ .filter((ms) => !Number.isNaN(ms) && ms !== probeNoonMs)
151
+ .sort((a, b) => a - b);
152
+ }
153
+ // Probe from the day containing `afterMs - 12h`, so its noon-UTC search origin is
154
+ // always at or before `afterMs` and an occurrence earlier the same day is still
155
+ // visible. That leaves under a day of slack behind the cursor, which can hold at
156
+ // most one occurrence (they are ~29.5 days apart) — hence a single re-probe.
157
+ async function findNext(afterMs) {
158
+ let probeMs = afterMs - HALF_DAY;
159
+ for (let probe = 0; probe < 2; probe++) {
160
+ const hits = await phaseTimesFrom(new Date(probeMs).toISOString().substring(0, 10));
161
+ const next = hits.find((ms) => ms > afterMs);
162
+ if (next != null)
163
+ return next;
164
+ if (hits.length === 0)
165
+ return null;
166
+ probeMs = hits[hits.length - 1] + HALF_DAY;
138
167
  }
139
168
  return null;
140
169
  }
141
- const allPhases = extractArray(result) ?? [];
142
- const matching = allPhases
143
- .filter((p) => {
144
- const name = (p.phase_name ?? p.name ?? p.phase ?? "").toLowerCase();
145
- return (name === phaseLabel.toLowerCase() ||
146
- name === args.phase.replace("_", " ").toLowerCase());
147
- })
148
- .slice(0, count);
149
- if (matching.length === 0) {
150
- // Return full calendar as fallback so the LLM can inspect it
151
- return {
152
- requested_phase: phaseLabel,
153
- search_window: { start_date: startDate, end_date: endDate },
154
- result_count: 0,
155
- note: "No matching phase found in window. Full calendar returned for inspection.",
156
- full_calendar: allPhases,
157
- };
170
+ const results = [];
171
+ let cursorMs = startMs - 1; // include an occurrence at exactly 00:00 on after_date
172
+ for (let i = 0; i < count; i++) {
173
+ const hitMs = await findNext(cursorMs);
174
+ if (hitMs == null)
175
+ break;
176
+ results.push({ phase: args.phase, datetime: new Date(hitMs).toISOString() });
177
+ cursorMs = hitMs;
178
+ }
179
+ // Every principal phase recurs every ~29.5 days, so an empty result is impossible.
180
+ // Fail loudly rather than handing back a plausible "there isn't one" non-answer.
181
+ if (results.length === 0) {
182
+ throw new Error(`Lunar phase search failed: the backend returned no ${phaseLabel} after ` +
183
+ `${startDate}. A ${phaseLabel} occurs roughly every 29.5 days, so this is a ` +
184
+ `backend fault, not a real answer — do not report that there is no upcoming ` +
185
+ `${phaseLabel}.`);
158
186
  }
159
187
  return {
160
188
  requested_phase: phaseLabel,
161
189
  after_date: startDate,
162
- results: matching,
163
- result_count: matching.length,
190
+ result_count: results.length,
191
+ results,
164
192
  };
165
193
  },
166
194
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openephemeris/mcp-server",
3
- "version": "4.0.0",
3
+ "version": "4.2.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",