@openephemeris/mcp-server 3.6.1 → 3.9.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 CHANGED
@@ -7,6 +7,41 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [3.9.0] — 2026-04-22
11
+
12
+ ### Added
13
+ - **Streamable HTTP transport** (`POST/GET/DELETE /mcp`) — implements the MCP 2025-11-25
14
+ specification. New integrations (Smithery, cursor, Claude Web) should prefer
15
+ `https://mcp.openephemeris.com/mcp` over the legacy SSE endpoint. The SSE endpoint
16
+ (`/sse`) is retained for backward compatibility.
17
+ - **`/.well-known/mcp/server-card.json`** — static server card served at the standard
18
+ well-known path. Allows Smithery and other registries to scan tool metadata without
19
+ requiring an authenticated session, enabling automatic listing and discovery.
20
+ - **`extractApiKey()` helper** — shared auth extraction (X-API-Key / Authorization: Bearer
21
+ / X-OpenEphemeris-API-Key / ?apiKey) used by both transports, eliminating duplication.
22
+
23
+ ### Changed
24
+ - `smithery.yaml` upgraded to modern `configSchema` + `commandFunction` format. Smithery
25
+ now renders a typed API key input field in its UI instead of a raw env map.
26
+ - `/health` now reports both `sse_sessions` and `http_sessions` counts, and lists
27
+ `transports: ["sse", "streamable-http"]` for client introspection.
28
+
29
+ ---
30
+
31
+ ## [3.8.0] — 2026-04-21
32
+
33
+ ### Added
34
+ - `human_design_bodygraph` — new dedicated tool for generating Human Design Bodygraph images
35
+ (PNG or SVG). Renders all 9 centers, 36 channels, and 64 gates with Personality/Design
36
+ color-coding. Supports `light`, `dark`, and `mono` styles. Developer tier, 2 credits.
37
+
38
+ ### Fixed
39
+ - **HD Bodygraph: missing 10-57 channel** — The "Channel of Perfected Form" (Gate 10 ↔ Gate 57)
40
+ was silently dropped from the visualization geometry while the math engine still reported it as
41
+ active. Charts with both gates activated now correctly render the channel.
42
+
43
+ ---
44
+
10
45
  ## [3.7.0] — 2026-04-19
11
46
 
12
47
  ### Changed
@@ -82,7 +117,7 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
82
117
 
83
118
 
84
119
  ### Fixed
85
- - Chiron position fallback for edge-date calculations now uses full Swiss Ephemeris path instead of simplified approximation
120
+ - Chiron position fallback for edge-date calculations now uses full high-precision ephemeris path instead of simplified approximation
86
121
  - SSE server chart wheel responses now correctly deliver native MCP image blocks instead of raw base64 JSON
87
122
 
88
123
  ### Changed
package/README.md CHANGED
@@ -224,10 +224,10 @@ OpenEphemeris API
224
224
 
225
225
  Generated by `npm run sync:readme` from `config/dev-allowlist.json` and the live tool registry.
226
226
 
227
- - Allowlisted operations: **22**
228
- - Methods: `GET=4`, `POST=18`, `PUT=0`, `PATCH=0`, `DELETE=0`
229
- - Registered tools (`OPENEPHEMERIS_PROFILE=dev`): **49**
230
- - Typed tools: `auth_login`, `auth_logout`, `auth_status`, `chinese_bazi`, `dev_call`, `dev_list_allowed`, `electional_aspect_search`, `electional_moment_analysis`, `electional_station_tracker`, `ephemeris_angles_points`, `ephemeris_aspect_check`, `ephemeris_bi_wheel`, `ephemeris_chart_wheel`, `ephemeris_composite`, `ephemeris_composite_midpoint`, `ephemeris_dignities`, `ephemeris_electional`, `ephemeris_fixed_stars`, `ephemeris_hermetic_lots`, `ephemeris_house_cusps`, `ephemeris_lunar_return`, `ephemeris_midpoints`, `ephemeris_moon_phase`, `ephemeris_natal_batch`, `ephemeris_natal_chart`, `ephemeris_natal_transits`, `ephemeris_next_eclipse`, `ephemeris_next_lunar_phase`, `ephemeris_overlay`, `ephemeris_planet_position`, `ephemeris_planetary_return`, `ephemeris_progressed_chart`, `ephemeris_relocation`, `ephemeris_retrograde_status`, `ephemeris_solar_return`, `ephemeris_synastry`, `ephemeris_transits`, `hd_opposition`, `hd_planetary_return`, `human_design_chart`, `human_design_composite`, `human_design_penta`, `vedic_chart`, `venus_eight_year_star`, `venus_elongations`, `venus_phase`, `venus_star_points`, `venus_star_points_conjunctions`, `venus_stations`
227
+ - Allowlisted operations: **23**
228
+ - Methods: `GET=4`, `POST=19`, `PUT=0`, `PATCH=0`, `DELETE=0`
229
+ - Registered tools (`OPENEPHEMERIS_PROFILE=dev`): **52**
230
+ - Typed tools: `acg_hits`, `acg_power_lines`, `auth_login`, `auth_logout`, `auth_status`, `chinese_bazi`, `dev_call`, `dev_list_allowed`, `electional_aspect_search`, `electional_moment_analysis`, `electional_station_tracker`, `ephemeris_angles_points`, `ephemeris_aspect_check`, `ephemeris_bi_wheel`, `ephemeris_chart_wheel`, `ephemeris_composite`, `ephemeris_composite_midpoint`, `ephemeris_dignities`, `ephemeris_electional`, `ephemeris_fixed_stars`, `ephemeris_hermetic_lots`, `ephemeris_house_cusps`, `ephemeris_lunar_return`, `ephemeris_midpoints`, `ephemeris_moon_phase`, `ephemeris_natal_batch`, `ephemeris_natal_chart`, `ephemeris_natal_transits`, `ephemeris_next_eclipse`, `ephemeris_next_lunar_phase`, `ephemeris_overlay`, `ephemeris_planet_position`, `ephemeris_planetary_return`, `ephemeris_progressed_chart`, `ephemeris_relocation`, `ephemeris_retrograde_status`, `ephemeris_solar_return`, `ephemeris_synastry`, `ephemeris_transits`, `hd_opposition`, `hd_planetary_return`, `human_design_bodygraph`, `human_design_chart`, `human_design_composite`, `human_design_penta`, `vedic_chart`, `venus_eight_year_star`, `venus_elongations`, `venus_phase`, `venus_star_points`, `venus_star_points_conjunctions`, `venus_stations`
231
231
  - Generic tools:
232
232
 
233
233
  ### Allowlist Families
@@ -239,12 +239,12 @@ Generated by `npm run sync:readme` from `config/dev-allowlist.json` and the live
239
239
  | `electional` | 4 | `GET /electional/aspect-search`, `GET /electional/find-window` |
240
240
  | `ephemeris` | 1 | `POST /ephemeris/relocation` |
241
241
  | `predictive` | 6 | `POST /predictive/returns`, `POST /predictive/returns/lunar` |
242
- | `visualization` | 2 | `POST /visualization/bi-wheel`, `POST /visualization/chart-wheel` |
242
+ | `visualization` | 3 | `POST /visualization/bi-wheel`, `POST /visualization/bodygraph` |
243
243
  <!-- GENERATED:RUNTIME_SNAPSHOT:END -->
244
244
 
245
245
  ## Why OpenEphemeris for AI Agents?
246
246
 
247
- Most LLMs (like Claude and ChatGPT) struggle heavily with astronomical calculations (trigonometry, Julian date conversions, and Swiss Ephemeris lookups). OpenEphemeris serves as a **secure, remote math engine**.
247
+ Most LLMs (like Claude and ChatGPT) struggle heavily with astronomical calculations (trigonometry, Julian date conversions, and planetary lookups). OpenEphemeris serves as a **secure, remote math engine**.
248
248
 
249
249
  By pairing LLMs with the OpenEphemeris MCP server, your agents can instantly access:
250
250
  - **Zero-hallucination coordinates**: Direct, sub-arcsecond NASA JPL DE440/DE441 calculations.
@@ -99,13 +99,17 @@
99
99
  "method": "POST",
100
100
  "path": "/visualization/bi-wheel"
101
101
  },
102
+ {
103
+ "method": "POST",
104
+ "path": "/visualization/bodygraph"
105
+ },
102
106
  {
103
107
  "method": "POST",
104
108
  "path": "/visualization/chart-wheel"
105
109
  }
106
110
  ],
107
- "last_generated_at": "2026-04-16T23:21:31.665Z",
108
- "openapi_sha256": "2349bd8e3e36dad609b75cc61acb1722206990581085d986b0cdad79317296aa",
111
+ "last_generated_at": "2026-04-22T15:19:21.508Z",
112
+ "openapi_sha256": "0c29ad2e37fd08d48800ec54059e262fbbdb681b844e00fed9274611bae678ac",
109
113
  "candidates_get": [
110
114
  {
111
115
  "method": "GET",
@@ -477,7 +481,14 @@
477
481
  ],
478
482
  "operationId": "features_acg_features_post"
479
483
  },
480
-
484
+ {
485
+ "method": "POST",
486
+ "path": "/acg/heatmap/composite",
487
+ "tags": [
488
+ "Astrocartography"
489
+ ],
490
+ "operationId": "AcgHeatmapCompositeAcgHeatmapCompositePost"
491
+ },
481
492
  {
482
493
  "method": "POST",
483
494
  "path": "/acg/hermetic-lines",
@@ -966,6 +977,14 @@
966
977
  ],
967
978
  "operationId": "generate_bi_wheel_visualization_bi_wheel_post"
968
979
  },
980
+ {
981
+ "method": "POST",
982
+ "path": "/visualization/bodygraph",
983
+ "tags": [
984
+ "visualization"
985
+ ],
986
+ "operationId": "GenerateBodygraphVisualization_visualization_bodygraph_post"
987
+ },
969
988
  {
970
989
  "method": "POST",
971
990
  "path": "/visualization/chart-wheel",
@@ -996,6 +1015,7 @@
996
1015
  "Non-GET allowlisted: POST /predictive/time-lords/profections",
997
1016
  "Non-GET allowlisted: POST /predictive/time-lords/zodiacal-releasing",
998
1017
  "Non-GET allowlisted: POST /visualization/bi-wheel",
1018
+ "Non-GET allowlisted: POST /visualization/bodygraph",
999
1019
  "Non-GET allowlisted: POST /visualization/chart-wheel"
1000
1020
  ]
1001
1021
  }
@@ -8,6 +8,7 @@ const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
8
8
  const RETRYABLE_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN"]);
9
9
  const BINARY_ENDPOINT_PREFIXES = [
10
10
  "/visualization/bi-wheel",
11
+ "/visualization/bodygraph",
11
12
  "/visualization/chart-wheel",
12
13
  "/comparative/visualization/bi-wheel",
13
14
  "/comparative/visualization/chart-wheel",
@@ -259,10 +260,13 @@ export class BackendClient {
259
260
  `All chart positions, aspects, and timing data MUST come from the API.`, 402, "quota_exceeded", false, UPGRADE_URL);
260
261
  }
261
262
  if (status === 403) {
262
- return new BackendError(`⚠️ ENDPOINT ACCESS RESTRICTED DO NOT FABRICATE DATA.\n\n` +
263
+ return new BackendError(`This feature is part of a higher-tier plan.\n\n` +
263
264
  `${msg}\n\n` +
264
- `Tell the user: this feature requires a higher-tier plan. ` +
265
- `They can upgrade at ${UPGRADE_URL} or manage their account at ${DASHBOARD_ACCOUNT_URL}.\n\n` +
265
+ `Let the user know warmly: this particular capability is available on a ` +
266
+ `higher plan tier. Share the upgrade link (${UPGRADE_URL}) as a helpful suggestion, ` +
267
+ `not as a gate. Mention what they CAN do on their current plan, and frame the upgrade ` +
268
+ `as unlocking deeper possibilities — charts, visualizations, and advanced HD analysis ` +
269
+ `are all available with a plan upgrade.\n\n` +
266
270
  `DO NOT attempt to generate astrological data from your own knowledge. ` +
267
271
  `All chart positions, aspects, and timing data MUST come from the API.`, 403, "tier_required", false, UPGRADE_URL);
268
272
  }
@@ -15,11 +15,13 @@
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
+ import { randomUUID } from "node:crypto";
18
19
  import express from "express";
19
20
  import axios from "axios";
20
21
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
21
22
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
22
- import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
23
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
24
+ import { CallToolRequestSchema, ListToolsRequestSchema, isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
23
25
  import { initTools, toolRegistry, formatToolResponse } from "./tools/index.js";
24
26
  import { BackendClient, runWithClient } from "./backend/client.js";
25
27
  // ---------------------------------------------------------------------------
@@ -76,6 +78,20 @@ async function validateApiKey(apiKey) {
76
78
  return true;
77
79
  }
78
80
  }
81
+ /**
82
+ * Extract an API key from a request using priority order:
83
+ * 1. X-API-Key header
84
+ * 2. X-OpenEphemeris-API-Key header
85
+ * 3. Authorization: Bearer <token>
86
+ * 4. ?apiKey query param
87
+ */
88
+ function extractApiKey(req) {
89
+ return (req.headers["x-api-key"] ||
90
+ req.headers["x-openephemeris-api-key"] ||
91
+ req.headers["authorization"]?.replace(/^Bearer\s+/i, "") ||
92
+ req.query.apiKey ||
93
+ undefined);
94
+ }
79
95
  // ---------------------------------------------------------------------------
80
96
  // MCP Server factory — one per SSE session
81
97
  // ---------------------------------------------------------------------------
@@ -132,9 +148,7 @@ function createMcpServer() {
132
148
  });
133
149
  return server;
134
150
  }
135
- // ---------------------------------------------------------------------------
136
- // Express + SSE wiring
137
- // ---------------------------------------------------------------------------
151
+ const httpSessions = new Map();
138
152
  async function main() {
139
153
  await initTools();
140
154
  const app = express();
@@ -158,7 +172,143 @@ async function main() {
158
172
  const sessionClients = new Map();
159
173
  // Health check
160
174
  app.get("/health", (_req, res) => {
161
- res.json({ ok: true, version, transport: "sse", sessions: transports.size });
175
+ res.json({
176
+ ok: true,
177
+ version,
178
+ transports: ["sse", "streamable-http"],
179
+ sse_sessions: transports.size,
180
+ http_sessions: httpSessions.size,
181
+ });
182
+ });
183
+ // ---------------------------------------------------------------------------
184
+ // Static server card — lets Smithery (and other registries) scan tools
185
+ // without needing an authenticated session. Updated on every deploy.
186
+ // Spec: https://smithery.ai/docs/build/publish#server-scanning
187
+ // ---------------------------------------------------------------------------
188
+ app.get("/.well-known/mcp/server-card.json", (_req, res) => {
189
+ const tools = Object.values(toolRegistry).map((tool) => ({
190
+ name: tool.name,
191
+ description: tool.description,
192
+ inputSchema: tool.inputSchema,
193
+ }));
194
+ res.json({
195
+ serverInfo: {
196
+ name: "Open Ephemeris",
197
+ version,
198
+ description: "NASA JPL-backed astronomical computation engine for AI agents. 48+ typed tools covering natal charts, transit forecasting, Human Design, eclipses, astrocartography, BaZi, and more — powered by DE440/DE441 for sub-arcsecond accuracy.",
199
+ iconUrl: "https://mcp.openephemeris.com/icon.png",
200
+ homepage: "https://openephemeris.com",
201
+ },
202
+ authentication: {
203
+ required: true,
204
+ schemes: ["apiKey"],
205
+ instructions: "Pass your Open Ephemeris API key via the X-API-Key header. Get a free Explorer key at https://openephemeris.com/dashboard",
206
+ },
207
+ tools,
208
+ resources: [],
209
+ prompts: [],
210
+ });
211
+ });
212
+ // ---------------------------------------------------------------------------
213
+ // Streamable HTTP transport — MCP spec 2025-11-25
214
+ // Endpoint: POST/GET/DELETE https://mcp.openephemeris.com/mcp
215
+ // Clients send X-API-Key (or Authorization: Bearer) on the init request.
216
+ // Session ID is managed via mcp-session-id response header.
217
+ // ---------------------------------------------------------------------------
218
+ // Parse JSON bodies only for /mcp routes (keeps SSE routes safe)
219
+ const jsonParser = express.json({ limit: "4mb" });
220
+ // POST /mcp — initialize a new session OR resume an existing one
221
+ app.post("/mcp", jsonParser, async (req, res) => {
222
+ const sessionId = req.headers["mcp-session-id"];
223
+ // Resume existing session
224
+ if (sessionId) {
225
+ const session = httpSessions.get(sessionId);
226
+ if (!session) {
227
+ res.status(404).json({
228
+ error: "session_not_found",
229
+ message: "Unknown or expired MCP session. Re-initialize to start a new one.",
230
+ });
231
+ return;
232
+ }
233
+ await runWithClient(session.client, () => session.transport.handleRequest(req, res, req.body));
234
+ return;
235
+ }
236
+ // New session — must be an initialize request
237
+ if (!isInitializeRequest(req.body)) {
238
+ res.status(400).json({
239
+ error: "expected_initialize",
240
+ message: "First request to /mcp must be an MCP Initialize request.",
241
+ });
242
+ return;
243
+ }
244
+ // Auth — required on Initialize
245
+ const apiKey = extractApiKey(req);
246
+ if (!apiKey) {
247
+ res.status(401).json({
248
+ error: "api_key_required",
249
+ message: "An Open Ephemeris API key is required. Pass it via X-API-Key header or Authorization: Bearer. Get a free key at https://openephemeris.com/dashboard",
250
+ });
251
+ return;
252
+ }
253
+ const valid = await validateApiKey(apiKey);
254
+ if (!valid) {
255
+ res.status(403).json({
256
+ error: "invalid_api_key",
257
+ message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
258
+ });
259
+ return;
260
+ }
261
+ // Create per-session transport, server, and client
262
+ const transport = new StreamableHTTPServerTransport({
263
+ sessionIdGenerator: randomUUID,
264
+ onsessioninitialized: (id) => {
265
+ httpSessions.set(id, { server, transport, client });
266
+ console.error(`[HTTP] Session initialized: ${id}`);
267
+ },
268
+ });
269
+ const client = new BackendClient({ baseURL: BACKEND_URL, apiKey });
270
+ const server = createMcpServer();
271
+ transport.onclose = () => {
272
+ if (transport.sessionId) {
273
+ httpSessions.delete(transport.sessionId);
274
+ console.error(`[HTTP] Session closed: ${transport.sessionId}`);
275
+ }
276
+ };
277
+ await server.connect(transport);
278
+ await runWithClient(client, () => transport.handleRequest(req, res, req.body));
279
+ });
280
+ // GET /mcp — SSE stream leg of Streamable HTTP (server → client events)
281
+ app.get("/mcp", async (req, res) => {
282
+ const sessionId = req.headers["mcp-session-id"];
283
+ if (!sessionId) {
284
+ res.status(400).json({
285
+ error: "session_id_required",
286
+ message: "mcp-session-id header is required for GET /mcp.",
287
+ });
288
+ return;
289
+ }
290
+ const session = httpSessions.get(sessionId);
291
+ if (!session) {
292
+ res.status(404).json({
293
+ error: "session_not_found",
294
+ message: "Unknown or expired MCP session.",
295
+ });
296
+ return;
297
+ }
298
+ await session.transport.handleRequest(req, res);
299
+ });
300
+ // DELETE /mcp — client-initiated session teardown
301
+ app.delete("/mcp", (req, res) => {
302
+ const sessionId = req.headers["mcp-session-id"];
303
+ if (sessionId) {
304
+ const session = httpSessions.get(sessionId);
305
+ if (session) {
306
+ httpSessions.delete(sessionId);
307
+ session.server.close().catch(() => { });
308
+ console.error(`[HTTP] Session deleted: ${sessionId}`);
309
+ }
310
+ }
311
+ res.status(204).end();
162
312
  });
163
313
  // Serve the MCP icon (earth + star)
164
314
  // Compiled JS at dist/src/server-sse.js: ../../assets
@@ -188,13 +338,15 @@ async function main() {
188
338
  res.status(404).end();
189
339
  }
190
340
  });
341
+ // ---------------------------------------------------------------------------
342
+ // Legacy SSE transport — MCP spec pre-2025 (kept for backward compatibility)
343
+ // Endpoint: GET https://mcp.openephemeris.com/sse
344
+ // New integrations should prefer /mcp (Streamable HTTP above).
345
+ // ---------------------------------------------------------------------------
191
346
  // SSE endpoint — clients GET /sse to establish a stream
192
347
  app.get("/sse", async (req, res) => {
193
348
  // Extract API key from query param or header
194
- const apiKey = req.query.apiKey ||
195
- req.headers["x-api-key"] ||
196
- req.headers["x-openephemeris-api-key"] ||
197
- req.headers["authorization"]?.replace(/^Bearer\s+/i, "");
349
+ const apiKey = extractApiKey(req);
198
350
  if (!apiKey) {
199
351
  res.status(401).json({
200
352
  error: "api_key_required",
@@ -38,9 +38,11 @@ export async function initTools(profile) {
38
38
  await import("./specialized/ephemeris_core.js");
39
39
  await import("./specialized/comparative.js");
40
40
  await import("./specialized/hd_group.js");
41
+ await import("./specialized/hd_bodygraph.js");
41
42
  await import("./specialized/hd_cycles.js");
42
43
  await import("./specialized/ephemeris_extended.js");
43
44
  await import("./specialized/venus_star_points.js");
45
+ await import("./specialized/acg.js");
44
46
  }
45
47
  }
46
48
  /**
@@ -71,7 +71,27 @@ registerTool({
71
71
  body.query_lon = args.query_longitude;
72
72
  if (args.radius_deg != null)
73
73
  body.radius_deg = args.radius_deg;
74
- return await getActiveClient().post("/acg/power-lines", body);
74
+ const raw = await getActiveClient().post("/acg/power-lines", body);
75
+ // Strip coordinate geometry to prevent context-window blowouts.
76
+ // Full GeoJSON can be 200K+ tokens; condensed metadata is ~2K.
77
+ if (raw?.type === "FeatureCollection" && Array.isArray(raw.features)) {
78
+ const condensed = raw.features.map((f) => ({
79
+ planet: f.properties?.planet ?? f.properties?.body,
80
+ angle: f.properties?.angle ?? f.properties?.line_type,
81
+ ...(f.properties?.label ? { label: f.properties.label } : {}),
82
+ ...(f.properties?.zodiac_sign ? { zodiac_sign: f.properties.zodiac_sign } : {}),
83
+ ...(f.properties?.longitude_at_equator != null
84
+ ? { longitude_at_equator: f.properties.longitude_at_equator }
85
+ : {}),
86
+ }));
87
+ return {
88
+ type: "acg_power_lines_summary",
89
+ total_lines: condensed.length,
90
+ note: "Coordinate geometry stripped for LLM context efficiency. Use the full API or a map rendering client for geographic visualization.",
91
+ lines: condensed,
92
+ };
93
+ }
94
+ return raw;
75
95
  },
76
96
  });
77
97
  // POST /acg/hits — OE-018
@@ -173,18 +173,21 @@ registerTool({
173
173
  },
174
174
  handler: async (args) => {
175
175
  validateRequired(args, ["natal_datetime", "natal_latitude", "natal_longitude"]);
176
- const subjects = [
177
- buildSubject("Natal", args.natal_datetime, args.natal_latitude, args.natal_longitude, args.natal_timezone),
178
- ];
176
+ // Backend expects: { subject: {...}, transit_datetime?: {...} }
177
+ // NOT a subjects[] array — natal-transits is a single-subject endpoint
178
+ const body = {
179
+ subject: buildSubject("Natal", args.natal_datetime, args.natal_latitude, args.natal_longitude, args.natal_timezone),
180
+ };
179
181
  if (args.transit_datetime) {
180
- subjects.push(buildSubject("Transit", args.transit_datetime, args.natal_latitude, args.natal_longitude, args.transit_timezone));
182
+ // transit_datetime is a DateTimeInput on the backend
183
+ body.transit_datetime = { iso: args.transit_datetime };
181
184
  }
182
185
  const query = {};
183
186
  if (args.format)
184
187
  query.format = args.format;
185
188
  return await getActiveClient().request("POST", "/comparative/natal-transits", {
186
189
  params: query,
187
- data: { subjects }
190
+ data: body,
188
191
  });
189
192
  },
190
193
  });
@@ -72,11 +72,15 @@ registerTool({
72
72
  },
73
73
  });
74
74
  // POST /ephemeris/retrograde-status
75
+ // NOTE: the backend handles one planet per call (planet_id is a non-nullable int
76
+ // that defaults to 0/Sun when omitted). We fan out to all 10 planets here so
77
+ // the documented "all planets" behaviour works correctly.
75
78
  registerTool({
76
79
  name: "ephemeris_retrograde_status",
77
80
  description: "Get retrograde/direct status and speed for all planets at a given date/time. " +
78
- "Returns is_retrograde flag, longitude speed, and station proximity.\n\n" +
79
- "CREDIT COST: 1 credit per call.",
81
+ "Returns is_retrograde flag, longitude speed, and station proximity for every planet.\n\n" +
82
+ "CREDIT COST: 1 credit per call.\n\n" +
83
+ "Optionally pass planet_id (0-9) to query a single planet.",
80
84
  inputSchema: {
81
85
  type: "object",
82
86
  properties: {
@@ -88,12 +92,34 @@ registerTool({
88
92
  },
89
93
  handler: async (args) => {
90
94
  validateRequired(args, ["datetime"]);
91
- const body = {
95
+ const client = getActiveClient();
96
+ // If a specific planet is requested, delegate directly.
97
+ if (args.planet_id != null) {
98
+ return await client.post("/ephemeris/retrograde-status", {
99
+ date_time: { iso: args.datetime },
100
+ planet_id: args.planet_id,
101
+ });
102
+ }
103
+ // Fan-out: the backend only handles one planet per call; query all 10 in parallel.
104
+ const PLANET_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
105
+ const results = await Promise.all(PLANET_IDS.map((pid) => client.post("/ephemeris/retrograde-status", {
92
106
  date_time: { iso: args.datetime },
93
- };
94
- if (args.planet_id != null)
95
- body.planet_id = args.planet_id;
96
- return await getActiveClient().post("/ephemeris/retrograde-status", body);
107
+ planet_id: pid,
108
+ }).catch(() => null)));
109
+ // Merge into a keyed object: { planet_name: {...status} }
110
+ const merged = {};
111
+ for (const res of results) {
112
+ if (res && typeof res === "object" && res.planet_name) {
113
+ const r = res;
114
+ merged[r.planet_name] = {
115
+ planet_id: r.planet_id,
116
+ is_retrograde: r.is_retrograde,
117
+ longitude_speed: r.longitude_speed,
118
+ motion_type: r.motion_type,
119
+ };
120
+ }
121
+ }
122
+ return { planets: merged, datetime: args.datetime, success: true };
97
123
  },
98
124
  });
99
125
  // POST /ephemeris/midpoints
@@ -150,7 +176,38 @@ registerTool({
150
176
  body.star_names = args.star_names;
151
177
  if (args.orb != null)
152
178
  body.orb = args.orb;
153
- return await getActiveClient().post("/ephemeris/fixed-stars", body);
179
+ const raw = await getActiveClient().post("/ephemeris/fixed-stars", body);
180
+ // Bug 6 workaround: backend returns longitude as degrees-within-sign (0–30),
181
+ // not full ecliptic longitude (0–360). Compute full_longitude from sign name.
182
+ const SIGN_ORDER = {
183
+ Aries: 0, Taurus: 1, Gemini: 2, Cancer: 3, Leo: 4, Virgo: 5,
184
+ Libra: 6, Scorpio: 7, Sagittarius: 8, Capricorn: 9, Aquarius: 10, Pisces: 11,
185
+ };
186
+ function enrichStar(star) {
187
+ if (!star || typeof star !== "object")
188
+ return star;
189
+ const signLon = typeof star.longitude === "number" ? star.longitude : 0;
190
+ const signName = star.sign ?? star.sign_name ?? "";
191
+ const signIndex = SIGN_ORDER[signName] ?? -1;
192
+ const fullLon = signIndex >= 0 ? signIndex * 30 + signLon : signLon;
193
+ return {
194
+ ...star,
195
+ longitude: fullLon, // ecliptic longitude 0–360
196
+ sign_longitude: signLon, // original within-sign degrees preserved
197
+ sign: signName || undefined,
198
+ };
199
+ }
200
+ // Normalise the response — backend may return array or { stars: [...] }
201
+ if (Array.isArray(raw)) {
202
+ return raw.map(enrichStar);
203
+ }
204
+ if (raw && typeof raw === "object") {
205
+ const starsKey = ["stars", "data", "fixed_stars", "results"].find((k) => Array.isArray(raw[k]));
206
+ if (starsKey) {
207
+ return { ...raw, [starsKey]: raw[starsKey].map(enrichStar) };
208
+ }
209
+ }
210
+ return raw;
154
211
  },
155
212
  });
156
213
  // POST /ephemeris/hermetic-lots
@@ -223,9 +280,10 @@ registerTool({
223
280
  },
224
281
  handler: async (args) => {
225
282
  validateRequired(args, ["longitude_1", "longitude_2"]);
283
+ // Backend field names are longitude1 / longitude2 (no underscore)
226
284
  const body = {
227
- longitude_1: args.longitude_1,
228
- longitude_2: args.longitude_2,
285
+ longitude1: args.longitude_1,
286
+ longitude2: args.longitude_2,
229
287
  };
230
288
  if (args.max_orb != null)
231
289
  body.max_orb = args.max_orb;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ import { registerTool, validateRequired } from "../index.js";
2
+ import { getActiveClient } from "../../backend/client.js";
3
+ /** Append 'Z' if no timezone offset is present in the datetime string. */
4
+ function ensureTimezone(dt) {
5
+ if (/[Zz]$/.test(dt) || /[+-]\d{2}:\d{2}$/.test(dt))
6
+ return dt;
7
+ return dt + "Z";
8
+ }
9
+ registerTool({
10
+ name: "human_design_bodygraph",
11
+ description: "Generate a Human Design Bodygraph image (PNG or SVG) from a birth datetime (UTC). " +
12
+ "Renders the full bodygraph circuit board showing all 9 centers, 36 channels, and 64 gates " +
13
+ "with Personality (conscious) and Design (unconscious) activations color-coded. " +
14
+ "Defined centers are filled with their HD doctrine color; open/undefined centers remain muted. " +
15
+ "The tool returns a raw image (base64) which most Vision-enabled AI agents can display and visually read.\n\n" +
16
+ "CREDIT COST: 2 credits per call.\n\n" +
17
+ "EXAMPLE: Generate a bodygraph for someone born April 15, 1990 at 19:30 UTC:\n" +
18
+ " datetime='1990-04-15T19:30:00Z'",
19
+ inputSchema: {
20
+ type: "object",
21
+ properties: {
22
+ datetime: {
23
+ type: "string",
24
+ description: "ISO 8601 birth datetime in UTC, e.g. '1990-04-15T19:30:00Z'. " +
25
+ "Human Design chart calculation is time-sensitive — accuracy to the minute matters.",
26
+ },
27
+ style: {
28
+ type: "string",
29
+ enum: ["light", "dark", "mono"],
30
+ description: "Aesthetic style of the bodygraph: 'dark' (default, deep background), " +
31
+ "'light' (cream background), or 'mono' (black-and-white print-ready).",
32
+ },
33
+ format: {
34
+ type: "string",
35
+ enum: ["png", "svg"],
36
+ description: "Output format. Defaults to 'png'.",
37
+ },
38
+ },
39
+ required: ["datetime"],
40
+ additionalProperties: false,
41
+ },
42
+ handler: async (args) => {
43
+ validateRequired(args, ["datetime"]);
44
+ const body = {
45
+ birth_datetime_utc: ensureTimezone(args.datetime),
46
+ };
47
+ const format = args.format || "png";
48
+ const styleParam = args.style ? `&style=${args.style}` : "";
49
+ return await getActiveClient().post(`/visualization/bodygraph?format=${format}&size=800${styleParam}`, body);
50
+ },
51
+ });
@@ -36,8 +36,9 @@ registerTool({
36
36
  handler: async (args) => {
37
37
  validateCoordinates(args, "latitude", "longitude");
38
38
  const params = {};
39
- if (args.datetime)
40
- params.datetime = args.datetime;
39
+ // Backend requires datetime even though the schema marks it optional.
40
+ // Default to current UTC time when caller omits it.
41
+ params.datetime = args.datetime ?? new Date().toISOString();
41
42
  if (args.latitude != null)
42
43
  params.latitude = args.latitude;
43
44
  if (args.longitude != null)
@@ -109,10 +110,23 @@ registerTool({
109
110
  const result = await getActiveClient().request("GET", "/calendar/astrology/moon-phases", {
110
111
  params: { start_date: startDate, end_date: endDate },
111
112
  });
112
- // Normalise response shape — API may return array, or { phases: [...] }, or { data: [...] }
113
- const allPhases = Array.isArray(result)
114
- ? result
115
- : result?.phases ?? result?.data ?? result?.events ?? [];
113
+ // Normalise response shape — defensively handle every known API envelope shape.
114
+ // Crash "allPhases.filter is not a function" occurs when a property like
115
+ // result.data is truthy but is an object, not an array.
116
+ function extractArray(val) {
117
+ if (Array.isArray(val))
118
+ return val;
119
+ if (val && typeof val === "object") {
120
+ // one level deeper: { phases: [...] }, { data: [...] }, etc.
121
+ for (const key of ["phases", "data", "events", "moon_phases", "results", "items"]) {
122
+ const nested = val[key];
123
+ if (Array.isArray(nested))
124
+ return nested;
125
+ }
126
+ }
127
+ return null;
128
+ }
129
+ const allPhases = extractArray(result) ?? [];
116
130
  const matching = allPhases
117
131
  .filter((p) => {
118
132
  const name = (p.phase_name ?? p.name ?? p.phase ?? "").toLowerCase();
@@ -2,7 +2,7 @@ import { registerTool, validateRequired } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  const DATETIME_DESC = "ISO 8601 datetime string, e.g. '1990-04-15T14:30:00' (local time at birth location). " +
4
4
  "Include timezone offset if known, e.g. '1990-04-15T14:30:00-05:00'.";
5
- /** Map human-readable house system names to Swiss Ephemeris single-letter codes */
5
+ /** Map human-readable house system names to standard single-letter codes */
6
6
  const HOUSE_SYSTEM_MAP = {
7
7
  placidus: "P", whole_sign: "W", equal: "E", koch: "K",
8
8
  campanus: "C", regiomontanus: "R", porphyry: "O",
@@ -127,9 +127,9 @@ registerTool({
127
127
  "Uranus returns (~84 years), etc.\n\n" +
128
128
  "CREDIT COST: 5 credits per call.\n\n" +
129
129
  "EXAMPLE: Saturn return for birth 1990-04-15 near year 2019:\n" +
130
- " body='saturn', birth_datetime='1990-04-15T14:30:00', target_datetime='2019-01-01'\n" +
130
+ " body='saturn', birth_datetime='1990-04-15T14:30:00Z', target_datetime='2019-01-01T00:00:00Z'\n" +
131
131
  "EXAMPLE: Chiron return for birth 1975-03-10 near age 50:\n" +
132
- " body='chiron', birth_datetime='1975-03-10T08:00:00', target_datetime='2025-01-01'",
132
+ " body='chiron', birth_datetime='1975-03-10T08:00:00Z', target_datetime='2025-01-01T00:00:00Z'",
133
133
  inputSchema: {
134
134
  type: "object",
135
135
  properties: {
@@ -145,16 +145,33 @@ registerTool({
145
145
  type: "string",
146
146
  description: "Date/time near which to find the return (ISO 8601). MUST include offset or Z. Required.",
147
147
  },
148
+ birth_latitude: {
149
+ type: "number",
150
+ description: "Birth latitude in decimal degrees. Required to get a full return chart (house cusps, angles).",
151
+ },
152
+ birth_longitude: {
153
+ type: "number",
154
+ description: "Birth longitude in decimal degrees. Required to get a full return chart (house cusps, angles).",
155
+ },
148
156
  },
149
157
  required: ["body", "birth_datetime", "target_datetime"],
150
158
  additionalProperties: false,
151
159
  },
152
160
  handler: async (args) => {
153
161
  validateRequired(args, ["body", "birth_datetime", "target_datetime"]);
154
- return await getActiveClient().post("/predictive/returns", {
162
+ const requestBody = {
155
163
  body: args.body,
156
164
  birth_datetime: { iso: args.birth_datetime },
157
165
  target_datetime: { iso: args.target_datetime },
158
- });
166
+ };
167
+ // Forward birth coordinates so the backend can compute the full return chart.
168
+ // Without these, computeReturnChartData sees {lat:0, lon:0} and returns nil.
169
+ if (args.birth_latitude != null && args.birth_longitude != null) {
170
+ requestBody.location = {
171
+ latitude: { decimal: args.birth_latitude },
172
+ longitude: { decimal: args.birth_longitude },
173
+ };
174
+ }
175
+ return await getActiveClient().post("/predictive/returns", requestBody);
159
176
  },
160
177
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openephemeris/mcp-server",
3
- "version": "3.6.1",
3
+ "version": "3.9.0",
4
4
  "description": "Model Context Protocol server for the Open Ephemeris astronomical computation API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -52,7 +52,7 @@
52
52
  "llm",
53
53
  "claude",
54
54
  "ai",
55
- "swiss ephemeris"
55
+ "ephemeris"
56
56
  ],
57
57
  "author": "Open Ephemeris",
58
58
  "homepage": "https://openephemeris.com",