@openephemeris/mcp-server 4.7.0 → 4.8.1

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +35 -3
  3. package/dist/index.js +5 -2
  4. package/dist/oauth/token.js +30 -3
  5. package/dist/server-sse.js +7 -2
  6. package/dist/tools/apps/bazi-app.js +4 -4
  7. package/dist/tools/apps/bi-wheel-app.js +57 -23
  8. package/dist/tools/apps/bodygraph-app.js +12 -9
  9. package/dist/tools/apps/chart-wheel-app.js +4 -4
  10. package/dist/tools/apps/location-tools.js +2 -2
  11. package/dist/tools/apps/moon-phase-app.js +1 -1
  12. package/dist/tools/apps/vedic-chart-app.js +1 -1
  13. package/dist/tools/auth.js +8 -0
  14. package/dist/tools/dev.js +15 -5
  15. package/dist/tools/index.d.ts +41 -2
  16. package/dist/tools/index.js +201 -4
  17. package/dist/tools/specialized/account.js +5 -1
  18. package/dist/tools/specialized/acg.js +2 -2
  19. package/dist/tools/specialized/bazi.js +7 -7
  20. package/dist/tools/specialized/bi_wheel.js +1 -1
  21. package/dist/tools/specialized/chart_wheel.js +1 -1
  22. package/dist/tools/specialized/comparative.js +4 -4
  23. package/dist/tools/specialized/eclipse.js +1 -1
  24. package/dist/tools/specialized/electional.js +5 -5
  25. package/dist/tools/specialized/ephemeris_core.js +2 -2
  26. package/dist/tools/specialized/ephemeris_extended.js +8 -8
  27. package/dist/tools/specialized/hd_bodygraph.js +1 -1
  28. package/dist/tools/specialized/hd_cycles.js +2 -2
  29. package/dist/tools/specialized/hd_group.js +2 -2
  30. package/dist/tools/specialized/human_design.js +1 -1
  31. package/dist/tools/specialized/moon.js +10 -13
  32. package/dist/tools/specialized/natal.js +1 -1
  33. package/dist/tools/specialized/progressed.js +1 -1
  34. package/dist/tools/specialized/relocation.js +1 -1
  35. package/dist/tools/specialized/returns.js +3 -3
  36. package/dist/tools/specialized/synastry.js +1 -1
  37. package/dist/tools/specialized/transits.js +1 -1
  38. package/dist/tools/specialized/vedic.js +1 -1
  39. package/dist/tools/specialized/venus_star_points.js +6 -6
  40. package/dist/ui/chart-wheel.html +56 -53
  41. package/package.json +4 -2
  42. package/smithery.yaml +14 -3
package/CHANGELOG.md CHANGED
@@ -7,6 +7,49 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [4.8.1] — 2026-08-02
11
+
12
+ ### Fixed
13
+ - Every tool now declares all four MCP annotation hints explicitly
14
+ (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`).
15
+ Notably `dev_write_api` is `destructiveHint: false` — every allowlisted
16
+ write is stateless chart computation — so strict clients no longer gate it
17
+ behind a confirmation, and `openWorldHint` is uniformly `false` (the server
18
+ reaches only the fixed OpenEphemeris API).
19
+ - `account_usage` reports a real authentication failure as a tool error
20
+ (`isError: true`) instead of a success-shaped message.
21
+
22
+ ## [4.8.0] — 2026-08-02
23
+
24
+ ### Added
25
+ - **Toolsets by tradition.** Tool definitions are re-sent to the model on every
26
+ message, so callers who work in one tradition can now advertise just that
27
+ tradition instead of the general-purpose default: `OPENEPHEMERIS_TOOLS=hd`
28
+ (stdio) or `?profile=hd,bazi` (hosted). Eight toolsets — `astrology`, `moon`,
29
+ `hd`, `bazi`, `vedic`, `acg`, `electional`, `venus` — each including
30
+ geocoding, account usage, and the API escape hatch. A Human Design session
31
+ drops from ~19,100 to ~7,800 advertised tokens (−59%); Vedic to ~3,400
32
+ (−82%). `astrology,moon` matches the default's tool count at ~1,700 fewer
33
+ tokens while covering the long tail (dignities, midpoints, hermetic lots,
34
+ fixed stars, composites) the curated set omits. As with `core`/`full`, this
35
+ filters `tools/list` only — every tool stays callable by name. Also exposed
36
+ as the `toolSurface` option in the Smithery config.
37
+
38
+ ### Fixed
39
+ - `explore_human_design` now states its real credit cost: 4 where the bodygraph
40
+ renders (2 chart + 2 visual), 2 in text-only hosts — it previously claimed a
41
+ flat 2. `ephemeris_moon_phase` (2, not 1) and `ephemeris_next_lunar_phase`
42
+ (1–2 per occurrence) corrected the same way.
43
+ - `explore_bi_wheel` in synastry mode now requires Person 2's coordinates (or a
44
+ resolvable `person2_location`) instead of silently casting Person 2's chart
45
+ for 0°N 0°E.
46
+ - Chart-wheel widget escapes birth parameters before rendering them into the
47
+ info chips.
48
+ - OAuth token endpoint binds authorization codes to their `redirect_uri` and
49
+ `client_id`; both checks were previously skippable by omitting the parameter.
50
+
51
+ ---
52
+
10
53
  ## [4.7.0] — 2026-08-02
11
54
 
12
55
  Historically correct birth-time conversion. IANA timezone databases — including
package/README.md CHANGED
@@ -358,6 +358,38 @@ On the remote HTTP server, append `?profile=full` to the connector URL (or send
358
358
  https://mcp.openephemeris.com/mcp?profile=full
359
359
  ```
360
360
 
361
+ ### Toolsets by tradition
362
+
363
+ If you work in one tradition, ask for it by name instead of taking the general-purpose default. You get that tradition in full — including the long-tail tools the core set leaves out — for a fraction of the context.
364
+
365
+ ```bash
366
+ OPENEPHEMERIS_TOOLS=hd npx -y @openephemeris/mcp-server # Human Design
367
+ OPENEPHEMERIS_TOOLS=astrology,moon npx -y @openephemeris/mcp-server
368
+ ```
369
+
370
+ ```
371
+ https://mcp.openephemeris.com/mcp?profile=hd,bazi
372
+ ```
373
+
374
+ | Toolset | What it covers | Tools | Approx. tokens |
375
+ |---|---|---:|---:|
376
+ | `astrology` | Natal, transits, synastry, progressions, returns, relocation, dignities, midpoints, lots, fixed stars, composites | 32 | 15,800 |
377
+ | `hd` | Human Design charts, transits, connection charts, penta, bodygraph | 14 | 7,800 |
378
+ | `bazi` | Four Pillars, Ten Gods, element balance, luck pillars, compatibility | 13 | 7,200 |
379
+ | `electional` | Timing windows, angle crossings, stations, moment analysis | 10 | 4,600 |
380
+ | `moon` | Phases, void-of-course, eclipses | 9 | 4,100 |
381
+ | `venus` | Star points, phases, elongations, stations | 11 | 3,700 |
382
+ | `acg` | Astrocartography lines and hits | 7 | 3,700 |
383
+ | `vedic` | Jyotish Rashi chart | 7 | 3,400 |
384
+ | — | *core (default)* | 36 | 19,100 |
385
+ | — | *full* | 70 | 34,600 |
386
+
387
+ Every selection also includes geocoding (`location_search`, `timezone_resolve`), `account_usage`, and the allowlist-gated proxy — so a birthplace is always resolvable and nothing is stranded.
388
+
389
+ Combine with commas; unknown names are ignored rather than rejected, so a typo degrades to a smaller surface instead of a dead connector. As with `core`/`full`, this only filters `tools/list` — every tool remains callable by name.
390
+
391
+ Why it matters: tool definitions are re-sent to the model on **every** pass. `astrology,moon` advertises the same number of tools as the default but costs ~1,700 fewer tokens per message and covers more of the tradition.
392
+
361
393
  The surface is fixed when the session initializes — this server does not advertise `tools.listChanged`, so switching requires reconnecting. `dev_list_allowed` enumerates every operation reachable through the generic proxy regardless of surface.
362
394
 
363
395
  ## Contributing & Support
@@ -443,8 +475,8 @@ Generated by `npm run sync:readme` from `config/dev-allowlist.json` and the live
443
475
  - Allowlisted operations: **31**
444
476
  - Methods: `GET=5`, `POST=26`, `PUT=0`, `PATCH=0`, `DELETE=0`
445
477
  - Registered tools (`OPENEPHEMERIS_PROFILE=dev`): **92**
446
- - Typed tools: `account_usage`, `acg_hits`, `acg_power_lines`, `auth_login`, `auth_logout`, `auth_status`, `bazi_annual_pillar`, `bazi_chart`, `bazi_compatibility`, `bazi_element_balance`, `bazi_luck_pillars`, `bazi_recalculate`, `bazi_ten_gods`, `bi_wheel_on_cross_aspect_click`, `bi_wheel_on_house_click`, `bi_wheel_on_planet_click`, `bi_wheel_recalculate`, `bi_wheel_synopsis`, `bodygraph_recalculate`, `chart_wheel_on_aspect_click`, `chart_wheel_on_house_click`, `chart_wheel_on_planet_click`, `chart_wheel_recalculate`, `chinese_bazi`, `dev_list_allowed`, `dev_read_api`, `dev_write_api`, `electional_angle_crossings`, `electional_aspect_search`, `electional_moment_analysis`, `electional_station_tracker`, `ephemeris_angles_points`, `ephemeris_aspect_check`, `ephemeris_bi_wheel`, `ephemeris_chart_wheel`, `ephemeris_composite`, `ephemeris_composite_midpoint`, `ephemeris_dignities`, `ephemeris_electional`, `ephemeris_fixed_stars`, `ephemeris_hermetic_lots`, `ephemeris_house_cusps`, `ephemeris_lunar_return`, `ephemeris_midpoints`, `ephemeris_moon_phase`, `ephemeris_natal_batch`, `ephemeris_natal_chart`, `ephemeris_natal_transits`, `ephemeris_next_eclipse`, `ephemeris_next_lunar_phase`, `ephemeris_overlay`, `ephemeris_planet_position`, `ephemeris_planetary_return`, `ephemeris_progressed_chart`, `ephemeris_relocation`, `ephemeris_retrograde_status`, `ephemeris_solar_return`, `ephemeris_synastry`, `ephemeris_transits`, `explore_bazi_chart`, `explore_bi_wheel`, `explore_human_design`, `explore_human_design_connection`, `explore_human_design_transit`, `explore_moon_phase`, `explore_natal_chart`, `explore_transit_timeline`, `explore_vedic_chart`, `hd_on_center_click`, `hd_on_channel_click`, `hd_on_connection_channel_click`, `hd_on_gate_click`, `hd_on_planet_click`, `hd_on_transit_channel_click`, `hd_on_variable_click`, `hd_opposition`, `hd_planetary_return`, `human_design_bodygraph`, `human_design_chart`, `human_design_composite`, `human_design_penta`, `location_search`, `moon_phase_recalculate`, `timezone_resolve`, `vedic_chart`, `vedic_chart_recalculate`, `venus_eight_year_star`, `venus_elongations`, `venus_phase`, `venus_star_points`, `venus_star_points_conjunctions`, `venus_stations`
447
- - Generic tools:
478
+ - Typed tools: `account_usage`, `acg_hits`, `acg_power_lines`, `auth_login`, `auth_logout`, `auth_status`, `bazi_annual_pillar`, `bazi_chart`, `bazi_compatibility`, `bazi_element_balance`, `bazi_luck_pillars`, `bazi_recalculate`, `bazi_ten_gods`, `bi_wheel_on_cross_aspect_click`, `bi_wheel_on_house_click`, `bi_wheel_on_planet_click`, `bi_wheel_recalculate`, `bi_wheel_synopsis`, `bodygraph_recalculate`, `chart_wheel_on_aspect_click`, `chart_wheel_on_house_click`, `chart_wheel_on_planet_click`, `chart_wheel_recalculate`, `chinese_bazi`, `electional_angle_crossings`, `electional_aspect_search`, `electional_moment_analysis`, `electional_station_tracker`, `ephemeris_angles_points`, `ephemeris_aspect_check`, `ephemeris_bi_wheel`, `ephemeris_chart_wheel`, `ephemeris_composite`, `ephemeris_composite_midpoint`, `ephemeris_dignities`, `ephemeris_electional`, `ephemeris_fixed_stars`, `ephemeris_hermetic_lots`, `ephemeris_house_cusps`, `ephemeris_lunar_return`, `ephemeris_midpoints`, `ephemeris_moon_phase`, `ephemeris_natal_batch`, `ephemeris_natal_chart`, `ephemeris_natal_transits`, `ephemeris_next_eclipse`, `ephemeris_next_lunar_phase`, `ephemeris_overlay`, `ephemeris_planet_position`, `ephemeris_planetary_return`, `ephemeris_progressed_chart`, `ephemeris_relocation`, `ephemeris_retrograde_status`, `ephemeris_solar_return`, `ephemeris_synastry`, `ephemeris_transits`, `explore_bazi_chart`, `explore_bi_wheel`, `explore_human_design`, `explore_human_design_connection`, `explore_human_design_transit`, `explore_moon_phase`, `explore_natal_chart`, `explore_transit_timeline`, `explore_vedic_chart`, `hd_on_center_click`, `hd_on_channel_click`, `hd_on_connection_channel_click`, `hd_on_gate_click`, `hd_on_planet_click`, `hd_on_transit_channel_click`, `hd_on_variable_click`, `hd_opposition`, `hd_planetary_return`, `human_design_bodygraph`, `human_design_chart`, `human_design_composite`, `human_design_penta`, `location_search`, `moon_phase_recalculate`, `timezone_resolve`, `vedic_chart`, `vedic_chart_recalculate`, `venus_eight_year_star`, `venus_elongations`, `venus_phase`, `venus_star_points`, `venus_star_points_conjunctions`, `venus_stations`
479
+ - Generic tools: `dev_list_allowed`, `dev_read_api`, `dev_write_api`
448
480
 
449
481
  ### Allowlist Families
450
482
 
@@ -466,5 +498,5 @@ Most LLMs (like Claude and ChatGPT) struggle heavily with astronomical calculati
466
498
 
467
499
  By pairing LLMs with the OpenEphemeris MCP server, your agents can instantly access:
468
500
  - **Zero-hallucination coordinates**: Direct, sub-arcsecond NASA JPL DE440 calculations spanning 1,100 years of astronomical data.
469
- - **LLM-optimized tokens (`format=llm`)**: We compress standard 25,000 token JSON chart responses into minimal text blocks, cutting your inference costs by 50%.
501
+ - **LLM-optimized tokens (`format=llm`)**: We compress standard 25,000 token JSON chart responses into minimal text blocks, cutting your inference costs by 50–73% depending on endpoint.
470
502
  - **Ready-to-use astrology layers**: Built-in support for Astrocartography geoJSON lines, Hermetic Lots, Fixed Stars, and complex Human Design matrix generation.
package/dist/index.js CHANGED
@@ -73,9 +73,12 @@ const server = new Server({
73
73
  "See the 'welcome_to_open_ephemeris' prompt for orientation.\n\n" +
74
74
  DATETIME_CONTRACT_INSTRUCTIONS,
75
75
  });
76
- // Which slice of the registry this process advertises. Tools outside the core
76
+ // Which slice of the registry this process advertises. Tools outside the
77
77
  // surface stay callable by name — this only controls what tools/list returns.
78
- // Set OPENEPHEMERIS_TOOLS=full to advertise everything.
78
+ // OPENEPHEMERIS_TOOLS=full advertise everything
79
+ // OPENEPHEMERIS_TOOLS=hd,bazi advertise those traditions (+ geocoding,
80
+ // account and the escape hatch)
81
+ // unset the curated core set
79
82
  const STDIO_SURFACE = parseToolSurface(process.env.OPENEPHEMERIS_TOOLS);
80
83
  /**
81
84
  * Stable pseudonymous id for this install. Derived from the configured
@@ -106,11 +106,38 @@ async function handleAuthorizationCode(req, res, store) {
106
106
  });
107
107
  return;
108
108
  }
109
- // Verify redirect_uri exact match
110
- if (redirect_uri && !timingSafeStringEqual(redirect_uri, stored.redirect_uri)) {
109
+ // Verify redirect_uri exact match.
110
+ //
111
+ // This used to read `if (redirect_uri && ...)`, so a client that simply omitted
112
+ // the parameter skipped the check entirely. RFC 6749 §4.1.3 requires the value
113
+ // to be present AND identical whenever the authorization request carried one,
114
+ // precisely so a dropped parameter cannot be the bypass. PKCE still gates the
115
+ // exchange, but that is the last line, not the only one.
116
+ if (stored.redirect_uri) {
117
+ if (!redirect_uri) {
118
+ res.status(400).json({
119
+ error: "invalid_request",
120
+ error_description: "Missing redirect_uri. It is required because the authorization request included one.",
121
+ });
122
+ return;
123
+ }
124
+ if (!timingSafeStringEqual(redirect_uri, stored.redirect_uri)) {
125
+ res.status(400).json({
126
+ error: "invalid_grant",
127
+ error_description: "redirect_uri does not match the value used during authorization.",
128
+ });
129
+ return;
130
+ }
131
+ }
132
+ // Bind the code to the client it was issued to. `client_id` was read off the
133
+ // request but never compared, so any client holding the verifier could redeem
134
+ // another client's code. DCR client_ids are deterministic and non-secret
135
+ // (see oauth/dcr.ts), which makes this binding the only thing distinguishing
136
+ // one registered client from another.
137
+ if (stored.client_id && client_id && !timingSafeStringEqual(client_id, stored.client_id)) {
111
138
  res.status(400).json({
112
139
  error: "invalid_grant",
113
- error_description: "redirect_uri does not match the value used during authorization.",
140
+ error_description: "client_id does not match the client the authorization code was issued to.",
114
141
  });
115
142
  return;
116
143
  }
@@ -23,7 +23,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
23
23
  import { InMemoryEventStore } from "./event-store.js";
24
24
  import { captureEvent, distinctIdFor } from "./analytics.js";
25
25
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
26
- import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools, parseToolSurface } from "./tools/index.js";
26
+ import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools, parseToolSurface, describeSurface } from "./tools/index.js";
27
27
  import { DATETIME_CONTRACT_INSTRUCTIONS } from "./tools/datetime.js";
28
28
  import { BackendClient, runWithClient } from "./backend/client.js";
29
29
  import { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
@@ -215,7 +215,10 @@ function transportProps(server, surface) {
215
215
  const info = server.getClientVersion();
216
216
  return {
217
217
  transport: "http",
218
- surface,
218
+ // describeSurface, not the raw value: a toolset selection is an object and
219
+ // would land in PostHog as "[object Object]", making the property useless
220
+ // for exactly the question it exists to answer — which surfaces get used.
221
+ surface: describeSurface(surface),
219
222
  client_name: info?.name ?? "unknown",
220
223
  client_version: info?.version ?? "unknown",
221
224
  server_version: version,
@@ -848,6 +851,8 @@ export async function createSseApp() {
848
851
  // `tools.listChanged`, so a host has no obligation to re-fetch the list.
849
852
  // Opt into the full surface with `?profile=full` on the connector URL, or
850
853
  // an `X-OE-Tool-Surface: full` header where the host allows custom headers.
854
+ // A comma list of traditions works the same way — `?profile=hd,bazi` —
855
+ // and advertises just those plus geocoding, account and the escape hatch.
851
856
  const surface = parseToolSurface(req.query.profile ?? req.headers["x-oe-tool-surface"]);
852
857
  const server = createMcpServer(analyticsId, surface);
853
858
  // Fire session_init after the handshake so getClientVersion() is populated
@@ -115,9 +115,9 @@ registerTool({
115
115
  "(Year = ancestry/early life, Month = parents/career, Day = self/spouse, Hour = children/later life).\n\n" +
116
116
  "CREDIT COST: 3 credits per call (1 base + 2 visual render).\n\n" +
117
117
  "Use this for a rich, interactive BaZi experience in MCP Apps-capable hosts (Claude Desktop). " +
118
- "Falls back to a text summary in other hosts. For non-visual data lookups, use chinese_bazi " +
119
- "instead; deeper derivations (Ten Gods, element balance, luck pillars) have dedicated tools " +
120
- "on the full tool surface (`?profile=full`).",
118
+ "Falls back to a text summary in other hosts, so it is also the right call without a " +
119
+ "renderer. Deeper derivations (Ten Gods, element balance, luck pillars) and a plain " +
120
+ "non-visual pillars lookup have dedicated tools on the full surface (`?profile=full`).",
121
121
  inputSchema: {
122
122
  type: "object",
123
123
  properties: {
@@ -204,7 +204,7 @@ registerTool({
204
204
  required: ["year", "month", "day"],
205
205
  },
206
206
  outputSchema: OUTPUT_SCHEMA_JSON,
207
- annotations: { title: "Recalculate BaZi Chart", readOnlyHint: true, openWorldHint: false },
207
+ annotations: { title: "Recalculate BaZi Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
208
208
  _meta: { ui: { resourceUri: BAZI_RESOURCE_URI, visibility: ["app"] } },
209
209
  handler: async (args) => {
210
210
  const components = parseBaziArgs(args);
@@ -104,13 +104,21 @@ const HOUSE_SYSTEM_MAP = {
104
104
  campanus: "C", regiomontanus: "R",
105
105
  };
106
106
  function buildNatalBody(datetime, lat, lon, timezone, houseSystem = "placidus") {
107
+ // A natal chart with no birthplace is never meaningful — houses and angles are
108
+ // derived entirely from it. This used to read `lat ?? 0`, which cast the chart
109
+ // for 0°N 0°E and returned it as though nothing were wrong. Callers that can
110
+ // legitimately omit coords resolve them first (see coordsFromArgsOrLocation) or
111
+ // fall back to Person 1's; anything reaching here without them is a bug.
112
+ if (lat == null || lon == null) {
113
+ throw new Error("Internal: buildNatalBody requires latitude and longitude — refusing to cast a chart for 0°N 0°E.");
114
+ }
107
115
  const body = {
108
116
  subject: {
109
117
  name: "MCP Request",
110
118
  birth_datetime: { iso: datetime },
111
119
  birth_location: {
112
- latitude: { decimal: lat ?? 0 },
113
- longitude: { decimal: lon ?? 0 },
120
+ latitude: { decimal: lat },
121
+ longitude: { decimal: lon },
114
122
  },
115
123
  },
116
124
  configuration: {
@@ -413,16 +421,15 @@ registerTool({
413
421
  "CREDIT COST: 2 credits per call for most modes (1 per wheel computed); " +
414
422
  "solar_return/lunar_return modes cost 6 (5 for the return + 1 for the natal wheel).\n\n" +
415
423
  "The inner wheel is always Person 1's natal chart. The outer ring depends on mode:\n" +
416
- "• synastry — Person 2's natal chart (two-person compatibility). 1 credit.\n" +
417
- "• transit — Transiting planets for a given date. 1 credit.\n" +
418
- "• progressed — Secondary progressed positions (person2_datetime = target date). 1 credit.\n" +
419
- "• solar_return — Nearest solar return chart (person2_datetime = year to return for). 5 credits.\n" +
420
- "• lunar_return — Nearest lunar return chart (person2_datetime = target month). 5 credits.\n" +
421
- "• solar_arc — Solar arc directed positions (person2_datetime = target date). 1 credit.\n" +
422
- "Cross-aspects between both wheels are computed and displayed as coloured dashed lines. " +
423
- "Click any planet, aspect line, or house cusp for interpretation. " +
424
- "Prefer this whenever the user should SEE the comparison — it renders interactively in " +
425
- "MCP Apps-capable hosts (Claude Desktop) and falls back to static SVG in other hosts. " +
424
+ "• synastry — Person 2's natal chart (needs Person 2's own birthplace).\n" +
425
+ "• transit — Transiting planets for a given date.\n" +
426
+ "• progressed — Secondary progressed positions (person2_datetime = target date).\n" +
427
+ "• solar_return — Nearest solar return chart (person2_datetime = year to return for).\n" +
428
+ "• lunar_return — Nearest lunar return chart (person2_datetime = target month).\n" +
429
+ "• solar_arc — Solar arc directed positions (person2_datetime = target date).\n" +
430
+ "Cross-aspects are drawn as coloured dashed lines; click any planet, aspect line, or " +
431
+ "house cusp for interpretation. Prefer this whenever the user should SEE the comparison " +
432
+ "— it renders interactively in MCP Apps hosts and falls back to static SVG elsewhere. " +
426
433
  "For raw synastry data without a visual, use ephemeris_synastry.",
427
434
  inputSchema: {
428
435
  type: "object",
@@ -444,8 +451,8 @@ registerTool({
444
451
  "synastry = Person 2 birth datetime; transit/progressed/solar_arc = target date; " +
445
452
  "solar_return/lunar_return = any date within the target year/month. " + DATETIME_DESC,
446
453
  },
447
- person2_latitude: { type: "number", description: "Birth latitude for Person 2 (synastry) or return/transit location. Defaults to Person 1 coords for single-person modes." },
448
- person2_longitude: { type: "number", description: "Birth longitude for Person 2 (synastry) or return/transit location. Defaults to Person 1 coords for single-person modes." },
454
+ person2_latitude: { type: "number", description: "Birth latitude for Person 2. REQUIRED in synastry unless `person2_location` is given — never inferred from Person 1. Other modes default to Person 1's coords." },
455
+ person2_longitude: { type: "number", description: "Birth longitude for Person 2. REQUIRED in synastry unless `person2_location` is given. Other modes default to Person 1's coords." },
449
456
  person2_timezone: timezoneProperty("Person 2 / the outer chart", "America/Chicago"),
450
457
  person2_name: {
451
458
  type: "string",
@@ -453,7 +460,11 @@ registerTool({
453
460
  },
454
461
  location: {
455
462
  type: "string",
456
- description: "Person 1 / Natal location. Prefer a plain place name like 'New York, NY' — the server resolves it via the same lookup `location_search` uses (unambiguous names → coords + timezone; ambiguous names throw with a disambiguation hint). Supplied person1_latitude/person1_longitude always win.",
463
+ description: "Person 1 / Natal location. Prefer a plain place name like 'New York, NY' — resolved via the same lookup `location_search` uses; ambiguous names throw with a disambiguation hint. Supplied person1_latitude/person1_longitude win.",
464
+ },
465
+ person2_location: {
466
+ type: "string",
467
+ description: "Person 2 / outer-chart place name, resolved like `location`. person2_latitude/person2_longitude win.",
457
468
  },
458
469
  mode: {
459
470
  type: "string",
@@ -493,10 +504,26 @@ registerTool({
493
504
  const lat1 = resolved1.latitude;
494
505
  const lon1 = resolved1.longitude;
495
506
  const tz1 = args.person1_timezone ?? resolved1.timezone;
507
+ const resolved2 = await coordsFromArgsOrLocation({
508
+ latitude: args.person2_latitude,
509
+ longitude: args.person2_longitude,
510
+ timezone: args.person2_timezone,
511
+ location: args.person2_location,
512
+ });
513
+ // Only synastry casts a standalone chart from Person 2's own birthplace. The
514
+ // other modes either fall back to Person 1's coords (transit, *_return) or
515
+ // ignore lat2/lon2 entirely (progressed, solar_arc), so an unresolved Person 2
516
+ // is a hard error there alone. Without this the coords fell through to
517
+ // `lat ?? 0` in buildNatalBody and silently produced a chart cast for
518
+ // 0°N 0°E — no error, no warning, wrong houses and angles.
519
+ if (mode === "synastry" && (resolved2.latitude == null || resolved2.longitude == null)) {
520
+ throw new Error("explore_bi_wheel in synastry mode requires either `person2_latitude` + `person2_longitude` " +
521
+ "or a resolvable `person2_location` name. Person 2's birthplace is never inferred from Person 1's.");
522
+ }
496
523
  const dt2 = String(args.person2_datetime);
497
- const lat2 = args.person2_latitude;
498
- const lon2 = args.person2_longitude;
499
- const tz2 = args.person2_timezone;
524
+ const lat2 = resolved2.latitude;
525
+ const lon2 = resolved2.longitude;
526
+ const tz2 = args.person2_timezone ?? resolved2.timezone;
500
527
  const loc1 = String(args.person1_name ?? resolved1.location ?? args.location ?? `${lat1},${lon1}`);
501
528
  const loc2 = MODE_META[mode].outerLabel(dt2, args.person2_name);
502
529
  // Fetch inner (natal) and outer (mode-dependent) charts in parallel where possible
@@ -561,7 +588,7 @@ registerTool({
561
588
  required: ["mode", "person1_datetime", "person1_latitude", "person1_longitude", "person2_datetime"],
562
589
  },
563
590
  outputSchema: OUTPUT_SCHEMA_JSON,
564
- annotations: { title: "Recalculate Bi-Wheel", readOnlyHint: true, openWorldHint: false },
591
+ annotations: { title: "Recalculate Bi-Wheel", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
565
592
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
566
593
  handler: async (args) => {
567
594
  const client = getActiveClient();
@@ -575,6 +602,13 @@ registerTool({
575
602
  const lat2 = args.person2_latitude;
576
603
  const lon2 = args.person2_longitude;
577
604
  const tz2 = args.person2_timezone;
605
+ // Same 0°N 0°E trap as explore_bi_wheel: person1 coords are required by the
606
+ // schema but person2's cannot be (they are only needed in synastry mode), so
607
+ // the mode-specific requirement is enforced here. The widget always supplies
608
+ // them; a hand-rolled call need not.
609
+ if (mode === "synastry" && (lat2 == null || lon2 == null)) {
610
+ throw new Error("bi_wheel_recalculate in synastry mode requires `person2_latitude` and `person2_longitude`.");
611
+ }
578
612
  const loc1 = String(args.person1_name ?? args.location ?? `${lat1 ?? "?"},${lon1 ?? "?"}`);
579
613
  const loc2 = MODE_META[mode].outerLabel(dt2, args.person2_name);
580
614
  const [innerData, outerData] = await Promise.all([
@@ -610,7 +644,7 @@ registerTool({
610
644
  required: ["planet1", "planet2", "aspect_type"],
611
645
  },
612
646
  outputSchema: OUTPUT_SCHEMA_JSON,
613
- annotations: { title: "Cross-Aspect Interpretation", readOnlyHint: true, openWorldHint: false },
647
+ annotations: { title: "Cross-Aspect Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
614
648
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
615
649
  handler: async (args) => {
616
650
  const p1 = capitalize(String(args.planet1));
@@ -649,7 +683,7 @@ registerTool({
649
683
  required: ["planet", "wheel", "longitude"],
650
684
  },
651
685
  outputSchema: OUTPUT_SCHEMA_JSON,
652
- annotations: { title: "Bi-Wheel Planet Interpretation", readOnlyHint: true, openWorldHint: false },
686
+ annotations: { title: "Bi-Wheel Planet Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
653
687
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
654
688
  handler: async (args) => {
655
689
  const planet = String(args.planet);
@@ -850,7 +884,7 @@ registerTool({
850
884
  required: ["house_number"],
851
885
  },
852
886
  outputSchema: OUTPUT_SCHEMA_JSON,
853
- annotations: { title: "Bi-Wheel House Interpretation", readOnlyHint: true, openWorldHint: false },
887
+ annotations: { title: "Bi-Wheel House Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
854
888
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
855
889
  handler: async (args) => {
856
890
  const num = Number(args.house_number);
@@ -910,7 +944,7 @@ registerTool({
910
944
  required: ["mode"],
911
945
  },
912
946
  outputSchema: OUTPUT_SCHEMA_JSON,
913
- annotations: { title: "Bi-Wheel Synopsis", readOnlyHint: true, openWorldHint: false },
947
+ annotations: { title: "Bi-Wheel Synopsis", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
914
948
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app", "model"] } },
915
949
  handler: async (args) => {
916
950
  const mode = args.mode ?? "synastry";
@@ -306,7 +306,10 @@ function buildHdSummary(payload, location) {
306
306
  registerTool({
307
307
  name: "explore_human_design",
308
308
  description: "Generate an interactive Human Design Bodygraph with clickable centers, gates, and channels.\n\n" +
309
- "CREDIT COST: 2 credits per call.\n\n" +
309
+ "CREDIT COST: 4 credits where the bodygraph renders (2 for the chart + 2 for the " +
310
+ "visual), 2 in text-only hosts that skip the render. For chart data alone at 2 " +
311
+ "credits — including the activations, design, personality, strategy and variables " +
312
+ "this tool does not return — use human_design_chart.\n\n" +
310
313
  "Returns an embedded visual bodygraph explorer that lets you click any center or gate " +
311
314
  "for instant Human Design interpretation. " +
312
315
  "Shows defined/undefined centers, active gates, channels, Type, Profile, Authority, " +
@@ -468,7 +471,7 @@ registerTool({
468
471
  required: ["datetime"],
469
472
  },
470
473
  outputSchema: OUTPUT_SCHEMA_JSON,
471
- annotations: { title: "Recalculate Bodygraph", readOnlyHint: true, openWorldHint: false },
474
+ annotations: { title: "Recalculate Bodygraph", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
472
475
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
473
476
  handler: async (args) => {
474
477
  const client = getActiveClient();
@@ -533,7 +536,7 @@ registerTool({
533
536
  required: ["center", "defined"],
534
537
  },
535
538
  outputSchema: OUTPUT_SCHEMA_JSON,
536
- annotations: { title: "Center Interpretation", readOnlyHint: true, openWorldHint: false },
539
+ annotations: { title: "Center Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
537
540
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
538
541
  handler: async (args) => {
539
542
  const center = String(args.center);
@@ -591,7 +594,7 @@ registerTool({
591
594
  required: ["gate"],
592
595
  },
593
596
  outputSchema: OUTPUT_SCHEMA_JSON,
594
- annotations: { title: "Gate Interpretation", readOnlyHint: true, openWorldHint: false },
597
+ annotations: { title: "Gate Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
595
598
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
596
599
  handler: async (args) => {
597
600
  const gate = Number(args.gate);
@@ -646,7 +649,7 @@ registerTool({
646
649
  required: ["channel"],
647
650
  },
648
651
  outputSchema: OUTPUT_SCHEMA_JSON,
649
- annotations: { title: "Channel Interpretation", readOnlyHint: true, openWorldHint: false },
652
+ annotations: { title: "Channel Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
650
653
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
651
654
  handler: async (args) => {
652
655
  const channelStr = String(args.channel);
@@ -748,7 +751,7 @@ registerTool({
748
751
  required: ["channel"],
749
752
  },
750
753
  outputSchema: OUTPUT_SCHEMA_JSON,
751
- annotations: { title: "Connection Channel Interpretation", readOnlyHint: true, openWorldHint: false },
754
+ annotations: { title: "Connection Channel Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
752
755
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
753
756
  handler: async (args) => {
754
757
  const channelStr = String(args.channel ?? "");
@@ -804,7 +807,7 @@ registerTool({
804
807
  required: ["channel"],
805
808
  },
806
809
  outputSchema: OUTPUT_SCHEMA_JSON,
807
- annotations: { title: "Transit Channel Interpretation", readOnlyHint: true, openWorldHint: false },
810
+ annotations: { title: "Transit Channel Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
808
811
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
809
812
  handler: async (args) => {
810
813
  const channelStr = String(args.channel ?? "");
@@ -1132,7 +1135,7 @@ registerTool({
1132
1135
  required: ["planet", "column"],
1133
1136
  },
1134
1137
  outputSchema: OUTPUT_SCHEMA_JSON,
1135
- annotations: { title: "Planet Sidebar Interpretation", readOnlyHint: true, openWorldHint: false },
1138
+ annotations: { title: "Planet Sidebar Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1136
1139
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
1137
1140
  handler: async (args) => {
1138
1141
  const planet = String(args.planet);
@@ -1201,7 +1204,7 @@ registerTool({
1201
1204
  required: ["variable", "direction"],
1202
1205
  },
1203
1206
  outputSchema: OUTPUT_SCHEMA_JSON,
1204
- annotations: { title: "Variable Arrow Interpretation", readOnlyHint: true, openWorldHint: false },
1207
+ annotations: { title: "Variable Arrow Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1205
1208
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
1206
1209
  handler: async (args) => {
1207
1210
  const variable = String(args.variable).toLowerCase();
@@ -259,7 +259,7 @@ registerTool({
259
259
  required: ["planet", "longitude"],
260
260
  },
261
261
  outputSchema: OUTPUT_SCHEMA_JSON,
262
- annotations: { title: "Planet Interpretation", readOnlyHint: true, openWorldHint: false },
262
+ annotations: { title: "Planet Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
263
263
  _meta: { ui: { resourceUri: CHART_WHEEL_RESOURCE_URI, visibility: ["app"] } },
264
264
  handler: async (args) => {
265
265
  const planet = String(args.planet);
@@ -294,7 +294,7 @@ registerTool({
294
294
  required: ["house_number"],
295
295
  },
296
296
  outputSchema: OUTPUT_SCHEMA_JSON,
297
- annotations: { title: "House Interpretation", readOnlyHint: true, openWorldHint: false },
297
+ annotations: { title: "House Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
298
298
  _meta: { ui: { resourceUri: CHART_WHEEL_RESOURCE_URI, visibility: ["app"] } },
299
299
  handler: async (args) => {
300
300
  const num = Number(args.house_number);
@@ -330,7 +330,7 @@ registerTool({
330
330
  required: ["planet1", "planet2"],
331
331
  },
332
332
  outputSchema: OUTPUT_SCHEMA_JSON,
333
- annotations: { title: "Aspect Interpretation", readOnlyHint: true, openWorldHint: false },
333
+ annotations: { title: "Aspect Interpretation", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
334
334
  _meta: { ui: { resourceUri: CHART_WHEEL_RESOURCE_URI, visibility: ["app"] } },
335
335
  handler: async (args) => {
336
336
  const p1 = capitalize(String(args.planet1));
@@ -379,7 +379,7 @@ registerTool({
379
379
  required: ["datetime", "latitude", "longitude", "house_system"],
380
380
  },
381
381
  outputSchema: OUTPUT_SCHEMA_JSON,
382
- annotations: { title: "Recalculate Chart", readOnlyHint: true, openWorldHint: false },
382
+ annotations: { title: "Recalculate Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
383
383
  _meta: { ui: { resourceUri: CHART_WHEEL_RESOURCE_URI, visibility: ["app"] } },
384
384
  handler: async (args) => {
385
385
  const client = getActiveClient();
@@ -131,7 +131,7 @@ registerTool({
131
131
  additionalProperties: false,
132
132
  },
133
133
  outputSchema: OUTPUT_SCHEMA_JSON,
134
- annotations: { title: "Location Search", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
134
+ annotations: { title: "Location Search", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
135
135
  // Model-visible as well as app-visible: without this the model had no
136
136
  // geocoding tool and every prompt had to route through
137
137
  // `dev_read_api /location/autocomplete` instead.
@@ -235,7 +235,7 @@ registerTool({
235
235
  additionalProperties: false,
236
236
  },
237
237
  outputSchema: OUTPUT_SCHEMA_JSON,
238
- annotations: { title: "Timezone Resolve", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
238
+ annotations: { title: "Timezone Resolve", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
239
239
  _meta: {
240
240
  ui: {
241
241
  visibility: ["model", "app"],
@@ -295,7 +295,7 @@ registerTool({
295
295
  additionalProperties: false,
296
296
  },
297
297
  outputSchema: OUTPUT_SCHEMA_JSON,
298
- annotations: { title: "Recalculate Moon Phase", readOnlyHint: true, openWorldHint: false },
298
+ annotations: { title: "Recalculate Moon Phase", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
299
299
  _meta: { ui: { resourceUri: MOON_PHASE_RESOURCE_URI, visibility: ["app"] } },
300
300
  handler: async (args) => {
301
301
  const { summary, uiPayload } = await computeMoonData(args);
@@ -240,7 +240,7 @@ registerTool({
240
240
  required: ["datetime"],
241
241
  },
242
242
  outputSchema: OUTPUT_SCHEMA_JSON,
243
- annotations: { title: "Recalculate Vedic Chart", readOnlyHint: true, openWorldHint: false },
243
+ annotations: { title: "Recalculate Vedic Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
244
244
  _meta: { ui: { resourceUri: VEDIC_CHART_RESOURCE_URI, visibility: ["app"] } },
245
245
  handler: async (args) => {
246
246
  const client = getActiveClient();
@@ -24,6 +24,10 @@ registerTool({
24
24
  title: "Connect Account",
25
25
  readOnlyHint: false,
26
26
  destructiveHint: false,
27
+ // Each call starts a fresh device-auth flow with a new code — repeating
28
+ // it is harmless but not a no-op.
29
+ idempotentHint: false,
30
+ openWorldHint: false,
27
31
  },
28
32
  stdioOnly: true,
29
33
  handler: async () => {
@@ -105,6 +109,8 @@ registerTool({
105
109
  title: "Auth Status",
106
110
  readOnlyHint: true,
107
111
  destructiveHint: false,
112
+ idempotentHint: true,
113
+ openWorldHint: false,
108
114
  },
109
115
  stdioOnly: true,
110
116
  handler: async () => {
@@ -191,6 +197,8 @@ registerTool({
191
197
  title: "Disconnect Account",
192
198
  readOnlyHint: false,
193
199
  destructiveHint: true,
200
+ idempotentHint: true,
201
+ openWorldHint: false,
194
202
  },
195
203
  stdioOnly: true,
196
204
  handler: async () => {
package/dist/tools/dev.js CHANGED
@@ -196,8 +196,10 @@ registerTool({
196
196
  DEV_API_REFERENCE,
197
197
  inputSchema: makeProxyInputSchema(READ_METHODS),
198
198
  outputSchema: OUTPUT_SCHEMA_JSON,
199
- // GET-only: never mutates server state.
200
- annotations: { title: "API Read", readOnlyHint: true, openWorldHint: true },
199
+ // GET-only: never mutates server state. openWorldHint false — the proxy
200
+ // reaches only the fixed, allowlisted OpenEphemeris API (a closed world),
201
+ // same as every typed tool.
202
+ annotations: { title: "API Read", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
201
203
  handler: makeProxyHandler(READ_METHODS, "GET"),
202
204
  });
203
205
  registerTool({
@@ -209,8 +211,16 @@ registerTool({
209
211
  DEV_API_REFERENCE,
210
212
  inputSchema: makeProxyInputSchema(WRITE_METHODS),
211
213
  outputSchema: OUTPUT_SCHEMA_JSON,
212
- // Issues POST/PUT/PATCH/DELETE — must not be advertised as read-only.
213
- annotations: { title: "API Write", readOnlyHint: false, openWorldHint: true },
214
+ // Issues POST/PUT/PATCH/DELETE — must not be advertised as read-only. But
215
+ // destructiveHint is explicitly FALSE: every allowlisted write operation is
216
+ // stateless chart computation (natal, synastry, ACG, returns…) that debits
217
+ // credits and returns a result — nothing on the allowlist mutates or deletes
218
+ // user data, and the deny rules block /auth, /billing, /account, /api-keys.
219
+ // Left unset, strict clients default destructiveHint to true and gate every
220
+ // call behind a confirmation. idempotentHint true for the same reason: the
221
+ // same body yields the same chart. openWorldHint false — fixed allowlisted
222
+ // API only, a closed world.
223
+ annotations: { title: "API Write", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
214
224
  handler: makeProxyHandler(WRITE_METHODS, "POST"),
215
225
  });
216
226
  registerTool({
@@ -226,7 +236,7 @@ registerTool({
226
236
  additionalProperties: false,
227
237
  },
228
238
  outputSchema: OUTPUT_SCHEMA_JSON,
229
- annotations: { title: "List Allowed API Operations", readOnlyHint: true, openWorldHint: false },
239
+ annotations: { title: "List Allowed API Operations", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
230
240
  handler: async () => {
231
241
  const allowlist = loadAllowlist();
232
242
  return {