@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
@@ -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,17 +27,8 @@ 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 { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
31
- import { BODYGRAPH_RESOURCE_URI, BODYGRAPH_MIME_TYPE, getBodygraphBundle, } from "./tools/apps/bodygraph-app.js";
32
- import { BI_WHEEL_RESOURCE_URI, BI_WHEEL_MIME_TYPE, getBiWheelBundle, } from "./tools/apps/bi-wheel-app.js";
33
- // NOTE: bazi, vedic-chart imports removed — tools disabled.
34
- // NOTE: transit-timeline, bazi imports removed — tools disabled.
35
- // NOTE: transit-timeline, vedic-chart imports removed — tools disabled.
36
- import { MOON_PHASE_RESOURCE_URI, MOON_PHASE_MIME_TYPE, getMoonPhaseBundle, } from "./tools/apps/moon-phase-app.js";
37
- import { TRANSIT_TIMELINE_RESOURCE_URI, TRANSIT_TIMELINE_MIME_TYPE, getTransitTimelineBundle, } from "./tools/apps/transit-timeline-app.js";
38
- import { VEDIC_CHART_RESOURCE_URI, VEDIC_CHART_MIME_TYPE, getVedicChartBundle, } from "./tools/apps/vedic-chart-app.js";
39
- import { BAZI_RESOURCE_URI, BAZI_MIME_TYPE, getBaziBundle, } from "./tools/apps/bazi-app.js";
30
+ import { BackendClient, backendClient, presentCredentialEnvVars, runWithClient } from "./backend/client.js";
31
+ import { listUiResources, readUiResource } from "./tools/apps/ui-resources.js";
40
32
  import { oauthDiscoveryRouter, PROTECTED_RESOURCE_METADATA_URL } from "./oauth/discovery.js";
41
33
  import { oauthDcrRouter } from "./oauth/dcr.js";
42
34
  import { oauthTokenRouter } from "./oauth/token.js";
@@ -386,113 +378,15 @@ function createMcpServer(analyticsId = "anonymous", surface = "core", activity =
386
378
  });
387
379
  // --- Resource handlers ---
388
380
  server.setRequestHandler(ListResourcesRequestSchema, async () => {
389
- const resources = [];
390
- if (getChartWheelBundle()) {
391
- resources.push({
392
- uri: CHART_WHEEL_RESOURCE_URI,
393
- name: "Chart Wheel Explorer",
394
- description: "Interactive natal chart wheel with clickable planets, houses, and aspects.",
395
- mimeType: CHART_WHEEL_MIME_TYPE,
396
- });
397
- }
398
- if (getBodygraphBundle()) {
399
- resources.push({
400
- uri: BODYGRAPH_RESOURCE_URI,
401
- name: "Human Design Bodygraph Explorer",
402
- description: "Interactive Human Design Bodygraph with clickable centers, gates, and channels.",
403
- mimeType: BODYGRAPH_MIME_TYPE,
404
- });
405
- }
406
- if (getBiWheelBundle()) {
407
- resources.push({
408
- uri: BI_WHEEL_RESOURCE_URI,
409
- name: "Bi-Wheel Explorer",
410
- description: "Interactive synastry or transit bi-wheel with cross-aspect lines and clickable planets.",
411
- mimeType: BI_WHEEL_MIME_TYPE,
412
- });
413
- }
414
- // NOTE: bazi resources omitted — tools disabled.
415
- // NOTE: transit-timeline, vedic-chart resources omitted — tools disabled.
416
- if (getMoonPhaseBundle()) {
417
- resources.push({
418
- uri: MOON_PHASE_RESOURCE_URI,
419
- name: "Moon Phase Explorer",
420
- description: "Interactive lunar phase dial showing illumination, sign, and void-of-course status.",
421
- mimeType: MOON_PHASE_MIME_TYPE,
422
- });
423
- }
424
- if (getTransitTimelineBundle()) {
425
- resources.push({
426
- uri: TRANSIT_TIMELINE_RESOURCE_URI,
427
- name: "Transit Timeline Explorer",
428
- description: "Interactive vertical timeline of upcoming transit hits grouped by month, with clickable events.",
429
- mimeType: TRANSIT_TIMELINE_MIME_TYPE,
430
- });
431
- }
432
- if (getVedicChartBundle()) {
433
- resources.push({
434
- uri: VEDIC_CHART_RESOURCE_URI,
435
- name: "Vedic Chart Explorer",
436
- description: "Interactive Vedic (Jyotish) South Indian Rashi grid with clickable sign placements.",
437
- mimeType: VEDIC_CHART_MIME_TYPE,
438
- });
439
- }
440
- if (getBaziBundle()) {
441
- resources.push({
442
- uri: BAZI_RESOURCE_URI,
443
- name: "BaZi Four Pillars Explorer",
444
- description: "Interactive BaZi (Four Pillars of Destiny) chart with clickable Year/Month/Day/Hour pillars.",
445
- mimeType: BAZI_MIME_TYPE,
446
- });
447
- }
448
- return { resources };
381
+ // One registry for both transports: versioned ui:// URIs + resource _meta
382
+ // (prefersBorder, CSP, widgetDescription). See tools/apps/ui-resources.ts.
383
+ return { resources: listUiResources() };
449
384
  });
450
385
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
451
386
  const uri = request.params.uri;
452
- if (uri === CHART_WHEEL_RESOURCE_URI) {
453
- const bundle = getChartWheelBundle();
454
- if (!bundle)
455
- throw new Error("Chart Wheel UI bundle not found. Run `npm run build:ui` to build it.");
456
- return { contents: [{ uri: CHART_WHEEL_RESOURCE_URI, mimeType: CHART_WHEEL_MIME_TYPE, text: bundle }] };
457
- }
458
- if (uri === BODYGRAPH_RESOURCE_URI) {
459
- const bundle = getBodygraphBundle();
460
- if (!bundle)
461
- throw new Error("Bodygraph UI bundle not found. Run `npm run build:ui` to build it.");
462
- return { contents: [{ uri: BODYGRAPH_RESOURCE_URI, mimeType: BODYGRAPH_MIME_TYPE, text: bundle }] };
463
- }
464
- if (uri === BI_WHEEL_RESOURCE_URI) {
465
- const bundle = getBiWheelBundle();
466
- if (!bundle)
467
- throw new Error("Bi-Wheel UI bundle not found. Run `npm run build:ui` to build it.");
468
- return { contents: [{ uri: BI_WHEEL_RESOURCE_URI, mimeType: BI_WHEEL_MIME_TYPE, text: bundle }] };
469
- }
470
- // NOTE: bazi resources omitted — tools disabled.
471
- // NOTE: transit-timeline, vedic-chart ReadResource handlers removed.
472
- if (uri === MOON_PHASE_RESOURCE_URI) {
473
- const bundle = getMoonPhaseBundle();
474
- if (!bundle)
475
- throw new Error("Moon Phase UI bundle not found. Run `npm run build:ui` to build it.");
476
- return { contents: [{ uri: MOON_PHASE_RESOURCE_URI, mimeType: MOON_PHASE_MIME_TYPE, text: bundle }] };
477
- }
478
- if (uri === TRANSIT_TIMELINE_RESOURCE_URI) {
479
- const bundle = getTransitTimelineBundle();
480
- if (!bundle)
481
- throw new Error("Transit Timeline UI bundle not found. Run `npm run build:ui` to build it.");
482
- return { contents: [{ uri: TRANSIT_TIMELINE_RESOURCE_URI, mimeType: TRANSIT_TIMELINE_MIME_TYPE, text: bundle }] };
483
- }
484
- if (uri === VEDIC_CHART_RESOURCE_URI) {
485
- const bundle = getVedicChartBundle();
486
- if (!bundle)
487
- throw new Error("Vedic Chart UI bundle not found. Run `npm run build:ui` to build it.");
488
- return { contents: [{ uri: VEDIC_CHART_RESOURCE_URI, mimeType: VEDIC_CHART_MIME_TYPE, text: bundle }] };
489
- }
490
- if (uri === BAZI_RESOURCE_URI) {
491
- const bundle = getBaziBundle();
492
- if (!bundle)
493
- throw new Error("BaZi UI bundle not found. Run `npm run build:ui` to build it.");
494
- return { contents: [{ uri: BAZI_RESOURCE_URI, mimeType: BAZI_MIME_TYPE, text: bundle }] };
495
- }
387
+ const result = readUiResource(uri);
388
+ if (result)
389
+ return result;
496
390
  throw new Error(`Unknown resource: ${uri}`);
497
391
  });
498
392
  return server;
@@ -557,12 +451,43 @@ function startSseKeepalive(res) {
557
451
  }, 25_000);
558
452
  res.on("close", () => clearInterval(ping));
559
453
  }
454
+ /**
455
+ * The hosted server is multi-tenant: every tool call must be authenticated
456
+ * and metered as the connecting user. A credential in the process environment
457
+ * breaks that — the client interceptor sends X-Service-Key ahead of the user's
458
+ * key (every user unmetered), and an env API key / JWT would bill every
459
+ * session to one account.
460
+ *
461
+ * - A service key in the environment is a hard startup failure: there is no
462
+ * safe way to run with it present.
463
+ * - A user API key / JWT in the environment is logged loudly and ignored.
464
+ * - The module singleton (built from the environment at import, and what
465
+ * getActiveClient() falls back to outside a session context) is stripped of
466
+ * every credential either way; per-session clients are built with
467
+ * `inheritEnvCredentials: false`.
468
+ *
469
+ * Exported for tests. stdio mode never calls this.
470
+ */
471
+ export function enforceHostedCredentialPolicy(env = process.env) {
472
+ const { serviceKeys, userCredentials } = presentCredentialEnvVars(env);
473
+ if (serviceKeys.length > 0) {
474
+ throw new Error(`Refusing to start the hosted MCP HTTP server: ${serviceKeys.join(", ")} is set. ` +
475
+ `A service key bypasses per-user metering for every session. Unset it — the ` +
476
+ `hosted server authenticates each session with the user's own API key or OAuth token.`);
477
+ }
478
+ if (userCredentials.length > 0) {
479
+ console.error(`[SECURITY] ${userCredentials.join(", ")} is set in the hosted MCP server environment. ` +
480
+ `IGNORING it — sessions authenticate only with their own credential. Remove it from the deployment.`);
481
+ }
482
+ backendClient.clearCredentials();
483
+ }
560
484
  /**
561
485
  * Build the full Express app (auth gate, OAuth routes, /mcp Streamable HTTP,
562
486
  * health, resources). Exported so tests can drive the REAL handlers with
563
487
  * supertest instead of a re-implemented stub. Does not listen.
564
488
  */
565
489
  export async function createSseApp() {
490
+ enforceHostedCredentialPolicy();
566
491
  await initTools();
567
492
  const app = express();
568
493
  // NOTE: express.json() is applied per-route (jsonParser on /mcp POST), not
@@ -700,18 +625,19 @@ export async function createSseApp() {
700
625
  serverInfo: {
701
626
  name: "Open Ephemeris",
702
627
  version,
703
- description: "NASA JPL DE440-backed astronomical computation engine for AI agents. 90+ typed tools covering " +
628
+ description: `NASA JPL DE440-backed astronomical computation engine for AI agents. ${tools.length} typed tools covering ` +
704
629
  "natal charts, transit forecasting, Human Design bodygraphs, eclipses, astrocartography " +
705
630
  "power lines, Venus Star Points, electional timing, synastry, composite charts, Vedic/Jyotish, " +
706
631
  "Chinese BaZi, and more — powered by JPL DE440 ephemerides for sub-arcsecond " +
707
- "zero-hallucination accuracy. Free Explorer tier available.",
632
+ "zero-hallucination accuracy. Free Explorer tier: 150 one-time credits.",
708
633
  iconUrl: "https://mcp.openephemeris.com/icon.png",
709
634
  homepage: "https://openephemeris.com",
710
635
  },
711
636
  authentication: {
712
637
  required: true,
713
638
  schemes: ["apiKey"],
714
- instructions: "Pass your Open Ephemeris API key via the X-API-Key header. " +
639
+ instructions: "Sign in with OAuth 2.1 (PKCE, dynamic client registration), or pass your Open Ephemeris API key " +
640
+ "via the X-API-Key header (or Authorization: Bearer opene-…). Every call is metered to that user. " +
715
641
  "Get a free Explorer key at https://openephemeris.com/dashboard — no credit card required.",
716
642
  },
717
643
  tools,
@@ -871,7 +797,9 @@ export async function createSseApp() {
871
797
  console.error(`[HTTP] Session initialized: ${id}`);
872
798
  },
873
799
  });
874
- const client = new BackendClient({ baseURL: BACKEND_URL, apiKey, jwt });
800
+ // inheritEnvCredentials:false — this client must authenticate as THIS
801
+ // session's user only, never as an env service key / API key / JWT.
802
+ const client = new BackendClient({ baseURL: BACKEND_URL, apiKey, jwt, inheritEnvCredentials: false });
875
803
  const analyticsId = distinctIdFor(apiKey ?? jwt);
876
804
  // Tool surface is fixed for the life of the session: we do not declare
877
805
  // `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
+ }
@@ -16,7 +16,7 @@
16
16
  * bazi_annual_pillar, bazi_compatibility, bazi_chart) already exist in
17
17
  * ../specialized/bazi.ts — this file is purely the interactive visual entry point.
18
18
  */
19
- export declare const BAZI_RESOURCE_URI = "ui://openephemeris/bazi";
19
+ export declare const BAZI_RESOURCE_URI: string;
20
20
  export declare const BAZI_MIME_TYPE = "text/html;profile=mcp-app";
21
21
  /** Read the pre-built HTML bundle. Returns null if not yet built. */
22
22
  export declare function getBaziBundle(): string | null;
@@ -20,15 +20,17 @@ import fs from "node:fs";
20
20
  import path from "node:path";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import { registerTool, SERVER_VERSION } from "../index.js";
23
+ import { UI_RESOURCE_MIME_TYPE, uiResourceUri } from "./ui-resource.js";
23
24
  import { getActiveClient } from "../../backend/client.js";
24
25
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
25
26
  // One BaZi component parser, shared with the specialized tools. The local copy
26
27
  // this replaces used `new Date(naive)`, which resolves in the host process's
27
28
  // timezone and silently shifted the hour pillar on any non-UTC server.
28
29
  import { parseBaziArgs, buildBaziConventionFields, CONVENTION_PROPERTIES } from "../specialized/bazi.js";
30
+ import { RENDER_TOKEN_PROPERTY, renderTokenFromVisual, renderTokenHeaders } from "./_render-token.js";
29
31
  // ── Constants ─────────────────────────────────────────────────────────────────
30
- export const BAZI_RESOURCE_URI = "ui://openephemeris/bazi";
31
- export const BAZI_MIME_TYPE = "text/html;profile=mcp-app";
32
+ export const BAZI_RESOURCE_URI = uiResourceUri("bazi");
33
+ export const BAZI_MIME_TYPE = UI_RESOURCE_MIME_TYPE;
32
34
  const here = path.dirname(fileURLToPath(import.meta.url));
33
35
  const BUNDLE_PATHS = [
34
36
  path.resolve(here, "..", "..", "..", "dist", "ui", "bazi.html"),
@@ -60,26 +62,39 @@ export function getBaziBundle() {
60
62
  export function clearBaziBundleCache() {
61
63
  cachedBundle = null;
62
64
  }
65
+ const CONVENTION_ARG_KEYS = ["year_boundary", "day_boundary", "true_solar_time", "latitude", "longitude", "timezone"];
66
+ function pickConventionArgs(args) {
67
+ const out = {};
68
+ for (const k of CONVENTION_ARG_KEYS) {
69
+ if (args?.[k] != null && args[k] !== "")
70
+ out[k] = args[k];
71
+ }
72
+ return out;
73
+ }
63
74
  /**
64
75
  * Fetch the BaZi chart via the include_visual intercept — one call returns
65
76
  * both the structured pillar data (year/month/day/hour/day_master) and the
66
77
  * Go-rendered SVG (bazi.RenderBaziChartSVG), same shape the strict
67
78
  * /chinese/bazi handler returns with a `visual` key attached.
68
79
  */
69
- async function fetchBaziChart(components, theme = "dark", conventionFields = {}, reconcile = false) {
80
+ async function fetchBaziChart(components, theme = "dark", conventionFields = {}, renderToken) {
70
81
  const client = getActiveClient();
71
82
  const body = {
72
83
  ...components,
73
84
  ...conventionFields,
74
85
  include_visual: true,
75
86
  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
87
  };
80
- return await client.request("POST", "/chinese/bazi", { data: body });
88
+ // bazi_recalculate's theme reconcile presents the token the first render
89
+ // was issued; the API waives the whole re-render only when it verifies for
90
+ // this account and these exact inputs (the old self-serve
91
+ // `_visual_reconcile` flag is gone).
92
+ return await client.request("POST", "/chinese/bazi", {
93
+ data: body,
94
+ headers: renderTokenHeaders(renderToken),
95
+ });
81
96
  }
82
- function buildModelPayload(data, components, theme) {
97
+ function buildModelPayload(data, components, theme, conventionArgs = {}) {
83
98
  return {
84
99
  year: data.year,
85
100
  month: data.month,
@@ -88,7 +103,9 @@ function buildModelPayload(data, components, theme) {
88
103
  day_master: data.day_master,
89
104
  _svg: data.visual?.data,
90
105
  _birth_params: components,
106
+ ...(Object.keys(conventionArgs).length ? { _convention_args: conventionArgs } : {}),
91
107
  _theme: theme,
108
+ ...(renderTokenFromVisual(data.visual) ? { _render_token: renderTokenFromVisual(data.visual) } : {}),
92
109
  };
93
110
  }
94
111
  function buildSummary(payload) {
@@ -180,7 +197,7 @@ registerTool({
180
197
  return { content: [{ type: "text", text: buildSummary(payload) }] };
181
198
  }
182
199
  const data = await fetchBaziChart(components, theme, conventionFields);
183
- const payload = buildModelPayload(data, components, theme);
200
+ const payload = buildModelPayload(data, components, theme, pickConventionArgs(args));
184
201
  const summary = buildSummary(payload);
185
202
  // MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
186
203
  // delivered through resources/read — NOT as a content block.
@@ -199,9 +216,9 @@ registerTool({
199
216
  name: "bazi_recalculate",
200
217
  description: "Recalculates a BaZi Four Pillars chart with new birth data or theme. " +
201
218
  "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.",
219
+ "server-rendered SVG to the host's actual light/dark theme. Free when it " +
220
+ "presents the render_token of the chart it re-renders (same birth data and " +
221
+ "conventions); otherwise billed like a new chart (3 credits).",
205
222
  inputSchema: {
206
223
  type: "object",
207
224
  properties: {
@@ -209,11 +226,15 @@ registerTool({
209
226
  month: { type: "integer" },
210
227
  day: { type: "integer" },
211
228
  hour: { type: "integer" },
229
+ minute: { type: "integer" },
230
+ ...CONVENTION_PROPERTIES,
231
+ timezone: { type: "string", description: "IANA timezone name the chart was cast in." },
212
232
  theme: {
213
233
  type: "string",
214
234
  enum: ["light", "dark"],
215
235
  description: "Render palette for the BaZi SVG. Mirrors the MCP host's light/dark color scheme.",
216
236
  },
237
+ render_token: RENDER_TOKEN_PROPERTY,
217
238
  },
218
239
  required: ["year", "month", "day"],
219
240
  },
@@ -223,8 +244,12 @@ registerTool({
223
244
  handler: async (args) => {
224
245
  const components = parseBaziArgs(args);
225
246
  const theme = args.theme === "light" ? "light" : "dark";
226
- const data = await fetchBaziChart(components, theme, {}, true);
227
- const payload = buildModelPayload(data, components, theme);
247
+ // Re-cast under the SAME conventions as the original chart — dropping them
248
+ // (as this tool used to) rendered a different chart on any non-default
249
+ // year/day boundary, and would not match the render token either.
250
+ const conventionArgs = pickConventionArgs(args);
251
+ const data = await fetchBaziChart(components, theme, buildBaziConventionFields(conventionArgs), args.render_token);
252
+ const payload = buildModelPayload(data, components, theme, conventionArgs);
228
253
  return {
229
254
  content: [{ type: "text", text: JSON.stringify({ ...payload, server_version: SERVER_VERSION }) }],
230
255
  };
@@ -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
- export declare const BI_WHEEL_RESOURCE_URI = "ui://openephemeris/bi-wheel";
23
+ import { getActiveClient } from "../../backend/client.js";
24
+ export declare const BI_WHEEL_RESOURCE_URI: string;
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;