@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 +113 -0
- package/README.md +35 -1
- package/dist/prompts.js +55 -42
- package/dist/tools/apps/bazi-app.js +3 -2
- package/dist/tools/apps/bi-wheel-app.js +12 -9
- package/dist/tools/apps/bodygraph-app.js +2 -2
- package/dist/tools/apps/chart-wheel-app.js +16 -17
- package/dist/tools/dev.js +4 -3
- package/dist/tools/index.js +3 -0
- package/dist/tools/specialized/bazi.js +3 -4
- package/dist/tools/specialized/comparative.js +28 -17
- package/dist/tools/specialized/electional.js +6 -3
- package/dist/tools/specialized/ephemeris_extended.js +46 -41
- package/dist/tools/specialized/moon.js +80 -52
- package/package.json +1 -1
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
|
-
|
|
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
|
-
"**
|
|
72
|
-
"`
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"-
|
|
77
|
-
"
|
|
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
|
-
"**
|
|
150
|
-
"`
|
|
151
|
-
"
|
|
152
|
-
"
|
|
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
|
-
"**
|
|
215
|
-
"`
|
|
216
|
-
"
|
|
217
|
-
"
|
|
218
|
-
"
|
|
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
|
-
"**
|
|
287
|
-
"`
|
|
288
|
-
"
|
|
289
|
-
"
|
|
290
|
-
"
|
|
291
|
-
"
|
|
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
|
-
"**
|
|
376
|
-
"`
|
|
377
|
-
"
|
|
378
|
-
"
|
|
379
|
-
"
|
|
380
|
-
"-
|
|
381
|
-
"
|
|
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
|
-
"**
|
|
549
|
-
"`
|
|
550
|
-
"
|
|
551
|
-
"
|
|
552
|
-
"
|
|
553
|
-
"-
|
|
554
|
-
"
|
|
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
|
-
"**
|
|
776
|
-
"`
|
|
777
|
-
"
|
|
778
|
-
"
|
|
779
|
-
"
|
|
780
|
-
"
|
|
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
|
|
119
|
-
"
|
|
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
|
|
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
|
-
"
|
|
422
|
-
"MCP Apps-capable hosts (Claude Desktop)
|
|
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: "
|
|
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:
|
|
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: "
|
|
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:
|
|
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
|
|
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
|
-
"
|
|
115
|
-
"
|
|
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
|
-
|
|
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:
|
|
347
|
-
timezone:
|
|
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: "
|
|
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
|
|
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.
|
|
194
|
-
"
|
|
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
|
|
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: {
|
package/dist/tools/index.js
CHANGED
|
@@ -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,
|
|
148
|
-
"
|
|
149
|
-
"
|
|
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:
|
|
34
|
-
person_a_timezone:
|
|
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:
|
|
38
|
-
person_b_timezone:
|
|
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:
|
|
80
|
-
person_a_timezone:
|
|
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:
|
|
84
|
-
person_b_timezone:
|
|
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:
|
|
126
|
-
person_a_timezone:
|
|
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:
|
|
130
|
-
person_b_timezone:
|
|
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:
|
|
175
|
-
natal_timezone:
|
|
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
|
|
179
|
-
transit_timezone:
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
|
92
|
-
"Returns is_retrograde flag, longitude speed, and station
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
"
|
|
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: {
|
|
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: "
|
|
172
|
-
"
|
|
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
|
-
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
8
|
-
"point in time. Returns phase name (New, Waxing Crescent, First Quarter, etc.), exact
|
|
9
|
-
"illumination %, and next void-of-course
|
|
10
|
-
"
|
|
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
|
|
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
|
|
72
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
163
|
-
|
|
190
|
+
result_count: results.length,
|
|
191
|
+
results,
|
|
164
192
|
};
|
|
165
193
|
},
|
|
166
194
|
});
|