@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.
@@ -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
- const resp = await axios.get(`${BACKEND_URL}/ephemeris/moon/phase`, {
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
- const result = await tool.handler(request.params.arguments ?? {});
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
- const isRetryable = error.retryable === true || error.status === 401 || error.code === "auth_required";
291
- console.error(`[MCP] Failed: ${toolName} (${durationMs}ms) - ${errorMessage}`);
292
- return {
293
- content: [
294
- {
295
- type: "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).`,
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
- async function main() {
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: Do NOT use express.json() globally SSEServerTransport.handlePostMessage
377
- // reads the raw request stream itself. Pre-parsing with express.json() consumes it,
378
- // causing "stream is not readable" errors.
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: ["sse", "streamable-http"],
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
- sessionIdGenerator: randomUUID,
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 — MCP spec pre-2025 (kept for backward compatibility)
653
- // Endpoint: GET https://mcp.openephemeris.com/sse
654
- // New integrations should prefer /mcp (Streamable HTTP above).
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
- // SSE endpoint clients GET /sse to establish a stream
657
- app.get("/sse", async (req, res) => {
658
- // Extract auth from query param or header (supports API keys + OAuth JWTs)
659
- const { apiKey, jwt } = extractAuth(req);
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
- sessionClients.set(sessionId, sessionClient);
698
- const server = createMcpServer();
699
- // Guard: prevent double-close from crashing the process
700
- let closed = false;
701
- const cleanup = () => {
702
- if (closed)
703
- return;
704
- closed = true;
705
- transports.delete(sessionId);
706
- sessionClients.delete(sessionId);
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
- main().catch((error) => {
759
- console.error("Fatal error:", error);
760
- process.exit(1);
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
- let diff = Math.abs(p1.longitude - p2.longitude) % 360;
252
- if (diff > 180)
253
- diff = 360 - diff;
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
- const applying = ((p1.speed ?? 1) - (p2.speed ?? 0)) < 0
258
- ? diff < asp.angle
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 computed)
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
- * Compute major aspects between a set of planets.
457
- * Returns a structured AspectData[] array the natal chart API does not
458
- * include aspects in its JSON response; we derive them from longitude data.
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 computeAspects(planets) {
461
- const ASPECTS = [
462
- { type: "conjunction", angle: 0, orb: 8 },
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 (let i = 0; i < planets.length; i++) {
472
- for (let j = i + 1; j < planets.length; j++) {
473
- const p1 = planets[i];
474
- const p2 = planets[j];
475
- // Angular separation: shortest arc (0–180)
476
- let diff = Math.abs(p1.longitude - p2.longitude) % 360;
477
- if (diff > 180)
478
- diff = 360 - diff;
479
- for (const asp of ASPECTS) {
480
- const orb = Math.abs(diff - asp.angle);
481
- // Tighten orbs when one or both bodies are extended (asteroids, etc.)
482
- const p1Extended = EXTENDED_BODIES.has(p1.name.toLowerCase());
483
- const p2Extended = EXTENDED_BODIES.has(p2.name.toLowerCase());
484
- const effectiveOrb = (p1Extended || p2Extended)
485
- ? Math.min(asp.orb * 0.5, 3) // half standard, capped at 3°
486
- : asp.orb;
487
- if (orb <= effectiveOrb) {
488
- // Applying: combined longitudinal speed closing the orb
489
- const applying = ((p1.speed ?? 1) - (p2.speed ?? 0)) < 0 ? diff < asp.angle : diff > asp.angle;
490
- results.push({
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. Compute aspects from longitude data (API doesn't return them) ──────
540
- const aspects = computeAspects(planetsArray);
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 (use computed aspects from planetsArray)
630
- const aspects = computeAspects(planetsArray
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: q=City Name)\n" +
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" +