@openephemeris/mcp-server 3.13.10 → 3.15.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 +53 -0
- package/README.md +6 -6
- package/dist/index.js +5 -43
- package/dist/oauth/discovery.d.ts +1 -0
- package/dist/oauth/discovery.js +8 -3
- package/dist/oauth/store.d.ts +20 -0
- package/dist/oauth/store.js +117 -3
- package/dist/oauth/token.js +4 -2
- package/dist/prompts.js +25 -16
- package/dist/server-sse.js +136 -78
- package/dist/tools/apps/bazi-app.js +2 -0
- package/dist/tools/apps/bi-wheel-app.js +65 -7
- package/dist/tools/apps/bodygraph-app.js +85 -11
- package/dist/tools/apps/chart-wheel-app.js +80 -7
- package/dist/tools/apps/location-tools.js +41 -5
- package/dist/tools/apps/moon-phase-app.js +82 -22
- package/dist/tools/apps/transit-timeline-app.js +75 -23
- package/dist/tools/apps/vedic-chart-app.js +2 -0
- package/dist/tools/auth.js +4 -0
- package/dist/tools/dev.js +104 -67
- package/dist/tools/index.d.ts +16 -0
- package/dist/tools/index.js +23 -8
- package/dist/tools/output-schemas.d.ts +37 -0
- package/dist/tools/output-schemas.js +130 -0
- package/dist/tools/specialized/acg.js +3 -0
- package/dist/tools/specialized/bazi.js +14 -6
- package/dist/tools/specialized/bi_wheel.js +5 -2
- package/dist/tools/specialized/chart_wheel.js +5 -2
- package/dist/tools/specialized/comparative.js +7 -2
- package/dist/tools/specialized/eclipse.js +2 -0
- package/dist/tools/specialized/electional.js +5 -0
- package/dist/tools/specialized/ephemeris_core.js +3 -0
- package/dist/tools/specialized/ephemeris_extended.js +9 -0
- package/dist/tools/specialized/hd_bodygraph.js +6 -3
- package/dist/tools/specialized/hd_cycles.js +3 -0
- package/dist/tools/specialized/hd_group.js +3 -0
- package/dist/tools/specialized/human_design.js +2 -0
- package/dist/tools/specialized/moon.js +3 -0
- package/dist/tools/specialized/natal.js +2 -0
- package/dist/tools/specialized/progressed.js +2 -0
- package/dist/tools/specialized/relocation.js +2 -0
- package/dist/tools/specialized/returns.js +4 -0
- package/dist/tools/specialized/synastry.js +2 -0
- package/dist/tools/specialized/transits.js +52 -43
- package/dist/tools/specialized/vedic.js +2 -0
- package/dist/tools/specialized/venus_star_points.js +7 -0
- package/dist/ui/bazi.html +22 -22
- package/dist/ui/bodygraph.html +1981 -1904
- package/dist/ui/chart-wheel.html +3148 -2806
- package/dist/ui/vedic-chart.html +22 -22
- package/package.json +18 -11
- package/smithery.yaml +16 -1
- package/dist/ui/bi-wheel.html +0 -7485
- package/dist/ui/moon-phase.html +0 -6758
- package/dist/ui/transit-timeline.html +0 -6835
package/dist/server-sse.js
CHANGED
|
@@ -27,11 +27,9 @@ 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
|
-
|
|
30
|
+
// NOTE: transit-timeline, bazi, vedic-chart imports removed — tools disabled.
|
|
31
31
|
import { MOON_PHASE_RESOURCE_URI, MOON_PHASE_MIME_TYPE, getMoonPhaseBundle, } from "./tools/apps/moon-phase-app.js";
|
|
32
|
-
import {
|
|
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";
|
|
32
|
+
import { oauthDiscoveryRouter, PROTECTED_RESOURCE_METADATA_URL } from "./oauth/discovery.js";
|
|
35
33
|
import { oauthDcrRouter } from "./oauth/dcr.js";
|
|
36
34
|
import { oauthTokenRouter } from "./oauth/token.js";
|
|
37
35
|
import { OAuthStore } from "./oauth/store.js";
|
|
@@ -68,8 +66,10 @@ const BACKEND_URL = process.env.OPENEPHEMERIS_BACKEND_URL ||
|
|
|
68
66
|
"https://api.openephemeris.com";
|
|
69
67
|
const version = resolveServerVersion();
|
|
70
68
|
/**
|
|
71
|
-
* Validate a user-supplied API key by hitting an auth-gated endpoint on
|
|
72
|
-
*
|
|
69
|
+
* Validate a user-supplied API key by hitting an auth-gated endpoint on the Go
|
|
70
|
+
* sidecar. Tri-state so callers can fail closed when validation is unavailable
|
|
71
|
+
* (APP-6): "valid" (2xx), "invalid" (401/403), or "unknown" (network error or
|
|
72
|
+
* non-auth status — could not confirm).
|
|
73
73
|
*/
|
|
74
74
|
async function validateApiKey(apiKey) {
|
|
75
75
|
try {
|
|
@@ -79,17 +79,26 @@ async function validateApiKey(apiKey) {
|
|
|
79
79
|
timeout: 5_000,
|
|
80
80
|
validateStatus: () => true, // don't throw on any status
|
|
81
81
|
});
|
|
82
|
-
// 2xx = valid key, 401/403 = invalid key, anything else = let through
|
|
83
82
|
if (resp.status === 401 || resp.status === 403)
|
|
84
|
-
return
|
|
85
|
-
|
|
83
|
+
return "invalid";
|
|
84
|
+
if (resp.status >= 200 && resp.status < 300)
|
|
85
|
+
return "valid";
|
|
86
|
+
return "unknown"; // 5xx etc. — can't confirm
|
|
86
87
|
}
|
|
87
88
|
catch {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return true;
|
|
89
|
+
console.warn("Could not validate API key against backend.");
|
|
90
|
+
return "unknown";
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Browser-originated requests carry an Origin header (the MCP apps run in a
|
|
95
|
+
* cross-origin iframe). Server-to-server MCP clients generally do not. We use
|
|
96
|
+
* this to fail closed for browsers when key validation is unavailable, while
|
|
97
|
+
* still letting trusted server clients through (APP-6).
|
|
98
|
+
*/
|
|
99
|
+
function isBrowserOrigin(req) {
|
|
100
|
+
return typeof req.headers.origin === "string" && req.headers.origin.length > 0;
|
|
101
|
+
}
|
|
93
102
|
/**
|
|
94
103
|
* Extract authentication from a request, distinguishing JWTs from API keys.
|
|
95
104
|
*
|
|
@@ -214,8 +223,12 @@ function createMcpServer() {
|
|
|
214
223
|
prompts: {},
|
|
215
224
|
resources: {},
|
|
216
225
|
experimental: {
|
|
217
|
-
// Declare MCP Apps extension (spec: io.modelcontextprotocol/ui, 2026-01-26)
|
|
218
|
-
|
|
226
|
+
// Declare MCP Apps extension (spec: io.modelcontextprotocol/ui, 2026-01-26).
|
|
227
|
+
// Advertise the supported UI MIME type so Directory-compliant hosts know
|
|
228
|
+
// this server returns renderable HTML resources.
|
|
229
|
+
"io.modelcontextprotocol/ui": {
|
|
230
|
+
mimeTypes: ["text/html;profile=mcp-app"],
|
|
231
|
+
},
|
|
219
232
|
},
|
|
220
233
|
},
|
|
221
234
|
instructions: "Open Ephemeris is a precision astronomical computation engine. " +
|
|
@@ -232,7 +245,15 @@ function createMcpServer() {
|
|
|
232
245
|
annotations: buildAnnotations(tool),
|
|
233
246
|
// Expose MCP Apps UI linkage so Claude Desktop can prefetch the resource
|
|
234
247
|
...(tool._meta?.ui?.resourceUri
|
|
235
|
-
? {
|
|
248
|
+
? {
|
|
249
|
+
_meta: {
|
|
250
|
+
"ui/resourceUri": tool._meta.ui.resourceUri,
|
|
251
|
+
ui: {
|
|
252
|
+
resourceUri: tool._meta.ui.resourceUri,
|
|
253
|
+
visibility: tool._meta.ui.visibility ?? ["model", "app"],
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
}
|
|
236
257
|
: {}),
|
|
237
258
|
})),
|
|
238
259
|
}));
|
|
@@ -264,16 +285,18 @@ function createMcpServer() {
|
|
|
264
285
|
}
|
|
265
286
|
catch (error) {
|
|
266
287
|
const startTime = Date.now(); // Not strictly accurate for errors but acceptable for logging structure if needed
|
|
288
|
+
const durationMs = Date.now() - startTime;
|
|
267
289
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
268
|
-
|
|
290
|
+
const isRetryable = error.retryable === true || error.status === 401 || error.code === "auth_required";
|
|
291
|
+
console.error(`[MCP] ❌ Failed: ${toolName} (${durationMs}ms) - ${errorMessage}`);
|
|
269
292
|
return {
|
|
270
293
|
content: [
|
|
271
294
|
{
|
|
272
295
|
type: "text",
|
|
273
|
-
text:
|
|
296
|
+
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).`,
|
|
274
297
|
},
|
|
275
298
|
],
|
|
276
|
-
isError:
|
|
299
|
+
isError: !isRetryable,
|
|
277
300
|
};
|
|
278
301
|
}
|
|
279
302
|
});
|
|
@@ -304,14 +327,7 @@ function createMcpServer() {
|
|
|
304
327
|
mimeType: BI_WHEEL_MIME_TYPE,
|
|
305
328
|
});
|
|
306
329
|
}
|
|
307
|
-
|
|
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
|
-
}
|
|
330
|
+
// NOTE: transit-timeline, bazi, vedic-chart resources omitted — tools disabled.
|
|
315
331
|
if (getMoonPhaseBundle()) {
|
|
316
332
|
resources.push({
|
|
317
333
|
uri: MOON_PHASE_RESOURCE_URI,
|
|
@@ -320,22 +336,6 @@ function createMcpServer() {
|
|
|
320
336
|
mimeType: MOON_PHASE_MIME_TYPE,
|
|
321
337
|
});
|
|
322
338
|
}
|
|
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
|
-
}
|
|
339
339
|
return { resources };
|
|
340
340
|
});
|
|
341
341
|
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
@@ -358,30 +358,13 @@ function createMcpServer() {
|
|
|
358
358
|
throw new Error("Bi-Wheel UI bundle not found. Run `npm run build:ui` to build it.");
|
|
359
359
|
return { contents: [{ uri: BI_WHEEL_RESOURCE_URI, mimeType: BI_WHEEL_MIME_TYPE, text: bundle }] };
|
|
360
360
|
}
|
|
361
|
-
|
|
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
|
-
}
|
|
361
|
+
// NOTE: transit-timeline, bazi, vedic-chart ReadResource handlers removed.
|
|
367
362
|
if (uri === MOON_PHASE_RESOURCE_URI) {
|
|
368
363
|
const bundle = getMoonPhaseBundle();
|
|
369
364
|
if (!bundle)
|
|
370
365
|
throw new Error("Moon Phase UI bundle not found. Run `npm run build:ui` to build it.");
|
|
371
366
|
return { contents: [{ uri: MOON_PHASE_RESOURCE_URI, mimeType: MOON_PHASE_MIME_TYPE, text: bundle }] };
|
|
372
367
|
}
|
|
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
|
-
}
|
|
385
368
|
throw new Error(`Unknown resource: ${uri}`);
|
|
386
369
|
});
|
|
387
370
|
return server;
|
|
@@ -393,12 +376,38 @@ async function main() {
|
|
|
393
376
|
// NOTE: Do NOT use express.json() globally — SSEServerTransport.handlePostMessage
|
|
394
377
|
// reads the raw request stream itself. Pre-parsing with express.json() consumes it,
|
|
395
378
|
// causing "stream is not readable" errors.
|
|
396
|
-
// CORS
|
|
397
|
-
|
|
398
|
-
|
|
379
|
+
// CORS + Origin allowlist
|
|
380
|
+
// Per Anthropic remote-MCP review criteria, browser-originated requests must
|
|
381
|
+
// be validated against an allowlist. Native (no-Origin) clients like Claude
|
|
382
|
+
// Desktop and CLI tooling are permitted; unknown browser origins are rejected.
|
|
383
|
+
const ORIGIN_ALLOWLIST = [
|
|
384
|
+
/^https:\/\/([a-z0-9-]+\.)*claude\.ai$/i,
|
|
385
|
+
/^https:\/\/([a-z0-9-]+\.)*claude\.com$/i,
|
|
386
|
+
/^https:\/\/([a-z0-9-]+\.)*anthropic\.com$/i,
|
|
387
|
+
/^https:\/\/([a-z0-9-]+\.)*openephemeris\.com$/i,
|
|
388
|
+
/^http:\/\/localhost(:\d+)?$/i,
|
|
389
|
+
/^http:\/\/127\.0\.0\.1(:\d+)?$/i,
|
|
390
|
+
];
|
|
391
|
+
function isOriginAllowed(origin) {
|
|
392
|
+
return ORIGIN_ALLOWLIST.some((m) => typeof m === "string" ? m === origin : m.test(origin));
|
|
393
|
+
}
|
|
394
|
+
app.use((req, res, next) => {
|
|
395
|
+
const origin = req.headers.origin;
|
|
396
|
+
// Browser request → must match allowlist. Native client (no Origin) → allow.
|
|
397
|
+
if (origin) {
|
|
398
|
+
if (!isOriginAllowed(origin)) {
|
|
399
|
+
res.status(403).json({
|
|
400
|
+
error: "origin_not_allowed",
|
|
401
|
+
error_description: `Origin '${origin}' is not permitted.`,
|
|
402
|
+
});
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
406
|
+
res.setHeader("Vary", "Origin");
|
|
407
|
+
}
|
|
399
408
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
400
409
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-OpenEphemeris-API-Key, mcp-session-id");
|
|
401
|
-
if (
|
|
410
|
+
if (req.method === "OPTIONS") {
|
|
402
411
|
res.status(204).end();
|
|
403
412
|
return;
|
|
404
413
|
}
|
|
@@ -417,10 +426,34 @@ async function main() {
|
|
|
417
426
|
maxRequests: 10,
|
|
418
427
|
trustProxy: true,
|
|
419
428
|
});
|
|
429
|
+
const dcrRateLimiter = createRateLimiter({
|
|
430
|
+
windowMs: 60_000,
|
|
431
|
+
maxRequests: 5,
|
|
432
|
+
trustProxy: true,
|
|
433
|
+
});
|
|
420
434
|
app.use(oauthDiscoveryRouter);
|
|
435
|
+
app.use("/oauth/register", dcrRateLimiter);
|
|
421
436
|
app.use(express.json({ limit: "1mb" }), oauthDcrRouter);
|
|
422
437
|
app.use("/oauth/token", tokenRateLimiter);
|
|
423
438
|
app.use(express.urlencoded({ extended: false }), oauthTokenRouter(oauthStore));
|
|
439
|
+
// Per-IP throttle on MCP session creation to bound unauthenticated session
|
|
440
|
+
// churn / volumetric abuse (APP-7). Only NEW sessions are limited — requests
|
|
441
|
+
// carrying an mcp-session-id are existing sessions (ongoing tool calls) and
|
|
442
|
+
// must never be throttled.
|
|
443
|
+
const sessionRateLimiter = createRateLimiter({
|
|
444
|
+
windowMs: 60_000,
|
|
445
|
+
maxRequests: 30,
|
|
446
|
+
trustProxy: true,
|
|
447
|
+
});
|
|
448
|
+
const throttleSessionCreation = (req, res, next) => {
|
|
449
|
+
if (req.headers["mcp-session-id"]) {
|
|
450
|
+
next();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
sessionRateLimiter(req, res, next);
|
|
454
|
+
};
|
|
455
|
+
app.use("/mcp", throttleSessionCreation);
|
|
456
|
+
app.use("/sse", sessionRateLimiter);
|
|
424
457
|
// Health check
|
|
425
458
|
app.get("/health", (_req, res) => {
|
|
426
459
|
res.json({
|
|
@@ -441,6 +474,7 @@ async function main() {
|
|
|
441
474
|
name: tool.name,
|
|
442
475
|
description: tool.description,
|
|
443
476
|
inputSchema: tool.inputSchema,
|
|
477
|
+
...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
|
|
444
478
|
annotations: buildAnnotations(tool),
|
|
445
479
|
}));
|
|
446
480
|
res.json({
|
|
@@ -490,17 +524,14 @@ async function main() {
|
|
|
490
524
|
await runWithClient(session.client, () => session.transport.handleRequest(req, res, req.body));
|
|
491
525
|
return;
|
|
492
526
|
}
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
});
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
|
-
// Auth — required on Initialize (supports both API keys and OAuth JWTs)
|
|
527
|
+
// Auth check FIRST — before validating request shape. RFC 9728 requires
|
|
528
|
+
// any unauthenticated POST to /mcp (even a probe with an empty/invalid
|
|
529
|
+
// body) to return 401 + WWW-Authenticate so OAuth clients (Claude Web)
|
|
530
|
+
// can bootstrap discovery. Returning 400 before auth causes the ofid_
|
|
531
|
+
// "Authorization with MCP server failed" error in Claude's connector UI.
|
|
502
532
|
const { apiKey, jwt } = extractAuth(req);
|
|
503
533
|
if (!apiKey && !jwt) {
|
|
534
|
+
res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
|
|
504
535
|
res.status(401).json({
|
|
505
536
|
error: "auth_required",
|
|
506
537
|
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",
|
|
@@ -510,14 +541,32 @@ async function main() {
|
|
|
510
541
|
// Validate API keys against the Go backend; JWTs are validated downstream
|
|
511
542
|
// by the Go sidecar's ValidateSupabaseJWT when tool calls are proxied.
|
|
512
543
|
if (apiKey) {
|
|
513
|
-
const
|
|
514
|
-
if (
|
|
515
|
-
res.
|
|
544
|
+
const keyStatus = await validateApiKey(apiKey);
|
|
545
|
+
if (keyStatus === "invalid") {
|
|
546
|
+
res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
|
|
547
|
+
res.status(401).json({
|
|
516
548
|
error: "invalid_api_key",
|
|
517
549
|
message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
|
|
518
550
|
});
|
|
519
551
|
return;
|
|
520
552
|
}
|
|
553
|
+
// Fail closed for browser clients when validation is unavailable (APP-6).
|
|
554
|
+
if (keyStatus === "unknown" && isBrowserOrigin(req)) {
|
|
555
|
+
res.set("Retry-After", "5");
|
|
556
|
+
res.status(503).json({
|
|
557
|
+
error: "validation_unavailable",
|
|
558
|
+
message: "Could not verify your API key right now. Please retry shortly.",
|
|
559
|
+
});
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// New session — must be an initialize request
|
|
564
|
+
if (!isInitializeRequest(req.body)) {
|
|
565
|
+
res.status(400).json({
|
|
566
|
+
error: "expected_initialize",
|
|
567
|
+
message: "First request to /mcp must be an MCP Initialize request.",
|
|
568
|
+
});
|
|
569
|
+
return;
|
|
521
570
|
}
|
|
522
571
|
// Create per-session transport, server, and client
|
|
523
572
|
const transport = new StreamableHTTPServerTransport({
|
|
@@ -617,14 +666,23 @@ async function main() {
|
|
|
617
666
|
}
|
|
618
667
|
// Validate API keys against the Go backend; JWTs validated downstream.
|
|
619
668
|
if (apiKey) {
|
|
620
|
-
const
|
|
621
|
-
if (
|
|
669
|
+
const keyStatus = await validateApiKey(apiKey);
|
|
670
|
+
if (keyStatus === "invalid") {
|
|
622
671
|
res.status(403).json({
|
|
623
672
|
error: "invalid_api_key",
|
|
624
673
|
message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
|
|
625
674
|
});
|
|
626
675
|
return;
|
|
627
676
|
}
|
|
677
|
+
// Fail closed for browser clients when validation is unavailable (APP-6).
|
|
678
|
+
if (keyStatus === "unknown" && isBrowserOrigin(req)) {
|
|
679
|
+
res.set("Retry-After", "5");
|
|
680
|
+
res.status(503).json({
|
|
681
|
+
error: "validation_unavailable",
|
|
682
|
+
message: "Could not verify your API key right now. Please retry shortly.",
|
|
683
|
+
});
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
628
686
|
}
|
|
629
687
|
const transport = new SSEServerTransport("/message", res);
|
|
630
688
|
const sessionId = transport.sessionId;
|
|
@@ -20,6 +20,7 @@ import path from "node:path";
|
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { registerTool, SERVER_VERSION } from "../index.js";
|
|
22
22
|
import { getActiveClient } from "../../backend/client.js";
|
|
23
|
+
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
23
24
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
24
25
|
export const BAZI_RESOURCE_URI = "ui://openephemeris/bazi";
|
|
25
26
|
export const BAZI_MIME_TYPE = "text/html;profile=mcp-app";
|
|
@@ -160,6 +161,7 @@ registerTool({
|
|
|
160
161
|
required: [],
|
|
161
162
|
additionalProperties: false,
|
|
162
163
|
},
|
|
164
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
163
165
|
annotations: {
|
|
164
166
|
title: "Interactive BaZi Four Pillars Explorer",
|
|
165
167
|
readOnlyHint: true,
|
|
@@ -25,6 +25,7 @@ import path from "node:path";
|
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
26
26
|
import { registerTool, SERVER_VERSION } from "../index.js";
|
|
27
27
|
import { getActiveClient } from "../../backend/client.js";
|
|
28
|
+
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
28
29
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
29
30
|
export const BI_WHEEL_RESOURCE_URI = "ui://openephemeris/bi-wheel";
|
|
30
31
|
export const BI_WHEEL_MIME_TYPE = "text/html;profile=mcp-app";
|
|
@@ -100,7 +101,7 @@ const HOUSE_SYSTEM_MAP = {
|
|
|
100
101
|
placidus: "P", whole_sign: "W", equal: "E", koch: "K",
|
|
101
102
|
campanus: "C", regiomontanus: "R",
|
|
102
103
|
};
|
|
103
|
-
function buildNatalBody(datetime, lat, lon, timezone) {
|
|
104
|
+
function buildNatalBody(datetime, lat, lon, timezone, houseSystem = "placidus") {
|
|
104
105
|
const body = {
|
|
105
106
|
subject: {
|
|
106
107
|
name: "MCP Request",
|
|
@@ -111,7 +112,7 @@ function buildNatalBody(datetime, lat, lon, timezone) {
|
|
|
111
112
|
},
|
|
112
113
|
},
|
|
113
114
|
configuration: {
|
|
114
|
-
house_system: HOUSE_SYSTEM_MAP["placidus"],
|
|
115
|
+
house_system: HOUSE_SYSTEM_MAP[houseSystem] ?? HOUSE_SYSTEM_MAP["placidus"],
|
|
115
116
|
},
|
|
116
117
|
};
|
|
117
118
|
if (timezone) {
|
|
@@ -275,19 +276,43 @@ export function computeCrossAspects(planets1, planets2) {
|
|
|
275
276
|
return results.sort((a, b) => a.orb - b.orb).slice(0, 30);
|
|
276
277
|
}
|
|
277
278
|
// ── Planet normalisation ───────────────────────────────────────────────────────
|
|
279
|
+
// Canonical underscore names accepted by the renderer.
|
|
278
280
|
const CLASSICAL_PLANETS = new Set([
|
|
279
281
|
"sun", "moon", "mercury", "venus", "mars",
|
|
280
282
|
"jupiter", "saturn", "uranus", "neptune", "pluto",
|
|
281
283
|
"chiron", "north_node", "south_node", "true_node", "asc", "mc",
|
|
282
284
|
]);
|
|
285
|
+
/**
|
|
286
|
+
* Normalise the Go backend's verbose planet key (e.g. "North Node (Mean)")
|
|
287
|
+
* to the canonical lowercase underscore form the renderer expects.
|
|
288
|
+
*/
|
|
289
|
+
function canonicalizePlanetName(raw) {
|
|
290
|
+
const lower = raw.toLowerCase();
|
|
291
|
+
// Various node/chiron aliases the Go PlanetName() function emits.
|
|
292
|
+
// Keep mean vs true DISTINCT so the UI can offer a node toggle; collapsing
|
|
293
|
+
// both to "north_node" (the old behaviour) produced two overlapping ☊ glyphs.
|
|
294
|
+
if (lower === "north node (true)" || lower === "true node")
|
|
295
|
+
return "true_node";
|
|
296
|
+
if (lower === "north node (mean)" || lower === "mean node" || lower === "north node")
|
|
297
|
+
return "north_node";
|
|
298
|
+
if (lower === "south node (mean)" || lower === "south node (true)")
|
|
299
|
+
return "south_node";
|
|
300
|
+
if (lower === "mean apogee" || lower === "black moon lilith" || lower === "lilith (mean)" || lower === "lilith (true)")
|
|
301
|
+
return "lilith";
|
|
302
|
+
// Strip parenthetical qualifiers for any other bodies just in case
|
|
303
|
+
return lower.replace(/\s*\(.*?\)/g, "").replace(/\s+/g, "_");
|
|
304
|
+
}
|
|
283
305
|
function normalizePlanets(raw) {
|
|
284
306
|
if (Array.isArray(raw))
|
|
285
307
|
return raw;
|
|
286
308
|
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
287
309
|
return Object.entries(raw)
|
|
288
|
-
.filter(([k]) =>
|
|
310
|
+
.filter(([k]) => {
|
|
311
|
+
const canonical = canonicalizePlanetName(k);
|
|
312
|
+
return CLASSICAL_PLANETS.has(canonical);
|
|
313
|
+
})
|
|
289
314
|
.map(([name, p]) => ({
|
|
290
|
-
name: name
|
|
315
|
+
name: canonicalizePlanetName(name),
|
|
291
316
|
longitude: (p.longitude ?? p.lon ?? 0),
|
|
292
317
|
speed: (p.longitude_speed ?? p.speed),
|
|
293
318
|
retrograde: Boolean(p.is_retrograde ?? p.retrograde),
|
|
@@ -309,14 +334,30 @@ function normalizeHouses(raw) {
|
|
|
309
334
|
return [];
|
|
310
335
|
}
|
|
311
336
|
// ── Payload builder ────────────────────────────────────────────────────────────
|
|
337
|
+
function extractAngles(data) {
|
|
338
|
+
const rawAngles = data.angles;
|
|
339
|
+
const rawHouseObj = data.houses;
|
|
340
|
+
const ascendant = rawAngles?.ascendant ??
|
|
341
|
+
data.ascendant ??
|
|
342
|
+
rawHouseObj?.cusps?.[0] ??
|
|
343
|
+
0;
|
|
344
|
+
const midheaven = rawAngles?.midheaven ??
|
|
345
|
+
data.midheaven ??
|
|
346
|
+
rawHouseObj?.cusps?.[9] ??
|
|
347
|
+
0;
|
|
348
|
+
return { ascendant, midheaven };
|
|
349
|
+
}
|
|
312
350
|
function buildBiWheelPayload(innerData, outerData, mode, params1, params2) {
|
|
313
351
|
const innerPlanets = normalizePlanets(innerData.planets);
|
|
314
352
|
const outerPlanets = normalizePlanets(outerData.planets);
|
|
315
353
|
const innerHouses = normalizeHouses(innerData.houses);
|
|
316
354
|
const outerHouses = normalizeHouses(outerData.houses);
|
|
317
355
|
const crossAspects = computeCrossAspects(innerPlanets, outerPlanets);
|
|
356
|
+
const { ascendant, midheaven } = extractAngles(innerData);
|
|
318
357
|
return {
|
|
319
358
|
mode,
|
|
359
|
+
ascendant,
|
|
360
|
+
midheaven,
|
|
320
361
|
inner: innerPlanets,
|
|
321
362
|
outer: outerPlanets,
|
|
322
363
|
inner_houses: innerHouses,
|
|
@@ -413,6 +454,7 @@ registerTool({
|
|
|
413
454
|
required: ["person1_datetime", "person2_datetime",
|
|
414
455
|
"person1_latitude", "person1_longitude"],
|
|
415
456
|
},
|
|
457
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
416
458
|
annotations: {
|
|
417
459
|
title: "Interactive Bi-Wheel Explorer",
|
|
418
460
|
readOnlyHint: true,
|
|
@@ -453,11 +495,16 @@ registerTool({
|
|
|
453
495
|
const payload = buildBiWheelPayload(innerData, outerData, mode, params1, params2);
|
|
454
496
|
const bundleAvailable = Boolean(getBiWheelBundle());
|
|
455
497
|
if (bundleAvailable) {
|
|
498
|
+
// MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
|
|
499
|
+
// delivered through resources/read — NOT as a content block. A URI-only
|
|
500
|
+
// `type: "resource"` block lacks the inline text/blob the base MCP schema
|
|
501
|
+
// requires and fails strict client validation. structuredContent
|
|
502
|
+
// preserves the full payload for hosts that don't render the iframe.
|
|
456
503
|
return {
|
|
457
504
|
content: [
|
|
458
505
|
{ type: "text", text: summary },
|
|
459
|
-
{ type: "text", text: JSON.stringify({ ...payload, server_version: SERVER_VERSION }) },
|
|
460
506
|
],
|
|
507
|
+
structuredContent: { ...payload, server_version: SERVER_VERSION },
|
|
461
508
|
_meta: {
|
|
462
509
|
"ui/resourceUri": BI_WHEEL_RESOURCE_URI,
|
|
463
510
|
ui: { resourceUri: BI_WHEEL_RESOURCE_URI },
|
|
@@ -487,14 +534,21 @@ registerTool({
|
|
|
487
534
|
person2_timezone: { type: "string" },
|
|
488
535
|
person2_name: { type: "string" },
|
|
489
536
|
location: { type: "string" },
|
|
537
|
+
house_system: {
|
|
538
|
+
type: "string",
|
|
539
|
+
enum: Object.keys(HOUSE_SYSTEM_MAP),
|
|
540
|
+
description: "House system for the inner (natal) wheel. Defaults to placidus.",
|
|
541
|
+
},
|
|
490
542
|
},
|
|
491
543
|
required: ["mode", "person1_datetime", "person1_latitude", "person1_longitude", "person2_datetime"],
|
|
492
544
|
},
|
|
545
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
493
546
|
annotations: { title: "Recalculate Bi-Wheel", readOnlyHint: true, openWorldHint: false },
|
|
494
547
|
_meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
|
|
495
548
|
handler: async (args) => {
|
|
496
549
|
const client = getActiveClient();
|
|
497
550
|
const mode = args.mode ?? "synastry";
|
|
551
|
+
const houseSystem = args.house_system ?? "placidus";
|
|
498
552
|
const dt1 = String(args.person1_datetime);
|
|
499
553
|
const lat1 = args.person1_latitude;
|
|
500
554
|
const lon1 = args.person1_longitude;
|
|
@@ -506,14 +560,14 @@ registerTool({
|
|
|
506
560
|
const loc1 = String(args.person1_name ?? args.location ?? `${lat1 ?? "?"},${lon1 ?? "?"}`);
|
|
507
561
|
const loc2 = MODE_META[mode].outerLabel(dt2, args.person2_name);
|
|
508
562
|
const [innerData, outerData] = await Promise.all([
|
|
509
|
-
client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1)),
|
|
563
|
+
client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1, houseSystem)),
|
|
510
564
|
fetchOuterChart(client, mode, dt1, lat1, lon1, tz1, dt2, lat2, lon2, tz2),
|
|
511
565
|
]);
|
|
512
566
|
const params1 = { datetime: dt1, timezone: tz1 ?? null, latitude: lat1 ?? null, longitude: lon1 ?? null, location: loc1 };
|
|
513
567
|
const params2 = { datetime: dt2, timezone: tz2 ?? null, latitude: lat2 ?? null, longitude: lon2 ?? null, location: loc2 };
|
|
514
568
|
const payload = buildBiWheelPayload(innerData, outerData, mode, params1, params2);
|
|
515
569
|
return {
|
|
516
|
-
content: [{ type: "text", text: JSON.stringify({ ...payload, server_version: SERVER_VERSION }) }],
|
|
570
|
+
content: [{ type: "text", text: JSON.stringify({ ...payload, house_system: houseSystem, server_version: SERVER_VERSION }) }],
|
|
517
571
|
};
|
|
518
572
|
},
|
|
519
573
|
});
|
|
@@ -537,6 +591,7 @@ registerTool({
|
|
|
537
591
|
},
|
|
538
592
|
required: ["planet1", "planet2", "aspect_type"],
|
|
539
593
|
},
|
|
594
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
540
595
|
annotations: { title: "Cross-Aspect Interpretation", readOnlyHint: true, openWorldHint: false },
|
|
541
596
|
_meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
|
|
542
597
|
handler: async (args) => {
|
|
@@ -575,6 +630,7 @@ registerTool({
|
|
|
575
630
|
},
|
|
576
631
|
required: ["planet", "wheel", "longitude"],
|
|
577
632
|
},
|
|
633
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
578
634
|
annotations: { title: "Bi-Wheel Planet Interpretation", readOnlyHint: true, openWorldHint: false },
|
|
579
635
|
_meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
|
|
580
636
|
handler: async (args) => {
|
|
@@ -775,6 +831,7 @@ registerTool({
|
|
|
775
831
|
},
|
|
776
832
|
required: ["house_number"],
|
|
777
833
|
},
|
|
834
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
778
835
|
annotations: { title: "Bi-Wheel House Interpretation", readOnlyHint: true, openWorldHint: false },
|
|
779
836
|
_meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
|
|
780
837
|
handler: async (args) => {
|
|
@@ -834,6 +891,7 @@ registerTool({
|
|
|
834
891
|
},
|
|
835
892
|
required: ["mode"],
|
|
836
893
|
},
|
|
894
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
837
895
|
annotations: { title: "Bi-Wheel Synopsis", readOnlyHint: true, openWorldHint: false },
|
|
838
896
|
_meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app", "model"] } },
|
|
839
897
|
handler: async (args) => {
|