@openephemeris/mcp-server 3.13.9 → 3.14.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 (70) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +3 -3
  3. package/config/dev-allowlist.json +28 -4
  4. package/dist/index.js +20 -13
  5. package/dist/oauth/dcr.d.ts +11 -0
  6. package/dist/oauth/dcr.js +49 -0
  7. package/dist/oauth/discovery.d.ts +12 -0
  8. package/dist/oauth/discovery.js +45 -0
  9. package/dist/oauth/pkce.d.ts +18 -0
  10. package/dist/oauth/pkce.js +34 -0
  11. package/dist/oauth/rate-limit.d.ts +20 -0
  12. package/dist/oauth/rate-limit.js +65 -0
  13. package/dist/oauth/store.d.ts +88 -0
  14. package/dist/oauth/store.js +361 -0
  15. package/dist/oauth/supabase-jwt.d.ts +39 -0
  16. package/dist/oauth/supabase-jwt.js +194 -0
  17. package/dist/oauth/token.d.ts +21 -0
  18. package/dist/oauth/token.js +215 -0
  19. package/dist/prompts.js +180 -159
  20. package/dist/server-sse.js +154 -49
  21. package/dist/tools/apps/bazi-app.d.ts +23 -0
  22. package/dist/tools/apps/bazi-app.js +226 -0
  23. package/dist/tools/apps/bi-wheel-app.d.ts +14 -7
  24. package/dist/tools/apps/bi-wheel-app.js +347 -113
  25. package/dist/tools/apps/bodygraph-app.js +106 -13
  26. package/dist/tools/apps/chart-wheel-app.js +53 -4
  27. package/dist/tools/apps/location-tools.js +3 -0
  28. package/dist/tools/apps/moon-phase-app.d.ts +19 -0
  29. package/dist/tools/apps/moon-phase-app.js +244 -0
  30. package/dist/tools/apps/transit-timeline-app.d.ts +20 -0
  31. package/dist/tools/apps/transit-timeline-app.js +295 -0
  32. package/dist/tools/apps/vedic-chart-app.d.ts +17 -0
  33. package/dist/tools/apps/vedic-chart-app.js +228 -0
  34. package/dist/tools/auth.js +4 -0
  35. package/dist/tools/dev.js +3 -0
  36. package/dist/tools/index.d.ts +6 -0
  37. package/dist/tools/index.js +9 -4
  38. package/dist/tools/output-schemas.d.ts +37 -0
  39. package/dist/tools/output-schemas.js +130 -0
  40. package/dist/tools/specialized/acg.js +5 -2
  41. package/dist/tools/specialized/bazi.js +375 -44
  42. package/dist/tools/specialized/bi_wheel.js +3 -1
  43. package/dist/tools/specialized/chart_wheel.js +3 -1
  44. package/dist/tools/specialized/comparative.js +9 -4
  45. package/dist/tools/specialized/eclipse.js +3 -1
  46. package/dist/tools/specialized/electional.js +9 -4
  47. package/dist/tools/specialized/ephemeris_core.js +5 -2
  48. package/dist/tools/specialized/ephemeris_extended.js +17 -8
  49. package/dist/tools/specialized/hd_bodygraph.js +3 -1
  50. package/dist/tools/specialized/hd_cycles.js +5 -2
  51. package/dist/tools/specialized/hd_group.js +5 -2
  52. package/dist/tools/specialized/human_design.js +3 -1
  53. package/dist/tools/specialized/moon.js +5 -2
  54. package/dist/tools/specialized/natal.js +3 -1
  55. package/dist/tools/specialized/progressed.js +3 -1
  56. package/dist/tools/specialized/relocation.js +3 -1
  57. package/dist/tools/specialized/returns.js +7 -3
  58. package/dist/tools/specialized/synastry.js +3 -1
  59. package/dist/tools/specialized/transits.js +53 -44
  60. package/dist/tools/specialized/vedic.js +3 -1
  61. package/dist/tools/specialized/venus_star_points.js +13 -6
  62. package/dist/ui/bazi.html +213 -0
  63. package/dist/ui/bi-wheel.html +3714 -3048
  64. package/dist/ui/bodygraph.html +1952 -1766
  65. package/dist/ui/chart-wheel.html +3431 -2964
  66. package/dist/ui/moon-phase.html +6764 -0
  67. package/dist/ui/transit-timeline.html +6874 -0
  68. package/dist/ui/vedic-chart.html +210 -0
  69. package/package.json +15 -12
  70. package/smithery.yaml +16 -1
@@ -27,6 +27,13 @@ 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
+ // NOTE: transit-timeline, bazi, vedic-chart imports removed — tools disabled.
31
+ import { MOON_PHASE_RESOURCE_URI, MOON_PHASE_MIME_TYPE, getMoonPhaseBundle, } from "./tools/apps/moon-phase-app.js";
32
+ import { oauthDiscoveryRouter, PROTECTED_RESOURCE_METADATA_URL } from "./oauth/discovery.js";
33
+ import { oauthDcrRouter } from "./oauth/dcr.js";
34
+ import { oauthTokenRouter } from "./oauth/token.js";
35
+ import { OAuthStore } from "./oauth/store.js";
36
+ import { createRateLimiter } from "./oauth/rate-limit.js";
30
37
  // ---------------------------------------------------------------------------
31
38
  // Helpers
32
39
  // ---------------------------------------------------------------------------
@@ -82,18 +89,31 @@ async function validateApiKey(apiKey) {
82
89
  }
83
90
  }
84
91
  /**
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
92
+ * Extract authentication from a request, distinguishing JWTs from API keys.
93
+ *
94
+ * Detection heuristic:
95
+ * - Bearer tokens starting with "eyJ" → Supabase JWT (from OAuth flow)
96
+ * - Bearer tokens starting with "opene-" → API key
97
+ * - X-API-Key / X-OpenEphemeris-API-Key headers → API key
98
+ * - ?apiKey query param → API key
99
+ *
100
+ * Returns { apiKey?, jwt? } — at most one will be set.
90
101
  */
91
- function extractApiKey(req) {
92
- return (req.headers["x-api-key"] ||
102
+ function extractAuth(req) {
103
+ // Explicit API key headers take precedence
104
+ const explicitKey = req.headers["x-api-key"] ||
93
105
  req.headers["x-openephemeris-api-key"] ||
94
- req.headers["authorization"]?.replace(/^Bearer\s+/i, "") ||
95
- req.query.apiKey ||
96
- undefined);
106
+ req.query.apiKey;
107
+ if (explicitKey)
108
+ return { apiKey: explicitKey };
109
+ // Bearer token — distinguish JWT from API key by prefix
110
+ const bearer = req.headers["authorization"]?.replace(/^Bearer\s+/i, "");
111
+ if (bearer) {
112
+ if (bearer.startsWith("eyJ"))
113
+ return { jwt: bearer }; // Supabase JWT
114
+ return { apiKey: bearer }; // API key
115
+ }
116
+ return {};
97
117
  }
98
118
  // ---------------------------------------------------------------------------
99
119
  // Helpers
@@ -210,7 +230,15 @@ function createMcpServer() {
210
230
  annotations: buildAnnotations(tool),
211
231
  // Expose MCP Apps UI linkage so Claude Desktop can prefetch the resource
212
232
  ...(tool._meta?.ui?.resourceUri
213
- ? { _meta: { ui: { resourceUri: tool._meta.ui.resourceUri, visibility: tool._meta.ui.visibility ?? ["model", "app"] } } }
233
+ ? {
234
+ _meta: {
235
+ "ui/resourceUri": tool._meta.ui.resourceUri,
236
+ ui: {
237
+ resourceUri: tool._meta.ui.resourceUri,
238
+ visibility: tool._meta.ui.visibility ?? ["model", "app"],
239
+ },
240
+ },
241
+ }
214
242
  : {}),
215
243
  })),
216
244
  }));
@@ -242,16 +270,18 @@ function createMcpServer() {
242
270
  }
243
271
  catch (error) {
244
272
  const startTime = Date.now(); // Not strictly accurate for errors but acceptable for logging structure if needed
273
+ const durationMs = Date.now() - startTime;
245
274
  const errorMessage = error instanceof Error ? error.message : String(error);
246
- console.error(`[MCP] \u274c Failed: ${toolName} - ${errorMessage}`);
275
+ const isRetryable = error.retryable === true || error.status === 401 || error.code === "auth_required";
276
+ console.error(`[MCP] ❌ Failed: ${toolName} (${durationMs}ms) - ${errorMessage}`);
247
277
  return {
248
278
  content: [
249
279
  {
250
280
  type: "text",
251
- text: JSON.stringify({ success: false, error: "TOOL_EXECUTION_ERROR", message: errorMessage }, null, 2),
281
+ text: `Tool execution failed:\n\n${errorMessage}\n\nPlease read the error above carefully and assist the user (e.g. by providing them the auth link or explaining the issue).`,
252
282
  },
253
283
  ],
254
- isError: true,
284
+ isError: !isRetryable,
255
285
  };
256
286
  }
257
287
  });
@@ -282,6 +312,15 @@ function createMcpServer() {
282
312
  mimeType: BI_WHEEL_MIME_TYPE,
283
313
  });
284
314
  }
315
+ // NOTE: transit-timeline, bazi, vedic-chart resources omitted — tools disabled.
316
+ if (getMoonPhaseBundle()) {
317
+ resources.push({
318
+ uri: MOON_PHASE_RESOURCE_URI,
319
+ name: "Moon Phase Explorer",
320
+ description: "Interactive lunar phase dial showing illumination, sign, and void-of-course status.",
321
+ mimeType: MOON_PHASE_MIME_TYPE,
322
+ });
323
+ }
285
324
  return { resources };
286
325
  });
287
326
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
@@ -304,6 +343,13 @@ function createMcpServer() {
304
343
  throw new Error("Bi-Wheel UI bundle not found. Run `npm run build:ui` to build it.");
305
344
  return { contents: [{ uri: BI_WHEEL_RESOURCE_URI, mimeType: BI_WHEEL_MIME_TYPE, text: bundle }] };
306
345
  }
346
+ // NOTE: transit-timeline, bazi, vedic-chart ReadResource handlers removed.
347
+ if (uri === MOON_PHASE_RESOURCE_URI) {
348
+ const bundle = getMoonPhaseBundle();
349
+ if (!bundle)
350
+ throw new Error("Moon Phase UI bundle not found. Run `npm run build:ui` to build it.");
351
+ return { contents: [{ uri: MOON_PHASE_RESOURCE_URI, mimeType: MOON_PHASE_MIME_TYPE, text: bundle }] };
352
+ }
307
353
  throw new Error(`Unknown resource: ${uri}`);
308
354
  });
309
355
  return server;
@@ -315,12 +361,38 @@ async function main() {
315
361
  // NOTE: Do NOT use express.json() globally — SSEServerTransport.handlePostMessage
316
362
  // reads the raw request stream itself. Pre-parsing with express.json() consumes it,
317
363
  // causing "stream is not readable" errors.
318
- // CORS required for Claude Web cross-origin SSE connections
319
- app.use((_req, res, next) => {
320
- res.setHeader("Access-Control-Allow-Origin", "*");
364
+ // CORS + Origin allowlist
365
+ // Per Anthropic remote-MCP review criteria, browser-originated requests must
366
+ // be validated against an allowlist. Native (no-Origin) clients like Claude
367
+ // Desktop and CLI tooling are permitted; unknown browser origins are rejected.
368
+ const ORIGIN_ALLOWLIST = [
369
+ /^https:\/\/([a-z0-9-]+\.)*claude\.ai$/i,
370
+ /^https:\/\/([a-z0-9-]+\.)*claude\.com$/i,
371
+ /^https:\/\/([a-z0-9-]+\.)*anthropic\.com$/i,
372
+ /^https:\/\/([a-z0-9-]+\.)*openephemeris\.com$/i,
373
+ /^http:\/\/localhost(:\d+)?$/i,
374
+ /^http:\/\/127\.0\.0\.1(:\d+)?$/i,
375
+ ];
376
+ function isOriginAllowed(origin) {
377
+ return ORIGIN_ALLOWLIST.some((m) => typeof m === "string" ? m === origin : m.test(origin));
378
+ }
379
+ app.use((req, res, next) => {
380
+ const origin = req.headers.origin;
381
+ // Browser request → must match allowlist. Native client (no Origin) → allow.
382
+ if (origin) {
383
+ if (!isOriginAllowed(origin)) {
384
+ res.status(403).json({
385
+ error: "origin_not_allowed",
386
+ error_description: `Origin '${origin}' is not permitted.`,
387
+ });
388
+ return;
389
+ }
390
+ res.setHeader("Access-Control-Allow-Origin", origin);
391
+ res.setHeader("Vary", "Origin");
392
+ }
321
393
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
322
394
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-OpenEphemeris-API-Key, mcp-session-id");
323
- if (_req.method === "OPTIONS") {
395
+ if (req.method === "OPTIONS") {
324
396
  res.status(204).end();
325
397
  return;
326
398
  }
@@ -330,6 +402,25 @@ async function main() {
330
402
  const transports = new Map();
331
403
  // Per-session BackendClient — each connection authenticates independently.
332
404
  const sessionClients = new Map();
405
+ // ---------------------------------------------------------------------------
406
+ // OAuth 2.1 routes — must be mounted BEFORE JSON body parser
407
+ // ---------------------------------------------------------------------------
408
+ const oauthStore = new OAuthStore();
409
+ const tokenRateLimiter = createRateLimiter({
410
+ windowMs: 60_000,
411
+ maxRequests: 10,
412
+ trustProxy: true,
413
+ });
414
+ const dcrRateLimiter = createRateLimiter({
415
+ windowMs: 60_000,
416
+ maxRequests: 5,
417
+ trustProxy: true,
418
+ });
419
+ app.use(oauthDiscoveryRouter);
420
+ app.use("/oauth/register", dcrRateLimiter);
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({
@@ -350,6 +441,7 @@ async function main() {
350
441
  name: tool.name,
351
442
  description: tool.description,
352
443
  inputSchema: tool.inputSchema,
444
+ ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
353
445
  annotations: buildAnnotations(tool),
354
446
  }));
355
447
  res.json({
@@ -399,6 +491,33 @@ async function main() {
399
491
  await runWithClient(session.client, () => session.transport.handleRequest(req, res, req.body));
400
492
  return;
401
493
  }
494
+ // Auth check FIRST — before validating request shape. RFC 9728 requires
495
+ // any unauthenticated POST to /mcp (even a probe with an empty/invalid
496
+ // body) to return 401 + WWW-Authenticate so OAuth clients (Claude Web)
497
+ // can bootstrap discovery. Returning 400 before auth causes the ofid_
498
+ // "Authorization with MCP server failed" error in Claude's connector UI.
499
+ const { apiKey, jwt } = extractAuth(req);
500
+ if (!apiKey && !jwt) {
501
+ res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
502
+ res.status(401).json({
503
+ error: "auth_required",
504
+ 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",
505
+ });
506
+ return;
507
+ }
508
+ // Validate API keys against the Go backend; JWTs are validated downstream
509
+ // by the Go sidecar's ValidateSupabaseJWT when tool calls are proxied.
510
+ if (apiKey) {
511
+ const valid = await validateApiKey(apiKey);
512
+ if (!valid) {
513
+ res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
514
+ res.status(401).json({
515
+ error: "invalid_api_key",
516
+ message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
517
+ });
518
+ return;
519
+ }
520
+ }
402
521
  // New session — must be an initialize request
403
522
  if (!isInitializeRequest(req.body)) {
404
523
  res.status(400).json({
@@ -407,23 +526,6 @@ async function main() {
407
526
  });
408
527
  return;
409
528
  }
410
- // Auth — required on Initialize
411
- const apiKey = extractApiKey(req);
412
- if (!apiKey) {
413
- 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",
416
- });
417
- return;
418
- }
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;
426
- }
427
529
  // Create per-session transport, server, and client
428
530
  const transport = new StreamableHTTPServerTransport({
429
531
  sessionIdGenerator: randomUUID,
@@ -432,7 +534,7 @@ async function main() {
432
534
  console.error(`[HTTP] Session initialized: ${id}`);
433
535
  },
434
536
  });
435
- const client = new BackendClient({ baseURL: BACKEND_URL, apiKey });
537
+ const client = new BackendClient({ baseURL: BACKEND_URL, apiKey, jwt });
436
538
  const server = createMcpServer();
437
539
  transport.onclose = () => {
438
540
  if (transport.sessionId) {
@@ -511,23 +613,25 @@ async function main() {
511
613
  // ---------------------------------------------------------------------------
512
614
  // SSE endpoint — clients GET /sse to establish a stream
513
615
  app.get("/sse", async (req, res) => {
514
- // Extract API key from query param or header
515
- const apiKey = extractApiKey(req);
516
- if (!apiKey) {
616
+ // Extract auth from query param or header (supports API keys + OAuth JWTs)
617
+ const { apiKey, jwt } = extractAuth(req);
618
+ if (!apiKey && !jwt) {
517
619
  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",
620
+ error: "auth_required",
621
+ 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
622
  });
521
623
  return;
522
624
  }
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;
625
+ // Validate API keys against the Go backend; JWTs validated downstream.
626
+ if (apiKey) {
627
+ const valid = await validateApiKey(apiKey);
628
+ if (!valid) {
629
+ res.status(403).json({
630
+ error: "invalid_api_key",
631
+ message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
632
+ });
633
+ return;
634
+ }
531
635
  }
532
636
  const transport = new SSEServerTransport("/message", res);
533
637
  const sessionId = transport.sessionId;
@@ -537,6 +641,7 @@ async function main() {
537
641
  const sessionClient = new BackendClient({
538
642
  baseURL: BACKEND_URL,
539
643
  apiKey,
644
+ jwt,
540
645
  });
541
646
  sessionClients.set(sessionId, sessionClient);
542
647
  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,226 @@
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
+ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
24
+ // ── Constants ─────────────────────────────────────────────────────────────────
25
+ export const BAZI_RESOURCE_URI = "ui://openephemeris/bazi";
26
+ export const BAZI_MIME_TYPE = "text/html;profile=mcp-app";
27
+ const here = path.dirname(fileURLToPath(import.meta.url));
28
+ const BUNDLE_PATHS = [
29
+ path.resolve(here, "..", "..", "..", "dist", "ui", "bazi.html"),
30
+ ];
31
+ function findBundlePath() {
32
+ for (const p of BUNDLE_PATHS) {
33
+ if (fs.existsSync(p))
34
+ return p;
35
+ }
36
+ return null;
37
+ }
38
+ let cachedBundle = null;
39
+ /** Read the pre-built HTML bundle. Returns null if not yet built. */
40
+ export function getBaziBundle() {
41
+ if (cachedBundle)
42
+ return cachedBundle;
43
+ const bundlePath = findBundlePath();
44
+ if (!bundlePath)
45
+ return null;
46
+ try {
47
+ cachedBundle = fs.readFileSync(bundlePath, "utf-8");
48
+ return cachedBundle;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /** Reset cache (useful in dev watch mode or tests). */
55
+ export function clearBaziBundleCache() {
56
+ cachedBundle = null;
57
+ }
58
+ function parseBaziArgs(args) {
59
+ let { year, month, day, hour } = args;
60
+ if (args.datetime && (!year || !month || !day)) {
61
+ const dt = new Date(args.datetime);
62
+ if (!isNaN(dt.getTime())) {
63
+ year = dt.getUTCFullYear();
64
+ month = dt.getUTCMonth() + 1;
65
+ day = dt.getUTCDate();
66
+ if (hour == null)
67
+ hour = dt.getUTCHours();
68
+ }
69
+ }
70
+ if (!year || !month || !day) {
71
+ throw new Error("Provide year/month/day fields, or a datetime ISO string. " +
72
+ "Example: year=1987, month=7, day=15 OR datetime='1987-07-15T14:00:00Z'");
73
+ }
74
+ const out = { year, month, day };
75
+ if (hour != null)
76
+ out.hour = hour;
77
+ return out;
78
+ }
79
+ // ── Summary builder ──────────────────────────────────────────────────────────
80
+ function buildBaziSummary(data) {
81
+ const chart = data.chart;
82
+ const balance = data.element_balance;
83
+ const tenGods = data.ten_gods;
84
+ const dm = chart?.day_master;
85
+ const dmStr = dm ? (dm.stem || "?") + " " + (dm.element || "?") : "Unknown";
86
+ let summary = "**BaZi Four Pillars** — Day Master: **" + dmStr + "**\n\n";
87
+ // Pillars summary
88
+ const keys = ["year", "month", "day", "hour"];
89
+ const labels = ["Year", "Month", "Day", "Hour"];
90
+ if (chart) {
91
+ summary += "| Pillar | Stem | Branch | Animal |\n|--------|------|--------|--------|\n";
92
+ for (let i = 0; i < keys.length; i++) {
93
+ const p = chart[keys[i]];
94
+ if (p) {
95
+ summary +=
96
+ "| " + labels[i] +
97
+ " | " + (p.stem?.name || "?") +
98
+ " (" + (p.stem?.element || "") + ")" +
99
+ " | " + (p.branch?.name || "?") +
100
+ " | " + (p.branch?.animal || "") +
101
+ " |\n";
102
+ }
103
+ }
104
+ summary += "\n";
105
+ }
106
+ // Element balance
107
+ if (balance?.elements) {
108
+ const strength = balance.day_master_strength || "unknown";
109
+ summary += "**Day Master Strength**: " + strength + "\n";
110
+ if (balance.yong_shen_element) {
111
+ summary += "**Useful God (用神)**: " + balance.yong_shen_element + "\n";
112
+ }
113
+ summary += "\n";
114
+ }
115
+ summary += "Click pillars on the chart for Ten Gods detail and interpretation.";
116
+ return summary;
117
+ }
118
+ // ── Tool: explore_bazi_chart ─────────────────────────────────────────────────
119
+ registerTool({
120
+ name: "explore_bazi_chart",
121
+ description: "Generate an interactive BaZi Four Pillars (四柱命盘) visualization. " +
122
+ "Displays the Year, Month, Day, and Hour pillars with Heavenly Stems, " +
123
+ "Earthly Branches, Hidden Stems, Ten Gods analysis, and a weighted " +
124
+ "Wu Xing (Five Elements) balance bar.\n\n" +
125
+ "Returns a rich interactive chart with:\n" +
126
+ " • Four-column pillar grid with Chinese characters and element colours\n" +
127
+ " • Day Master emphasis and badge\n" +
128
+ " • Hidden Stems with strength indicators (main/secondary/residual)\n" +
129
+ " • Ten Gods classification for each pillar\n" +
130
+ " • Wu Xing balance bar with percentages and Yong Shen (Useful God)\n" +
131
+ " • Click pillars for detailed Ten Gods drawer\n\n" +
132
+ "CREDIT COST: 3 credits (batches three BaZi endpoints).\n\n" +
133
+ "Use this for a rich, interactive BaZi experience in MCP Apps-capable hosts (Claude Desktop). " +
134
+ "Falls back to a markdown table summary in other hosts.",
135
+ inputSchema: {
136
+ type: "object",
137
+ properties: {
138
+ year: {
139
+ type: "integer",
140
+ description: "Gregorian birth year, e.g. 1987.",
141
+ },
142
+ month: {
143
+ type: "integer",
144
+ description: "Birth month (1–12).",
145
+ },
146
+ day: {
147
+ type: "integer",
148
+ description: "Birth day of month (1–31).",
149
+ },
150
+ hour: {
151
+ type: "integer",
152
+ description: "Birth hour (0–23). Optional, defaults to 12 (noon). " +
153
+ "Chinese shí hours are 2-hour blocks — precision within a 2-hour window is sufficient.",
154
+ },
155
+ datetime: {
156
+ type: "string",
157
+ description: "Alternative to year/month/day: ISO 8601 datetime (e.g. '1987-07-15T14:00:00Z'). " +
158
+ "year/month/day/hour are extracted automatically.",
159
+ },
160
+ },
161
+ required: [],
162
+ additionalProperties: false,
163
+ },
164
+ outputSchema: OUTPUT_SCHEMA_JSON,
165
+ annotations: {
166
+ title: "Interactive BaZi Four Pillars Explorer",
167
+ readOnlyHint: true,
168
+ destructiveHint: false,
169
+ idempotentHint: true,
170
+ openWorldHint: false,
171
+ },
172
+ _meta: {
173
+ ui: {
174
+ resourceUri: BAZI_RESOURCE_URI,
175
+ visibility: ["model", "app"],
176
+ },
177
+ },
178
+ handler: async (args) => {
179
+ const components = parseBaziArgs(args);
180
+ const client = getActiveClient();
181
+ const body = {
182
+ year: components.year,
183
+ month: components.month,
184
+ day: components.day,
185
+ };
186
+ if (components.hour != null)
187
+ body.hour = components.hour;
188
+ // Batch all three BaZi endpoints in parallel
189
+ const [chartResult, tenGodsResult, balanceResult] = await Promise.allSettled([
190
+ client.request("POST", "/chinese/bazi", { data: body }),
191
+ client.request("POST", "/chinese/bazi/ten-gods", { data: body }),
192
+ client.request("POST", "/chinese/bazi/element-balance", { data: body }),
193
+ ]);
194
+ const chartData = chartResult.status === "fulfilled"
195
+ ? chartResult.value
196
+ : { error: chartResult.reason?.message };
197
+ const tenGodsData = tenGodsResult.status === "fulfilled"
198
+ ? tenGodsResult.value
199
+ : { error: tenGodsResult.reason?.message };
200
+ const balanceData = balanceResult.status === "fulfilled"
201
+ ? balanceResult.value
202
+ : { error: balanceResult.reason?.message };
203
+ const uiPayload = {
204
+ chart: chartData,
205
+ ten_gods: tenGodsData,
206
+ element_balance: balanceData,
207
+ server_version: SERVER_VERSION,
208
+ };
209
+ const summary = buildBaziSummary(uiPayload);
210
+ const bundleAvailable = Boolean(getBaziBundle());
211
+ if (bundleAvailable) {
212
+ return {
213
+ content: [
214
+ { type: "text", text: summary },
215
+ { type: "text", text: JSON.stringify(uiPayload) },
216
+ ],
217
+ _meta: {
218
+ "ui/resourceUri": BAZI_RESOURCE_URI,
219
+ ui: { resourceUri: BAZI_RESOURCE_URI },
220
+ },
221
+ };
222
+ }
223
+ // Fallback: text summary only
224
+ return { content: [{ type: "text", text: summary }] };
225
+ },
226
+ });
@@ -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;