@openephemeris/mcp-server 3.2.8 → 3.2.10

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.
@@ -38,6 +38,12 @@ export declare class BackendClient {
38
38
  private _pendingAuthFlow;
39
39
  private _authFlowResult;
40
40
  constructor(config: BackendConfig);
41
+ /**
42
+ * Override the API key at runtime. Used by the SSE server to inject the
43
+ * per-session user's key so that all tool calls are authenticated and
44
+ * metered against the correct user account.
45
+ */
46
+ setApiKey(key: string): void;
41
47
  /**
42
48
  * Start the device auth flow in the background if not already running.
43
49
  * Called automatically when no credentials are found in the interceptor.
@@ -105,6 +105,17 @@ export class BackendClient {
105
105
  return req;
106
106
  });
107
107
  }
108
+ /**
109
+ * Override the API key at runtime. Used by the SSE server to inject the
110
+ * per-session user's key so that all tool calls are authenticated and
111
+ * metered against the correct user account.
112
+ */
113
+ setApiKey(key) {
114
+ this.apiKey = key;
115
+ // Clear any cached JWT/service key so the interceptor uses the new API key
116
+ this.serviceKey = undefined;
117
+ this.jwt = undefined;
118
+ }
108
119
  /**
109
120
  * Start the device auth flow in the background if not already running.
110
121
  * Called automatically when no credentials are found in the interceptor.
@@ -238,16 +249,29 @@ export class BackendClient {
238
249
  `or set OPENEPHEMERIS_API_KEY in your MCP server config.`, 401, "auth_required", false, LOGIN_SIGNUP_URL);
239
250
  }
240
251
  if (status === 402) {
241
- return new BackendError(`Usage quota exceeded: ${msg}. Review usage and overage settings in ${DASHBOARD_ACCOUNT_URL}, ` +
242
- `or upgrade at ${UPGRADE_URL}.`, 402, "quota_exceeded", false, UPGRADE_URL);
252
+ return new BackendError(`⚠️ USAGE QUOTA EXCEEDED DO NOT FABRICATE DATA.\n\n` +
253
+ `${msg}\n\n` +
254
+ `Tell the user: their API credit quota has been reached for this billing period. ` +
255
+ `They can review usage and enable overages at ${DASHBOARD_ACCOUNT_URL}, ` +
256
+ `or upgrade their plan at ${UPGRADE_URL}.\n\n` +
257
+ `DO NOT attempt to generate astrological data from your own knowledge. ` +
258
+ `All chart positions, aspects, and timing data MUST come from the API.`, 402, "quota_exceeded", false, UPGRADE_URL);
243
259
  }
244
260
  if (status === 403) {
245
- return new BackendError(`Access blocked by plan or policy: ${msg}. If this endpoint is tier-gated, upgrade at ${UPGRADE_URL}. ` +
246
- `Manage keys and billing at ${DASHBOARD_ACCOUNT_URL}.`, 403, "tier_required", false, UPGRADE_URL);
261
+ return new BackendError(`⚠️ ENDPOINT ACCESS RESTRICTED DO NOT FABRICATE DATA.\n\n` +
262
+ `${msg}\n\n` +
263
+ `Tell the user: this feature requires a higher-tier plan. ` +
264
+ `They can upgrade at ${UPGRADE_URL} or manage their account at ${DASHBOARD_ACCOUNT_URL}.\n\n` +
265
+ `DO NOT attempt to generate astrological data from your own knowledge. ` +
266
+ `All chart positions, aspects, and timing data MUST come from the API.`, 403, "tier_required", false, UPGRADE_URL);
247
267
  }
248
268
  if (status === 429) {
249
- return new BackendError(`Rate limit exceeded. Your plan credits may be exhausted or requests are too frequent. ` +
250
- `Check usage at ${DASHBOARD_ACCOUNT_URL}, then retry in a moment. (${msg})`, 429, "rate_limited", true);
269
+ return new BackendError(`⚠️ RATE LIMIT EXCEEDED DO NOT FABRICATE DATA.\n\n` +
270
+ `${msg}\n\n` +
271
+ `Tell the user: requests are being sent too frequently or their plan's rate limit has been hit. ` +
272
+ `They should wait a moment and retry, or check usage at ${DASHBOARD_ACCOUNT_URL}.\n\n` +
273
+ `DO NOT attempt to generate astrological data from your own knowledge while waiting. ` +
274
+ `Simply inform the user and wait for them to retry.`, 429, "rate_limited", true);
251
275
  }
252
276
  if (status === 404)
253
277
  return new BackendError(`Resource not found: ${msg}`, 404, "not_found", false);
@@ -8,9 +8,9 @@
8
8
  * Architecture:
9
9
  * - The SSE server validates the user's API key at connection time by
10
10
  * making a lightweight call to the Go backend.
11
- * - All subsequent tool calls use the server's OPENEPHEMERIS_SERVICE_KEY
12
- * (set as a Fly secret) via the singleton BackendClient's X-Service-Key header.
13
- * - This avoids the need to refactor all tool handlers for per-session auth.
11
+ * - The validated key is injected into the singleton BackendClient so
12
+ * all subsequent tool calls authenticate as the connecting user.
13
+ * - Usage credits are metered against the user's account and tier.
14
14
  */
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
@@ -21,6 +21,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
21
21
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
22
22
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
23
23
  import { initTools, toolRegistry } from "./tools/index.js";
24
+ import { backendClient } from "./backend/client.js";
24
25
  // ---------------------------------------------------------------------------
25
26
  // Helpers
26
27
  // ---------------------------------------------------------------------------
@@ -204,6 +205,9 @@ async function main() {
204
205
  });
205
206
  return;
206
207
  }
208
+ // Inject the validated API key into the singleton BackendClient so all
209
+ // tool calls authenticate as this user and usage is metered correctly.
210
+ backendClient.setApiKey(apiKey);
207
211
  const transport = new SSEServerTransport("/message", res);
208
212
  const sessionId = transport.sessionId;
209
213
  transports.set(sessionId, transport);
@@ -6,7 +6,9 @@ registerTool({
6
6
  "Returns a list of exact transit moments — when transiting planets form specified aspects " +
7
7
  "to natal planet positions. Ideal for generating horoscope timelines, event forecasting, or " +
8
8
  "finding optimal timing windows.\n\n" +
9
- "CREDIT COST: 5 credits per call.\n\n" +
9
+ "HOW IT WORKS: This tool first calculates the natal chart to extract your actual planetary " +
10
+ "ecliptic longitudes, then searches for transiting planets crossing those exact degrees.\n\n" +
11
+ "CREDIT COST: 6 credits per call (1 for natal + 5 for transit search).\n\n" +
10
12
  "EXAMPLE: Find all Saturn transits to the natal Sun/Moon for the next 6 months:\n" +
11
13
  " natal_datetime='1990-04-15T14:30:00', natal_latitude=41.8781, natal_longitude=-87.6298,\n" +
12
14
  " start_date='2026-01-01', end_date='2026-06-30', transiting_planets=['saturn']",
@@ -42,16 +44,7 @@ registerTool({
42
44
  natal_points: {
43
45
  type: "array",
44
46
  items: { type: "string" },
45
- description: "Natal point IDs to receive transits. E.g. ['sun', 'moon', 'asc', 'mc']. Omit for all core points.",
46
- },
47
- aspects: {
48
- type: "array",
49
- items: { type: "string", enum: ["conjunction", "opposition", "trine", "square", "sextile"] },
50
- description: "Aspect types to include in the search. Defaults to all major aspects.",
51
- },
52
- orb: {
53
- type: "number",
54
- description: "Maximum orb in degrees (default: 1.0).",
47
+ description: "Natal point IDs whose exact longitudes should be targeted. E.g. ['sun', 'moon', 'asc', 'mc']. Omit for all core points.",
55
48
  },
56
49
  },
57
50
  required: ["natal_datetime", "natal_latitude", "natal_longitude", "start_date", "end_date"],
@@ -59,6 +52,58 @@ registerTool({
59
52
  },
60
53
  handler: async (args) => {
61
54
  validateRequired(args, ["natal_datetime", "natal_latitude", "natal_longitude", "start_date", "end_date"]);
55
+ // ── Step 1: Compute natal chart to extract real planetary longitudes ──
56
+ const natalBody = {
57
+ subject: {
58
+ name: "Transit Natal Subject",
59
+ birth_datetime: { iso: args.natal_datetime },
60
+ birth_location: {
61
+ latitude: { decimal: args.natal_latitude },
62
+ longitude: { decimal: args.natal_longitude }
63
+ },
64
+ },
65
+ };
66
+ const natalResult = await backendClient.post("/ephemeris/natal", natalBody);
67
+ // Extract ecliptic longitudes from natal chart planets
68
+ const targetDegrees = [];
69
+ const natalPositionMap = {};
70
+ // The natal response typically has a `planets` array or object
71
+ const planets = natalResult?.planets || natalResult?.data?.planets || [];
72
+ const planetArray = Array.isArray(planets) ? planets : Object.values(planets);
73
+ // Filter to requested natal points if specified
74
+ const wantedPoints = args.natal_points
75
+ ? new Set(args.natal_points.map((p) => p.toLowerCase()))
76
+ : null;
77
+ for (const planet of planetArray) {
78
+ const name = (planet.name || planet.id || "").toLowerCase();
79
+ const lon = planet.longitude ?? planet.ecliptic_longitude ?? planet.lon;
80
+ if (typeof lon !== "number" || !isFinite(lon))
81
+ continue;
82
+ if (wantedPoints && !wantedPoints.has(name))
83
+ continue;
84
+ targetDegrees.push(Math.round(lon * 100) / 100); // 2 decimal places
85
+ natalPositionMap[name] = lon;
86
+ }
87
+ // Also extract angles (ASC, MC, DSC, IC) if present
88
+ const angles = natalResult?.angles || natalResult?.data?.angles;
89
+ if (angles) {
90
+ const angleEntries = Array.isArray(angles) ? angles : Object.entries(angles).map(([k, v]) => ({ name: k, ...(typeof v === 'object' ? v : { longitude: v }) }));
91
+ for (const angle of angleEntries) {
92
+ const name = (angle.name || angle.id || "").toLowerCase();
93
+ const lon = angle.longitude ?? angle.ecliptic_longitude ?? angle.lon;
94
+ if (typeof lon !== "number" || !isFinite(lon))
95
+ continue;
96
+ if (wantedPoints && !wantedPoints.has(name))
97
+ continue;
98
+ targetDegrees.push(Math.round(lon * 100) / 100);
99
+ natalPositionMap[name] = lon;
100
+ }
101
+ }
102
+ if (targetDegrees.length === 0) {
103
+ throw new Error("Could not extract natal planet longitudes from the natal chart calculation. " +
104
+ "Please verify your natal_datetime, natal_latitude, and natal_longitude are correct.");
105
+ }
106
+ // ── Step 2: Search transits against those real natal longitudes ──
62
107
  let startStr = args.start_date;
63
108
  if (/^\d{4}-\d{2}-\d{2}$/.test(startStr)) {
64
109
  startStr += "T00:00:00Z";
@@ -67,26 +112,19 @@ registerTool({
67
112
  if (/^\d{4}-\d{2}-\d{2}$/.test(endStr)) {
68
113
  endStr += "T23:59:59Z";
69
114
  }
70
- const body = {
115
+ const transitBody = {
71
116
  start_date: startStr,
72
117
  end_date: endStr,
73
- natal_chart: {
74
- datetime: args.natal_datetime,
75
- latitude: args.natal_latitude,
76
- longitude: args.natal_longitude,
77
- },
118
+ target_degrees: targetDegrees,
78
119
  };
79
120
  if (args.transiting_planets)
80
- body.planet_names = args.transiting_planets;
81
- const searchCriteria = {};
82
- if (args.natal_points)
83
- searchCriteria.natal_points = args.natal_points;
84
- if (args.aspects)
85
- searchCriteria.aspects = args.aspects;
86
- if (args.orb != null)
87
- searchCriteria.orb = args.orb;
88
- if (Object.keys(searchCriteria).length > 0)
89
- body.search_criteria = searchCriteria;
90
- return await backendClient.request("POST", "/predictive/transits/search", { data: body });
121
+ transitBody.planet_names = args.transiting_planets;
122
+ const transitResult = await backendClient.request("POST", "/predictive/transits/search", { data: transitBody });
123
+ // Return combined context so the LLM knows what natal positions were targeted
124
+ return {
125
+ natal_positions: natalPositionMap,
126
+ target_degrees_used: targetDegrees,
127
+ transit_results: transitResult,
128
+ };
91
129
  },
92
130
  });
@@ -111,7 +111,7 @@ describe("BackendClient", () => {
111
111
  baseURL: srv.baseURL,
112
112
  });
113
113
  const message = await getErrorMessage(() => client.get("/quota"));
114
- expect(message).toContain("Usage quota exceeded:");
114
+ expect(message).toContain("USAGE QUOTA EXCEEDED");
115
115
  expect(message).toContain("https://openephemeris.com/pay");
116
116
  expect(message).toContain("https://openephemeris.com/dashboard?tab=account");
117
117
  }
@@ -132,7 +132,7 @@ describe("BackendClient", () => {
132
132
  baseURL: srv.baseURL,
133
133
  });
134
134
  const message = await getErrorMessage(() => client.get("/tier-gated"));
135
- expect(message).toContain("Access blocked by plan or policy:");
135
+ expect(message).toContain("ENDPOINT ACCESS RESTRICTED");
136
136
  expect(message).toContain("https://openephemeris.com/pay");
137
137
  expect(message).toContain("https://openephemeris.com/dashboard?tab=account");
138
138
  }
@@ -153,7 +153,7 @@ describe("BackendClient", () => {
153
153
  baseURL: srv.baseURL,
154
154
  });
155
155
  const message = await getErrorMessage(() => client.get("/rate-limit"));
156
- expect(message).toContain("Rate limit exceeded.");
156
+ expect(message).toContain("RATE LIMIT EXCEEDED");
157
157
  expect(message).toContain("https://openephemeris.com/dashboard?tab=account");
158
158
  }
159
159
  finally {
@@ -273,7 +273,7 @@ describe("BackendClient", () => {
273
273
  params: { format: "png" },
274
274
  data: {},
275
275
  }));
276
- expect(message).toContain("Access blocked by plan or policy:");
276
+ expect(message).toContain("ENDPOINT ACCESS RESTRICTED");
277
277
  expect(message).toContain("Startup (Pro) tier");
278
278
  expect(message).toContain("https://openephemeris.com/pay");
279
279
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openephemeris/mcp-server",
3
- "version": "3.2.8",
3
+ "version": "3.2.10",
4
4
  "description": "Model Context Protocol server for the Open Ephemeris astronomical computation API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",