@openephemeris/mcp-server 4.16.0 → 4.18.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 (37) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +15 -15
  3. package/config/dev-allowlist.json +31 -19
  4. package/dist/backend/client.d.ts +57 -1
  5. package/dist/backend/client.js +125 -15
  6. package/dist/prompts.js +26 -24
  7. package/dist/server-sse.d.ts +18 -0
  8. package/dist/server-sse.js +41 -6
  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.js +36 -12
  12. package/dist/tools/apps/bi-wheel-app.d.ts +9 -2
  13. package/dist/tools/apps/bi-wheel-app.js +76 -117
  14. package/dist/tools/apps/bodygraph-app.js +88 -41
  15. package/dist/tools/apps/chart-wheel-app.js +56 -16
  16. package/dist/tools/apps/location-tools.js +10 -3
  17. package/dist/tools/apps/moon-phase-app.js +10 -2
  18. package/dist/tools/apps/transit-timeline-app.d.ts +3 -0
  19. package/dist/tools/apps/transit-timeline-app.js +77 -9
  20. package/dist/tools/apps/vedic-chart-app.js +10 -1
  21. package/dist/tools/datetime-historical.js +7 -2
  22. package/dist/tools/datetime.js +2 -1
  23. package/dist/tools/dev.js +7 -6
  24. package/dist/tools/specialized/electional.js +4 -3
  25. package/dist/tools/specialized/ephemeris_extended.js +19 -4
  26. package/dist/tools/specialized/hd_group.js +2 -2
  27. package/dist/tools/specialized/moon.d.ts +1 -1
  28. package/dist/tools/specialized/moon.js +51 -43
  29. package/dist/tools/specialized/progressed.js +2 -25
  30. package/dist/tools/specialized/transits.js +5 -5
  31. package/dist/ui/bazi.html +1523 -1522
  32. package/dist/ui/bi-wheel.html +406 -374
  33. package/dist/ui/bodygraph.html +83 -79
  34. package/dist/ui/chart-wheel.html +394 -361
  35. package/dist/ui/transit-timeline.html +3 -3
  36. package/dist/ui/vedic-chart.html +818 -818
  37. package/package.json +1 -1
@@ -1,4 +1,22 @@
1
1
  import express from "express";
2
+ /**
3
+ * The hosted server is multi-tenant: every tool call must be authenticated
4
+ * and metered as the connecting user. A credential in the process environment
5
+ * breaks that — the client interceptor sends X-Service-Key ahead of the user's
6
+ * key (every user unmetered), and an env API key / JWT would bill every
7
+ * session to one account.
8
+ *
9
+ * - A service key in the environment is a hard startup failure: there is no
10
+ * safe way to run with it present.
11
+ * - A user API key / JWT in the environment is logged loudly and ignored.
12
+ * - The module singleton (built from the environment at import, and what
13
+ * getActiveClient() falls back to outside a session context) is stripped of
14
+ * every credential either way; per-session clients are built with
15
+ * `inheritEnvCredentials: false`.
16
+ *
17
+ * Exported for tests. stdio mode never calls this.
18
+ */
19
+ export declare function enforceHostedCredentialPolicy(env?: NodeJS.ProcessEnv): void;
2
20
  /**
3
21
  * Build the full Express app (auth gate, OAuth routes, /mcp Streamable HTTP,
4
22
  * health, resources). Exported so tests can drive the REAL handlers with
@@ -8,7 +8,8 @@
8
8
  * Architecture:
9
9
  * - The SSE server validates the user's API key at connection time by
10
10
  * making a lightweight call to the Go backend.
11
- * - The validated key is injected into the singleton BackendClient so
11
+ * - Each session gets its own BackendClient built from the validated key
12
+ * (never from env credentials — see enforceHostedCredentialPolicy), so
12
13
  * all subsequent tool calls authenticate as the connecting user.
13
14
  * - Usage credits are metered against the user's account and tier.
14
15
  */
@@ -26,7 +27,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema
26
27
  import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools, parseToolSurface, describeSurface } from "./tools/index.js";
27
28
  import { buildUiMeta } from "./tools/ui-meta.js";
28
29
  import { SERVER_INSTRUCTIONS } from "./instructions.js";
29
- import { BackendClient, runWithClient } from "./backend/client.js";
30
+ import { BackendClient, backendClient, presentCredentialEnvVars, runWithClient } from "./backend/client.js";
30
31
  import { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
31
32
  import { BODYGRAPH_RESOURCE_URI, BODYGRAPH_MIME_TYPE, getBodygraphBundle, } from "./tools/apps/bodygraph-app.js";
32
33
  import { BI_WHEEL_RESOURCE_URI, BI_WHEEL_MIME_TYPE, getBiWheelBundle, } from "./tools/apps/bi-wheel-app.js";
@@ -557,12 +558,43 @@ function startSseKeepalive(res) {
557
558
  }, 25_000);
558
559
  res.on("close", () => clearInterval(ping));
559
560
  }
561
+ /**
562
+ * The hosted server is multi-tenant: every tool call must be authenticated
563
+ * and metered as the connecting user. A credential in the process environment
564
+ * breaks that — the client interceptor sends X-Service-Key ahead of the user's
565
+ * key (every user unmetered), and an env API key / JWT would bill every
566
+ * session to one account.
567
+ *
568
+ * - A service key in the environment is a hard startup failure: there is no
569
+ * safe way to run with it present.
570
+ * - A user API key / JWT in the environment is logged loudly and ignored.
571
+ * - The module singleton (built from the environment at import, and what
572
+ * getActiveClient() falls back to outside a session context) is stripped of
573
+ * every credential either way; per-session clients are built with
574
+ * `inheritEnvCredentials: false`.
575
+ *
576
+ * Exported for tests. stdio mode never calls this.
577
+ */
578
+ export function enforceHostedCredentialPolicy(env = process.env) {
579
+ const { serviceKeys, userCredentials } = presentCredentialEnvVars(env);
580
+ if (serviceKeys.length > 0) {
581
+ throw new Error(`Refusing to start the hosted MCP HTTP server: ${serviceKeys.join(", ")} is set. ` +
582
+ `A service key bypasses per-user metering for every session. Unset it — the ` +
583
+ `hosted server authenticates each session with the user's own API key or OAuth token.`);
584
+ }
585
+ if (userCredentials.length > 0) {
586
+ console.error(`[SECURITY] ${userCredentials.join(", ")} is set in the hosted MCP server environment. ` +
587
+ `IGNORING it — sessions authenticate only with their own credential. Remove it from the deployment.`);
588
+ }
589
+ backendClient.clearCredentials();
590
+ }
560
591
  /**
561
592
  * Build the full Express app (auth gate, OAuth routes, /mcp Streamable HTTP,
562
593
  * health, resources). Exported so tests can drive the REAL handlers with
563
594
  * supertest instead of a re-implemented stub. Does not listen.
564
595
  */
565
596
  export async function createSseApp() {
597
+ enforceHostedCredentialPolicy();
566
598
  await initTools();
567
599
  const app = express();
568
600
  // NOTE: express.json() is applied per-route (jsonParser on /mcp POST), not
@@ -700,18 +732,19 @@ export async function createSseApp() {
700
732
  serverInfo: {
701
733
  name: "Open Ephemeris",
702
734
  version,
703
- description: "NASA JPL DE440-backed astronomical computation engine for AI agents. 90+ typed tools covering " +
735
+ description: `NASA JPL DE440-backed astronomical computation engine for AI agents. ${tools.length} typed tools covering ` +
704
736
  "natal charts, transit forecasting, Human Design bodygraphs, eclipses, astrocartography " +
705
737
  "power lines, Venus Star Points, electional timing, synastry, composite charts, Vedic/Jyotish, " +
706
738
  "Chinese BaZi, and more — powered by JPL DE440 ephemerides for sub-arcsecond " +
707
- "zero-hallucination accuracy. Free Explorer tier available.",
739
+ "zero-hallucination accuracy. Free Explorer tier: 150 one-time credits.",
708
740
  iconUrl: "https://mcp.openephemeris.com/icon.png",
709
741
  homepage: "https://openephemeris.com",
710
742
  },
711
743
  authentication: {
712
744
  required: true,
713
745
  schemes: ["apiKey"],
714
- instructions: "Pass your Open Ephemeris API key via the X-API-Key header. " +
746
+ instructions: "Sign in with OAuth 2.1 (PKCE, dynamic client registration), or pass your Open Ephemeris API key " +
747
+ "via the X-API-Key header (or Authorization: Bearer opene-…). Every call is metered to that user. " +
715
748
  "Get a free Explorer key at https://openephemeris.com/dashboard — no credit card required.",
716
749
  },
717
750
  tools,
@@ -871,7 +904,9 @@ export async function createSseApp() {
871
904
  console.error(`[HTTP] Session initialized: ${id}`);
872
905
  },
873
906
  });
874
- const client = new BackendClient({ baseURL: BACKEND_URL, apiKey, jwt });
907
+ // inheritEnvCredentials:false — this client must authenticate as THIS
908
+ // session's user only, never as an env service key / API key / JWT.
909
+ const client = new BackendClient({ baseURL: BACKEND_URL, apiKey, jwt, inheritEnvCredentials: false });
875
910
  const analyticsId = distinctIdFor(apiKey ?? jwt);
876
911
  // Tool surface is fixed for the life of the session: we do not declare
877
912
  // `tools.listChanged`, so a host has no obligation to re-fetch the list.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * _render-token.ts — plumbing for the API's server-issued render token.
3
+ *
4
+ * The Go API stamps every charged visual on a render-family route
5
+ * (/human-design/chart + /visualization/bodygraph, /human-design/transit-chart,
6
+ * /human-design/composite, /chinese/bazi, /vedic/chart) with a short-lived
7
+ * token bound to the account and the chart-defining inputs
8
+ * (auth/render_token.go). Presenting it on a re-render of the SAME chart —
9
+ * the iframe's host light/dark reconciliation, a layout flip — makes that
10
+ * re-render free (base charge and visual surcharge). Anything else is charged
11
+ * normally, so passing a stale or foreign token is harmless.
12
+ *
13
+ * Flow: explore_* puts the token on the payload as `_render_token`; the iframe
14
+ * hands it back as the `render_token` argument of its recalc / refetch call;
15
+ * the tool sends it in the X-OE-Render-Token header.
16
+ */
17
+ export declare const RENDER_TOKEN_HEADER = "X-OE-Render-Token";
18
+ /** Input-schema property for the app-only recalc args. Never set by the model. */
19
+ export declare const RENDER_TOKEN_PROPERTY: {
20
+ readonly type: "string";
21
+ readonly description: string;
22
+ };
23
+ /** Request headers presenting `token`, or undefined when there is none. */
24
+ export declare function renderTokenHeaders(token: unknown): Record<string, string> | undefined;
25
+ /**
26
+ * The token the API injected on the root <svg> of a /visualization/bodygraph
27
+ * response (a binary route, whose response headers the backend client does
28
+ * not surface).
29
+ */
30
+ export declare function renderTokenFromSvg(svg: string | null | undefined): string | undefined;
31
+ /** `visual.render_token` from an include_visual JSON response. */
32
+ export declare function renderTokenFromVisual(visual: unknown): string | undefined;
33
+ /** Copy of tool args with the per-call token removed (for `_refetch.args`). */
34
+ export declare function withoutRenderToken<T extends Record<string, unknown>>(args: T): T;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * _render-token.ts — plumbing for the API's server-issued render token.
3
+ *
4
+ * The Go API stamps every charged visual on a render-family route
5
+ * (/human-design/chart + /visualization/bodygraph, /human-design/transit-chart,
6
+ * /human-design/composite, /chinese/bazi, /vedic/chart) with a short-lived
7
+ * token bound to the account and the chart-defining inputs
8
+ * (auth/render_token.go). Presenting it on a re-render of the SAME chart —
9
+ * the iframe's host light/dark reconciliation, a layout flip — makes that
10
+ * re-render free (base charge and visual surcharge). Anything else is charged
11
+ * normally, so passing a stale or foreign token is harmless.
12
+ *
13
+ * Flow: explore_* puts the token on the payload as `_render_token`; the iframe
14
+ * hands it back as the `render_token` argument of its recalc / refetch call;
15
+ * the tool sends it in the X-OE-Render-Token header.
16
+ */
17
+ export const RENDER_TOKEN_HEADER = "X-OE-Render-Token";
18
+ /** Input-schema property for the app-only recalc args. Never set by the model. */
19
+ export const RENDER_TOKEN_PROPERTY = {
20
+ type: "string",
21
+ description: "Set automatically by the embedded app when it re-renders a chart it already paid for " +
22
+ "(e.g. to match the host's light/dark theme). Never pass this yourself.",
23
+ };
24
+ /** Request headers presenting `token`, or undefined when there is none. */
25
+ export function renderTokenHeaders(token) {
26
+ return typeof token === "string" && token.trim() !== ""
27
+ ? { [RENDER_TOKEN_HEADER]: token.trim() }
28
+ : undefined;
29
+ }
30
+ /**
31
+ * The token the API injected on the root <svg> of a /visualization/bodygraph
32
+ * response (a binary route, whose response headers the backend client does
33
+ * not surface).
34
+ */
35
+ export function renderTokenFromSvg(svg) {
36
+ if (!svg)
37
+ return undefined;
38
+ const m = /<svg\b[^>]*\sdata-oe-render-token="([A-Za-z0-9._-]+)"/.exec(svg);
39
+ return m ? m[1] : undefined;
40
+ }
41
+ /** `visual.render_token` from an include_visual JSON response. */
42
+ export function renderTokenFromVisual(visual) {
43
+ const t = visual?.render_token;
44
+ return typeof t === "string" && t !== "" ? t : undefined;
45
+ }
46
+ /** Copy of tool args with the per-call token removed (for `_refetch.args`). */
47
+ export function withoutRenderToken(args) {
48
+ const { render_token: _drop, ...rest } = args;
49
+ return rest;
50
+ }
@@ -26,6 +26,7 @@ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
26
26
  // this replaces used `new Date(naive)`, which resolves in the host process's
27
27
  // timezone and silently shifted the hour pillar on any non-UTC server.
28
28
  import { parseBaziArgs, buildBaziConventionFields, CONVENTION_PROPERTIES } from "../specialized/bazi.js";
29
+ import { RENDER_TOKEN_PROPERTY, renderTokenFromVisual, renderTokenHeaders } from "./_render-token.js";
29
30
  // ── Constants ─────────────────────────────────────────────────────────────────
30
31
  export const BAZI_RESOURCE_URI = "ui://openephemeris/bazi";
31
32
  export const BAZI_MIME_TYPE = "text/html;profile=mcp-app";
@@ -60,26 +61,39 @@ export function getBaziBundle() {
60
61
  export function clearBaziBundleCache() {
61
62
  cachedBundle = null;
62
63
  }
64
+ const CONVENTION_ARG_KEYS = ["year_boundary", "day_boundary", "true_solar_time", "latitude", "longitude", "timezone"];
65
+ function pickConventionArgs(args) {
66
+ const out = {};
67
+ for (const k of CONVENTION_ARG_KEYS) {
68
+ if (args?.[k] != null && args[k] !== "")
69
+ out[k] = args[k];
70
+ }
71
+ return out;
72
+ }
63
73
  /**
64
74
  * Fetch the BaZi chart via the include_visual intercept — one call returns
65
75
  * both the structured pillar data (year/month/day/hour/day_master) and the
66
76
  * Go-rendered SVG (bazi.RenderBaziChartSVG), same shape the strict
67
77
  * /chinese/bazi handler returns with a `visual` key attached.
68
78
  */
69
- async function fetchBaziChart(components, theme = "dark", conventionFields = {}, reconcile = false) {
79
+ async function fetchBaziChart(components, theme = "dark", conventionFields = {}, renderToken) {
70
80
  const client = getActiveClient();
71
81
  const body = {
72
82
  ...components,
73
83
  ...conventionFields,
74
84
  include_visual: true,
75
85
  visual_config: { format: "svg", theme, size: 800 },
76
- // Set only by bazi_recalculate's theme-reconcile path below — waives the
77
- // visual surcharge server-side for a same-birth-data re-render (#551).
78
- ...(reconcile ? { _visual_reconcile: true } : {}),
79
86
  };
80
- return await client.request("POST", "/chinese/bazi", { data: body });
87
+ // bazi_recalculate's theme reconcile presents the token the first render
88
+ // was issued; the API waives the whole re-render only when it verifies for
89
+ // this account and these exact inputs (the old self-serve
90
+ // `_visual_reconcile` flag is gone).
91
+ return await client.request("POST", "/chinese/bazi", {
92
+ data: body,
93
+ headers: renderTokenHeaders(renderToken),
94
+ });
81
95
  }
82
- function buildModelPayload(data, components, theme) {
96
+ function buildModelPayload(data, components, theme, conventionArgs = {}) {
83
97
  return {
84
98
  year: data.year,
85
99
  month: data.month,
@@ -88,7 +102,9 @@ function buildModelPayload(data, components, theme) {
88
102
  day_master: data.day_master,
89
103
  _svg: data.visual?.data,
90
104
  _birth_params: components,
105
+ ...(Object.keys(conventionArgs).length ? { _convention_args: conventionArgs } : {}),
91
106
  _theme: theme,
107
+ ...(renderTokenFromVisual(data.visual) ? { _render_token: renderTokenFromVisual(data.visual) } : {}),
92
108
  };
93
109
  }
94
110
  function buildSummary(payload) {
@@ -180,7 +196,7 @@ registerTool({
180
196
  return { content: [{ type: "text", text: buildSummary(payload) }] };
181
197
  }
182
198
  const data = await fetchBaziChart(components, theme, conventionFields);
183
- const payload = buildModelPayload(data, components, theme);
199
+ const payload = buildModelPayload(data, components, theme, pickConventionArgs(args));
184
200
  const summary = buildSummary(payload);
185
201
  // MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
186
202
  // delivered through resources/read — NOT as a content block.
@@ -199,9 +215,9 @@ registerTool({
199
215
  name: "bazi_recalculate",
200
216
  description: "Recalculates a BaZi Four Pillars chart with new birth data or theme. " +
201
217
  "App-only: called by the embedded chart itself to reconcile the initial " +
202
- "server-rendered SVG to the host's actual light/dark theme. Billed at 1 " +
203
- "credit (base only) — the visual-render surcharge is waived because this " +
204
- "re-renders already-computed data, not a new chart.",
218
+ "server-rendered SVG to the host's actual light/dark theme. Free when it " +
219
+ "presents the render_token of the chart it re-renders (same birth data and " +
220
+ "conventions); otherwise billed like a new chart (3 credits).",
205
221
  inputSchema: {
206
222
  type: "object",
207
223
  properties: {
@@ -209,11 +225,15 @@ registerTool({
209
225
  month: { type: "integer" },
210
226
  day: { type: "integer" },
211
227
  hour: { type: "integer" },
228
+ minute: { type: "integer" },
229
+ ...CONVENTION_PROPERTIES,
230
+ timezone: { type: "string", description: "IANA timezone name the chart was cast in." },
212
231
  theme: {
213
232
  type: "string",
214
233
  enum: ["light", "dark"],
215
234
  description: "Render palette for the BaZi SVG. Mirrors the MCP host's light/dark color scheme.",
216
235
  },
236
+ render_token: RENDER_TOKEN_PROPERTY,
217
237
  },
218
238
  required: ["year", "month", "day"],
219
239
  },
@@ -223,8 +243,12 @@ registerTool({
223
243
  handler: async (args) => {
224
244
  const components = parseBaziArgs(args);
225
245
  const theme = args.theme === "light" ? "light" : "dark";
226
- const data = await fetchBaziChart(components, theme, {}, true);
227
- const payload = buildModelPayload(data, components, theme);
246
+ // Re-cast under the SAME conventions as the original chart — dropping them
247
+ // (as this tool used to) rendered a different chart on any non-default
248
+ // year/day boundary, and would not match the render token either.
249
+ const conventionArgs = pickConventionArgs(args);
250
+ const data = await fetchBaziChart(components, theme, buildBaziConventionFields(conventionArgs), args.render_token);
251
+ const payload = buildModelPayload(data, components, theme, conventionArgs);
228
252
  return {
229
253
  content: [{ type: "text", text: JSON.stringify({ ...payload, server_version: SERVER_VERSION }) }],
230
254
  };
@@ -12,20 +12,27 @@
12
12
  * Supported modes (BiWheelMode):
13
13
  * synastry — two natal charts (person1 inner, person2 outer)
14
14
  * transit — natal inner + transiting planets outer
15
- * progressed — natal inner + secondary progressions outer (POST /ephemeris/progressed)
15
+ * progressed — natal inner + secondary progressions outer (POST /ephemeris/progressed, method=secondary)
16
16
  * solar_return — natal inner + solar return chart outer (POST /predictive/returns/solar)
17
17
  * lunar_return — natal inner + lunar return chart outer (POST /predictive/returns/lunar)
18
- * solar_arc — natal inner + solar arc directions outer (client-side Naibod approximation)
18
+ * solar_arc — natal inner + solar arc directions outer (POST /ephemeris/progressed, method=solar_arc)
19
19
  *
20
20
  * Cross-aspects are computed client-side (UI) AND server-side (for summary/fallback).
21
21
  * The payload cross_aspects array is capped at 30 tightest-orb aspects.
22
22
  */
23
+ import { getActiveClient } from "../../backend/client.js";
23
24
  export declare const BI_WHEEL_RESOURCE_URI = "ui://openephemeris/bi-wheel";
24
25
  export declare const BI_WHEEL_MIME_TYPE = "text/html;profile=mcp-app";
25
26
  /** Read the pre-built HTML bundle. Returns null if not yet built. */
26
27
  export declare function getBiWheelBundle(): string | null;
27
28
  export declare function clearBiWheelBundleCache(): void;
28
29
  export type BiWheelMode = "synastry" | "transit" | "progressed" | "solar_return" | "lunar_return" | "solar_arc";
30
+ /**
31
+ * Route outer-wheel fetch to the correct API endpoint based on mode, and hand
32
+ * back a chart in the natal-chart shape (top-level planets/houses/angles):
33
+ * /ephemeris/progressed nests it under `data`, the return endpoints under `chart`.
34
+ */
35
+ export declare function fetchOuterChart(client: Pick<ReturnType<typeof getActiveClient>, "post">, mode: BiWheelMode, dt1: string, lat1: number | undefined, lon1: number | undefined, tz1: string | undefined, dt2: string, lat2: number | undefined, lon2: number | undefined, tz2: string | undefined, houseSystem?: string): Promise<Record<string, unknown>>;
29
36
  interface PlanetPoint {
30
37
  name: string;
31
38
  longitude: number;