@openephemeris/mcp-server 3.13.9 → 3.13.10

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 (61) hide show
  1. package/README.md +2 -2
  2. package/config/dev-allowlist.json +28 -4
  3. package/dist/index.js +45 -0
  4. package/dist/oauth/dcr.d.ts +11 -0
  5. package/dist/oauth/dcr.js +49 -0
  6. package/dist/oauth/discovery.d.ts +11 -0
  7. package/dist/oauth/discovery.js +40 -0
  8. package/dist/oauth/pkce.d.ts +18 -0
  9. package/dist/oauth/pkce.js +34 -0
  10. package/dist/oauth/rate-limit.d.ts +20 -0
  11. package/dist/oauth/rate-limit.js +65 -0
  12. package/dist/oauth/store.d.ts +68 -0
  13. package/dist/oauth/store.js +247 -0
  14. package/dist/oauth/supabase-jwt.d.ts +39 -0
  15. package/dist/oauth/supabase-jwt.js +194 -0
  16. package/dist/oauth/token.d.ts +21 -0
  17. package/dist/oauth/token.js +213 -0
  18. package/dist/prompts.js +171 -159
  19. package/dist/server-sse.js +134 -36
  20. package/dist/tools/apps/bazi-app.d.ts +23 -0
  21. package/dist/tools/apps/bazi-app.js +224 -0
  22. package/dist/tools/apps/bi-wheel-app.d.ts +14 -7
  23. package/dist/tools/apps/bi-wheel-app.js +293 -108
  24. package/dist/tools/apps/bodygraph-app.js +38 -3
  25. package/dist/tools/apps/moon-phase-app.d.ts +19 -0
  26. package/dist/tools/apps/moon-phase-app.js +190 -0
  27. package/dist/tools/apps/transit-timeline-app.d.ts +20 -0
  28. package/dist/tools/apps/transit-timeline-app.js +243 -0
  29. package/dist/tools/apps/vedic-chart-app.d.ts +17 -0
  30. package/dist/tools/apps/vedic-chart-app.js +226 -0
  31. package/dist/tools/index.js +4 -0
  32. package/dist/tools/specialized/acg.js +2 -2
  33. package/dist/tools/specialized/bazi.js +367 -44
  34. package/dist/tools/specialized/bi_wheel.js +1 -1
  35. package/dist/tools/specialized/chart_wheel.js +1 -1
  36. package/dist/tools/specialized/comparative.js +4 -4
  37. package/dist/tools/specialized/eclipse.js +1 -1
  38. package/dist/tools/specialized/electional.js +4 -4
  39. package/dist/tools/specialized/ephemeris_core.js +2 -2
  40. package/dist/tools/specialized/ephemeris_extended.js +8 -8
  41. package/dist/tools/specialized/hd_bodygraph.js +1 -1
  42. package/dist/tools/specialized/hd_cycles.js +2 -2
  43. package/dist/tools/specialized/hd_group.js +2 -2
  44. package/dist/tools/specialized/human_design.js +1 -1
  45. package/dist/tools/specialized/moon.js +2 -2
  46. package/dist/tools/specialized/natal.js +1 -1
  47. package/dist/tools/specialized/progressed.js +1 -1
  48. package/dist/tools/specialized/relocation.js +1 -1
  49. package/dist/tools/specialized/returns.js +3 -3
  50. package/dist/tools/specialized/synastry.js +1 -1
  51. package/dist/tools/specialized/transits.js +1 -1
  52. package/dist/tools/specialized/vedic.js +1 -1
  53. package/dist/tools/specialized/venus_star_points.js +6 -6
  54. package/dist/ui/bazi.html +213 -0
  55. package/dist/ui/bi-wheel.html +762 -489
  56. package/dist/ui/bodygraph.html +1788 -1649
  57. package/dist/ui/chart-wheel.html +1772 -1593
  58. package/dist/ui/moon-phase.html +6758 -0
  59. package/dist/ui/transit-timeline.html +6835 -0
  60. package/dist/ui/vedic-chart.html +210 -0
  61. package/package.json +2 -2
@@ -27,6 +27,15 @@ import { BackendClient, runWithClient } from "./backend/client.js";
27
27
  import { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
28
28
  import { BODYGRAPH_RESOURCE_URI, BODYGRAPH_MIME_TYPE, getBodygraphBundle, } from "./tools/apps/bodygraph-app.js";
29
29
  import { BI_WHEEL_RESOURCE_URI, BI_WHEEL_MIME_TYPE, getBiWheelBundle, } from "./tools/apps/bi-wheel-app.js";
30
+ import { TRANSIT_TIMELINE_RESOURCE_URI, TRANSIT_TIMELINE_MIME_TYPE, getTransitTimelineBundle, } from "./tools/apps/transit-timeline-app.js";
31
+ import { MOON_PHASE_RESOURCE_URI, MOON_PHASE_MIME_TYPE, getMoonPhaseBundle, } from "./tools/apps/moon-phase-app.js";
32
+ import { BAZI_RESOURCE_URI, BAZI_MIME_TYPE, getBaziBundle, } from "./tools/apps/bazi-app.js";
33
+ import { VEDIC_CHART_RESOURCE_URI, VEDIC_CHART_MIME_TYPE, getVedicChartBundle, } from "./tools/apps/vedic-chart-app.js";
34
+ import { oauthDiscoveryRouter } from "./oauth/discovery.js";
35
+ import { oauthDcrRouter } from "./oauth/dcr.js";
36
+ import { oauthTokenRouter } from "./oauth/token.js";
37
+ import { OAuthStore } from "./oauth/store.js";
38
+ import { createRateLimiter } from "./oauth/rate-limit.js";
30
39
  // ---------------------------------------------------------------------------
31
40
  // Helpers
32
41
  // ---------------------------------------------------------------------------
@@ -82,18 +91,31 @@ async function validateApiKey(apiKey) {
82
91
  }
83
92
  }
84
93
  /**
85
- * Extract an API key from a request using priority order:
86
- * 1. X-API-Key header
87
- * 2. X-OpenEphemeris-API-Key header
88
- * 3. Authorization: Bearer <token>
89
- * 4. ?apiKey query param
94
+ * Extract authentication from a request, distinguishing JWTs from API keys.
95
+ *
96
+ * Detection heuristic:
97
+ * - Bearer tokens starting with "eyJ" → Supabase JWT (from OAuth flow)
98
+ * - Bearer tokens starting with "opene-" → API key
99
+ * - X-API-Key / X-OpenEphemeris-API-Key headers → API key
100
+ * - ?apiKey query param → API key
101
+ *
102
+ * Returns { apiKey?, jwt? } — at most one will be set.
90
103
  */
91
- function extractApiKey(req) {
92
- return (req.headers["x-api-key"] ||
104
+ function extractAuth(req) {
105
+ // Explicit API key headers take precedence
106
+ const explicitKey = req.headers["x-api-key"] ||
93
107
  req.headers["x-openephemeris-api-key"] ||
94
- req.headers["authorization"]?.replace(/^Bearer\s+/i, "") ||
95
- req.query.apiKey ||
96
- undefined);
108
+ req.query.apiKey;
109
+ if (explicitKey)
110
+ return { apiKey: explicitKey };
111
+ // Bearer token — distinguish JWT from API key by prefix
112
+ const bearer = req.headers["authorization"]?.replace(/^Bearer\s+/i, "");
113
+ if (bearer) {
114
+ if (bearer.startsWith("eyJ"))
115
+ return { jwt: bearer }; // Supabase JWT
116
+ return { apiKey: bearer }; // API key
117
+ }
118
+ return {};
97
119
  }
98
120
  // ---------------------------------------------------------------------------
99
121
  // Helpers
@@ -282,6 +304,38 @@ function createMcpServer() {
282
304
  mimeType: BI_WHEEL_MIME_TYPE,
283
305
  });
284
306
  }
307
+ if (getTransitTimelineBundle()) {
308
+ resources.push({
309
+ uri: TRANSIT_TIMELINE_RESOURCE_URI,
310
+ name: "Transit Timeline Explorer",
311
+ description: "Interactive Gantt-style transit timeline showing planetary aspects over time.",
312
+ mimeType: TRANSIT_TIMELINE_MIME_TYPE,
313
+ });
314
+ }
315
+ if (getMoonPhaseBundle()) {
316
+ resources.push({
317
+ uri: MOON_PHASE_RESOURCE_URI,
318
+ name: "Moon Phase Explorer",
319
+ description: "Interactive lunar phase dial showing illumination, sign, and void-of-course status.",
320
+ mimeType: MOON_PHASE_MIME_TYPE,
321
+ });
322
+ }
323
+ if (getBaziBundle()) {
324
+ resources.push({
325
+ uri: BAZI_RESOURCE_URI,
326
+ name: "BaZi Four Pillars Explorer",
327
+ description: "Interactive BaZi chart with pillars, Ten Gods, hidden stems, and Wu Xing balance.",
328
+ mimeType: BAZI_MIME_TYPE,
329
+ });
330
+ }
331
+ if (getVedicChartBundle()) {
332
+ resources.push({
333
+ uri: VEDIC_CHART_RESOURCE_URI,
334
+ name: "Vedic Jyotish Chart",
335
+ description: "Interactive South Indian Rashi grid with planet placements, Lagna marker, and Nakshatra detail.",
336
+ mimeType: VEDIC_CHART_MIME_TYPE,
337
+ });
338
+ }
285
339
  return { resources };
286
340
  });
287
341
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
@@ -304,6 +358,30 @@ function createMcpServer() {
304
358
  throw new Error("Bi-Wheel UI bundle not found. Run `npm run build:ui` to build it.");
305
359
  return { contents: [{ uri: BI_WHEEL_RESOURCE_URI, mimeType: BI_WHEEL_MIME_TYPE, text: bundle }] };
306
360
  }
361
+ if (uri === TRANSIT_TIMELINE_RESOURCE_URI) {
362
+ const bundle = getTransitTimelineBundle();
363
+ if (!bundle)
364
+ throw new Error("Transit Timeline UI bundle not found. Run `npm run build:ui` to build it.");
365
+ return { contents: [{ uri: TRANSIT_TIMELINE_RESOURCE_URI, mimeType: TRANSIT_TIMELINE_MIME_TYPE, text: bundle }] };
366
+ }
367
+ if (uri === MOON_PHASE_RESOURCE_URI) {
368
+ const bundle = getMoonPhaseBundle();
369
+ if (!bundle)
370
+ throw new Error("Moon Phase UI bundle not found. Run `npm run build:ui` to build it.");
371
+ return { contents: [{ uri: MOON_PHASE_RESOURCE_URI, mimeType: MOON_PHASE_MIME_TYPE, text: bundle }] };
372
+ }
373
+ if (uri === BAZI_RESOURCE_URI) {
374
+ const bundle = getBaziBundle();
375
+ if (!bundle)
376
+ throw new Error("BaZi UI bundle not found. Run `npm run build:ui` to build it.");
377
+ return { contents: [{ uri: BAZI_RESOURCE_URI, mimeType: BAZI_MIME_TYPE, text: bundle }] };
378
+ }
379
+ if (uri === VEDIC_CHART_RESOURCE_URI) {
380
+ const bundle = getVedicChartBundle();
381
+ if (!bundle)
382
+ throw new Error("Vedic Chart UI bundle not found. Run `npm run build:ui` to build it.");
383
+ return { contents: [{ uri: VEDIC_CHART_RESOURCE_URI, mimeType: VEDIC_CHART_MIME_TYPE, text: bundle }] };
384
+ }
307
385
  throw new Error(`Unknown resource: ${uri}`);
308
386
  });
309
387
  return server;
@@ -330,6 +408,19 @@ async function main() {
330
408
  const transports = new Map();
331
409
  // Per-session BackendClient — each connection authenticates independently.
332
410
  const sessionClients = new Map();
411
+ // ---------------------------------------------------------------------------
412
+ // OAuth 2.1 routes — must be mounted BEFORE JSON body parser
413
+ // ---------------------------------------------------------------------------
414
+ const oauthStore = new OAuthStore();
415
+ const tokenRateLimiter = createRateLimiter({
416
+ windowMs: 60_000,
417
+ maxRequests: 10,
418
+ trustProxy: true,
419
+ });
420
+ app.use(oauthDiscoveryRouter);
421
+ app.use(express.json({ limit: "1mb" }), oauthDcrRouter);
422
+ app.use("/oauth/token", tokenRateLimiter);
423
+ app.use(express.urlencoded({ extended: false }), oauthTokenRouter(oauthStore));
333
424
  // Health check
334
425
  app.get("/health", (_req, res) => {
335
426
  res.json({
@@ -407,22 +498,26 @@ async function main() {
407
498
  });
408
499
  return;
409
500
  }
410
- // Auth — required on Initialize
411
- const apiKey = extractApiKey(req);
412
- if (!apiKey) {
501
+ // Auth — required on Initialize (supports both API keys and OAuth JWTs)
502
+ const { apiKey, jwt } = extractAuth(req);
503
+ if (!apiKey && !jwt) {
413
504
  res.status(401).json({
414
- error: "api_key_required",
415
- message: "An Open Ephemeris API key is required. Pass it via X-API-Key header or Authorization: Bearer. Get a free key at https://openephemeris.com/dashboard",
505
+ error: "auth_required",
506
+ message: "Authentication required. Pass an API key via X-API-Key header, or a Bearer token via Authorization header. Get a free key at https://openephemeris.com/dashboard",
416
507
  });
417
508
  return;
418
509
  }
419
- const valid = await validateApiKey(apiKey);
420
- if (!valid) {
421
- res.status(403).json({
422
- error: "invalid_api_key",
423
- message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
424
- });
425
- return;
510
+ // Validate API keys against the Go backend; JWTs are validated downstream
511
+ // by the Go sidecar's ValidateSupabaseJWT when tool calls are proxied.
512
+ if (apiKey) {
513
+ const valid = await validateApiKey(apiKey);
514
+ if (!valid) {
515
+ res.status(403).json({
516
+ error: "invalid_api_key",
517
+ message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
518
+ });
519
+ return;
520
+ }
426
521
  }
427
522
  // Create per-session transport, server, and client
428
523
  const transport = new StreamableHTTPServerTransport({
@@ -432,7 +527,7 @@ async function main() {
432
527
  console.error(`[HTTP] Session initialized: ${id}`);
433
528
  },
434
529
  });
435
- const client = new BackendClient({ baseURL: BACKEND_URL, apiKey });
530
+ const client = new BackendClient({ baseURL: BACKEND_URL, apiKey, jwt });
436
531
  const server = createMcpServer();
437
532
  transport.onclose = () => {
438
533
  if (transport.sessionId) {
@@ -511,23 +606,25 @@ async function main() {
511
606
  // ---------------------------------------------------------------------------
512
607
  // SSE endpoint — clients GET /sse to establish a stream
513
608
  app.get("/sse", async (req, res) => {
514
- // Extract API key from query param or header
515
- const apiKey = extractApiKey(req);
516
- if (!apiKey) {
609
+ // Extract auth from query param or header (supports API keys + OAuth JWTs)
610
+ const { apiKey, jwt } = extractAuth(req);
611
+ if (!apiKey && !jwt) {
517
612
  res.status(401).json({
518
- error: "api_key_required",
519
- message: "An OpenEphemeris API key is required. Generate one at https://openephemeris.com/dashboard?tab=apikeys",
613
+ error: "auth_required",
614
+ message: "Authentication required. Pass an API key via X-API-Key header or query param, or a Bearer JWT via Authorization header. Get a free key at https://openephemeris.com/dashboard?tab=apikeys",
520
615
  });
521
616
  return;
522
617
  }
523
- // Validate the user's key against the Go backend
524
- const valid = await validateApiKey(apiKey);
525
- if (!valid) {
526
- res.status(403).json({
527
- error: "invalid_api_key",
528
- message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
529
- });
530
- return;
618
+ // Validate API keys against the Go backend; JWTs validated downstream.
619
+ if (apiKey) {
620
+ const valid = await validateApiKey(apiKey);
621
+ if (!valid) {
622
+ res.status(403).json({
623
+ error: "invalid_api_key",
624
+ message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
625
+ });
626
+ return;
627
+ }
531
628
  }
532
629
  const transport = new SSEServerTransport("/message", res);
533
630
  const sessionId = transport.sessionId;
@@ -537,6 +634,7 @@ async function main() {
537
634
  const sessionClient = new BackendClient({
538
635
  baseURL: BACKEND_URL,
539
636
  apiKey,
637
+ jwt,
540
638
  });
541
639
  sessionClients.set(sessionId, sessionClient);
542
640
  const server = createMcpServer();
@@ -0,0 +1,23 @@
1
+ /**
2
+ * bazi-app.ts — MCP App tool registration for the BaZi Four Pillars Explorer.
3
+ *
4
+ * Registers 1 tool:
5
+ * • explore_bazi_chart — primary entry point, returns data + UI resource [model]
6
+ *
7
+ * Batches three backend API calls in parallel:
8
+ * POST /chinese/bazi → four pillars
9
+ * POST /chinese/bazi/ten-gods → ten gods + hidden stems
10
+ * POST /chinese/bazi/element-balance → wu xing balance + yong shen
11
+ *
12
+ * Merges results into a single UI payload and returns it with a resource URI
13
+ * so the iframe can render the interactive Four Pillars visualization.
14
+ *
15
+ * Also exports resource helpers (getBaziBundle, etc.) for use in
16
+ * index.ts and server-sse.ts.
17
+ */
18
+ export declare const BAZI_RESOURCE_URI = "ui://openephemeris/bazi";
19
+ export declare const BAZI_MIME_TYPE = "text/html;profile=mcp-app";
20
+ /** Read the pre-built HTML bundle. Returns null if not yet built. */
21
+ export declare function getBaziBundle(): string | null;
22
+ /** Reset cache (useful in dev watch mode or tests). */
23
+ export declare function clearBaziBundleCache(): void;
@@ -0,0 +1,224 @@
1
+ /**
2
+ * bazi-app.ts — MCP App tool registration for the BaZi Four Pillars Explorer.
3
+ *
4
+ * Registers 1 tool:
5
+ * • explore_bazi_chart — primary entry point, returns data + UI resource [model]
6
+ *
7
+ * Batches three backend API calls in parallel:
8
+ * POST /chinese/bazi → four pillars
9
+ * POST /chinese/bazi/ten-gods → ten gods + hidden stems
10
+ * POST /chinese/bazi/element-balance → wu xing balance + yong shen
11
+ *
12
+ * Merges results into a single UI payload and returns it with a resource URI
13
+ * so the iframe can render the interactive Four Pillars visualization.
14
+ *
15
+ * Also exports resource helpers (getBaziBundle, etc.) for use in
16
+ * index.ts and server-sse.ts.
17
+ */
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { registerTool, SERVER_VERSION } from "../index.js";
22
+ import { getActiveClient } from "../../backend/client.js";
23
+ // ── Constants ─────────────────────────────────────────────────────────────────
24
+ export const BAZI_RESOURCE_URI = "ui://openephemeris/bazi";
25
+ export const BAZI_MIME_TYPE = "text/html;profile=mcp-app";
26
+ const here = path.dirname(fileURLToPath(import.meta.url));
27
+ const BUNDLE_PATHS = [
28
+ path.resolve(here, "..", "..", "..", "dist", "ui", "bazi.html"),
29
+ ];
30
+ function findBundlePath() {
31
+ for (const p of BUNDLE_PATHS) {
32
+ if (fs.existsSync(p))
33
+ return p;
34
+ }
35
+ return null;
36
+ }
37
+ let cachedBundle = null;
38
+ /** Read the pre-built HTML bundle. Returns null if not yet built. */
39
+ export function getBaziBundle() {
40
+ if (cachedBundle)
41
+ return cachedBundle;
42
+ const bundlePath = findBundlePath();
43
+ if (!bundlePath)
44
+ return null;
45
+ try {
46
+ cachedBundle = fs.readFileSync(bundlePath, "utf-8");
47
+ return cachedBundle;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /** Reset cache (useful in dev watch mode or tests). */
54
+ export function clearBaziBundleCache() {
55
+ cachedBundle = null;
56
+ }
57
+ function parseBaziArgs(args) {
58
+ let { year, month, day, hour } = args;
59
+ if (args.datetime && (!year || !month || !day)) {
60
+ const dt = new Date(args.datetime);
61
+ if (!isNaN(dt.getTime())) {
62
+ year = dt.getUTCFullYear();
63
+ month = dt.getUTCMonth() + 1;
64
+ day = dt.getUTCDate();
65
+ if (hour == null)
66
+ hour = dt.getUTCHours();
67
+ }
68
+ }
69
+ if (!year || !month || !day) {
70
+ throw new Error("Provide year/month/day fields, or a datetime ISO string. " +
71
+ "Example: year=1987, month=7, day=15 OR datetime='1987-07-15T14:00:00Z'");
72
+ }
73
+ const out = { year, month, day };
74
+ if (hour != null)
75
+ out.hour = hour;
76
+ return out;
77
+ }
78
+ // ── Summary builder ──────────────────────────────────────────────────────────
79
+ function buildBaziSummary(data) {
80
+ const chart = data.chart;
81
+ const balance = data.element_balance;
82
+ const tenGods = data.ten_gods;
83
+ const dm = chart?.day_master;
84
+ const dmStr = dm ? (dm.stem || "?") + " " + (dm.element || "?") : "Unknown";
85
+ let summary = "**BaZi Four Pillars** — Day Master: **" + dmStr + "**\n\n";
86
+ // Pillars summary
87
+ const keys = ["year", "month", "day", "hour"];
88
+ const labels = ["Year", "Month", "Day", "Hour"];
89
+ if (chart) {
90
+ summary += "| Pillar | Stem | Branch | Animal |\n|--------|------|--------|--------|\n";
91
+ for (let i = 0; i < keys.length; i++) {
92
+ const p = chart[keys[i]];
93
+ if (p) {
94
+ summary +=
95
+ "| " + labels[i] +
96
+ " | " + (p.stem?.name || "?") +
97
+ " (" + (p.stem?.element || "") + ")" +
98
+ " | " + (p.branch?.name || "?") +
99
+ " | " + (p.branch?.animal || "") +
100
+ " |\n";
101
+ }
102
+ }
103
+ summary += "\n";
104
+ }
105
+ // Element balance
106
+ if (balance?.elements) {
107
+ const strength = balance.day_master_strength || "unknown";
108
+ summary += "**Day Master Strength**: " + strength + "\n";
109
+ if (balance.yong_shen_element) {
110
+ summary += "**Useful God (用神)**: " + balance.yong_shen_element + "\n";
111
+ }
112
+ summary += "\n";
113
+ }
114
+ summary += "Click pillars on the chart for Ten Gods detail and interpretation.";
115
+ return summary;
116
+ }
117
+ // ── Tool: explore_bazi_chart ─────────────────────────────────────────────────
118
+ registerTool({
119
+ name: "explore_bazi_chart",
120
+ description: "Generate an interactive BaZi Four Pillars (四柱命盘) visualization. " +
121
+ "Displays the Year, Month, Day, and Hour pillars with Heavenly Stems, " +
122
+ "Earthly Branches, Hidden Stems, Ten Gods analysis, and a weighted " +
123
+ "Wu Xing (Five Elements) balance bar.\n\n" +
124
+ "Returns a rich interactive chart with:\n" +
125
+ " • Four-column pillar grid with Chinese characters and element colours\n" +
126
+ " • Day Master emphasis and badge\n" +
127
+ " • Hidden Stems with strength indicators (main/secondary/residual)\n" +
128
+ " • Ten Gods classification for each pillar\n" +
129
+ " • Wu Xing balance bar with percentages and Yong Shen (Useful God)\n" +
130
+ " • Click pillars for detailed Ten Gods drawer\n\n" +
131
+ "CREDIT COST: 3 credits (batches three BaZi endpoints).\n\n" +
132
+ "Use this for a rich, interactive BaZi experience in MCP Apps-capable hosts (Claude Desktop). " +
133
+ "Falls back to a markdown table summary in other hosts.",
134
+ inputSchema: {
135
+ type: "object",
136
+ properties: {
137
+ year: {
138
+ type: "integer",
139
+ description: "Gregorian birth year, e.g. 1987.",
140
+ },
141
+ month: {
142
+ type: "integer",
143
+ description: "Birth month (1–12).",
144
+ },
145
+ day: {
146
+ type: "integer",
147
+ description: "Birth day of month (1–31).",
148
+ },
149
+ hour: {
150
+ type: "integer",
151
+ description: "Birth hour (0–23). Optional, defaults to 12 (noon). " +
152
+ "Chinese shí hours are 2-hour blocks — precision within a 2-hour window is sufficient.",
153
+ },
154
+ datetime: {
155
+ type: "string",
156
+ description: "Alternative to year/month/day: ISO 8601 datetime (e.g. '1987-07-15T14:00:00Z'). " +
157
+ "year/month/day/hour are extracted automatically.",
158
+ },
159
+ },
160
+ required: [],
161
+ additionalProperties: false,
162
+ },
163
+ annotations: {
164
+ title: "Interactive BaZi Four Pillars Explorer",
165
+ readOnlyHint: true,
166
+ destructiveHint: false,
167
+ idempotentHint: true,
168
+ openWorldHint: false,
169
+ },
170
+ _meta: {
171
+ ui: {
172
+ resourceUri: BAZI_RESOURCE_URI,
173
+ visibility: ["model", "app"],
174
+ },
175
+ },
176
+ handler: async (args) => {
177
+ const components = parseBaziArgs(args);
178
+ const client = getActiveClient();
179
+ const body = {
180
+ year: components.year,
181
+ month: components.month,
182
+ day: components.day,
183
+ };
184
+ if (components.hour != null)
185
+ body.hour = components.hour;
186
+ // Batch all three BaZi endpoints in parallel
187
+ const [chartResult, tenGodsResult, balanceResult] = await Promise.allSettled([
188
+ client.request("POST", "/chinese/bazi", { data: body }),
189
+ client.request("POST", "/chinese/bazi/ten-gods", { data: body }),
190
+ client.request("POST", "/chinese/bazi/element-balance", { data: body }),
191
+ ]);
192
+ const chartData = chartResult.status === "fulfilled"
193
+ ? chartResult.value
194
+ : { error: chartResult.reason?.message };
195
+ const tenGodsData = tenGodsResult.status === "fulfilled"
196
+ ? tenGodsResult.value
197
+ : { error: tenGodsResult.reason?.message };
198
+ const balanceData = balanceResult.status === "fulfilled"
199
+ ? balanceResult.value
200
+ : { error: balanceResult.reason?.message };
201
+ const uiPayload = {
202
+ chart: chartData,
203
+ ten_gods: tenGodsData,
204
+ element_balance: balanceData,
205
+ server_version: SERVER_VERSION,
206
+ };
207
+ const summary = buildBaziSummary(uiPayload);
208
+ const bundleAvailable = Boolean(getBaziBundle());
209
+ if (bundleAvailable) {
210
+ return {
211
+ content: [
212
+ { type: "text", text: summary },
213
+ { type: "text", text: JSON.stringify(uiPayload) },
214
+ ],
215
+ _meta: {
216
+ "ui/resourceUri": BAZI_RESOURCE_URI,
217
+ ui: { resourceUri: BAZI_RESOURCE_URI },
218
+ },
219
+ };
220
+ }
221
+ // Fallback: text summary only
222
+ return { content: [{ type: "text", text: summary }] };
223
+ },
224
+ });
@@ -1,15 +1,21 @@
1
1
  /**
2
2
  * bi-wheel-app.ts — MCP App tool registration for the Bi-Wheel Explorer.
3
3
  *
4
- * Registers 3 tools:
5
- * • explore_bi_wheel — primary entry point: fetches two charts, computes
6
- * cross-aspects, returns data + UI resource [model]
4
+ * Registers 6 tools:
5
+ * • explore_bi_wheel — primary entry point [model + app]
6
+ * bi_wheel_recalculate — re-fetch with new params [app-only]
7
7
  * • bi_wheel_on_cross_aspect_click — cross-aspect click handler [app-only]
8
- * • bi_wheel_on_planet_click — planet click handler (inner or outer wheel) [app-only]
8
+ * • bi_wheel_on_planet_click — planet click handler [app-only]
9
+ * • bi_wheel_on_house_click — house cusp click handler [app-only]
10
+ * • bi_wheel_synopsis — full reading overview [model + app]
9
11
  *
10
- * Modes:
11
- * synastry fetch two natal charts in parallel (person1 inner, person2 outer)
12
- * transit fetch one natal chart (inner) + one transit chart (outer)
12
+ * Supported modes (BiWheelMode):
13
+ * synastry — two natal charts (person1 inner, person2 outer)
14
+ * transit — natal inner + transiting planets outer
15
+ * progressed — natal inner + secondary progressions outer (POST /ephemeris/progressed)
16
+ * solar_return — natal inner + solar return chart outer (POST /predictive/returns/solar)
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)
13
19
  *
14
20
  * Cross-aspects are computed client-side (UI) AND server-side (for summary/fallback).
15
21
  * The payload cross_aspects array is capped at 30 tightest-orb aspects.
@@ -19,6 +25,7 @@ export declare const BI_WHEEL_MIME_TYPE = "text/html;profile=mcp-app";
19
25
  /** Read the pre-built HTML bundle. Returns null if not yet built. */
20
26
  export declare function getBiWheelBundle(): string | null;
21
27
  export declare function clearBiWheelBundleCache(): void;
28
+ export type BiWheelMode = "synastry" | "transit" | "progressed" | "solar_return" | "lunar_return" | "solar_arc";
22
29
  interface PlanetPoint {
23
30
  name: string;
24
31
  longitude: number;