@openephemeris/mcp-server 4.0.0 → 4.1.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,82 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [4.1.0] — 2026-07-26
11
+
12
+ Follow-up to 4.0.0. The new `400` was telling REST callers to do something that, on
13
+ most endpoints, was impossible. It now isn't — the remedy it names is real everywhere.
14
+
15
+ ### Added
16
+
17
+ - **`date_time.timezone` — every datetime in the API now accepts its own zone.** 4.0.0's
18
+ rejection message offered two fixes: put an offset on the value, or "keep the local
19
+ wall-clock time and pass the zone alongside it". The second one only worked on requests
20
+ built around a `subject`, because the zone lived on `subject.birth_location.timezone`.
21
+ Endpoints that take a bare `date_time` — `/ephemeris/angles-points`, `/ephemeris/house-cusps`,
22
+ `/ephemeris/planet-position`, `/ephemeris/dignities`, `/ephemeris/midpoints`,
23
+ `/ephemeris/retrograde-status`, `/ephemeris/fixed-stars`, `/ephemeris/hermetic-lots`
24
+ and `/ephemeris/lunar-phase` — had nowhere to put a zone, so a caller following the
25
+ error's own advice got the same `400` back. Nine endpoints, one impossible instruction.
26
+
27
+ The zone now belongs to the datetime rather than to the request, so this works
28
+ everywhere a datetime is accepted:
29
+
30
+ ```json
31
+ { "date_time": { "iso": "1987-07-15T09:01:00",
32
+ "timezone": { "iana_name": "America/Chicago" } } }
33
+ ```
34
+
35
+ Purely additive — an omitted `timezone` behaves exactly as before, and where a request
36
+ already carries a zone (a subject's birth location) that remains the fallback. A zone
37
+ already written on the string always wins, so a stray `timezone` can never move an
38
+ instant that the caller had already pinned.
39
+
40
+ - **`docs/datetime-contract.md`** — one authoritative statement of the rule: when a zone
41
+ is required, the date-only and `*_utc` carve-outs, how to pass a zone on each request
42
+ shape, and what the response echoes. The reason four endpoint families drifted to four
43
+ different readings of "ISO 8601 date-time" is that no such document existed.
44
+
45
+ ### Fixed
46
+
47
+ - **The `400` no longer advertises a remedy the endpoint doesn't have.** Fields that are
48
+ a bare datetime *string* rather than an object — the ACG `epoch` family, the Venus date
49
+ ranges, the `datetime` query parameter on the `GET /ephemeris/moon/*` endpoints — have
50
+ no `timezone` companion and cannot grow one. Their rejection message now names only the
51
+ fix that exists: put the zone on the value. Being told to do something impossible is
52
+ worse than a terse error.
53
+
54
+ - **The OpenAPI spec now states the contract where callers actually read it.** The
55
+ `date_time` fields said "ISO 8601 date-time" and nothing more, so the only place the
56
+ rule appeared was in the error you got for breaking it. The `iso`, `components` and
57
+ `timezone` fields, the `epoch`/date-range string fields, and the spec's own
58
+ "Supported Formats" section now all state it. That section had been listing a zone-less
59
+ `"2000-01-01T12:00:00"` as an accepted input — the exact value the API rejects.
60
+
61
+ - **`/timezone/offset` documented behaviour it has never had.** Its `datetime_utc` field
62
+ claimed a naive value was "interpreted as UTC". The field is a strict RFC 3339 instant,
63
+ so a zone-less value was never interpreted at all — it failed to parse. Now documented
64
+ as what it is.
65
+
66
+ - **The spec's front-page example used field names that do not exist** (`iso_string`,
67
+ a `timezone.name`, and latitude/longitude directly under `subject`). Replaced with the
68
+ real request shape.
69
+
70
+ - **Four tool modules were still teaching the pre-4.0.0 contract.** `chart_wheel` told the
71
+ model to "include timezone offset **if known**"; `ephemeris_composite`,
72
+ `ephemeris_composite_midpoint`, `ephemeris_overlay`, `ephemeris_natal_transits`, the
73
+ electional search tools and `explore_bi_wheel` described their datetimes as plain
74
+ "(ISO 8601)" with no zone requirement. 4.0.0 migrated the rest of the surface and missed
75
+ these. They now use the same canonical description as every other tool, and the composite
76
+ tools reject a zone-less datetime locally instead of spending a credit to learn it.
77
+
78
+ ### Changed
79
+
80
+ - The tool-surface token ceilings are **unchanged** (core 20,000 / full 36,500). The
81
+ descriptions above cost ~0.4k core / ~1.0k full and fit inside the existing budget;
82
+ measured after the change, core is 18.7k and full is 34.8k.
83
+
84
+ ---
85
+
10
86
  ## [4.0.0] — 2026-07-26
11
87
 
12
88
  **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";
@@ -90,9 +90,11 @@ registerTool({
90
90
  name: "ephemeris_retrograde_status",
91
91
  description: "Get retrograde/direct status and speed for all planets at a given date/time. " +
92
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.",
93
+ "✅ Answers 'is X retrograde?' at ONE instant. For WHEN a planet turns retrograde or " +
94
+ "direct, or whether it stations anywhere in a date range, use electional_station_tracker.\n\n" +
95
+ "CREDIT COST: for a single planet — the common case — pass planet_id (0-9, e.g. 2 for " +
96
+ "Mercury): 1 credit. Omitting planet_id runs the all-planets sweep: 10 credits (the " +
97
+ "backend bills one credit per body and this fans out to 10).",
96
98
  inputSchema: {
97
99
  type: "object",
98
100
  properties: {
@@ -168,14 +170,28 @@ registerTool({
168
170
  // POST /ephemeris/fixed-stars
169
171
  registerTool({
170
172
  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" +
173
+ description: "Fixed star positions (ecliptic longitude, magnitude) and the chart points conjunct them. " +
174
+ "Always scans the ten traditional planets.\n\n" +
175
+ "⚠️ ANGLES REQUIRE A LOCATION: 'what stars are on my Ascendant?' is only answerable with " +
176
+ "latitude AND longitude — ASC/MC/DSC/IC depend on place, not just time. Supply both and the " +
177
+ "four angles join the scan; omit them and the result covers PLANETS ONLY. Each conjunction " +
178
+ "is tagged point_type 'planet' or 'angle', and the angle longitudes used come back in " +
179
+ "`angles`, so never report a planetary hit as an Ascendant hit.\n\n" +
173
180
  "CREDIT COST: 1 credit per call.",
174
181
  inputSchema: {
175
182
  type: "object",
176
183
  properties: {
177
184
  datetime: { type: "string", description: DATETIME_DESC },
178
185
  timezone: TIMEZONE_PROPERTY,
186
+ latitude: {
187
+ type: "number",
188
+ description: "Observer latitude in decimal degrees. Required (with longitude) to scan the angles. " +
189
+ "Resolve from a place name with location_search; never recall coordinates from memory.",
190
+ },
191
+ longitude: {
192
+ type: "number",
193
+ description: "Observer longitude in decimal degrees. Required (with latitude) to scan the angles.",
194
+ },
179
195
  star_names: {
180
196
  type: "array",
181
197
  items: { type: "string" },
@@ -190,45 +206,26 @@ registerTool({
190
206
  annotations: { title: "Fixed Stars", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
191
207
  handler: async (args) => {
192
208
  validateRequired(args, ["datetime"]);
209
+ // A lone latitude would silently drop the angles from the scan, which is
210
+ // the exact failure this tool exists to avoid — reject it instead.
211
+ validateCoordinates(args, "latitude", "longitude");
193
212
  const body = {
194
213
  date_time: { iso: localToUtcIso("datetime", args.datetime, args.timezone) },
195
214
  };
215
+ if (args.latitude != null)
216
+ body.latitude = args.latitude;
217
+ if (args.longitude != null)
218
+ body.longitude = args.longitude;
196
219
  if (args.star_names)
197
220
  body.star_names = args.star_names;
198
221
  if (args.orb != null)
199
222
  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;
223
+ // No response rewriting: /ephemeris/fixed-stars returns full ecliptic
224
+ // longitude (0–360) directly. It previously returned degrees-within-sign,
225
+ // and the workaround here reconstructed the full value from a `sign`
226
+ // field the endpoint never actually sent — so it was inert. Both the
227
+ // backend bug and the dead workaround are gone.
228
+ return await getActiveClient().post("/ephemeris/fixed-stars", body);
232
229
  },
233
230
  });
234
231
  // 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" +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openephemeris/mcp-server",
3
- "version": "4.0.0",
3
+ "version": "4.1.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",