@openephemeris/mcp-server 3.17.0 → 3.20.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 +77 -0
- package/LICENSE +21 -21
- package/README.md +1 -1
- package/dist/backend/client.d.ts +9 -0
- package/dist/backend/client.js +32 -5
- package/dist/event-store.d.ts +11 -0
- package/dist/event-store.js +61 -0
- package/dist/index.js +12 -11
- package/dist/oauth/session-utils.d.ts +59 -0
- package/dist/oauth/session-utils.js +110 -0
- package/dist/server-sse.d.ts +7 -1
- package/dist/server-sse.js +223 -127
- package/dist/tools/apps/bi-wheel-app.js +8 -6
- package/dist/tools/apps/chart-wheel-app.js +36 -51
- package/dist/tools/dev.js +1 -1
- package/dist/tools/index.d.ts +25 -0
- package/dist/tools/index.js +33 -0
- package/dist/tools/output-schemas.d.ts +1 -1
- package/dist/tools/output-schemas.js +1 -1
- package/dist/tools/specialized/acg.js +6 -2
- package/dist/tools/specialized/progressed.js +3 -1
- package/dist/ui/bi-wheel.html +5 -2
- package/dist/ui/bodygraph.html +30 -3
- package/dist/ui/chart-wheel.html +20 -22
- package/dist/ui/moon-phase.html +39 -32
- package/package.json +8 -4
- package/dist/tools/apps/bazi-app.d.ts +0 -23
- package/dist/tools/apps/bazi-app.js +0 -226
- package/dist/tools/apps/transit-timeline-app.d.ts +0 -20
- package/dist/tools/apps/transit-timeline-app.js +0 -295
- package/dist/tools/apps/vedic-chart-app.d.ts +0 -17
- package/dist/tools/apps/vedic-chart-app.js +0 -228
- package/dist/ui/bazi.html +0 -213
- package/dist/ui/transit-timeline.html +0 -6874
- package/dist/ui/vedic-chart.html +0 -210
package/dist/server-sse.js
CHANGED
|
@@ -14,15 +14,15 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import fs from "node:fs";
|
|
16
16
|
import path from "node:path";
|
|
17
|
-
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
18
|
import { randomUUID } from "node:crypto";
|
|
19
19
|
import express from "express";
|
|
20
20
|
import axios from "axios";
|
|
21
21
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
22
|
-
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
23
22
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
23
|
+
import { InMemoryEventStore } from "./event-store.js";
|
|
24
24
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
|
|
25
|
-
import { initTools, toolRegistry, formatToolResponse, modelVisibleTools } from "./tools/index.js";
|
|
25
|
+
import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools } from "./tools/index.js";
|
|
26
26
|
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";
|
|
@@ -34,6 +34,7 @@ import { oauthDcrRouter } from "./oauth/dcr.js";
|
|
|
34
34
|
import { oauthTokenRouter } from "./oauth/token.js";
|
|
35
35
|
import { OAuthStore } from "./oauth/store.js";
|
|
36
36
|
import { createRateLimiter } from "./oauth/rate-limit.js";
|
|
37
|
+
import { isJwtExpired, encodeSessionId, replayTargetMachine, } from "./oauth/session-utils.js";
|
|
37
38
|
// ---------------------------------------------------------------------------
|
|
38
39
|
// Helpers
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
@@ -73,9 +74,14 @@ const version = resolveServerVersion();
|
|
|
73
74
|
*/
|
|
74
75
|
async function validateApiKey(apiKey) {
|
|
75
76
|
try {
|
|
76
|
-
|
|
77
|
+
// Use a genuinely free, auth-gated endpoint. /catalogs/bodies is metered at
|
|
78
|
+
// 0 credits (usage_meter.go EstimateUsageUnits) yet still requires auth (NOT
|
|
79
|
+
// in middleware.go IsPublicPath), so an invalid key returns 401/403 while a
|
|
80
|
+
// valid key is validated without charging a credit on every session init.
|
|
81
|
+
// (The previous /ephemeris/moon/phase probe fell through to the default
|
|
82
|
+
// 1-credit charge — connecting cost the user a credit.)
|
|
83
|
+
const resp = await axios.get(`${BACKEND_URL}/catalogs/bodies`, {
|
|
77
84
|
headers: { "X-API-Key": apiKey },
|
|
78
|
-
params: { datetime: new Date().toISOString() }, // required param per OpenAPI spec
|
|
79
85
|
timeout: 5_000,
|
|
80
86
|
validateStatus: () => true, // don't throw on any status
|
|
81
87
|
});
|
|
@@ -279,25 +285,22 @@ function createMcpServer() {
|
|
|
279
285
|
try {
|
|
280
286
|
const startTime = Date.now();
|
|
281
287
|
console.error(`[MCP] Executing tool: ${toolName}`);
|
|
282
|
-
|
|
288
|
+
// Bound execution so a hung backend request can't pin the session forever.
|
|
289
|
+
// 120s comfortably covers the slowest known endpoint (cold heatmap
|
|
290
|
+
// composite ~9.3s) with a wide margin.
|
|
291
|
+
const result = await withTimeout(tool.handler(request.params.arguments ?? {}), TOOL_CALL_TIMEOUT_MS, toolName);
|
|
283
292
|
const durationMs = Date.now() - startTime;
|
|
284
293
|
return formatToolResponse(toolName, result, durationMs);
|
|
285
294
|
}
|
|
286
295
|
catch (error) {
|
|
287
|
-
const startTime = Date.now(); // Not strictly accurate for errors but acceptable for logging structure if needed
|
|
288
|
-
const durationMs = Date.now() - startTime;
|
|
289
296
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
},
|
|
298
|
-
],
|
|
299
|
-
isError: !isRetryable,
|
|
300
|
-
};
|
|
297
|
+
console.error(`[MCP] ❌ Failed: ${toolName} - ${errorMessage}`);
|
|
298
|
+
// All failures surface as isError: true so the host can recover
|
|
299
|
+
// (retry / OAuth refresh). The remote transport has no device-auth flow,
|
|
300
|
+
// so formatToolError's device-auth exception never fires here — a 401
|
|
301
|
+
// (including the "disconnect and reconnect" message) renders as a real
|
|
302
|
+
// error and prompts the host's OAuth refresh.
|
|
303
|
+
return formatToolError(error);
|
|
301
304
|
}
|
|
302
305
|
});
|
|
303
306
|
// --- Resource handlers ---
|
|
@@ -370,12 +373,62 @@ function createMcpServer() {
|
|
|
370
373
|
return server;
|
|
371
374
|
}
|
|
372
375
|
const httpSessions = new Map();
|
|
373
|
-
|
|
376
|
+
// Sessions are held in memory and only freed on explicit DELETE / transport
|
|
377
|
+
// close. Hosts that vanish without teardown (tab closed, network drop) would
|
|
378
|
+
// otherwise leak sessions until process restart, so reap anything idle past
|
|
379
|
+
// the TTL. A reaped session 404s on its next request and the client
|
|
380
|
+
// re-initializes per the MCP spec.
|
|
381
|
+
const SESSION_IDLE_TTL_MS = 2 * 60 * 60 * 1000; // 2h
|
|
382
|
+
const TOOL_CALL_TIMEOUT_MS = 120_000;
|
|
383
|
+
function withTimeout(promise, ms, label) {
|
|
384
|
+
return new Promise((resolve, reject) => {
|
|
385
|
+
const timer = setTimeout(() => reject(new Error(`Tool '${label}' timed out after ${ms / 1000}s`)), ms);
|
|
386
|
+
promise.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
function reapIdleSessions() {
|
|
390
|
+
const cutoff = Date.now() - SESSION_IDLE_TTL_MS;
|
|
391
|
+
for (const [id, session] of httpSessions) {
|
|
392
|
+
if (session.lastSeen < cutoff) {
|
|
393
|
+
httpSessions.delete(id);
|
|
394
|
+
session.server.close().catch(() => { });
|
|
395
|
+
console.error(`[HTTP] Session reaped (idle > ${SESSION_IDLE_TTL_MS / 60000}m): ${id}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Keep a long-lived SSE response alive through Fly's proxy and any
|
|
401
|
+
* intermediaries: disable the socket idle timeout and emit an SSE comment
|
|
402
|
+
* frame every 25s (comment frames are ignored by EventSource parsers).
|
|
403
|
+
* Call only after the transport has sent the SSE headers.
|
|
404
|
+
*/
|
|
405
|
+
function startSseKeepalive(res) {
|
|
406
|
+
res.socket?.setTimeout(0);
|
|
407
|
+
const ping = setInterval(() => {
|
|
408
|
+
if (res.writableEnded || res.destroyed) {
|
|
409
|
+
clearInterval(ping);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
res.write(":ping\n\n");
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
clearInterval(ping);
|
|
417
|
+
}
|
|
418
|
+
}, 25_000);
|
|
419
|
+
res.on("close", () => clearInterval(ping));
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Build the full Express app (auth gate, OAuth routes, /mcp Streamable HTTP,
|
|
423
|
+
* health, resources). Exported so tests can drive the REAL handlers with
|
|
424
|
+
* supertest instead of a re-implemented stub. Does not listen.
|
|
425
|
+
*/
|
|
426
|
+
export async function createSseApp() {
|
|
374
427
|
await initTools();
|
|
375
428
|
const app = express();
|
|
376
|
-
// NOTE:
|
|
377
|
-
//
|
|
378
|
-
//
|
|
429
|
+
// NOTE: express.json() is applied per-route (jsonParser on /mcp POST), not
|
|
430
|
+
// globally — the OAuth token route needs urlencoded, and transports that
|
|
431
|
+
// read the raw request stream break if the body is pre-consumed.
|
|
379
432
|
// CORS + Origin allowlist
|
|
380
433
|
// Per Anthropic remote-MCP review criteria, browser-originated requests must
|
|
381
434
|
// be validated against an allowlist. Native (no-Origin) clients like Claude
|
|
@@ -391,6 +444,36 @@ async function main() {
|
|
|
391
444
|
function isOriginAllowed(origin) {
|
|
392
445
|
return ORIGIN_ALLOWLIST.some((m) => typeof m === "string" ? m === origin : m.test(origin));
|
|
393
446
|
}
|
|
447
|
+
// DNS-rebinding defense: Origin validation alone doesn't cover the Host
|
|
448
|
+
// header, which is the vector rebinding attacks use. Restrict the MCP
|
|
449
|
+
// endpoints to the hosts we actually serve. Override/extend with
|
|
450
|
+
// MCP_ALLOWED_HOSTS (comma-separated) if the deployment hostname changes.
|
|
451
|
+
const HOST_ALLOWLIST = [
|
|
452
|
+
/^mcp\.openephemeris\.com$/i,
|
|
453
|
+
/^oe-mcp-live\.fly\.dev$/i,
|
|
454
|
+
/^localhost(:\d+)?$/i,
|
|
455
|
+
/^127\.0\.0\.1(:\d+)?$/i,
|
|
456
|
+
/^\[::1\](:\d+)?$/i,
|
|
457
|
+
...(process.env.MCP_ALLOWED_HOSTS ?? "")
|
|
458
|
+
.split(",")
|
|
459
|
+
.map((h) => h.trim())
|
|
460
|
+
.filter(Boolean)
|
|
461
|
+
.map((h) => new RegExp(`^${h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(:\\d+)?$`, "i")),
|
|
462
|
+
];
|
|
463
|
+
const MCP_ROUTES = new Set(["/mcp"]);
|
|
464
|
+
app.use((req, res, next) => {
|
|
465
|
+
if (MCP_ROUTES.has(req.path)) {
|
|
466
|
+
const host = req.headers.host;
|
|
467
|
+
if (host && !HOST_ALLOWLIST.some((m) => m.test(host))) {
|
|
468
|
+
res.status(403).json({
|
|
469
|
+
error: "host_not_allowed",
|
|
470
|
+
error_description: `Host '${host}' is not permitted.`,
|
|
471
|
+
});
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
next();
|
|
476
|
+
});
|
|
394
477
|
app.use((req, res, next) => {
|
|
395
478
|
const origin = req.headers.origin;
|
|
396
479
|
// Browser request → must match allowlist. Native client (no Origin) → allow.
|
|
@@ -413,10 +496,6 @@ async function main() {
|
|
|
413
496
|
}
|
|
414
497
|
next();
|
|
415
498
|
});
|
|
416
|
-
// Track active transports by session ID
|
|
417
|
-
const transports = new Map();
|
|
418
|
-
// Per-session BackendClient — each connection authenticates independently.
|
|
419
|
-
const sessionClients = new Map();
|
|
420
499
|
// ---------------------------------------------------------------------------
|
|
421
500
|
// OAuth 2.1 routes — must be mounted BEFORE JSON body parser
|
|
422
501
|
// ---------------------------------------------------------------------------
|
|
@@ -453,14 +532,12 @@ async function main() {
|
|
|
453
532
|
sessionRateLimiter(req, res, next);
|
|
454
533
|
};
|
|
455
534
|
app.use("/mcp", throttleSessionCreation);
|
|
456
|
-
app.use("/sse", sessionRateLimiter);
|
|
457
535
|
// Health check
|
|
458
536
|
app.get("/health", (_req, res) => {
|
|
459
537
|
res.json({
|
|
460
538
|
ok: true,
|
|
461
539
|
version,
|
|
462
|
-
transports: ["
|
|
463
|
-
sse_sessions: transports.size,
|
|
540
|
+
transports: ["streamable-http"],
|
|
464
541
|
http_sessions: httpSessions.size,
|
|
465
542
|
});
|
|
466
543
|
});
|
|
@@ -513,14 +590,56 @@ async function main() {
|
|
|
513
590
|
const sessionId = req.headers["mcp-session-id"];
|
|
514
591
|
// Resume existing session
|
|
515
592
|
if (sessionId) {
|
|
593
|
+
// A session id alone is not a credential: require a key/JWT BEFORE the
|
|
594
|
+
// session lookup so an unauthenticated probe can't learn whether a
|
|
595
|
+
// session id exists, and reject an expired JWT with the OAuth-refresh
|
|
596
|
+
// signal (same semantics as the init gate below).
|
|
597
|
+
const resumedAuth = extractAuth(req);
|
|
598
|
+
if (!resumedAuth.apiKey && !resumedAuth.jwt) {
|
|
599
|
+
res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
|
|
600
|
+
res.status(401).json({
|
|
601
|
+
error: "auth_required",
|
|
602
|
+
message: "Authentication required on every request. Pass an API key via X-API-Key header, or a Bearer token via Authorization header.",
|
|
603
|
+
});
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (resumedAuth.jwt && isJwtExpired(resumedAuth.jwt)) {
|
|
607
|
+
res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", error_description="The access token expired", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
|
|
608
|
+
res.status(401).json({
|
|
609
|
+
error: "invalid_token",
|
|
610
|
+
message: "Your session token has expired. Re-authorize to continue.",
|
|
611
|
+
});
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
516
614
|
const session = httpSessions.get(sessionId);
|
|
517
615
|
if (!session) {
|
|
616
|
+
// Cross-machine session recovery (Fly): min_machines_running >= 2 but
|
|
617
|
+
// httpSessions is per-process. If the session id encodes a DIFFERENT
|
|
618
|
+
// machine, ask Fly's proxy to replay this request to the owning machine
|
|
619
|
+
// — unless it was already replayed once (loop guard), in which case we
|
|
620
|
+
// fall through to 404 so the client re-initializes per the MCP spec.
|
|
621
|
+
const target = replayTargetMachine(sessionId, {
|
|
622
|
+
alreadyReplayed: typeof req.headers["fly-replay-src"] === "string",
|
|
623
|
+
});
|
|
624
|
+
if (target) {
|
|
625
|
+
res.set("fly-replay", `instance=${target}`);
|
|
626
|
+
res.status(307).end();
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
518
629
|
res.status(404).json({
|
|
519
630
|
error: "session_not_found",
|
|
520
631
|
message: "Unknown or expired MCP session. Re-initialize to start a new one.",
|
|
521
632
|
});
|
|
522
633
|
return;
|
|
523
634
|
}
|
|
635
|
+
// Refresh the session's Bearer JWT if the host rotated it. claude.ai
|
|
636
|
+
// refreshes tokens via /oauth/token roughly hourly (self-signed Supabase
|
|
637
|
+
// JWTs expire in 1h); the frozen JWT captured at init would otherwise 401
|
|
638
|
+
// forever. Re-read the header and push the fresh token into the client.
|
|
639
|
+
if (resumedAuth.jwt) {
|
|
640
|
+
session.client.setJwt(resumedAuth.jwt);
|
|
641
|
+
}
|
|
642
|
+
session.lastSeen = Date.now();
|
|
524
643
|
await runWithClient(session.client, () => session.transport.handleRequest(req, res, req.body));
|
|
525
644
|
return;
|
|
526
645
|
}
|
|
@@ -538,6 +657,19 @@ async function main() {
|
|
|
538
657
|
});
|
|
539
658
|
return;
|
|
540
659
|
}
|
|
660
|
+
// Reject already-expired JWTs at the gate with an OAuth-refresh signal.
|
|
661
|
+
// Signature is validated downstream by the Go API — here we only decode the
|
|
662
|
+
// `exp` claim (no verification) so an expired token triggers the host's
|
|
663
|
+
// token refresh (401 invalid_token) instead of dying as a confusing tool
|
|
664
|
+
// error mid-session.
|
|
665
|
+
if (jwt && isJwtExpired(jwt)) {
|
|
666
|
+
res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", error_description="The access token expired", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
|
|
667
|
+
res.status(401).json({
|
|
668
|
+
error: "invalid_token",
|
|
669
|
+
message: "Your session token has expired. Re-authorize to continue.",
|
|
670
|
+
});
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
541
673
|
// Validate API keys against the Go backend; JWTs are validated downstream
|
|
542
674
|
// by the Go sidecar's ValidateSupabaseJWT when tool calls are proxied.
|
|
543
675
|
if (apiKey) {
|
|
@@ -570,9 +702,16 @@ async function main() {
|
|
|
570
702
|
}
|
|
571
703
|
// Create per-session transport, server, and client
|
|
572
704
|
const transport = new StreamableHTTPServerTransport({
|
|
573
|
-
|
|
705
|
+
// Embed the Fly machine id so a resume request landing on another machine
|
|
706
|
+
// can be replayed to the owner (see the resume branch above). No-op off
|
|
707
|
+
// Fly (FLY_MACHINE_ID unset) — the raw UUID is used unchanged.
|
|
708
|
+
sessionIdGenerator: () => encodeSessionId(randomUUID()),
|
|
709
|
+
// Last-Event-ID resumability: a reconnecting SSE stream replays the
|
|
710
|
+
// events it missed instead of silently losing them. Per-process is fine
|
|
711
|
+
// — the session is machine-pinned via fly-replay.
|
|
712
|
+
eventStore: new InMemoryEventStore(),
|
|
574
713
|
onsessioninitialized: (id) => {
|
|
575
|
-
httpSessions.set(id, { server, transport, client });
|
|
714
|
+
httpSessions.set(id, { server, transport, client, lastSeen: Date.now() });
|
|
576
715
|
console.error(`[HTTP] Session initialized: ${id}`);
|
|
577
716
|
},
|
|
578
717
|
});
|
|
@@ -599,12 +738,24 @@ async function main() {
|
|
|
599
738
|
}
|
|
600
739
|
const session = httpSessions.get(sessionId);
|
|
601
740
|
if (!session) {
|
|
741
|
+
// Cross-machine replay for the SSE leg (same scheme as POST /mcp resume).
|
|
742
|
+
const target = replayTargetMachine(sessionId, {
|
|
743
|
+
alreadyReplayed: typeof req.headers["fly-replay-src"] === "string",
|
|
744
|
+
});
|
|
745
|
+
if (target) {
|
|
746
|
+
res.set("fly-replay", `instance=${target}`);
|
|
747
|
+
res.status(307).end();
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
602
750
|
res.status(404).json({
|
|
603
751
|
error: "session_not_found",
|
|
604
752
|
message: "Unknown or expired MCP session.",
|
|
605
753
|
});
|
|
606
754
|
return;
|
|
607
755
|
}
|
|
756
|
+
session.lastSeen = Date.now();
|
|
757
|
+
// The SSE leg stays open indefinitely — keep it alive through proxies.
|
|
758
|
+
startSseKeepalive(res);
|
|
608
759
|
await session.transport.handleRequest(req, res);
|
|
609
760
|
});
|
|
610
761
|
// DELETE /mcp — client-initiated session teardown
|
|
@@ -649,102 +800,34 @@ async function main() {
|
|
|
649
800
|
}
|
|
650
801
|
});
|
|
651
802
|
// ---------------------------------------------------------------------------
|
|
652
|
-
// Legacy SSE transport —
|
|
653
|
-
//
|
|
654
|
-
//
|
|
803
|
+
// Legacy SSE transport — RETIRED in 3.20.0.
|
|
804
|
+
// The pre-2025 HTTP+SSE transport (/sse + /message) was SDK-deprecated, had
|
|
805
|
+
// no cross-machine replay (broken with min_machines_running >= 2), and live
|
|
806
|
+
// metrics showed zero sessions. 410 Gone points stragglers at /mcp.
|
|
655
807
|
// ---------------------------------------------------------------------------
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
if (!apiKey && !jwt) {
|
|
661
|
-
res.status(401).json({
|
|
662
|
-
error: "auth_required",
|
|
663
|
-
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",
|
|
664
|
-
});
|
|
665
|
-
return;
|
|
666
|
-
}
|
|
667
|
-
// Validate API keys against the Go backend; JWTs validated downstream.
|
|
668
|
-
if (apiKey) {
|
|
669
|
-
const keyStatus = await validateApiKey(apiKey);
|
|
670
|
-
if (keyStatus === "invalid") {
|
|
671
|
-
res.status(403).json({
|
|
672
|
-
error: "invalid_api_key",
|
|
673
|
-
message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
|
|
674
|
-
});
|
|
675
|
-
return;
|
|
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
|
-
}
|
|
686
|
-
}
|
|
687
|
-
const transport = new SSEServerTransport("/message", res);
|
|
688
|
-
const sessionId = transport.sessionId;
|
|
689
|
-
transports.set(sessionId, transport);
|
|
690
|
-
// Create a per-session BackendClient so concurrent SSE connections
|
|
691
|
-
// don't overwrite each other's API key on the shared singleton.
|
|
692
|
-
const sessionClient = new BackendClient({
|
|
693
|
-
baseURL: BACKEND_URL,
|
|
694
|
-
apiKey,
|
|
695
|
-
jwt,
|
|
808
|
+
const legacySseGone = (_req, res) => {
|
|
809
|
+
res.status(410).json({
|
|
810
|
+
error: "transport_retired",
|
|
811
|
+
message: "The legacy SSE transport has been retired. Connect via Streamable HTTP at https://mcp.openephemeris.com/mcp instead.",
|
|
696
812
|
});
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
try {
|
|
708
|
-
server.close().catch(() => { });
|
|
709
|
-
}
|
|
710
|
-
catch { /* ignore */ }
|
|
711
|
-
};
|
|
712
|
-
transport.onclose = cleanup;
|
|
713
|
-
// Handle abrupt client disconnects (browser tab closed, etc.)
|
|
714
|
-
res.on("close", cleanup);
|
|
715
|
-
try {
|
|
716
|
-
// server.connect() calls transport.start() internally in SDK v1.27+
|
|
717
|
-
await server.connect(transport);
|
|
718
|
-
}
|
|
719
|
-
catch (err) {
|
|
720
|
-
console.error(`SSE session ${sessionId} failed to start:`, err);
|
|
721
|
-
cleanup();
|
|
722
|
-
}
|
|
723
|
-
});
|
|
724
|
-
// Message endpoint — clients POST /message?sessionId=xxx
|
|
725
|
-
app.post("/message", async (req, res) => {
|
|
726
|
-
const sessionId = req.query.sessionId;
|
|
727
|
-
const transport = transports.get(sessionId);
|
|
728
|
-
const sessionClient = sessionClients.get(sessionId);
|
|
729
|
-
if (!transport) {
|
|
730
|
-
res.status(400).json({
|
|
731
|
-
error: "invalid_session",
|
|
732
|
-
message: "Unknown or expired session. Re-connect to /sse first.",
|
|
733
|
-
});
|
|
734
|
-
return;
|
|
735
|
-
}
|
|
736
|
-
// Run handlePostMessage inside the session's client context so all tool
|
|
737
|
-
// handlers reach getActiveClient() and get this user's BackendClient.
|
|
738
|
-
if (sessionClient) {
|
|
739
|
-
await runWithClient(sessionClient, () => transport.handlePostMessage(req, res));
|
|
740
|
-
}
|
|
741
|
-
else {
|
|
742
|
-
await transport.handlePostMessage(req, res);
|
|
743
|
-
}
|
|
744
|
-
});
|
|
745
|
-
app.listen(PORT, "0.0.0.0", () => {
|
|
813
|
+
};
|
|
814
|
+
app.get("/sse", legacySseGone);
|
|
815
|
+
app.post("/message", legacySseGone);
|
|
816
|
+
return app;
|
|
817
|
+
}
|
|
818
|
+
async function main() {
|
|
819
|
+
const app = await createSseApp();
|
|
820
|
+
// Reap abandoned sessions (no DELETE, no transport close) every 10 minutes.
|
|
821
|
+
setInterval(reapIdleSessions, 10 * 60 * 1000).unref();
|
|
822
|
+
const httpServer = app.listen(PORT, "0.0.0.0", () => {
|
|
746
823
|
console.log(`OpenEphemeris MCP SSE server v${version} listening on port ${PORT}`);
|
|
747
824
|
});
|
|
825
|
+
// SSE streams are long-lived by design; Node 18+ closes idle connections
|
|
826
|
+
// after 5s keep-alive by default at the server level — the per-response
|
|
827
|
+
// socket timeout is disabled in startSseKeepalive, but also raise the
|
|
828
|
+
// server-wide headers/request timeouts so slow proxies don't get cut.
|
|
829
|
+
httpServer.keepAliveTimeout = 65_000;
|
|
830
|
+
httpServer.headersTimeout = 70_000;
|
|
748
831
|
}
|
|
749
832
|
// Prevent SDK recursive close bugs from crashing the process
|
|
750
833
|
process.on("uncaughtException", (err) => {
|
|
@@ -755,7 +838,20 @@ process.on("uncaughtException", (err) => {
|
|
|
755
838
|
console.error("Uncaught exception:", err);
|
|
756
839
|
process.exit(1);
|
|
757
840
|
});
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
841
|
+
// Log-and-continue on unhandled promise rejections. A rejected promise from a
|
|
842
|
+
// single request (e.g. a client that disconnected mid-stream) must never take
|
|
843
|
+
// down the whole server and kill every other live session. Log for triage and
|
|
844
|
+
// keep serving.
|
|
845
|
+
process.on("unhandledRejection", (reason) => {
|
|
846
|
+
console.error("Unhandled promise rejection:", reason);
|
|
761
847
|
});
|
|
848
|
+
// Only listen when run as the entry point — importing createSseApp from
|
|
849
|
+
// tests must not bind a port or install the reaper interval.
|
|
850
|
+
const isDirectRun = process.argv[1] !== undefined &&
|
|
851
|
+
import.meta.url.toLowerCase() === pathToFileURL(process.argv[1]).href.toLowerCase();
|
|
852
|
+
if (isDirectRun) {
|
|
853
|
+
main().catch((error) => {
|
|
854
|
+
console.error("Fatal error:", error);
|
|
855
|
+
process.exit(1);
|
|
856
|
+
});
|
|
857
|
+
}
|
|
@@ -248,15 +248,17 @@ export function computeCrossAspects(planets1, planets2) {
|
|
|
248
248
|
const results = [];
|
|
249
249
|
for (const p1 of planets1) {
|
|
250
250
|
for (const p2 of planets2) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
251
|
+
// Signed separation of p2 ahead of p1 (0–360), folded to shortest arc.
|
|
252
|
+
const delta = (((p2.longitude - p1.longitude) % 360) + 360) % 360;
|
|
253
|
+
const diff = delta > 180 ? 360 - delta : delta;
|
|
254
|
+
// Rate of change of the shortest-arc separation, from real daily motion.
|
|
255
|
+
const rel = (p2.speed ?? 0) - (p1.speed ?? 0);
|
|
256
|
+
const dDiff = delta > 180 ? -rel : rel;
|
|
254
257
|
for (const asp of CROSS_ASPECT_DEFS) {
|
|
255
258
|
const orb = Math.abs(diff - asp.angle);
|
|
256
259
|
if (orb <= asp.orb) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
: diff > asp.angle;
|
|
260
|
+
// Applying = the separation is moving toward the exact aspect angle.
|
|
261
|
+
const applying = (diff - asp.angle) * dDiff < 0;
|
|
260
262
|
results.push({
|
|
261
263
|
planet1: p1.name,
|
|
262
264
|
wheel1: "inner",
|
|
@@ -189,7 +189,7 @@ registerTool({
|
|
|
189
189
|
const chartData = await client.post("/ephemeris/natal-chart", natalBody);
|
|
190
190
|
// Build human-readable summary for the LLM context
|
|
191
191
|
const summary = buildChartSummary(chartData, String(args.location ?? `${lat}, ${lon}`));
|
|
192
|
-
// Build the UI payload (planets normalised to array, aspects
|
|
192
|
+
// Build the UI payload (planets normalised to array, server aspects mapped)
|
|
193
193
|
const modelPayload = buildModelPayload(chartData, {
|
|
194
194
|
datetime,
|
|
195
195
|
timezone: args.timezone ?? null,
|
|
@@ -453,52 +453,39 @@ const ALL_KNOWN_BODIES = new Set([...CLASSICAL_PLANETS, ...EXTENDED_BODIES]);
|
|
|
453
453
|
* Tokens saved vs. full uiPayload: ~85–95% reduction (no 100KB SVG, no asteroid flood).
|
|
454
454
|
*/
|
|
455
455
|
/**
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
456
|
+
* Map the server-computed aspects array from /ephemeris/natal-chart (and the
|
|
457
|
+
* solar-return / progressed responses) to the AspectData shape the chart-wheel
|
|
458
|
+
* UI consumes: { planet1, planet2, type, angle, orb, applying }.
|
|
459
|
+
*
|
|
460
|
+
* The Go API computes aspects by default (options.include_aspects = true),
|
|
461
|
+
* including a proper ephemeris-based is_applying flag — so no client-side
|
|
462
|
+
* longitude heuristic is needed. Aspects are optionally filtered to the set of
|
|
463
|
+
* bodies actually displayed so lines never reference a hidden planet.
|
|
459
464
|
*/
|
|
460
|
-
function
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
{ type: "opposition", angle: 180, orb: 8 },
|
|
464
|
-
{ type: "trine", angle: 120, orb: 8 },
|
|
465
|
-
{ type: "square", angle: 90, orb: 7 },
|
|
466
|
-
{ type: "sextile", angle: 60, orb: 6 },
|
|
467
|
-
{ type: "quincunx", angle: 150, orb: 3 },
|
|
468
|
-
{ type: "semisquare", angle: 45, orb: 2 },
|
|
469
|
-
];
|
|
465
|
+
function mapServerAspects(rawAspects, allowedNames) {
|
|
466
|
+
if (!Array.isArray(rawAspects))
|
|
467
|
+
return [];
|
|
470
468
|
const results = [];
|
|
471
|
-
for (
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
planet1: p1.name,
|
|
492
|
-
planet2: p2.name,
|
|
493
|
-
type: asp.type,
|
|
494
|
-
angle: asp.angle,
|
|
495
|
-
orb: Math.round(orb * 100) / 100,
|
|
496
|
-
applying,
|
|
497
|
-
});
|
|
498
|
-
break; // Each pair only matches one aspect type
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
}
|
|
469
|
+
for (const a of rawAspects) {
|
|
470
|
+
if (!a || typeof a !== "object")
|
|
471
|
+
continue;
|
|
472
|
+
const p1 = canonicalizeBodyName(String(a.planet1 ?? ""));
|
|
473
|
+
const p2 = canonicalizeBodyName(String(a.planet2 ?? ""));
|
|
474
|
+
if (!p1 || !p2)
|
|
475
|
+
continue;
|
|
476
|
+
if (allowedNames && (!allowedNames.has(p1) || !allowedNames.has(p2)))
|
|
477
|
+
continue;
|
|
478
|
+
results.push({
|
|
479
|
+
planet1: p1,
|
|
480
|
+
planet2: p2,
|
|
481
|
+
type: String(a.aspect_type ?? a.type ?? "aspect").toLowerCase(),
|
|
482
|
+
angle: Number(a.exact_angle ?? a.angle ?? 0),
|
|
483
|
+
orb: Math.round(Number(a.orb ?? 0) * 100) / 100,
|
|
484
|
+
// is_applying may be null when the server cannot determine it
|
|
485
|
+
applying: typeof a.is_applying === "boolean" ? a.is_applying
|
|
486
|
+
: typeof a.applying === "boolean" ? a.applying
|
|
487
|
+
: undefined,
|
|
488
|
+
});
|
|
502
489
|
}
|
|
503
490
|
return results;
|
|
504
491
|
}
|
|
@@ -536,8 +523,8 @@ function buildModelPayload(chartData, birthParams, houseSystem, svgBase, bodyFil
|
|
|
536
523
|
// Already normalised (e.g. house system recalculate path)
|
|
537
524
|
planetsArray = chartData.planets;
|
|
538
525
|
}
|
|
539
|
-
// ── 2.
|
|
540
|
-
const aspects =
|
|
526
|
+
// ── 2. Map server-computed aspects (filtered to displayed bodies) ─────────
|
|
527
|
+
const aspects = mapServerAspects(chartData.aspects, new Set(planetsArray.map((p) => p.name)));
|
|
541
528
|
// ── 3. Extract angles — API nests ascendant/MC under an `angles` key ──────
|
|
542
529
|
const rawAngles = chartData.angles;
|
|
543
530
|
const rawHouseObj = chartData.houses;
|
|
@@ -626,10 +613,8 @@ function buildChartSummary(data, location) {
|
|
|
626
613
|
elementCount[el]++;
|
|
627
614
|
}
|
|
628
615
|
const domElement = Object.entries(elementCount).sort((a, b) => b[1] - a[1])[0];
|
|
629
|
-
// Strongest aspect by tightest orb (
|
|
630
|
-
const aspects =
|
|
631
|
-
.filter((p) => p.longitude != null)
|
|
632
|
-
.map((p) => ({ name: p.name, longitude: p.longitude, speed: p.longitude_speed })));
|
|
616
|
+
// Strongest aspect by tightest orb (server-computed aspects)
|
|
617
|
+
const aspects = mapServerAspects(data.aspects);
|
|
633
618
|
const strongest = aspects.sort((a, b) => a.orb - b.orb)[0];
|
|
634
619
|
const strongestNote = strongest
|
|
635
620
|
? `Closest aspect: ${capitalize(strongest.planet1)} ${strongest.type} ${capitalize(strongest.planet2)} (${strongest.orb.toFixed(1)}° orb)`
|
package/dist/tools/dev.js
CHANGED
|
@@ -73,7 +73,7 @@ const DEV_API_REFERENCE = "Target API: Open Ephemeris REST API (https://api.open
|
|
|
73
73
|
" POST /acg/power-lines — Astrocartography power lines (lat/lon GeoJSON)\n" +
|
|
74
74
|
" POST /acg/hits — ACG power at a specific location\n" +
|
|
75
75
|
" GET /calendar/astrology/moon-phases — Moon phase calendar for a date range\n" +
|
|
76
|
-
" GET /location/autocomplete — Geocode a place name (query:
|
|
76
|
+
" GET /location/autocomplete — Geocode a place name (query: query=City Name)\n" +
|
|
77
77
|
" POST /timezone/lookup — Resolve timezone + UTC offset for a location\n" +
|
|
78
78
|
" POST /chinese/bazi — Chinese Ba Zi (Four Pillars) chart (body: {year, month, day, hour})\n" +
|
|
79
79
|
" GET /chinese/zodiac — Chinese zodiac year element/animal\n" +
|