@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(
|
|
242
|
-
|
|
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(
|
|
246
|
-
|
|
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(
|
|
250
|
-
|
|
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);
|
package/dist/src/server-sse.js
CHANGED
|
@@ -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
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
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
|
-
"
|
|
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
|
|
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
|
|
115
|
+
const transitBody = {
|
|
71
116
|
start_date: startStr,
|
|
72
117
|
end_date: endStr,
|
|
73
|
-
|
|
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
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
}
|