@openephemeris/mcp-server 3.2.12 → 3.2.13

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.
Files changed (80) hide show
  1. package/dist/backend/client.d.ts +23 -1
  2. package/dist/backend/client.js +144 -25
  3. package/dist/index.js +94 -2
  4. package/dist/tools/dev.js +29 -21
  5. package/dist/tools/index.d.ts +8 -0
  6. package/dist/tools/index.js +30 -1
  7. package/dist/tools/specialized/eclipse.js +20 -26
  8. package/dist/tools/specialized/electional.js +4 -3
  9. package/dist/tools/specialized/human_design.js +27 -11
  10. package/dist/tools/specialized/moon.js +3 -2
  11. package/dist/tools/specialized/natal.js +36 -15
  12. package/dist/tools/specialized/relocation.js +14 -10
  13. package/dist/tools/specialized/returns.js +126 -0
  14. package/dist/tools/specialized/synastry.js +22 -12
  15. package/dist/tools/specialized/transits.js +78 -28
  16. package/package.json +3 -2
  17. package/dist/scripts/dev-allowlist.js +0 -287
  18. package/dist/scripts/pack-audit.d.ts +0 -1
  19. package/dist/scripts/pack-audit.js +0 -45
  20. package/dist/scripts/schema-packs.d.ts +0 -1
  21. package/dist/scripts/schema-packs.js +0 -150
  22. package/dist/scripts/smoke-dev-profile.d.ts +0 -1
  23. package/dist/scripts/smoke-dev-profile.js +0 -25
  24. package/dist/scripts/sync-readme.d.ts +0 -1
  25. package/dist/scripts/sync-readme.js +0 -141
  26. package/dist/scripts/test-all-tools.d.ts +0 -1
  27. package/dist/scripts/test-all-tools.js +0 -104
  28. package/dist/scripts/test-client.d.ts +0 -1
  29. package/dist/scripts/test-client.js +0 -69
  30. package/dist/scripts/test-sse-client.d.ts +0 -1
  31. package/dist/scripts/test-sse-client.js +0 -221
  32. package/dist/src/backend/client.d.ts +0 -67
  33. package/dist/src/backend/client.js +0 -363
  34. package/dist/src/index.d.ts +0 -2
  35. package/dist/src/index.js +0 -184
  36. package/dist/src/schema-packs/llm.d.ts +0 -105
  37. package/dist/src/schema-packs/llm.js +0 -429
  38. package/dist/src/tools/dev.d.ts +0 -1
  39. package/dist/src/tools/dev.js +0 -195
  40. package/dist/src/tools/index.d.ts +0 -33
  41. package/dist/src/tools/index.js +0 -60
  42. package/dist/src/tools/specialized/eclipse.d.ts +0 -1
  43. package/dist/src/tools/specialized/eclipse.js +0 -50
  44. package/dist/src/tools/specialized/electional.d.ts +0 -1
  45. package/dist/src/tools/specialized/electional.js +0 -80
  46. package/dist/src/tools/specialized/human_design.d.ts +0 -1
  47. package/dist/src/tools/specialized/human_design.js +0 -69
  48. package/dist/src/tools/specialized/moon.d.ts +0 -1
  49. package/dist/src/tools/specialized/moon.js +0 -51
  50. package/dist/src/tools/specialized/natal.d.ts +0 -1
  51. package/dist/src/tools/specialized/natal.js +0 -92
  52. package/dist/src/tools/specialized/relocation.d.ts +0 -1
  53. package/dist/src/tools/specialized/relocation.js +0 -76
  54. package/dist/src/tools/specialized/synastry.d.ts +0 -1
  55. package/dist/src/tools/specialized/synastry.js +0 -73
  56. package/dist/src/tools/specialized/transits.d.ts +0 -1
  57. package/dist/src/tools/specialized/transits.js +0 -130
  58. package/dist/test/allowlist-and-tools.test.d.ts +0 -1
  59. package/dist/test/allowlist-and-tools.test.js +0 -96
  60. package/dist/test/backend-client.test.d.ts +0 -1
  61. package/dist/test/backend-client.test.js +0 -284
  62. package/dist/test/credentials.test.d.ts +0 -1
  63. package/dist/test/credentials.test.js +0 -143
  64. /package/dist/{src/auth → auth}/credentials.d.ts +0 -0
  65. /package/dist/{src/auth → auth}/credentials.js +0 -0
  66. /package/dist/{src/auth → auth}/device-auth.d.ts +0 -0
  67. /package/dist/{src/auth → auth}/device-auth.js +0 -0
  68. /package/dist/{src/server-sse.d.ts → server-sse.d.ts} +0 -0
  69. /package/dist/{src/server-sse.js → server-sse.js} +0 -0
  70. /package/dist/{src/tools → tools}/auth.d.ts +0 -0
  71. /package/dist/{src/tools → tools}/auth.js +0 -0
  72. /package/dist/{src/tools → tools}/specialized/bazi.d.ts +0 -0
  73. /package/dist/{src/tools → tools}/specialized/bazi.js +0 -0
  74. /package/dist/{src/tools → tools}/specialized/bi_wheel.d.ts +0 -0
  75. /package/dist/{src/tools → tools}/specialized/bi_wheel.js +0 -0
  76. /package/dist/{src/tools → tools}/specialized/chart_wheel.d.ts +0 -0
  77. /package/dist/{src/tools → tools}/specialized/chart_wheel.js +0 -0
  78. /package/dist/{scripts/dev-allowlist.d.ts → tools/specialized/returns.d.ts} +0 -0
  79. /package/dist/{src/tools → tools}/specialized/vedic.d.ts +0 -0
  80. /package/dist/{src/tools → tools}/specialized/vedic.js +0 -0
@@ -21,20 +21,42 @@ export interface BinaryBackendResponse {
21
21
  encoding: "base64";
22
22
  data_base64: string;
23
23
  }
24
+ export declare class BackendError extends Error {
25
+ readonly status: number;
26
+ readonly code: string;
27
+ readonly retryable: boolean;
28
+ readonly upgradeUrl?: string | undefined;
29
+ constructor(message: string, status: number, code: string, retryable: boolean, upgradeUrl?: string | undefined);
30
+ }
24
31
  export declare class BackendClient {
25
32
  private client;
26
33
  private userId;
27
34
  private jwt?;
28
35
  private serviceKey?;
29
36
  private apiKey?;
37
+ private credentialManager;
38
+ private _pendingAuthFlow;
39
+ private _authFlowResult;
30
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;
47
+ /**
48
+ * Start the device auth flow in the background if not already running.
49
+ * Called automatically when no credentials are found in the interceptor.
50
+ * Idempotent — only starts once per client lifetime.
51
+ */
52
+ private autoStartDeviceAuth;
31
53
  private expectsBinaryResponse;
32
54
  private toBuffer;
33
55
  private decodePayload;
34
56
  private extractMessage;
35
57
  private normalizeContentType;
36
58
  private isBinaryContentType;
37
- /** Maps an AxiosError to a concise MCP-friendly Error. */
59
+ /** Maps an AxiosError to a structured BackendError. */
38
60
  private mapError;
39
61
  get<T>(path: string, params?: Record<string, any>, timeoutMs?: number): Promise<T>;
40
62
  post<T>(path: string, data?: any, timeoutMs?: number): Promise<T>;
@@ -1,6 +1,10 @@
1
1
  import axios, { AxiosError } from "axios";
2
+ import { CredentialManager } from "../auth/credentials.js";
3
+ import { DeviceAuthFlow } from "../auth/device-auth.js";
2
4
  const DEFAULT_TIMEOUT_MS = 45_000;
3
- const RATE_LIMIT_RETRY_DELAYS_MS = [1_000, 2_000]; // Two retries on 429
5
+ const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]; // Three retries with backoff
6
+ const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
7
+ const RETRYABLE_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN"]);
4
8
  const BINARY_ENDPOINT_PREFIXES = [
5
9
  "/visualization/bi-wheel",
6
10
  "/visualization/chart-wheel",
@@ -13,29 +17,51 @@ const UPGRADE_URL = "https://openephemeris.com/pay";
13
17
  function sleep(ms) {
14
18
  return new Promise((resolve) => setTimeout(resolve, ms));
15
19
  }
20
+ export class BackendError extends Error {
21
+ status;
22
+ code;
23
+ retryable;
24
+ upgradeUrl;
25
+ constructor(message, status, code, retryable, upgradeUrl) {
26
+ super(message);
27
+ this.status = status;
28
+ this.code = code;
29
+ this.retryable = retryable;
30
+ this.upgradeUrl = upgradeUrl;
31
+ this.name = "BackendError";
32
+ }
33
+ }
16
34
  export class BackendClient {
17
35
  client;
18
36
  userId;
19
37
  jwt;
20
38
  serviceKey;
21
39
  apiKey;
40
+ credentialManager;
41
+ _pendingAuthFlow = null;
42
+ _authFlowResult = null;
22
43
  constructor(config) {
23
44
  this.userId = config.userId || "anonymous";
24
45
  this.jwt =
25
46
  config.jwt ||
26
47
  config.authToken ||
48
+ process.env.OPENEPHEMERIS_JWT ||
27
49
  process.env.ASTROMCP_JWT ||
28
50
  process.env.MERIDIAN_AUTH_TOKEN;
29
51
  this.serviceKey =
30
52
  config.serviceKey ||
53
+ process.env.OPENEPHEMERIS_SERVICE_KEY ||
31
54
  process.env.ASTROMCP_SERVICE_KEY ||
32
55
  process.env.MERIDIAN_SERVICE_KEY;
33
56
  this.apiKey =
34
57
  config.apiKey ||
58
+ process.env.OPENEPHEMERIS_API_KEY ||
35
59
  process.env.ASTROMCP_API_KEY ||
36
60
  process.env.MERIDIAN_API_KEY;
61
+ this.credentialManager = new CredentialManager();
37
62
  this.client = axios.create({
38
63
  baseURL: config.baseURL ||
64
+ process.env.OPENEPHEMERIS_BACKEND_URL ||
39
65
  process.env.ASTROMCP_BACKEND_URL ||
40
66
  process.env.MERIDIAN_BACKEND_URL ||
41
67
  "https://api.openephemeris.com",
@@ -44,8 +70,8 @@ export class BackendClient {
44
70
  "Content-Type": "application/json",
45
71
  },
46
72
  });
47
- // Add auth interceptor (priority: service key > API key > JWT)
48
- this.client.interceptors.request.use((req) => {
73
+ // Add auth interceptor (priority: service key > API key > JWT env > cached JWT)
74
+ this.client.interceptors.request.use(async (req) => {
49
75
  req.headers = req.headers ?? {};
50
76
  // Explicit per-request headers win.
51
77
  const hasAuthHeader = typeof req.headers.Authorization === "string" &&
@@ -64,10 +90,75 @@ export class BackendClient {
64
90
  else if (this.jwt) {
65
91
  req.headers.Authorization = `Bearer ${this.jwt}`;
66
92
  }
93
+ else {
94
+ // Fall through to cached credentials from device auth flow
95
+ const cachedToken = await this.credentialManager.getValidToken();
96
+ if (cachedToken) {
97
+ req.headers.Authorization = `Bearer ${cachedToken}`;
98
+ }
99
+ else {
100
+ // No credentials anywhere — auto-start device auth
101
+ await this.autoStartDeviceAuth();
102
+ }
103
+ }
67
104
  }
68
105
  return req;
69
106
  });
70
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
+ }
119
+ /**
120
+ * Start the device auth flow in the background if not already running.
121
+ * Called automatically when no credentials are found in the interceptor.
122
+ * Idempotent — only starts once per client lifetime.
123
+ */
124
+ async autoStartDeviceAuth() {
125
+ if (this._pendingAuthFlow)
126
+ return; // Already started
127
+ try {
128
+ const flow = new DeviceAuthFlow();
129
+ const startResult = await flow.start();
130
+ this._authFlowResult = {
131
+ verification_uri: startResult.verification_uri,
132
+ user_code: startResult.user_code,
133
+ verification_uri_complete: startResult.verification_uri_complete,
134
+ };
135
+ // Print to stderr so it's visible in MCP host logs
136
+ console.error(`\n🔗 OpenEphemeris: Account connection required.\n` +
137
+ ` Visit: ${startResult.verification_uri}\n` +
138
+ ` Code: ${startResult.user_code}\n` +
139
+ ` Or: ${startResult.verification_uri_complete}\n`);
140
+ // Poll in background (don't await)
141
+ this._pendingAuthFlow = flow
142
+ .poll(startResult.device_code, (attempt) => {
143
+ if (attempt % 12 === 0) {
144
+ console.error(` Still waiting... Enter code ${startResult.user_code} at ${startResult.verification_uri}`);
145
+ }
146
+ })
147
+ .then(() => {
148
+ console.error(`✅ Authenticated! Subsequent requests will use your account.`);
149
+ this._authFlowResult = null;
150
+ })
151
+ .catch((err) => {
152
+ console.error(`❌ Device auth failed: ${err.message}`);
153
+ this._pendingAuthFlow = null; // Allow retry
154
+ });
155
+ }
156
+ catch (err) {
157
+ console.error(`Could not start device auth: ${err.message}`);
158
+ // Don't block — the request will go out unauthenticated and
159
+ // the 401 response will surface the error message to the user.
160
+ }
161
+ }
71
162
  expectsBinaryResponse(path, options) {
72
163
  const normalizedPath = path.split("?")[0].trim().toLowerCase();
73
164
  if (BINARY_ENDPOINT_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix))) {
@@ -139,36 +230,61 @@ export class BackendClient {
139
230
  isBinaryContentType(contentType) {
140
231
  return contentType.startsWith("image/") || contentType === "application/octet-stream";
141
232
  }
142
- /** Maps an AxiosError to a concise MCP-friendly Error. */
233
+ /** Maps an AxiosError to a structured BackendError. */
143
234
  mapError(error) {
144
235
  if (error.response) {
145
236
  const status = error.response.status;
146
237
  const data = this.decodePayload(error.response.data);
147
238
  const msg = this.extractMessage(data, error.message);
148
239
  if (status === 401) {
149
- return new Error(`Authentication required: ${msg}. Sign in or create an account at ${LOGIN_SIGNUP_URL}, ` +
150
- `create credentials in ${DASHBOARD_ACCOUNT_URL}, and set ASTROMCP_API_KEY (or ASTROMCP_JWT / ASTROMCP_SERVICE_KEY) before retrying.`);
240
+ const authInfo = this._authFlowResult;
241
+ if (authInfo) {
242
+ return new BackendError(`🔗 Account connection required. Please visit ${authInfo.verification_uri} ` +
243
+ `and enter code: ${authInfo.user_code}\n\n` +
244
+ `Or open this direct link: ${authInfo.verification_uri_complete}\n\n` +
245
+ `Once you authorize, retry your request — the server is polling in the background. ` +
246
+ `If you don't have an account, you can create one at the link above (free tier, no credit card needed).`, 401, "auth_required", true, authInfo.verification_uri_complete);
247
+ }
248
+ return new BackendError(`Authentication required: ${msg}. Run auth.login to connect your account, ` +
249
+ `or set OPENEPHEMERIS_API_KEY in your MCP server config.`, 401, "auth_required", false, LOGIN_SIGNUP_URL);
151
250
  }
152
251
  if (status === 402) {
153
- return new Error(`Usage quota exceeded: ${msg}. Review usage and overage settings in ${DASHBOARD_ACCOUNT_URL}, ` +
154
- `or upgrade at ${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);
155
259
  }
156
260
  if (status === 403) {
157
- return new Error(`Access blocked by plan or policy: ${msg}. If this endpoint is tier-gated, upgrade at ${UPGRADE_URL}. ` +
158
- `Manage keys and billing at ${DASHBOARD_ACCOUNT_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);
159
267
  }
160
268
  if (status === 429) {
161
- return new Error(`Rate limit exceeded. Your plan credits may be exhausted or requests are too frequent. ` +
162
- `Check usage at ${DASHBOARD_ACCOUNT_URL}, then retry in a moment. (${msg})`);
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);
163
275
  }
164
276
  if (status === 404)
165
- return new Error(`Resource not found: ${msg}`);
277
+ return new BackendError(`Resource not found: ${msg}`, 404, "not_found", false);
166
278
  if (status === 400)
167
- return new Error(`Invalid request: ${msg}`);
279
+ return new BackendError(`Invalid request: ${msg}`, 400, "bad_request", false);
168
280
  if (status >= 500)
169
- return new Error(`Backend error (${status}): ${msg}`);
281
+ return new BackendError(`Backend error (${status}): ${msg}`, status, "server_error", true);
282
+ }
283
+ // Explicit timeout or abort interception
284
+ if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") {
285
+ return new BackendError(`The computation took too long and timed out. This usually happens when requesting a massive date range (like 20+ years of transits) or an overly complex query. Please narrow your request and try again.`, 408, "timeout", false);
170
286
  }
171
- return new Error(`Network error: ${error.message}`);
287
+ return new BackendError(`Network error: ${error.message}`, 0, "network_error", true);
172
288
  }
173
289
  async get(path, params, timeoutMs) {
174
290
  return this.request("GET", path, { params, timeoutMs });
@@ -183,8 +299,8 @@ export class BackendClient {
183
299
  const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
184
300
  const expectsBinary = this.expectsBinaryResponse(path, options);
185
301
  let lastError = new Error("Unknown error");
186
- // Attempt + retries on 429
187
- for (let attempt = 0; attempt <= RATE_LIMIT_RETRY_DELAYS_MS.length; attempt++) {
302
+ // Attempt + retries on transient failures (429, 502, 503, 504, network errors)
303
+ for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
188
304
  try {
189
305
  const response = await this.client.request({
190
306
  method,
@@ -213,9 +329,11 @@ export class BackendClient {
213
329
  }
214
330
  catch (err) {
215
331
  if (err instanceof AxiosError) {
216
- if (err.response?.status === 429 && attempt < RATE_LIMIT_RETRY_DELAYS_MS.length) {
217
- const delay = RATE_LIMIT_RETRY_DELAYS_MS[attempt];
218
- await sleep(delay);
332
+ const isRetryable = (err.response && RETRYABLE_STATUSES.has(err.response.status)) ||
333
+ (!err.response && err.code && RETRYABLE_CODES.has(err.code));
334
+ if (isRetryable && attempt < RETRY_DELAYS_MS.length) {
335
+ const jitter = Math.random() * 500;
336
+ await sleep(RETRY_DELAYS_MS[attempt] + jitter);
219
337
  continue;
220
338
  }
221
339
  lastError = this.mapError(err);
@@ -234,11 +352,12 @@ export class BackendClient {
234
352
  }
235
353
  // Singleton instance
236
354
  export const backendClient = new BackendClient({
237
- baseURL: process.env.ASTROMCP_BACKEND_URL ||
355
+ baseURL: process.env.OPENEPHEMERIS_BACKEND_URL ||
356
+ process.env.ASTROMCP_BACKEND_URL ||
238
357
  process.env.MERIDIAN_BACKEND_URL ||
239
358
  "https://api.openephemeris.com",
240
359
  userId: process.env.MCP_USER_ID || "anonymous",
241
- jwt: process.env.ASTROMCP_JWT || process.env.MERIDIAN_AUTH_TOKEN,
242
- serviceKey: process.env.ASTROMCP_SERVICE_KEY || process.env.MERIDIAN_SERVICE_KEY,
243
- apiKey: process.env.ASTROMCP_API_KEY || process.env.MERIDIAN_API_KEY,
360
+ jwt: process.env.OPENEPHEMERIS_JWT || process.env.ASTROMCP_JWT || process.env.MERIDIAN_AUTH_TOKEN,
361
+ serviceKey: process.env.OPENEPHEMERIS_SERVICE_KEY || process.env.ASTROMCP_SERVICE_KEY || process.env.MERIDIAN_SERVICE_KEY,
362
+ apiKey: process.env.OPENEPHEMERIS_API_KEY || process.env.ASTROMCP_API_KEY || process.env.MERIDIAN_API_KEY,
244
363
  });
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
- import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
7
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
8
8
  import { initTools, toolRegistry } from "./tools/index.js";
9
9
  function resolveServerVersion() {
10
10
  try {
@@ -24,9 +24,16 @@ function resolveServerVersion() {
24
24
  const server = new Server({
25
25
  name: "openephemeris-mcp",
26
26
  version: resolveServerVersion(),
27
+ icons: [
28
+ {
29
+ src: "https://mcp.openephemeris.com/icon.png",
30
+ mimeType: "image/png",
31
+ }
32
+ ]
27
33
  }, {
28
34
  capabilities: {
29
35
  tools: {},
36
+ prompts: {},
30
37
  },
31
38
  });
32
39
  // List available tools
@@ -44,6 +51,47 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
44
51
  })),
45
52
  };
46
53
  });
54
+ // List available prompts
55
+ server.setRequestHandler(ListPromptsRequestSchema, async () => {
56
+ return {
57
+ prompts: [
58
+ {
59
+ name: "welcome_to_open_ephemeris",
60
+ description: "Getting started guide for the Open Ephemeris MCP. Use this to orient yourself to available tools, astrology terminology, and usage.",
61
+ }
62
+ ]
63
+ };
64
+ });
65
+ // Get prompt content
66
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
67
+ if (request.params.name !== "welcome_to_open_ephemeris") {
68
+ throw new Error(`Unknown prompt: ${request.params.name}`);
69
+ }
70
+ return {
71
+ description: "Open Ephemeris Agent & Developer Orientation",
72
+ messages: [
73
+ {
74
+ role: "user",
75
+ content: {
76
+ type: "text",
77
+ text: "Welcome to the **Open Ephemeris MCP Server**!\n\n" +
78
+ "You are now connected to an enterprise-grade astrological and astronomical computation engine powered by NASA JPL DE440 and DE441 ephemeris data. As a generative guide, here is how you can best utilize these tools to help the user:\n\n" +
79
+ "### Core Capabilities\n" +
80
+ "- **Birth Charts:** Use `ephemeris_natal_chart` for full planetary positions, houses, dignities, and aspects.\n" +
81
+ "- **Predictive Timing:** `ephemeris_transits` searches for exact dates when transiting planets hit natal points.\n" +
82
+ "- **Location Astrology:** `ephemeris_relocation` projects a natal chart to a new geographic location (Astrocartography).\n" +
83
+ "- **Specialized Modalities:** Dedicated tools for `vedic_chart`, `chinese_bazi`, and `human_design_chart`.\n" +
84
+ "- **Eclipse Hunter:** `ephemeris_next_eclipse` dynamically scans 20 years for the next total or partial hit at the user's location.\n\n" +
85
+ "### Best Practices for AI Agents\n" +
86
+ "1. **Coordinate Formatting:** Always convert addresses/city names into `latitude` and `longitude` decimals *before* calling the tools (e.g., Chicago is `41.8781, -87.6298`).\n" +
87
+ "2. **Datetime Handling:** Most endpoints accept ISO 8601 strings. If you have a local birth time but the endpoint asks for UTC, explicitly append a `Z` or timezone offset (e.g., `1990-04-15T19:30:00Z`).\n" +
88
+ "3. **Token Efficiency:** Our responses are hyper-condensed to save you context space. You don't need to struggle with raw JSON arrays; interpret the condensed strings directly (e.g., `Su 15°Pi30 H10`).\n\n" +
89
+ "You're equipped with professional-grade math. Start by asking the user if they'd like to calculate a natal chart, check recent transits, or explore another modality!"
90
+ }
91
+ }
92
+ ]
93
+ };
94
+ });
47
95
  // Handle tool calls
48
96
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
49
97
  const toolName = request.params.name;
@@ -51,19 +99,63 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
51
99
  if (!tool) {
52
100
  throw new Error(`Unknown tool: ${toolName}`);
53
101
  }
102
+ const startTime = Date.now();
103
+ console.error(`[MCP] Executing tool: ${toolName}`);
54
104
  try {
55
105
  const result = await tool.handler(request.params.arguments ?? {});
106
+ const durationMs = Date.now() - startTime;
107
+ console.error(`[MCP] ✅ Success: ${toolName} (${durationMs}ms)`);
108
+ // Intercept binary backend responses (like Chart Wheels) to return native MCP Images
109
+ if (result &&
110
+ typeof result === "object" &&
111
+ "encoding" in result &&
112
+ result.encoding === "base64" &&
113
+ "data_base64" in result &&
114
+ "content_type" in result) {
115
+ const binResp = result;
116
+ console.error(`[MCP] 🎨 Returning image (${binResp.content_length} bytes) for ${toolName}`);
117
+ return {
118
+ content: [
119
+ {
120
+ type: "image",
121
+ data: binResp.data_base64,
122
+ mimeType: binResp.content_type,
123
+ },
124
+ ],
125
+ };
126
+ }
127
+ const jsonStr = JSON.stringify(result, null, 2);
128
+ // Safety limit: ~100kb JSON is roughly 20-30k tokens.
129
+ // Usually happens if LLMs ask for 50+ years of transits or raw ephemeris arrays.
130
+ if (jsonStr.length > 100_000) {
131
+ console.error(`[MCP] ⚠️ Warning: Payload too large (${(jsonStr.length / 1024).toFixed(1)}kb) for ${toolName}`);
132
+ return {
133
+ content: [
134
+ {
135
+ type: "text",
136
+ text: JSON.stringify({
137
+ success: false,
138
+ error: "PAYLOAD_TOO_LARGE",
139
+ message: `The requested query produced too much data (${(jsonStr.length / 1024).toFixed(1)}kb). Please significantly narrow your request parameters (e.g., fewer years, smaller coordinate bounding box, etc.) to fit within context limits.`
140
+ }, null, 2),
141
+ },
142
+ ],
143
+ isError: true,
144
+ };
145
+ }
56
146
  return {
57
147
  content: [
58
148
  {
59
149
  type: "text",
60
- text: JSON.stringify(result, null, 2),
150
+ text: jsonStr,
61
151
  },
62
152
  ],
63
153
  };
64
154
  }
65
155
  catch (error) {
156
+ const durationMs = Date.now() - startTime;
66
157
  const errorMessage = error instanceof Error ? error.message : String(error);
158
+ console.error(`[MCP] ❌ Failed: ${toolName} (${durationMs}ms) - ${errorMessage}`);
67
159
  return {
68
160
  content: [
69
161
  {
package/dist/tools/dev.js CHANGED
@@ -1,17 +1,19 @@
1
- import { registerTool } from "./index.js";
1
+ import { registerTool, validateRequired } from "./index.js";
2
2
  import { backendClient } from "../backend/client.js";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  function getAllowlistPath() {
7
- const envPath = process.env.ASTROMCP_DEV_ALLOWLIST_PATH;
7
+ const envPath = process.env.OPENEPHEMERIS_DEV_ALLOWLIST_PATH || process.env.ASTROMCP_DEV_ALLOWLIST_PATH;
8
8
  if (envPath && envPath.trim())
9
9
  return envPath;
10
10
  // dist lives at mcp-server/dist; src lives at mcp-server/src.
11
11
  // We resolve relative to current file location for both dev+prod.
12
12
  const here = path.dirname(fileURLToPath(import.meta.url));
13
- // here: .../dist/tools OR .../src/tools
14
- const mcpServerRoot = path.resolve(here, "..", "..");
13
+ let mcpServerRoot = path.resolve(here, "..", "..");
14
+ if (path.basename(mcpServerRoot) === "dist") {
15
+ mcpServerRoot = path.resolve(mcpServerRoot, "..");
16
+ }
15
17
  return path.join(mcpServerRoot, "config", "dev-allowlist.json");
16
18
  }
17
19
  function loadAllowlist() {
@@ -30,11 +32,11 @@ function isAllowedOperation(method, pathname, allow) {
30
32
  return allow.some((e) => e.method === method && e.path === pathname);
31
33
  }
32
34
  registerTool({
33
- name: "dev.call",
35
+ name: "dev_call",
34
36
  description: "Call any allowlisted Open Ephemeris API endpoint directly. This is the power-user escape hatch " +
35
- "— use the typed tools (ephemeris.natal_chart, ephemeris.transits, etc.) first for common operations. " +
36
- "Call dev.list_allowed to see all currently available endpoint paths.\n\n" +
37
- "AUTH: Set ASTROMCP_API_KEY in your environment. See openephemeris.com/dashboard for active plan limits.\n\n" +
37
+ "— use the typed tools (ephemeris_natal_chart, ephemeris_transits, etc.) first for common operations. " +
38
+ "Call dev_list_allowed to see all currently available endpoint paths.\n\n" +
39
+ "AUTH: Set OPENEPHEMERIS_API_KEY in your environment. See openephemeris.com/dashboard for active plan limits.\n\n" +
38
40
  "CREDIT COSTS:\n" +
39
41
  " • Standard chart math (natal, progressed, bazi, HD): 1 credit\n" +
40
42
  " • Visualization rendering (chart-wheel, bi-wheel, charts/*): 2 credits\n" +
@@ -42,9 +44,9 @@ registerTool({
42
44
  " • Predictive ops (transits, returns, transit-chart): 5 credits\n" +
43
45
  " • ACG / astrocartography: 5 credits\n" +
44
46
  " • Catalog / metadata / health endpoints: 0 credits\n" +
45
- " • format=llm (token-optimized output): requires Pro tier ($49+/mo)\n\n" +
47
+ " • format=llm (token-optimized output): available on all tiers\n\n" +
46
48
  "COMMON CALLS:\n" +
47
- " POST /ephemeris/natal-chart — Full natal chart (body: {datetime, latitude, longitude})\n" +
49
+ " POST /ephemeris/natal-chart — Full natal chart (body: {subject: {name: 'Name', birth_datetime: {iso: '1990-04-15T14:30:00-05:00'}, birth_location: {latitude: {decimal: 40.0}, longitude: {decimal: -70.0}, timezone: {}}}})\n" +
48
50
  " POST /ephemeris/natal/batch — Up to 50 natal charts in one request\n" +
49
51
  " POST /ephemeris/relocation — Relocated chart (same natal, new location)\n" +
50
52
  " POST /predictive/transits/search — Transit event search over a date range\n" +
@@ -52,15 +54,16 @@ registerTool({
52
54
  " POST /predictive/returns/lunar — Lunar return chart\n" +
53
55
  " POST /comparative/synastry — Two-person synastry chart\n" +
54
56
  " POST /comparative/composite — Composite (midpoint) chart\n" +
55
- " POST /human-design/chart — Full HD bodygraph\n" +
57
+ " POST /human-design/chart — Full HD chart (body: {birth_datetime_utc: '1990-04-15T19:30:00Z'}) — lat/lon optional\n" +
58
+ " POST /time/julian-day — Convert date to JD (body: {year: 1987, month: 7, day: 15, hour: 14, minute: 1})\n" +
56
59
  " GET /ephemeris/moon/phase — Current/queried moon phase\n" +
57
60
  " GET /ephemeris/moon/void-of-course — Next void-of-course period\n" +
58
61
  " GET /ephemeris/agro/daily — Biodynamic farming day quality\n" +
59
62
  " GET /ephemeris/agro/calendar — Multi-day biodynamic calendar\n" +
60
63
  " GET /ephemeris/agro/void-of-course — Biodynamic VoC periods\n" +
64
+ " GET /eclipse/next-visible — Next eclipse visible from a location (query: lat, lon, type=solar|lunar)\n" +
61
65
  " GET /eclipse/solar/global — Next global solar eclipse (query: date=YYYY-MM-DD)\n" +
62
- " GET /eclipse/solar/local — Local solar eclipse (query: latitude, longitude)\n" +
63
- " GET /eclipse/next-visible — Next eclipse visible from a location\n" +
66
+ " GET /eclipse/solar/local — Local solar eclipse (query: lat, lon)\n" +
64
67
  " GET /tidal/forcing — Gravitational tidal forcing index\n" +
65
68
  " GET /tidal/forcing/deep-time — Extended tidal deep-time analysis\n" +
66
69
  " POST /acg/power-lines — Astrocartography power lines (lat/lon GeoJSON)\n" +
@@ -68,9 +71,9 @@ registerTool({
68
71
  " GET /calendar/astrology/moon-phases — Moon phase calendar for a date range\n" +
69
72
  " GET /location/autocomplete — Geocode a place name (query: q=City Name)\n" +
70
73
  " POST /timezone/lookup — Resolve timezone + UTC offset for a location\n" +
71
- " POST /chinese/bazi — Chinese Ba Zi (Four Pillars) chart\n" +
74
+ " POST /chinese/bazi — Chinese Ba Zi (Four Pillars) chart (body: {year, month, day, hour})\n" +
72
75
  " GET /chinese/zodiac — Chinese zodiac year element/animal\n" +
73
- " POST /vedic/chart — Vedic (Jyotish) natal chart\n" +
76
+ " POST /vedic/chart — Vedic (Jyotish) natal chart (body: {datetime_utc, latitude, longitude})\n" +
74
77
  " GET /catalogs/bodies — List all supported celestial bodies\n\n" +
75
78
  "BINARY RESPONSES:\n" +
76
79
  " • Binary/image endpoints return {content_type, content_length, encoding, data_base64}\n" +
@@ -118,8 +121,13 @@ registerTool({
118
121
  additionalProperties: false,
119
122
  },
120
123
  handler: async (args) => {
121
- const method = String(args.method || "").toUpperCase();
122
- const pathname = String(args.path || "");
124
+ validateRequired(args, ["path"]);
125
+ const methodStr = String(args.method || "GET").toUpperCase();
126
+ if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(methodStr)) {
127
+ throw new Error(`Invalid HTTP method: ${methodStr}`);
128
+ }
129
+ const method = methodStr;
130
+ const pathname = String(args.path);
123
131
  if (!pathname.startsWith("/")) {
124
132
  throw new Error("path must start with '/'");
125
133
  }
@@ -161,12 +169,12 @@ registerTool({
161
169
  },
162
170
  });
163
171
  registerTool({
164
- name: "dev.list_allowed",
172
+ name: "dev_list_allowed",
165
173
  description: "List all API operations (method + path) that this MCP instance is authorized to call. " +
166
174
  "Returns endpoint entries grouped by method, plus the active deny rules. " +
167
- "Use this to discover what's available before calling dev.call, or to verify an endpoint path. " +
168
- "Typed shortcut tools (ephemeris.natal_chart, ephemeris.transits, etc.) cover the most common operations — " +
169
- "check those first before reaching for dev.call.",
175
+ "Use this to discover what's available before calling dev_call, or to verify an endpoint path. " +
176
+ "Typed shortcut tools (ephemeris_natal_chart, ephemeris_transits, etc.) cover the most common operations — " +
177
+ "check those first before reaching for dev_call.",
170
178
  inputSchema: {
171
179
  type: "object",
172
180
  properties: {},
@@ -23,3 +23,11 @@ export type ToolProfile = "dev" | "legacy";
23
23
  * - `legacy`: registers only the generic tools (back-compat).
24
24
  */
25
25
  export declare function initTools(profile?: ToolProfile): Promise<void>;
26
+ /**
27
+ * Throws an error if any of the required keys are missing or empty.
28
+ */
29
+ export declare function validateRequired(args: any, requiredKeys: string[]): void;
30
+ /**
31
+ * Throws an error if only one of the coordinate pair is provided.
32
+ */
33
+ export declare function validateCoordinates(args: any, latKey: string, lonKey: string): void;
@@ -14,7 +14,9 @@ export async function initTools(profile) {
14
14
  if (toolsInitialized)
15
15
  return;
16
16
  toolsInitialized = true;
17
- const resolvedProfile = (profile || process.env.ASTROMCP_PROFILE || "dev").toLowerCase();
17
+ const resolvedProfile = (profile || process.env.OPENEPHEMERIS_PROFILE || process.env.ASTROMCP_PROFILE || "dev").toLowerCase();
18
+ // Always register auth tools (available in all profiles).
19
+ await import("./auth.js");
18
20
  // Always register the generic proxy tools (dev.call + dev.list_allowed).
19
21
  await import("./dev.js");
20
22
  if (resolvedProfile === "dev") {
@@ -27,5 +29,32 @@ export async function initTools(profile) {
27
29
  await import("./specialized/synastry.js");
28
30
  await import("./specialized/relocation.js");
29
31
  await import("./specialized/electional.js");
32
+ await import("./specialized/vedic.js");
33
+ await import("./specialized/bazi.js");
34
+ await import("./specialized/chart_wheel.js");
35
+ await import("./specialized/bi_wheel.js");
36
+ }
37
+ }
38
+ /**
39
+ * Throws an error if any of the required keys are missing or empty.
40
+ */
41
+ export function validateRequired(args, requiredKeys) {
42
+ if (!args)
43
+ throw new Error("Missing arguments object.");
44
+ const missing = requiredKeys.filter((k) => args[k] == null || args[k] === "");
45
+ if (missing.length > 0) {
46
+ throw new Error(`Missing required arguments: ${missing.join(", ")}`);
47
+ }
48
+ }
49
+ /**
50
+ * Throws an error if only one of the coordinate pair is provided.
51
+ */
52
+ export function validateCoordinates(args, latKey, lonKey) {
53
+ if (!args)
54
+ return;
55
+ const hasLat = args[latKey] != null && args[latKey] !== "";
56
+ const hasLon = args[lonKey] != null && args[lonKey] !== "";
57
+ if (hasLat !== hasLon) {
58
+ throw new Error(`Both ${latKey} and ${lonKey} must be provided together, or both omitted.`);
30
59
  }
31
60
  }