@openephemeris/mcp-server 3.19.0 → 3.21.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,13 +14,14 @@
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
+ import { captureEvent, distinctIdFor } from "./analytics.js";
24
25
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
25
26
  import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools } from "./tools/index.js";
26
27
  import { BackendClient, runWithClient } from "./backend/client.js";
@@ -179,30 +180,26 @@ const WELCOME_PROMPT_CONTENT = [
179
180
  role: "assistant",
180
181
  content: {
181
182
  type: "text",
182
- text: "🌟 **Welcome to Open Ephemeris**a NASA JPL-backed astronomical computation engine.\n\n" +
183
- "I have access to 50+ precision tools covering:\n\n" +
184
- " **Natal Charts** full planetary positions, house cusps, aspects, dignities\n" +
185
- " **Transits & Progressions** — real-time and secondary progressions\n" +
186
- " **Synastry & Composites** — relationship compatibility analysis\n" +
187
- " **Human Design** — bodygraph charts, gates, channels, types & profiles\n" +
188
- " **Astrocartography (ACG)** power lines, city hits, local space lines\n" +
189
- " **Eclipses** solar/lunar eclipse lookup with visibility data\n" +
190
- " **Vedic/Jyotish** D-charts, dashas, nakshatras, Ashtakavarga\n" +
191
- "• **Chinese BaZi** — Four Pillars, Ten Gods, Luck Pillars\n" +
192
- " **Venus Star Points** 8-year Venus cycle phases\n" +
193
- " **Electional Astrology**station tracking, timing windows\n" +
194
- " **Chart Wheels** visual PNG/SVG wheels and bi-wheels\n" +
195
- "• **Time Lords** — Firdaria, Profections, Zodiacal Releasing\n\n" +
196
- "**Tips:** Use `format=\"llm\"` on any tool to get ~50% smaller, token-optimized output. " +
197
- "All computations are sub-arcsecond precision using JPL DE440 ephemerides.\n\n" +
198
- "Just tell me what you'd like to explore!",
183
+ text: "🌟 **Welcome!** You're connected to Open Ephemeris — every chart here is computed " +
184
+ "from NASA JPL data, not guessed.\n\n" +
185
+ "Two great ways to start:\n\n" +
186
+ "1. **\"What's in the sky right now?\"** — instant, no birth details needed " +
187
+ "(live planets, moon phase, retrogrades).\n" +
188
+ "2. **\"Draw my birth chart\"** — an interactive wheel you can click and explore. " +
189
+ "Just share your birth date, exact time, and city.\n\n" +
190
+ "From there we can go deep: relationship synastry, Human Design bodygraphs, " +
191
+ "astrocartography (where on Earth your chart comes alive), Vedic, BaZi, transits, " +
192
+ "eclipses, and more.\n\n" +
193
+ "_(For the model: prefer explore_natal_chart / explore_moon_phase and the other " +
194
+ "explore_* tools for anything the user should SEE they render interactive visuals. " +
195
+ "Use format=\"llm\" on data tools for compact output.)_",
199
196
  },
200
197
  },
201
198
  ];
202
199
  // ---------------------------------------------------------------------------
203
200
  // MCP Server factory — one per SSE/HTTP session
204
201
  // ---------------------------------------------------------------------------
205
- function createMcpServer() {
202
+ function createMcpServer(analyticsId = "anonymous") {
206
203
  const server = new Server({
207
204
  name: "openephemeris-mcp",
208
205
  title: "Open Ephemeris",
@@ -237,14 +234,18 @@ function createMcpServer() {
237
234
  },
238
235
  },
239
236
  },
240
- instructions: "Open Ephemeris is a precision astronomical computation engine. " +
241
- "Use format='llm' on any tool for compact, token-efficient output. " +
242
- "All computations use JPL DE440 ephemerides for sub-arcsecond accuracy. " +
243
- "See the 'welcome_to_open_ephemeris' prompt for a full capability overview.",
237
+ instructions: "Open Ephemeris computes real astronomy (JPL DE440, sub-arcsecond) — never guess " +
238
+ "or approximate positions yourself; always call a tool. " +
239
+ "For anything the user should SEE (natal chart, bi-wheel, bodygraph, moon phase), " +
240
+ "prefer the explore_* tools they render interactive visuals inline. " +
241
+ "ephemeris_* tools return data; use format='llm' on them for compact output. " +
242
+ "If the user has no birth data handy, start with the sky right now — " +
243
+ "explore_moon_phase and ephemeris_retrograde_status need none. " +
244
+ "See the 'welcome_to_open_ephemeris' prompt for orientation.",
244
245
  });
245
246
  // --- Tool handlers ---
246
247
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
247
- tools: modelVisibleTools().map((tool) => ({
248
+ tools: modelVisibleTools("http").map((tool) => ({
248
249
  name: tool.name,
249
250
  description: tool.description,
250
251
  inputSchema: tool.inputSchema,
@@ -279,7 +280,9 @@ function createMcpServer() {
279
280
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
280
281
  const toolName = request.params.name;
281
282
  const tool = toolRegistry[toolName];
282
- if (!tool) {
283
+ if (!tool || tool.stdioOnly) {
284
+ // stdioOnly tools (device auth) mutate process-global state and must not
285
+ // be callable on the multi-tenant HTTP transport, even by name.
283
286
  throw new Error(`Unknown tool: ${toolName}`);
284
287
  }
285
288
  try {
@@ -290,11 +293,15 @@ function createMcpServer() {
290
293
  // composite ~9.3s) with a wide margin.
291
294
  const result = await withTimeout(tool.handler(request.params.arguments ?? {}), TOOL_CALL_TIMEOUT_MS, toolName);
292
295
  const durationMs = Date.now() - startTime;
296
+ captureEvent("mcp_tool_call", analyticsId, { tool: toolName, duration_ms: durationMs });
293
297
  return formatToolResponse(toolName, result, durationMs);
294
298
  }
295
299
  catch (error) {
296
300
  const errorMessage = error instanceof Error ? error.message : String(error);
297
301
  console.error(`[MCP] ❌ Failed: ${toolName} - ${errorMessage}`);
302
+ // status makes 402 (paywall) and 429 (rate limit) countable in PostHog.
303
+ const status = error?.response?.status;
304
+ captureEvent("mcp_tool_error", analyticsId, { tool: toolName, status: status ?? "unknown" });
298
305
  // All failures surface as isError: true so the host can recover
299
306
  // (retry / OAuth refresh). The remote transport has no device-auth flow,
300
307
  // so formatToolError's device-auth exception never fires here — a 401
@@ -418,12 +425,17 @@ function startSseKeepalive(res) {
418
425
  }, 25_000);
419
426
  res.on("close", () => clearInterval(ping));
420
427
  }
421
- async function main() {
428
+ /**
429
+ * Build the full Express app (auth gate, OAuth routes, /mcp Streamable HTTP,
430
+ * health, resources). Exported so tests can drive the REAL handlers with
431
+ * supertest instead of a re-implemented stub. Does not listen.
432
+ */
433
+ export async function createSseApp() {
422
434
  await initTools();
423
435
  const app = express();
424
- // NOTE: Do NOT use express.json() globally SSEServerTransport.handlePostMessage
425
- // reads the raw request stream itself. Pre-parsing with express.json() consumes it,
426
- // causing "stream is not readable" errors.
436
+ // NOTE: express.json() is applied per-route (jsonParser on /mcp POST), not
437
+ // globally the OAuth token route needs urlencoded, and transports that
438
+ // read the raw request stream break if the body is pre-consumed.
427
439
  // CORS + Origin allowlist
428
440
  // Per Anthropic remote-MCP review criteria, browser-originated requests must
429
441
  // be validated against an allowlist. Native (no-Origin) clients like Claude
@@ -455,7 +467,7 @@ async function main() {
455
467
  .filter(Boolean)
456
468
  .map((h) => new RegExp(`^${h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(:\\d+)?$`, "i")),
457
469
  ];
458
- const MCP_ROUTES = new Set(["/mcp", "/sse", "/message"]);
470
+ const MCP_ROUTES = new Set(["/mcp"]);
459
471
  app.use((req, res, next) => {
460
472
  if (MCP_ROUTES.has(req.path)) {
461
473
  const host = req.headers.host;
@@ -491,10 +503,6 @@ async function main() {
491
503
  }
492
504
  next();
493
505
  });
494
- // Track active transports by session ID
495
- const transports = new Map();
496
- // Per-session BackendClient — each connection authenticates independently.
497
- const sessionClients = new Map();
498
506
  // ---------------------------------------------------------------------------
499
507
  // OAuth 2.1 routes — must be mounted BEFORE JSON body parser
500
508
  // ---------------------------------------------------------------------------
@@ -531,14 +539,12 @@ async function main() {
531
539
  sessionRateLimiter(req, res, next);
532
540
  };
533
541
  app.use("/mcp", throttleSessionCreation);
534
- app.use("/sse", sessionRateLimiter);
535
542
  // Health check
536
543
  app.get("/health", (_req, res) => {
537
544
  res.json({
538
545
  ok: true,
539
546
  version,
540
- transports: ["sse", "streamable-http"],
541
- sse_sessions: transports.size,
547
+ transports: ["streamable-http"],
542
548
  http_sessions: httpSessions.size,
543
549
  });
544
550
  });
@@ -548,7 +554,7 @@ async function main() {
548
554
  // Spec: https://smithery.ai/docs/build/publish#server-scanning
549
555
  // ---------------------------------------------------------------------------
550
556
  app.get("/.well-known/mcp/server-card.json", (_req, res) => {
551
- const tools = modelVisibleTools().map((tool) => ({
557
+ const tools = modelVisibleTools("http").map((tool) => ({
552
558
  name: tool.name,
553
559
  description: tool.description,
554
560
  inputSchema: tool.inputSchema,
@@ -591,6 +597,27 @@ async function main() {
591
597
  const sessionId = req.headers["mcp-session-id"];
592
598
  // Resume existing session
593
599
  if (sessionId) {
600
+ // A session id alone is not a credential: require a key/JWT BEFORE the
601
+ // session lookup so an unauthenticated probe can't learn whether a
602
+ // session id exists, and reject an expired JWT with the OAuth-refresh
603
+ // signal (same semantics as the init gate below).
604
+ const resumedAuth = extractAuth(req);
605
+ if (!resumedAuth.apiKey && !resumedAuth.jwt) {
606
+ res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
607
+ res.status(401).json({
608
+ error: "auth_required",
609
+ message: "Authentication required on every request. Pass an API key via X-API-Key header, or a Bearer token via Authorization header.",
610
+ });
611
+ return;
612
+ }
613
+ if (resumedAuth.jwt && isJwtExpired(resumedAuth.jwt)) {
614
+ res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", error_description="The access token expired", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
615
+ res.status(401).json({
616
+ error: "invalid_token",
617
+ message: "Your session token has expired. Re-authorize to continue.",
618
+ });
619
+ return;
620
+ }
594
621
  const session = httpSessions.get(sessionId);
595
622
  if (!session) {
596
623
  // Cross-machine session recovery (Fly): min_machines_running >= 2 but
@@ -612,26 +639,6 @@ async function main() {
612
639
  });
613
640
  return;
614
641
  }
615
- // A session id alone is not a credential: require the caller to still
616
- // present a key/JWT on every resume, and reject an expired JWT with the
617
- // OAuth-refresh signal (same semantics as the init gate below).
618
- const resumedAuth = extractAuth(req);
619
- if (!resumedAuth.apiKey && !resumedAuth.jwt) {
620
- res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
621
- res.status(401).json({
622
- error: "auth_required",
623
- message: "Authentication required on every request. Pass an API key via X-API-Key header, or a Bearer token via Authorization header.",
624
- });
625
- return;
626
- }
627
- if (resumedAuth.jwt && isJwtExpired(resumedAuth.jwt)) {
628
- res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", error_description="The access token expired", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
629
- res.status(401).json({
630
- error: "invalid_token",
631
- message: "Your session token has expired. Re-authorize to continue.",
632
- });
633
- return;
634
- }
635
642
  // Refresh the session's Bearer JWT if the host rotated it. claude.ai
636
643
  // refreshes tokens via /oauth/token roughly hourly (self-signed Supabase
637
644
  // JWTs expire in 1h); the frozen JWT captured at init would otherwise 401
@@ -706,13 +713,19 @@ async function main() {
706
713
  // can be replayed to the owner (see the resume branch above). No-op off
707
714
  // Fly (FLY_MACHINE_ID unset) — the raw UUID is used unchanged.
708
715
  sessionIdGenerator: () => encodeSessionId(randomUUID()),
716
+ // Last-Event-ID resumability: a reconnecting SSE stream replays the
717
+ // events it missed instead of silently losing them. Per-process is fine
718
+ // — the session is machine-pinned via fly-replay.
719
+ eventStore: new InMemoryEventStore(),
709
720
  onsessioninitialized: (id) => {
710
721
  httpSessions.set(id, { server, transport, client, lastSeen: Date.now() });
711
722
  console.error(`[HTTP] Session initialized: ${id}`);
712
723
  },
713
724
  });
714
725
  const client = new BackendClient({ baseURL: BACKEND_URL, apiKey, jwt });
715
- const server = createMcpServer();
726
+ const analyticsId = distinctIdFor(apiKey ?? jwt);
727
+ captureEvent("mcp_session_init", analyticsId, { auth: apiKey ? "api_key" : "oauth" });
728
+ const server = createMcpServer(analyticsId);
716
729
  transport.onclose = () => {
717
730
  if (transport.sessionId) {
718
731
  httpSessions.delete(transport.sessionId);
@@ -796,101 +809,23 @@ async function main() {
796
809
  }
797
810
  });
798
811
  // ---------------------------------------------------------------------------
799
- // Legacy SSE transport — MCP spec pre-2025 (kept for backward compatibility)
800
- // Endpoint: GET https://mcp.openephemeris.com/sse
801
- // New integrations should prefer /mcp (Streamable HTTP above).
812
+ // Legacy SSE transport — RETIRED in 3.20.0.
813
+ // The pre-2025 HTTP+SSE transport (/sse + /message) was SDK-deprecated, had
814
+ // no cross-machine replay (broken with min_machines_running >= 2), and live
815
+ // metrics showed zero sessions. 410 Gone points stragglers at /mcp.
802
816
  // ---------------------------------------------------------------------------
803
- // SSE endpoint clients GET /sse to establish a stream
804
- app.get("/sse", async (req, res) => {
805
- // Extract auth from query param or header (supports API keys + OAuth JWTs)
806
- const { apiKey, jwt } = extractAuth(req);
807
- if (!apiKey && !jwt) {
808
- res.status(401).json({
809
- error: "auth_required",
810
- 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",
811
- });
812
- return;
813
- }
814
- // Validate API keys against the Go backend; JWTs validated downstream.
815
- if (apiKey) {
816
- const keyStatus = await validateApiKey(apiKey);
817
- if (keyStatus === "invalid") {
818
- res.status(403).json({
819
- error: "invalid_api_key",
820
- message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
821
- });
822
- return;
823
- }
824
- // Fail closed for browser clients when validation is unavailable (APP-6).
825
- if (keyStatus === "unknown" && isBrowserOrigin(req)) {
826
- res.set("Retry-After", "5");
827
- res.status(503).json({
828
- error: "validation_unavailable",
829
- message: "Could not verify your API key right now. Please retry shortly.",
830
- });
831
- return;
832
- }
833
- }
834
- const transport = new SSEServerTransport("/message", res);
835
- const sessionId = transport.sessionId;
836
- transports.set(sessionId, transport);
837
- // Create a per-session BackendClient so concurrent SSE connections
838
- // don't overwrite each other's API key on the shared singleton.
839
- const sessionClient = new BackendClient({
840
- baseURL: BACKEND_URL,
841
- apiKey,
842
- jwt,
817
+ const legacySseGone = (_req, res) => {
818
+ res.status(410).json({
819
+ error: "transport_retired",
820
+ message: "The legacy SSE transport has been retired. Connect via Streamable HTTP at https://mcp.openephemeris.com/mcp instead.",
843
821
  });
844
- sessionClients.set(sessionId, sessionClient);
845
- const server = createMcpServer();
846
- // Guard: prevent double-close from crashing the process
847
- let closed = false;
848
- const cleanup = () => {
849
- if (closed)
850
- return;
851
- closed = true;
852
- transports.delete(sessionId);
853
- sessionClients.delete(sessionId);
854
- try {
855
- server.close().catch(() => { });
856
- }
857
- catch { /* ignore */ }
858
- };
859
- transport.onclose = cleanup;
860
- // Handle abrupt client disconnects (browser tab closed, etc.)
861
- res.on("close", cleanup);
862
- try {
863
- // server.connect() calls transport.start() internally in SDK v1.27+
864
- await server.connect(transport);
865
- // Stream is established — keep it alive through Fly's proxy.
866
- startSseKeepalive(res);
867
- }
868
- catch (err) {
869
- console.error(`SSE session ${sessionId} failed to start:`, err);
870
- cleanup();
871
- }
872
- });
873
- // Message endpoint — clients POST /message?sessionId=xxx
874
- app.post("/message", async (req, res) => {
875
- const sessionId = req.query.sessionId;
876
- const transport = transports.get(sessionId);
877
- const sessionClient = sessionClients.get(sessionId);
878
- if (!transport) {
879
- res.status(400).json({
880
- error: "invalid_session",
881
- message: "Unknown or expired session. Re-connect to /sse first.",
882
- });
883
- return;
884
- }
885
- // Run handlePostMessage inside the session's client context so all tool
886
- // handlers reach getActiveClient() and get this user's BackendClient.
887
- if (sessionClient) {
888
- await runWithClient(sessionClient, () => transport.handlePostMessage(req, res));
889
- }
890
- else {
891
- await transport.handlePostMessage(req, res);
892
- }
893
- });
822
+ };
823
+ app.get("/sse", legacySseGone);
824
+ app.post("/message", legacySseGone);
825
+ return app;
826
+ }
827
+ async function main() {
828
+ const app = await createSseApp();
894
829
  // Reap abandoned sessions (no DELETE, no transport close) every 10 minutes.
895
830
  setInterval(reapIdleSessions, 10 * 60 * 1000).unref();
896
831
  const httpServer = app.listen(PORT, "0.0.0.0", () => {
@@ -919,7 +854,13 @@ process.on("uncaughtException", (err) => {
919
854
  process.on("unhandledRejection", (reason) => {
920
855
  console.error("Unhandled promise rejection:", reason);
921
856
  });
922
- main().catch((error) => {
923
- console.error("Fatal error:", error);
924
- process.exit(1);
925
- });
857
+ // Only listen when run as the entry point — importing createSseApp from
858
+ // tests must not bind a port or install the reaper interval.
859
+ const isDirectRun = process.argv[1] !== undefined &&
860
+ import.meta.url.toLowerCase() === pathToFileURL(process.argv[1]).href.toLowerCase();
861
+ if (isDirectRun) {
862
+ main().catch((error) => {
863
+ console.error("Fatal error:", error);
864
+ process.exit(1);
865
+ });
866
+ }
@@ -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",
@@ -404,7 +406,9 @@ function buildBiWheelSummary(innerPlanets, outerPlanets, crossAspects, mode, loc
404
406
  // ── Tool: explore_bi_wheel ─────────────────────────────────────────────────────
405
407
  registerTool({
406
408
  name: "explore_bi_wheel",
407
- description: "Generate an interactive bi-wheel chart comparing two astrological chart positions. " +
409
+ description: "Generate an interactive bi-wheel chart comparing two astrological chart positions.\n\n" +
410
+ "CREDIT COST: 2 credits per call for most modes (1 per wheel computed); " +
411
+ "solar_return/lunar_return modes cost 6 (5 for the return + 1 for the natal wheel).\n\n" +
408
412
  "The inner wheel is always Person 1's natal chart. The outer ring depends on mode:\n" +
409
413
  "• synastry — Person 2's natal chart (two-person compatibility). 1 credit.\n" +
410
414
  "• transit — Transiting planets for a given date. 1 credit.\n" +
@@ -356,7 +356,8 @@ function buildHdSummary(payload, location) {
356
356
  // ── Tool: explore_human_design ────────────────────────────────────────────
357
357
  registerTool({
358
358
  name: "explore_human_design",
359
- description: "Generate an interactive Human Design Bodygraph with clickable centers, gates, and channels. " +
359
+ description: "Generate an interactive Human Design Bodygraph with clickable centers, gates, and channels.\n\n" +
360
+ "CREDIT COST: 2 credits per call.\n\n" +
360
361
  "Returns an embedded visual bodygraph explorer that lets you click any center or gate " +
361
362
  "for instant Human Design interpretation. " +
362
363
  "Shows defined/undefined centers, active gates, channels, Type, Profile, Authority, " +
@@ -1325,7 +1326,8 @@ registerTool({
1325
1326
  // Premium (Developer tier). Calls POST /human-design/transit-chart.
1326
1327
  registerTool({
1327
1328
  name: "explore_human_design_transit",
1328
- description: "Overlay the current (or a chosen) planetary transit on a person's natal Human Design bodygraph. " +
1329
+ description: "Overlay the current (or a chosen) planetary transit on a person's natal Human Design bodygraph.\n\n" +
1330
+ "CREDIT COST: 3 credits per call.\n\n" +
1329
1331
  "Highlights the channels a transit temporarily COMPLETES with the natal chart and any centers it " +
1330
1332
  "newly defines — the core of a Human Design transit reading. " +
1331
1333
  "Returns an interactive overlay bodygraph in MCP Apps-capable hosts (Claude Desktop), with a text " +
@@ -1415,7 +1417,8 @@ registerTool({
1415
1417
  registerTool({
1416
1418
  name: "explore_human_design_connection",
1417
1419
  description: "Compare two people's Human Design charts and classify every connected channel by HD connection " +
1418
- "theory electromagnetic (attraction), companionship (sameness), dominance (one defines), and " +
1420
+ "theory. CREDIT COST: 3 credits per call. Classifications: " +
1421
+ "electromagnetic (attraction), companionship (sameness), dominance (one defines), and " +
1419
1422
  "compromise (friction). Returns an interactive two-person overlay bodygraph in MCP Apps-capable " +
1420
1423
  "hosts, with a text summary fallback elsewhere. Premium (Developer tier). NASA JPL DE440 ephemerides.",
1421
1424
  inputSchema: {
@@ -104,9 +104,11 @@ function buildNatalBody(datetime, lat, lon, houseSystem, timezone) {
104
104
  // ── Tool: explore_natal_chart ──────────────────────────────────────────────
105
105
  registerTool({
106
106
  name: "explore_natal_chart",
107
- description: "Generate an interactive natal chart wheel with clickable planets, houses, and aspects. " +
107
+ description: "PRIMARY tool for any natal/birth-chart request. Renders an interactive, clickable chart " +
108
+ "wheel — prefer this over ephemeris_natal_chart when the user wants to SEE a chart. " +
108
109
  "Returns an embedded visual chart explorer that lets you click any planet, house, or aspect " +
109
- "line for instant astrological interpretation. " +
110
+ "line for instant astrological interpretation.\n\n" +
111
+ "CREDIT COST: 1 credit per call.\n\n" +
110
112
  "Supports house system switching (Placidus, Whole Sign, Equal, Koch). " +
111
113
  "The chart is computed using NASA JPL DE440 ephemerides for sub-arcsecond precision. " +
112
114
  "Use this instead of ephemeris_chart_wheel for a richer, interactive experience in " +
@@ -189,7 +191,7 @@ registerTool({
189
191
  const chartData = await client.post("/ephemeris/natal-chart", natalBody);
190
192
  // Build human-readable summary for the LLM context
191
193
  const summary = buildChartSummary(chartData, String(args.location ?? `${lat}, ${lon}`));
192
- // Build the UI payload (planets normalised to array, aspects computed)
194
+ // Build the UI payload (planets normalised to array, server aspects mapped)
193
195
  const modelPayload = buildModelPayload(chartData, {
194
196
  datetime,
195
197
  timezone: args.timezone ?? null,
@@ -453,52 +455,39 @@ const ALL_KNOWN_BODIES = new Set([...CLASSICAL_PLANETS, ...EXTENDED_BODIES]);
453
455
  * Tokens saved vs. full uiPayload: ~85–95% reduction (no 100KB SVG, no asteroid flood).
454
456
  */
455
457
  /**
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.
458
+ * Map the server-computed aspects array from /ephemeris/natal-chart (and the
459
+ * solar-return / progressed responses) to the AspectData shape the chart-wheel
460
+ * UI consumes: { planet1, planet2, type, angle, orb, applying }.
461
+ *
462
+ * The Go API computes aspects by default (options.include_aspects = true),
463
+ * including a proper ephemeris-based is_applying flag — so no client-side
464
+ * longitude heuristic is needed. Aspects are optionally filtered to the set of
465
+ * bodies actually displayed so lines never reference a hidden planet.
459
466
  */
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
- ];
467
+ function mapServerAspects(rawAspects, allowedNames) {
468
+ if (!Array.isArray(rawAspects))
469
+ return [];
470
470
  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
- }
471
+ for (const a of rawAspects) {
472
+ if (!a || typeof a !== "object")
473
+ continue;
474
+ const p1 = canonicalizeBodyName(String(a.planet1 ?? ""));
475
+ const p2 = canonicalizeBodyName(String(a.planet2 ?? ""));
476
+ if (!p1 || !p2)
477
+ continue;
478
+ if (allowedNames && (!allowedNames.has(p1) || !allowedNames.has(p2)))
479
+ continue;
480
+ results.push({
481
+ planet1: p1,
482
+ planet2: p2,
483
+ type: String(a.aspect_type ?? a.type ?? "aspect").toLowerCase(),
484
+ angle: Number(a.exact_angle ?? a.angle ?? 0),
485
+ orb: Math.round(Number(a.orb ?? 0) * 100) / 100,
486
+ // is_applying may be null when the server cannot determine it
487
+ applying: typeof a.is_applying === "boolean" ? a.is_applying
488
+ : typeof a.applying === "boolean" ? a.applying
489
+ : undefined,
490
+ });
502
491
  }
503
492
  return results;
504
493
  }
@@ -536,8 +525,8 @@ function buildModelPayload(chartData, birthParams, houseSystem, svgBase, bodyFil
536
525
  // Already normalised (e.g. house system recalculate path)
537
526
  planetsArray = chartData.planets;
538
527
  }
539
- // ── 2. Compute aspects from longitude data (API doesn't return them) ──────
540
- const aspects = computeAspects(planetsArray);
528
+ // ── 2. Map server-computed aspects (filtered to displayed bodies) ─────────
529
+ const aspects = mapServerAspects(chartData.aspects, new Set(planetsArray.map((p) => p.name)));
541
530
  // ── 3. Extract angles — API nests ascendant/MC under an `angles` key ──────
542
531
  const rawAngles = chartData.angles;
543
532
  const rawHouseObj = chartData.houses;
@@ -626,10 +615,8 @@ function buildChartSummary(data, location) {
626
615
  elementCount[el]++;
627
616
  }
628
617
  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 })));
618
+ // Strongest aspect by tightest orb (server-computed aspects)
619
+ const aspects = mapServerAspects(data.aspects);
633
620
  const strongest = aspects.sort((a, b) => a.orb - b.orb)[0];
634
621
  const strongestNote = strongest
635
622
  ? `Closest aspect: ${capitalize(strongest.planet1)} ${strongest.type} ${capitalize(strongest.planet2)} (${strongest.orb.toFixed(1)}° orb)`
@@ -3,7 +3,7 @@ import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
4
  registerTool({
5
5
  name: "location_search",
6
- description: "App-only tool for getting location autocomplete suggestions. Returns display name, region (state/province), latitude, longitude, and IANA timezone. Optional bias params (country/region/near) improve ranking; a trailing \"City, ST\" qualifier in the query is also honored.",
6
+ description: "App-only tool for getting location autocomplete suggestions. CREDIT COST: 1 credit per call. Returns display name, region (state/province), latitude, longitude, and IANA timezone. Optional bias params (country/region/near) improve ranking; a trailing \"City, ST\" qualifier in the query is also honored.",
7
7
  inputSchema: {
8
8
  type: "object",
9
9
  properties: {
@@ -63,7 +63,7 @@ registerTool({
63
63
  });
64
64
  registerTool({
65
65
  name: "timezone_resolve",
66
- description: "App-only tool for resolving IANA timezone by latitude and longitude.",
66
+ description: "App-only tool for resolving IANA timezone by latitude and longitude. CREDIT COST: 1 credit per call.",
67
67
  inputSchema: {
68
68
  type: "object",
69
69
  properties: {
@@ -98,6 +98,7 @@ registerTool({
98
98
  " • Void-of-Course status with timing details\n" +
99
99
  " • Lunar age (days in the synodic cycle)\n" +
100
100
  " • Upcoming New Moon and Full Moon dates\n\n" +
101
+ "CREDIT COST: 3 credits per call (phase + void-of-course + aspects, 1 each).\n\n" +
101
102
  "Use this for a rich, interactive lunar phase experience in MCP Apps-capable hosts (Claude Desktop). " +
102
103
  "Falls back to a text summary in other hosts.",
103
104
  inputSchema: {