@openephemeris/mcp-server 4.17.0 → 4.19.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +10 -10
  3. package/dist/backend/client.d.ts +57 -1
  4. package/dist/backend/client.js +125 -15
  5. package/dist/index.js +8 -110
  6. package/dist/prompts.js +26 -24
  7. package/dist/server-sse.d.ts +18 -0
  8. package/dist/server-sse.js +48 -120
  9. package/dist/tools/apps/_render-token.d.ts +34 -0
  10. package/dist/tools/apps/_render-token.js +50 -0
  11. package/dist/tools/apps/bazi-app.d.ts +1 -1
  12. package/dist/tools/apps/bazi-app.js +39 -14
  13. package/dist/tools/apps/bi-wheel-app.d.ts +10 -3
  14. package/dist/tools/apps/bi-wheel-app.js +79 -119
  15. package/dist/tools/apps/bodygraph-app.d.ts +7 -4
  16. package/dist/tools/apps/bodygraph-app.js +101 -46
  17. package/dist/tools/apps/chart-wheel-app.d.ts +1 -1
  18. package/dist/tools/apps/chart-wheel-app.js +59 -18
  19. package/dist/tools/apps/location-tools.js +10 -3
  20. package/dist/tools/apps/moon-phase-app.d.ts +1 -1
  21. package/dist/tools/apps/moon-phase-app.js +13 -4
  22. package/dist/tools/apps/transit-timeline-app.d.ts +1 -1
  23. package/dist/tools/apps/transit-timeline-app.js +6 -6
  24. package/dist/tools/apps/ui-resource.d.ts +43 -0
  25. package/dist/tools/apps/ui-resource.js +66 -0
  26. package/dist/tools/apps/ui-resources.d.ts +22 -0
  27. package/dist/tools/apps/ui-resources.js +105 -0
  28. package/dist/tools/apps/vedic-chart-app.d.ts +1 -1
  29. package/dist/tools/apps/vedic-chart-app.js +13 -3
  30. package/dist/tools/datetime-historical.js +7 -2
  31. package/dist/tools/datetime.js +2 -1
  32. package/dist/tools/dev.js +7 -6
  33. package/dist/tools/specialized/electional.js +4 -3
  34. package/dist/tools/specialized/ephemeris_extended.js +19 -4
  35. package/dist/tools/specialized/hd_group.js +2 -2
  36. package/dist/tools/specialized/moon.d.ts +1 -1
  37. package/dist/tools/specialized/moon.js +51 -43
  38. package/dist/tools/specialized/progressed.js +2 -25
  39. package/dist/tools/specialized/transits.js +5 -5
  40. package/dist/ui/bazi.html +1199 -1161
  41. package/dist/ui/bi-wheel.html +573 -504
  42. package/dist/ui/bodygraph.html +437 -337
  43. package/dist/ui/chart-wheel.html +654 -584
  44. package/dist/ui/moon-phase.html +190 -155
  45. package/dist/ui/transit-timeline.html +865 -830
  46. package/dist/ui/vedic-chart.html +971 -934
  47. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,58 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [4.19.0] — 2026-09-27
11
+
12
+ ### Changed
13
+ - **The Human Design bodygraph is easier to read.** Gate numbers use a clean sans-serif and are
14
+ noticeably larger at chat width; the Design and Personality planet columns sit close to the body
15
+ under their own headings; open centres use the theme's colour instead of glaring white; channels
16
+ defined from both sides are drawn as two parallel stripes (Design red beside Personality); the
17
+ mandala's body is about 1.3× larger inside its rings.
18
+ - **Select a single activation.** Clicking a planet row (for example Personality Moon) highlights
19
+ only that activation's gate, stub and channel, never its Design twin. The rendered SVG tags every
20
+ element by side and activation, so your own CSS can do the same.
21
+ - **Every chart widget fits the chat better.** Widgets size themselves from the host; scrolling the
22
+ conversation past a chart scrolls instead of zooming (zoom with Ctrl/Cmd + scroll or a pinch); the
23
+ fullscreen button only appears where the host supports fullscreen; moon phase and transit timeline
24
+ support picture-in-picture; fullscreen uses one shared layout that stacks into a single column on
25
+ phones; all seven widgets share one colour and font set, and the host draws the card border.
26
+
27
+ ### Fixed
28
+ - Changing theme no longer drops a widget out of fullscreen.
29
+ - On moon phase and transit timeline, the info button no longer covers the fullscreen button.
30
+ - Bodygraph hover highlights work again.
31
+ - Widget resources are versioned, so hosts that cache them (such as ChatGPT) pick up new widget
32
+ versions after an update without reinstalling the app.
33
+
34
+ ## [4.18.0] — 2026-09-26
35
+
36
+ ### Fixed
37
+ - **`explore_bi_wheel` progressed, solar return and lunar return modes work again.** They sent
38
+ requests the API rejected ("Validation failed" / "birth_datetime is required"); all six modes now
39
+ return a chart.
40
+ - **Solar arc directions are exact.** `explore_bi_wheel` mode `solar_arc` used a flat ~1°-per-year
41
+ estimate and left the houses at their birth positions. It now uses the Sun's real arc from the API:
42
+ every planet, angle and house cusp is directed by the same arc, with correct signs and houses.
43
+ `ephemeris_progressed_chart` with `method: "solar_arc"` directs the angles and house cusps too.
44
+ - **Every lunar node is named Mean or True.** The charts show "North Node (Mean)", "North Node
45
+ (True)", "South Node (Mean)" and "South Node (True)" as four separate points. The two South Nodes
46
+ used to merge into one, and the mean North Node was shown as a bare "North Node". The Mean/True
47
+ toggle now applies to the South Node as well.
48
+ - **Bi-wheel recalculation keeps the house system you choose** for the outer chart too (it was always
49
+ Placidus).
50
+ - **Black Moon Lilith appears on the chart wheel, named Mean, True or Interpolated.** Asking
51
+ `explore_natal_chart` for `lilith` or `lilith_true` returned the chart without it. The three Liliths
52
+ are now shown as "Lilith (Mean)", "Lilith (True)" and "Lilith (Interpolated)" (new slug
53
+ `lilith_interpolated`), never merged into one.
54
+ - **`explore_natal_chart` with `bodies: ["all"]` works.** It was rejected because it asked for Vertex and
55
+ Part of Fortune as extra bodies; "all" now covers every body the chart can add.
56
+
57
+ ### Removed
58
+ - **`include_visual` on `ephemeris_progressed_chart`.** The API never draws a progressed chart
59
+ image, so the option returned no picture. It was never charged. To see progressions over the natal
60
+ chart, use `explore_bi_wheel` with mode `progressed`.
61
+
10
62
  ## [4.17.0] — 2026-09-24
11
63
 
12
64
  ### Added
package/README.md CHANGED
@@ -212,7 +212,7 @@ The server is hosted at `https://mcp.openephemeris.com/mcp` with full Streamable
212
212
 
213
213
  - Missing/invalid credentials (`401`): tool call fails with a message that points users to sign up/sign in at `https://openephemeris.com/login?signup=true&redirect=%2Fdashboard%3Ftab%3Daccount`, then create/manage keys in `https://openephemeris.com/dashboard?tab=account`.
214
214
  - Tier-gated endpoint (`403`): tool call returns an upgrade-required message with `https://openephemeris.com/pay` and dashboard billing/key management link.
215
- - Monthly quota exhausted (`402`): tool call returns usage quota guidance with both dashboard (`/dashboard?tab=account`) and upgrade (`/pay`) links.
215
+ - Out of credits (`402`): tool call returns a one-tap top-up link (Explorer's 150 free credits are one-time and do not reset; plan allowances renew each billing period) plus the dashboard usage link. Failed calls (any `4xx`/`5xx`) are refunded.
216
216
  - Burst/rate limit (`429`): tool call returns retry guidance and links to dashboard usage monitoring.
217
217
 
218
218
  ## What You Can Ask
@@ -256,13 +256,13 @@ instead of the picture, so nothing breaks, you just don't get the wheel.
256
256
  | Tool | What opens | What you can click | Credits |
257
257
  |---|---|---|---|
258
258
  | `explore_natal_chart` | Natal wheel — planets, houses, aspects, angles | Planets, houses, aspect lines; recalculate with new settings | 1 |
259
- | `explore_bi_wheel` | Two charts on one wheel: transits, synastry, progressions | Either wheel's planets, houses, and the aspects between them | 2 |
260
- | `explore_human_design` | Human Design bodygraph, with a mandala view toggle | Centers, gates, channels, planets, variables | 2 |
261
- | `explore_human_design_transit` | Today's planets laid over a natal bodygraph | Transit-activated channels | 3 |
262
- | `explore_human_design_connection` | Two bodygraphs combined, every shared channel classified | Connection channels by type | 3 |
259
+ | `explore_bi_wheel` | Two charts on one wheel: transits, synastry, progressions | Either wheel's planets, houses, and the aspects between them | 2 (6 for solar/lunar return) |
260
+ | `explore_human_design` | Human Design bodygraph, with a mandala view toggle | Centers, gates, channels, planets, variables | 4 |
261
+ | `explore_human_design_transit` | Today's planets laid over a natal bodygraph | Transit-activated channels | 5 |
262
+ | `explore_human_design_connection` | Two bodygraphs combined, every shared channel classified | Connection channels by type | 5 |
263
263
  | `explore_vedic_chart` | South Indian Rashi grid — sidereal placements and Lagna | Each rashi, for its placements and nakshatras | 3 |
264
264
  | `explore_bazi_chart` | Four Pillars (四柱命盘) — Year, Month, Day, Hour | Each pillar | 3 |
265
- | `explore_transit_timeline` | Upcoming transit hits in date order | Individual hits | 6 |
265
+ | `explore_transit_timeline` | Upcoming transit hits in date order | Individual hits | 6 for up to 1 year (priced by span) |
266
266
  | `explore_moon_phase` | Moon dial — illumination, phase, sign, void-of-course | Recalculate for another moment | 3 |
267
267
 
268
268
  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.
@@ -300,10 +300,10 @@ Screenshots of each are on the way.
300
300
  | Lunar return | `ephemeris_lunar_return` | Explorer |
301
301
  | Planetary return | `ephemeris_planetary_return` | Explorer |
302
302
  | Astrocartography lines | `acg_power_lines` | Developer |
303
- | ACG hits at location | `acg_hits` | Scale |
303
+ | ACG hits at location | `acg_hits` | Developer |
304
304
  | Venus Star Points | `venus_star_points` + 4 more | Explorer |
305
- | Chart wheel image | `ephemeris_chart_wheel` | Developer |
306
- | Bi-wheel image | `ephemeris_bi_wheel` | Developer |
305
+ | Chart wheel image | `ephemeris_chart_wheel` | Explorer |
306
+ | Bi-wheel image | `ephemeris_bi_wheel` | Explorer |
307
307
  | Dignities / Midpoints / Fixed stars | `ephemeris_dignities`, `ephemeris_midpoints`, `ephemeris_fixed_stars` | Explorer |
308
308
 
309
309
  ## Tooling Model
@@ -335,7 +335,7 @@ Screenshots of each are on the way.
335
335
  | `OPENEPHEMERIS_PROFILE` | No | `dev` by default |
336
336
  | `OPENEPHEMERIS_TOOLS` | No | `core` (default) advertises a focused everyday tool set; `full` advertises every tool. See [Tool surface](#tool-surface) |
337
337
  | `OPENEPHEMERIS_TELEMETRY` | No | Set to `0`/`false`/`off` to disable anonymous usage reporting. `DO_NOT_TRACK=1` also works. See [Telemetry](#telemetry) |
338
- | `OPENEPHEMERIS_SERVICE_KEY` | No | Internal service auth |
338
+ | `OPENEPHEMERIS_SERVICE_KEY` | No | Internal service auth (stdio only; the hosted server refuses to start with one and always authenticates as the signed-in user) |
339
339
  | `OPENEPHEMERIS_JWT` | No | Bearer token auth |
340
340
  | `OPENEPHEMERIS_DEV_ALLOWLIST_PATH` | No | Override allowlist file path |
341
341
  | `MCP_USER_ID` | No | Per-instance user identifier |
@@ -6,7 +6,29 @@ export interface BackendConfig {
6
6
  apiKey?: string;
7
7
  /** Back-compat: previous name for JWT token (Bearer). */
8
8
  authToken?: string;
9
+ /**
10
+ * When false, the client uses ONLY the credentials passed in this config and
11
+ * never falls back to the process environment (OPENEPHEMERIS_SERVICE_KEY,
12
+ * OPENEPHEMERIS_API_KEY, OPENEPHEMERIS_JWT and their legacy aliases).
13
+ *
14
+ * The hosted HTTP server builds one client per user session and MUST pass
15
+ * false: the interceptor sends X-Service-Key ahead of the user's own key, so
16
+ * an env credential inherited by a per-session client would make every
17
+ * user's calls unmetered (service key) or billed to one account (API key /
18
+ * JWT). Defaults to true so the stdio/local server keeps reading its config
19
+ * from the environment exactly as before.
20
+ */
21
+ inheritEnvCredentials?: boolean;
9
22
  }
23
+ /** Env vars holding a service key — bypasses metering; never valid on the hosted server. */
24
+ export declare const SERVICE_KEY_ENV_VARS: readonly ["OPENEPHEMERIS_SERVICE_KEY", "ASTROMCP_SERVICE_KEY", "MERIDIAN_SERVICE_KEY"];
25
+ /** Env vars holding one user's credential — on the hosted server they would bill every session to one account. */
26
+ export declare const USER_CREDENTIAL_ENV_VARS: readonly ["OPENEPHEMERIS_API_KEY", "ASTROMCP_API_KEY", "MERIDIAN_API_KEY", "OPENEPHEMERIS_JWT", "ASTROMCP_JWT", "MERIDIAN_AUTH_TOKEN"];
27
+ /** Names of the credential env vars that are set (non-empty) in `env`. */
28
+ export declare function presentCredentialEnvVars(env?: NodeJS.ProcessEnv): {
29
+ serviceKeys: string[];
30
+ userCredentials: string[];
31
+ };
10
32
  export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
11
33
  export interface BackendRequestOptions {
12
34
  params?: Record<string, unknown>;
@@ -25,8 +47,33 @@ export declare const DASHBOARD_ACCOUNT_URL = "https://openephemeris.com/dashboar
25
47
  export declare const LOGIN_SIGNUP_URL = "https://openephemeris.com/login?signup=true&redirect=%2Fdashboard%3Ftab%3Daccount";
26
48
  export declare const UPGRADE_URL = "https://openephemeris.com/pricing";
27
49
  /** One-tap $5 → 150-credit top-up (signs the user in if needed, then redirects to the prefilled Stripe Payment Link). */
28
- export declare const TOPUP_URL = "https://openephemeris.com/topup?pack=payg_5";
50
+ export declare const TOPUP_URL = "https://openephemeris.com/topup?pack=payg_5&src=mcp_402";
51
+ /**
52
+ * The API's 402 carries `upgrade.topup_url`: the same /topup link, signed with
53
+ * the caller's account so it opens Stripe checkout without a sign-in step
54
+ * (most people hitting the wall are in ChatGPT/Claude and not signed in on the
55
+ * site). Use it when present, re-tagged src=mcp_402 so the funnel can tell MCP
56
+ * clicks from raw API ones; otherwise fall back to the unsigned link.
57
+ */
58
+ export declare function resolveTopupUrl(apiTopupUrl: unknown): string;
29
59
  export declare const WALLET_TOPUP_URL = "https://openephemeris.com/wallet";
60
+ /**
61
+ * Whether an error body (typically a 422) is really the credit wall: the
62
+ * detail / type / field codes mention insufficient credits or an exceeded
63
+ * quota. Checked over the whole serialized body because the signal can sit in
64
+ * `detail`, `type`, or a nested `errors[].code`.
65
+ */
66
+ export declare function looksLikeCreditExhaustion(data: unknown, msg?: string): boolean;
67
+ /**
68
+ * True for errors that mean "this account cannot be charged / is not allowed"
69
+ * — the credit wall (402), missing auth (401), or a tier gate (403). Tools
70
+ * that fan out or degrade gracefully must rethrow these instead of swallowing
71
+ * them: a placeholder result at zero credits hides the paywall (and its
72
+ * top-up link) from the assistant and reads as real data.
73
+ */
74
+ export declare function isBillingOrAuthError(err: unknown): err is BackendError;
75
+ /** Rethrows the first billing/auth BackendError among settled results, if any. */
76
+ export declare function rethrowBillingOrAuth(results: PromiseSettledResult<unknown>[]): void;
30
77
  export declare class BackendError extends Error {
31
78
  readonly status: number;
32
79
  readonly code: string;
@@ -59,6 +106,15 @@ export declare class BackendClient {
59
106
  * (JWT is the lowest-priority credential in the interceptor).
60
107
  */
61
108
  setJwt(jwt: string): void;
109
+ /**
110
+ * Drop every credential this client holds (service key, API key, JWT). The
111
+ * hosted HTTP server calls this on the module singleton at startup: the
112
+ * singleton is what getActiveClient() falls back to outside a session
113
+ * context, and it was built from the environment at import time.
114
+ */
115
+ clearCredentials(): void;
116
+ /** Which configured credential the interceptor will send (diagnostics/tests). */
117
+ credentialKind(): "service_key" | "api_key" | "jwt" | null;
62
118
  /**
63
119
  * Start the device auth flow in the background if not already running.
64
120
  * Called automatically when no credentials are found in the interceptor.
@@ -2,6 +2,29 @@ import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import axios, { AxiosError } from "axios";
3
3
  import { CredentialManager } from "../auth/credentials.js";
4
4
  import { DeviceAuthFlow } from "../auth/device-auth.js";
5
+ /** Env vars holding a service key — bypasses metering; never valid on the hosted server. */
6
+ export const SERVICE_KEY_ENV_VARS = [
7
+ "OPENEPHEMERIS_SERVICE_KEY",
8
+ "ASTROMCP_SERVICE_KEY",
9
+ "MERIDIAN_SERVICE_KEY",
10
+ ];
11
+ /** Env vars holding one user's credential — on the hosted server they would bill every session to one account. */
12
+ export const USER_CREDENTIAL_ENV_VARS = [
13
+ "OPENEPHEMERIS_API_KEY",
14
+ "ASTROMCP_API_KEY",
15
+ "MERIDIAN_API_KEY",
16
+ "OPENEPHEMERIS_JWT",
17
+ "ASTROMCP_JWT",
18
+ "MERIDIAN_AUTH_TOKEN",
19
+ ];
20
+ /** Names of the credential env vars that are set (non-empty) in `env`. */
21
+ export function presentCredentialEnvVars(env = process.env) {
22
+ const isSet = (name) => typeof env[name] === "string" && env[name].trim() !== "";
23
+ return {
24
+ serviceKeys: SERVICE_KEY_ENV_VARS.filter(isSet),
25
+ userCredentials: USER_CREDENTIAL_ENV_VARS.filter(isSet),
26
+ };
27
+ }
5
28
  const DEFAULT_TIMEOUT_MS = 45_000;
6
29
  const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]; // Three retries with backoff
7
30
  // NOTE: 429 is intentionally NOT retryable. Every expensive compute endpoint is
@@ -23,8 +46,64 @@ export const DASHBOARD_ACCOUNT_URL = "https://openephemeris.com/dashboard?tab=ac
23
46
  export const LOGIN_SIGNUP_URL = "https://openephemeris.com/login?signup=true&redirect=%2Fdashboard%3Ftab%3Daccount";
24
47
  export const UPGRADE_URL = "https://openephemeris.com/pricing";
25
48
  /** One-tap $5 → 150-credit top-up (signs the user in if needed, then redirects to the prefilled Stripe Payment Link). */
26
- export const TOPUP_URL = "https://openephemeris.com/topup?pack=payg_5";
49
+ export const TOPUP_URL = "https://openephemeris.com/topup?pack=payg_5&src=mcp_402";
50
+ /**
51
+ * The API's 402 carries `upgrade.topup_url`: the same /topup link, signed with
52
+ * the caller's account so it opens Stripe checkout without a sign-in step
53
+ * (most people hitting the wall are in ChatGPT/Claude and not signed in on the
54
+ * site). Use it when present, re-tagged src=mcp_402 so the funnel can tell MCP
55
+ * clicks from raw API ones; otherwise fall back to the unsigned link.
56
+ */
57
+ export function resolveTopupUrl(apiTopupUrl) {
58
+ if (typeof apiTopupUrl !== "string")
59
+ return TOPUP_URL;
60
+ try {
61
+ const url = new URL(apiTopupUrl);
62
+ if (url.protocol !== "https:" || url.hostname !== "openephemeris.com" || url.pathname !== "/topup") {
63
+ return TOPUP_URL;
64
+ }
65
+ url.searchParams.set("src", "mcp_402");
66
+ return url.toString();
67
+ }
68
+ catch {
69
+ return TOPUP_URL;
70
+ }
71
+ }
27
72
  export const WALLET_TOPUP_URL = "https://openephemeris.com/wallet";
73
+ const CREDIT_EXHAUSTION_RE = /credits?_insufficient|insufficient[ _-]?credits?|not enough credits|out of credits|credits?[ _-]exhausted|quota[ _-]exceeded|usage_quota|requires \d+ credits/i;
74
+ /**
75
+ * Whether an error body (typically a 422) is really the credit wall: the
76
+ * detail / type / field codes mention insufficient credits or an exceeded
77
+ * quota. Checked over the whole serialized body because the signal can sit in
78
+ * `detail`, `type`, or a nested `errors[].code`.
79
+ */
80
+ export function looksLikeCreditExhaustion(data, msg) {
81
+ let text = msg ?? "";
82
+ try {
83
+ text += " " + (typeof data === "string" ? data : JSON.stringify(data ?? ""));
84
+ }
85
+ catch {
86
+ // unserializable body — fall back to the message alone
87
+ }
88
+ return CREDIT_EXHAUSTION_RE.test(text);
89
+ }
90
+ /**
91
+ * True for errors that mean "this account cannot be charged / is not allowed"
92
+ * — the credit wall (402), missing auth (401), or a tier gate (403). Tools
93
+ * that fan out or degrade gracefully must rethrow these instead of swallowing
94
+ * them: a placeholder result at zero credits hides the paywall (and its
95
+ * top-up link) from the assistant and reads as real data.
96
+ */
97
+ export function isBillingOrAuthError(err) {
98
+ return err instanceof BackendError && (err.status === 402 || err.status === 401 || err.status === 403);
99
+ }
100
+ /** Rethrows the first billing/auth BackendError among settled results, if any. */
101
+ export function rethrowBillingOrAuth(results) {
102
+ for (const r of results) {
103
+ if (r.status === "rejected" && isBillingOrAuthError(r.reason))
104
+ throw r.reason;
105
+ }
106
+ }
28
107
  function sleep(ms) {
29
108
  return new Promise((resolve) => setTimeout(resolve, ms));
30
109
  }
@@ -53,22 +132,24 @@ export class BackendClient {
53
132
  _authFlowResult = null;
54
133
  constructor(config) {
55
134
  this.userId = config.userId || "anonymous";
135
+ // See BackendConfig.inheritEnvCredentials — per-session hosted clients opt out.
136
+ const env = config.inheritEnvCredentials === false ? {} : process.env;
56
137
  this.jwt =
57
138
  config.jwt ||
58
139
  config.authToken ||
59
- process.env.OPENEPHEMERIS_JWT ||
60
- process.env.ASTROMCP_JWT ||
61
- process.env.MERIDIAN_AUTH_TOKEN;
140
+ env.OPENEPHEMERIS_JWT ||
141
+ env.ASTROMCP_JWT ||
142
+ env.MERIDIAN_AUTH_TOKEN;
62
143
  this.serviceKey =
63
144
  config.serviceKey ||
64
- process.env.OPENEPHEMERIS_SERVICE_KEY ||
65
- process.env.ASTROMCP_SERVICE_KEY ||
66
- process.env.MERIDIAN_SERVICE_KEY;
145
+ env.OPENEPHEMERIS_SERVICE_KEY ||
146
+ env.ASTROMCP_SERVICE_KEY ||
147
+ env.MERIDIAN_SERVICE_KEY;
67
148
  this.apiKey =
68
149
  config.apiKey ||
69
- process.env.OPENEPHEMERIS_API_KEY ||
70
- process.env.ASTROMCP_API_KEY ||
71
- process.env.MERIDIAN_API_KEY;
150
+ env.OPENEPHEMERIS_API_KEY ||
151
+ env.ASTROMCP_API_KEY ||
152
+ env.MERIDIAN_API_KEY;
72
153
  this.credentialManager = new CredentialManager();
73
154
  this.client = axios.create({
74
155
  baseURL: config.baseURL ||
@@ -138,6 +219,27 @@ export class BackendClient {
138
219
  setJwt(jwt) {
139
220
  this.jwt = jwt;
140
221
  }
222
+ /**
223
+ * Drop every credential this client holds (service key, API key, JWT). The
224
+ * hosted HTTP server calls this on the module singleton at startup: the
225
+ * singleton is what getActiveClient() falls back to outside a session
226
+ * context, and it was built from the environment at import time.
227
+ */
228
+ clearCredentials() {
229
+ this.serviceKey = undefined;
230
+ this.apiKey = undefined;
231
+ this.jwt = undefined;
232
+ }
233
+ /** Which configured credential the interceptor will send (diagnostics/tests). */
234
+ credentialKind() {
235
+ if (this.serviceKey)
236
+ return "service_key";
237
+ if (this.apiKey)
238
+ return "api_key";
239
+ if (this.jwt)
240
+ return "jwt";
241
+ return null;
242
+ }
141
243
  /**
142
244
  * Start the device auth flow in the background if not already running.
143
245
  * Called automatically when no credentials are found in the interceptor.
@@ -270,7 +372,12 @@ export class BackendClient {
270
372
  `through Claude's connector settings, your session expired: in Claude's connector ` +
271
373
  `settings, toggle OpenEphemeris off and on to reconnect — this only takes a moment.`, 401, "auth_required", false, LOGIN_SIGNUP_URL);
272
374
  }
273
- if (status === 402) {
375
+ // A 422 whose body says the caller ran out of credits is the credit wall
376
+ // wearing a validation status (the Go visual add-on reservation returned
377
+ // 422 `credits_insufficient` before it moved to 402). Route it through
378
+ // the same 402 path so the assistant gets the one credit-wall message
379
+ // and top-up link, not "Validation error: ...".
380
+ if (status === 402 || (status === 422 && looksLikeCreditExhaustion(data, msg))) {
274
381
  // PRICING SSOT: apps/web/config/billing-plans.ts — Explorer grant
275
382
  // (EXPLORER.creditsNumeric = 150, one-time), Pro/`developer`
276
383
  // (monthlyPrice = 29, credits = 75,000), and CREDIT_TOPUPS
@@ -289,18 +396,21 @@ export class BackendClient {
289
396
  // One direct link, not a dashboard tab: /topup?pack=payg_5 signs the
290
397
  // user in if needed and redirects straight to the prefilled Stripe
291
398
  // Payment Link for the $5 → 150-credit pack.
399
+ const topupUrl = resolveTopupUrl(dataObj.upgrade?.topup_url);
292
400
  let upsellMsg = "";
293
- let actionUrl = TOPUP_URL;
401
+ let actionUrl = topupUrl;
294
402
  if (currentTier === 'explorer') {
295
403
  upsellMsg =
296
- `Let the user know warmly: Their 150 free credits are used up. ` +
297
- `$5 gets 150 more — one tap: ${TOPUP_URL}. ` +
404
+ `Let the user know warmly: Their 150 free credits are used up (they do not reset). ` +
405
+ `$5 gets 150 more — one tap, no sign-in, opens checkout directly: ${topupUrl} ` +
406
+ `(show this exact link to the user as a clickable link). ` +
298
407
  `Or Pro ($29/mo, 75,000 credits): ${UPGRADE_URL}?plan=developer`;
299
408
  }
300
409
  else if (currentTier === 'payg') {
301
410
  upsellMsg =
302
411
  `Let the user know warmly: Their wallet balance is empty. ` +
303
- `$5 gets 150 more credits — one tap: ${TOPUP_URL}. ` +
412
+ `$5 gets 150 more credits — one tap, no sign-in, opens checkout directly: ${topupUrl} ` +
413
+ `(show this exact link to the user as a clickable link). ` +
304
414
  `Or Pro ($29/mo, 75,000 credits): ${UPGRADE_URL}?plan=developer`;
305
415
  }
306
416
  else {
package/dist/index.js CHANGED
@@ -10,14 +10,8 @@ import { buildUiMeta } from "./tools/ui-meta.js";
10
10
  import { SERVER_INSTRUCTIONS } from "./instructions.js";
11
11
  import { captureEvent, captureFirstToolCallOnce, distinctIdFor } from "./analytics.js";
12
12
  import { listPrompts, getPromptContent } from "./prompts.js";
13
- // ── MCP App resource imports ────────────────────────────────────────────────
14
- import { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
15
- import { BODYGRAPH_RESOURCE_URI, BODYGRAPH_MIME_TYPE, getBodygraphBundle, } from "./tools/apps/bodygraph-app.js";
16
- import { BI_WHEEL_RESOURCE_URI, BI_WHEEL_MIME_TYPE, getBiWheelBundle, } from "./tools/apps/bi-wheel-app.js";
17
- import { MOON_PHASE_RESOURCE_URI, MOON_PHASE_MIME_TYPE, getMoonPhaseBundle, } from "./tools/apps/moon-phase-app.js";
18
- import { TRANSIT_TIMELINE_RESOURCE_URI, TRANSIT_TIMELINE_MIME_TYPE, getTransitTimelineBundle, } from "./tools/apps/transit-timeline-app.js";
19
- import { VEDIC_CHART_RESOURCE_URI, VEDIC_CHART_MIME_TYPE, getVedicChartBundle, } from "./tools/apps/vedic-chart-app.js";
20
- import { BAZI_RESOURCE_URI, BAZI_MIME_TYPE, getBaziBundle, } from "./tools/apps/bazi-app.js";
13
+ // ── MCP App resources (list/read registry shared with server-sse.ts) ────────
14
+ import { listUiResources, readUiResource } from "./tools/apps/ui-resources.js";
21
15
  // ─────────────────────────────────────────────────────────────────────────────
22
16
  function resolveServerVersion() {
23
17
  try {
@@ -157,112 +151,16 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
157
151
  ],
158
152
  };
159
153
  });
160
- // List available resources
161
154
  server.setRequestHandler(ListResourcesRequestSchema, async () => {
162
- const resources = [];
163
- if (getChartWheelBundle()) {
164
- resources.push({
165
- uri: CHART_WHEEL_RESOURCE_URI,
166
- name: "Chart Wheel Explorer",
167
- description: "Interactive natal chart wheel with clickable planets, houses, and aspects.",
168
- mimeType: CHART_WHEEL_MIME_TYPE,
169
- });
170
- }
171
- if (getBodygraphBundle()) {
172
- resources.push({
173
- uri: BODYGRAPH_RESOURCE_URI,
174
- name: "Human Design Bodygraph Explorer",
175
- description: "Interactive Human Design Bodygraph with clickable centers, gates, and channels.",
176
- mimeType: BODYGRAPH_MIME_TYPE,
177
- });
178
- }
179
- if (getBiWheelBundle()) {
180
- resources.push({
181
- uri: BI_WHEEL_RESOURCE_URI,
182
- name: "Bi-Wheel Explorer",
183
- description: "Interactive synastry or transit bi-wheel with cross-aspect lines and clickable planets.",
184
- mimeType: BI_WHEEL_MIME_TYPE,
185
- });
186
- }
187
- if (getMoonPhaseBundle()) {
188
- resources.push({
189
- uri: MOON_PHASE_RESOURCE_URI,
190
- name: "Moon Phase Explorer",
191
- description: "Interactive lunar phase dial showing illumination, sign, and void-of-course status.",
192
- mimeType: MOON_PHASE_MIME_TYPE,
193
- });
194
- }
195
- if (getTransitTimelineBundle()) {
196
- resources.push({
197
- uri: TRANSIT_TIMELINE_RESOURCE_URI,
198
- name: "Transit Timeline Explorer",
199
- description: "Interactive vertical timeline of upcoming transit hits grouped by month, with clickable events.",
200
- mimeType: TRANSIT_TIMELINE_MIME_TYPE,
201
- });
202
- }
203
- if (getVedicChartBundle()) {
204
- resources.push({
205
- uri: VEDIC_CHART_RESOURCE_URI,
206
- name: "Vedic Chart Explorer",
207
- description: "Interactive Vedic (Jyotish) South Indian Rashi grid with clickable sign placements.",
208
- mimeType: VEDIC_CHART_MIME_TYPE,
209
- });
210
- }
211
- if (getBaziBundle()) {
212
- resources.push({
213
- uri: BAZI_RESOURCE_URI,
214
- name: "BaZi Four Pillars Explorer",
215
- description: "Interactive BaZi (Four Pillars of Destiny) chart with clickable Year/Month/Day/Hour pillars.",
216
- mimeType: BAZI_MIME_TYPE,
217
- });
218
- }
219
- return { resources };
155
+ // One registry for both transports: versioned ui:// URIs + resource _meta
156
+ // (prefersBorder, CSP, widgetDescription). See tools/apps/ui-resources.ts.
157
+ return { resources: listUiResources() };
220
158
  });
221
- // Read resource
222
159
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
223
160
  const uri = request.params.uri;
224
- if (uri === CHART_WHEEL_RESOURCE_URI) {
225
- const bundle = getChartWheelBundle();
226
- if (!bundle)
227
- throw new Error("Chart Wheel UI bundle not found. Run `npm run build:ui` to build it.");
228
- return { contents: [{ uri: CHART_WHEEL_RESOURCE_URI, mimeType: CHART_WHEEL_MIME_TYPE, text: bundle }] };
229
- }
230
- if (uri === BODYGRAPH_RESOURCE_URI) {
231
- const bundle = getBodygraphBundle();
232
- if (!bundle)
233
- throw new Error("Bodygraph UI bundle not found. Run `npm run build:ui` to build it.");
234
- return { contents: [{ uri: BODYGRAPH_RESOURCE_URI, mimeType: BODYGRAPH_MIME_TYPE, text: bundle }] };
235
- }
236
- if (uri === BI_WHEEL_RESOURCE_URI) {
237
- const bundle = getBiWheelBundle();
238
- if (!bundle)
239
- throw new Error("Bi-Wheel UI bundle not found. Run `npm run build:ui` to build it.");
240
- return { contents: [{ uri: BI_WHEEL_RESOURCE_URI, mimeType: BI_WHEEL_MIME_TYPE, text: bundle }] };
241
- }
242
- if (uri === MOON_PHASE_RESOURCE_URI) {
243
- const bundle = getMoonPhaseBundle();
244
- if (!bundle)
245
- throw new Error("Moon Phase UI bundle not found. Run `npm run build:ui` to build it.");
246
- return { contents: [{ uri: MOON_PHASE_RESOURCE_URI, mimeType: MOON_PHASE_MIME_TYPE, text: bundle }] };
247
- }
248
- if (uri === TRANSIT_TIMELINE_RESOURCE_URI) {
249
- const bundle = getTransitTimelineBundle();
250
- if (!bundle)
251
- throw new Error("Transit Timeline UI bundle not found. Run `npm run build:ui` to build it.");
252
- return { contents: [{ uri: TRANSIT_TIMELINE_RESOURCE_URI, mimeType: TRANSIT_TIMELINE_MIME_TYPE, text: bundle }] };
253
- }
254
- if (uri === VEDIC_CHART_RESOURCE_URI) {
255
- const bundle = getVedicChartBundle();
256
- if (!bundle)
257
- throw new Error("Vedic Chart UI bundle not found. Run `npm run build:ui` to build it.");
258
- return { contents: [{ uri: VEDIC_CHART_RESOURCE_URI, mimeType: VEDIC_CHART_MIME_TYPE, text: bundle }] };
259
- }
260
- if (uri === BAZI_RESOURCE_URI) {
261
- const bundle = getBaziBundle();
262
- if (!bundle)
263
- throw new Error("BaZi UI bundle not found. Run `npm run build:ui` to build it.");
264
- return { contents: [{ uri: BAZI_RESOURCE_URI, mimeType: BAZI_MIME_TYPE, text: bundle }] };
265
- }
161
+ const result = readUiResource(uri);
162
+ if (result)
163
+ return result;
266
164
  throw new Error(`Unknown resource: ${uri}`);
267
165
  });
268
166
  // Handle tool calls
package/dist/prompts.js CHANGED
@@ -36,8 +36,8 @@ export const PROMPTS = [
36
36
  "| `solar_return_year_ahead` | Annual Solar Return chart — themes for the coming birthday year |\n" +
37
37
  "| `developer_api_integration` | Help a dev integrate the Open Ephemeris API into their app |\n\n" +
38
38
  "## Before Calling Any Tool — Three Rules\n" +
39
- "1. **Coordinates first**: Convert city names to decimal lat/lon before calling tools " +
40
- "(e.g. Chicago → 41.8781, -87.6298). Positive = North/East; negative = South/West.\n" +
39
+ "1. **Coordinates first**: Resolve place names with `location_search` — never recall " +
40
+ "coordinates from memory. Positive = North/East; negative = South/West.\n" +
41
41
  "2. **Datetime format**: Pass birth times as local ISO 8601 + separate `timezone` IANA name " +
42
42
  "(e.g. `America/Chicago`), OR as a UTC-offset datetime `1990-04-15T14:30:00-05:00`. " +
43
43
  "Never append `Z` to a local birth time — `Z` means UTC.\n" +
@@ -870,7 +870,7 @@ export const PROMPTS = [
870
870
  "| Feature | MCP Tool | REST Endpoint | Credits |\n" +
871
871
  "|---------|----------|---------------|---------|\n" +
872
872
  "| Natal chart | `ephemeris_natal_chart` | `POST /ephemeris/natal-chart` | 1 |\n" +
873
- "| Natal batch (up to 100) | `ephemeris_natal_batch` | `POST /ephemeris/natal/batch` | 1/subject |\n" +
873
+ "| Natal batch (up to 100, Startup+) | `ephemeris_natal_batch` | `POST /ephemeris/natal/batch` | 1/subject |\n" +
874
874
  "| Vedic / Jyotish chart | `vedic_chart` | `POST /vedic/chart` | 1 |\n" +
875
875
  "| Human Design | `human_design_chart` | `POST /human-design/chart` | 2 |\n" +
876
876
  "| BaZi Four Pillars | `chinese_bazi` | `POST /chinese/bazi` | 1 |\n" +
@@ -879,38 +879,39 @@ export const PROMPTS = [
879
879
  "| Midpoint composite | `ephemeris_composite_midpoint` | `POST /comparative/composite/midpoint` | 3 |\n" +
880
880
  "| House overlay | `ephemeris_overlay` | `POST /comparative/overlay` | 3 |\n" +
881
881
  "| Natal transits (now) | `ephemeris_natal_transits` | `POST /comparative/natal-transits` | 3 |\n" +
882
- "| Transit search (range) | `ephemeris_transits` | `POST /predictive/transits/search` | 6 |\n" +
882
+ "| Transit search (range) | `ephemeris_transits` | `POST /predictive/transits/search` | 5–70 by span (≤1y 5 … ≤40y 70); tool +1 for the natal chart |\n" +
883
883
  "| Relocation chart | `ephemeris_relocation` | `POST /ephemeris/relocation` | 1 |\n" +
884
884
  "| Solar return | `ephemeris_solar_return` | `POST /predictive/returns/solar` | 5 |\n" +
885
885
  "| Lunar return | `ephemeris_lunar_return` | `POST /predictive/returns/lunar` | 5 |\n" +
886
886
  "| Progressions | `ephemeris_progressed_chart` | `POST /ephemeris/progressed` | 1 |\n" +
887
- "| ACG power lines | `acg_power_lines` | `POST /acg/power-lines` | 10 |\n" +
888
- "| ACG city hits | `acg_hits` | `POST /acg/hits` | 15 |\n" +
887
+ "| ACG power lines (Pro+) | `acg_power_lines` | `POST /acg/power-lines` | 10 |\n" +
888
+ "| ACG city hits (Pro+) | `acg_hits` | `POST /acg/hits` | 10 |\n" +
889
889
  "| Eclipse finder | `ephemeris_next_eclipse` | `GET /eclipse/next-visible` | 1 |\n" +
890
- "| Moon phase (current) | `ephemeris_moon_phase` | `GET /ephemeris/moon/phase` | 1 |\n" +
891
- "| Next lunar phase | `ephemeris_next_lunar_phase` | `GET /calendar/astrology/moon-phases` | 1 |\n" +
892
- "| Electional windows | `ephemeris_electional` | `GET /electional/find-window` | 5 |\n" +
893
- "| Electional score | `electional_moment_analysis` | `GET /electional/moment-analysis` | 1 |\n" +
890
+ "| Moon phase (current) | `ephemeris_moon_phase` | `GET /ephemeris/moon/phase` + `/void-of-course` | 2 |\n" +
891
+ "| Next lunar phase | `ephemeris_next_lunar_phase` | `GET /calendar/astrology/moon-phases` | 2 per occurrence |\n" +
892
+ "| Electional windows (Pro+) | `ephemeris_electional` | `GET /electional/find-window` | 5 / 8 / 12 for ≤30 / 60 / 120 days |\n" +
893
+ "| Electional score | `electional_moment_analysis` | `GET /electional/moment-analysis` | 5 |\n" +
894
894
  "| Dignities | `ephemeris_dignities` | `POST /ephemeris/dignities` | 1 |\n" +
895
895
  "| Hermetic lots | `ephemeris_hermetic_lots` | `POST /ephemeris/hermetic-lots` | 1 |\n" +
896
896
  "| Midpoints | `ephemeris_midpoints` | `POST /ephemeris/midpoints` | 1 |\n\n" +
897
897
  "## Step 3 — Critical Implementation Patterns\n\n" +
898
898
  "### ⚠️ Datetime Handling — Most Common Source of Bugs\n" +
899
899
  "```\n" +
900
- "# CORRECT — Local birth time + IANA timezone (Western astrology)\n" +
900
+ "# CORRECT — local wall-clock time + IANA zone (server applies historical DST)\n" +
901
901
  "POST /ephemeris/natal-chart\n" +
902
- "{ \"datetime\": \"1990-04-15T14:30:00\", \"timezone\": \"America/Chicago\" }\n\n" +
903
- "# ALSO CORRECT — Local time with UTC offset embedded\n" +
904
- "{ \"datetime\": \"1990-04-15T14:30:00-05:00\" }\n\n" +
905
- "# CORRECT — UTC required for HD and Vedic\n" +
902
+ "{ \"subject\": { \"name\": \"A\", \"birth_datetime\": { \"iso\": \"1990-04-15T14:30:00\" },\n" +
903
+ " \"birth_location\": { \"latitude\": { \"decimal\": 41.88 }, \"longitude\": { \"decimal\": -87.63 },\n" +
904
+ " \"timezone\": { \"iana_name\": \"America/Chicago\" } } } }\n\n" +
906
905
  "POST /human-design/chart\n" +
907
- "{ \"datetime\": \"1990-04-15T19:30:00Z\" } // Z = UTC\n\n" +
908
- "# WRONG — Never append Z to a local time\n" +
909
- "{ \"datetime\": \"1990-04-15T14:30:00Z\" } // ❌ reads as UTC 2:30pm, not Central 2:30pm\n" +
906
+ "{ \"birth_datetime_local\": \"1990-04-15T14:30:00\", \"timezone\": { \"iana_name\": \"America/Chicago\" },\n" +
907
+ " \"latitude\": 41.88, \"longitude\": -87.63 }\n\n" +
908
+ "# ALSO CORRECT — an explicit instant (offset or Z): trusted as-is\n" +
909
+ "\"1990-04-15T14:30:00-05:00\" or \"1990-04-15T19:30:00Z\"\n\n" +
910
+ "# WRONG — Z on a local time reads as UTC 2:30pm, not Central 2:30pm (no error raised)\n" +
911
+ "\"1990-04-15T14:30:00Z\"\n" +
910
912
  "```\n\n" +
911
- "**Rule of thumb**:\n" +
912
- "- Natal charts (Western, BaZi, electional): local time + `timezone` IANA name\n" +
913
- "- Human Design, Vedic: always UTC with Z\n\n" +
913
+ "**Rule of thumb**: on every endpoint, prefer local time + IANA zone. A zone-less clock time " +
914
+ "with no zone is a hard 400; a bare date resolves to 12:00 UTC.\n\n" +
914
915
  "### Coordinates\n" +
915
916
  "- Decimal degrees, at least 4 decimal places\n" +
916
917
  "- Positive = North/East; Negative = South/West\n" +
@@ -938,9 +939,10 @@ export const PROMPTS = [
938
939
  "### Credit Optimization\n" +
939
940
  "- **Batch natal charts**: use `POST /ephemeris/natal/batch` for multi-user onboarding (1 credit/subject vs N calls)\n" +
940
941
  "- **Current transits**: prefer `ephemeris_natal_transits` (3 credits) over " +
941
- "`ephemeris_transits` (6 credits) when you only need right-now, not a date range\n" +
942
- "- **ACG**: call `acg_hits` (city-level, 15 credits) only for specific targets; " +
943
- "use `acg_power_lines` (10 credits) for global maps\n" +
942
+ "`ephemeris_transits` (6+ credits, priced by span) when you only need right-now, not a date range\n" +
943
+ "- **Range searches**: priced by the span you ask for, before compute; a span over the plan cap " +
944
+ "(transit search: Explorer/PAYG 1y, Pro 5y, Startup 10y, Scale 40y) is a 400 `search_span_limit` — split it\n" +
945
+ "- **Failed calls are free**: every 4xx/5xx response is refunded\n" +
944
946
  "- **Format matters**: `format: 'llm'` reduces token usage in your LLM calls significantly\n\n" +
945
947
  "## Step 4 — Generate a Code Scaffold\n" +
946
948
  "Ask: 'What language would you like the scaffold in? And what is the first feature you want to implement?'\n\n" +