@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.
- package/CHANGELOG.md +39 -0
- package/README.md +3 -3
- package/config/dev-allowlist.json +28 -4
- package/dist/index.js +20 -13
- package/dist/oauth/dcr.d.ts +11 -0
- package/dist/oauth/dcr.js +49 -0
- package/dist/oauth/discovery.d.ts +12 -0
- package/dist/oauth/discovery.js +45 -0
- package/dist/oauth/pkce.d.ts +18 -0
- package/dist/oauth/pkce.js +34 -0
- package/dist/oauth/rate-limit.d.ts +20 -0
- package/dist/oauth/rate-limit.js +65 -0
- package/dist/oauth/store.d.ts +88 -0
- package/dist/oauth/store.js +361 -0
- package/dist/oauth/supabase-jwt.d.ts +39 -0
- package/dist/oauth/supabase-jwt.js +194 -0
- package/dist/oauth/token.d.ts +21 -0
- package/dist/oauth/token.js +215 -0
- package/dist/prompts.js +180 -159
- package/dist/server-sse.js +154 -49
- package/dist/tools/apps/bazi-app.d.ts +23 -0
- package/dist/tools/apps/bazi-app.js +226 -0
- package/dist/tools/apps/bi-wheel-app.d.ts +14 -7
- package/dist/tools/apps/bi-wheel-app.js +347 -113
- package/dist/tools/apps/bodygraph-app.js +106 -13
- package/dist/tools/apps/chart-wheel-app.js +53 -4
- package/dist/tools/apps/location-tools.js +3 -0
- package/dist/tools/apps/moon-phase-app.d.ts +19 -0
- package/dist/tools/apps/moon-phase-app.js +244 -0
- package/dist/tools/apps/transit-timeline-app.d.ts +20 -0
- package/dist/tools/apps/transit-timeline-app.js +295 -0
- package/dist/tools/apps/vedic-chart-app.d.ts +17 -0
- package/dist/tools/apps/vedic-chart-app.js +228 -0
- package/dist/tools/auth.js +4 -0
- package/dist/tools/dev.js +3 -0
- package/dist/tools/index.d.ts +6 -0
- package/dist/tools/index.js +9 -4
- package/dist/tools/output-schemas.d.ts +37 -0
- package/dist/tools/output-schemas.js +130 -0
- package/dist/tools/specialized/acg.js +5 -2
- package/dist/tools/specialized/bazi.js +375 -44
- package/dist/tools/specialized/bi_wheel.js +3 -1
- package/dist/tools/specialized/chart_wheel.js +3 -1
- package/dist/tools/specialized/comparative.js +9 -4
- package/dist/tools/specialized/eclipse.js +3 -1
- package/dist/tools/specialized/electional.js +9 -4
- package/dist/tools/specialized/ephemeris_core.js +5 -2
- package/dist/tools/specialized/ephemeris_extended.js +17 -8
- package/dist/tools/specialized/hd_bodygraph.js +3 -1
- package/dist/tools/specialized/hd_cycles.js +5 -2
- package/dist/tools/specialized/hd_group.js +5 -2
- package/dist/tools/specialized/human_design.js +3 -1
- package/dist/tools/specialized/moon.js +5 -2
- package/dist/tools/specialized/natal.js +3 -1
- package/dist/tools/specialized/progressed.js +3 -1
- package/dist/tools/specialized/relocation.js +3 -1
- package/dist/tools/specialized/returns.js +7 -3
- package/dist/tools/specialized/synastry.js +3 -1
- package/dist/tools/specialized/transits.js +53 -44
- package/dist/tools/specialized/vedic.js +3 -1
- package/dist/tools/specialized/venus_star_points.js +13 -6
- package/dist/ui/bazi.html +213 -0
- package/dist/ui/bi-wheel.html +3714 -3048
- package/dist/ui/bodygraph.html +1952 -1766
- package/dist/ui/chart-wheel.html +3431 -2964
- package/dist/ui/moon-phase.html +6764 -0
- package/dist/ui/transit-timeline.html +6874 -0
- package/dist/ui/vedic-chart.html +210 -0
- package/package.json +15 -12
- package/smithery.yaml +16 -1
package/dist/server-sse.js
CHANGED
|
@@ -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
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
|
92
|
-
|
|
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.
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
? {
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
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 (
|
|
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
|
|
515
|
-
const apiKey =
|
|
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: "
|
|
519
|
-
message: "
|
|
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
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
|
5
|
-
* • explore_bi_wheel — primary entry point
|
|
6
|
-
*
|
|
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
|
|
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
|
-
*
|
|
11
|
-
* synastry
|
|
12
|
-
* transit
|
|
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;
|