@openephemeris/mcp-server 3.1.0 → 3.2.2

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 (58) hide show
  1. package/README.md +51 -22
  2. package/config/dev-allowlist.json +1262 -1318
  3. package/dist/index.js +0 -0
  4. package/dist/scripts/dev-allowlist.d.ts +1 -0
  5. package/dist/scripts/dev-allowlist.js +287 -0
  6. package/dist/scripts/pack-audit.d.ts +1 -0
  7. package/dist/scripts/pack-audit.js +45 -0
  8. package/dist/scripts/schema-packs.d.ts +1 -0
  9. package/dist/scripts/schema-packs.js +150 -0
  10. package/dist/scripts/smoke-dev-profile.d.ts +1 -0
  11. package/dist/scripts/smoke-dev-profile.js +25 -0
  12. package/dist/scripts/sync-readme.d.ts +1 -0
  13. package/dist/scripts/sync-readme.js +141 -0
  14. package/dist/scripts/test-client.d.ts +1 -0
  15. package/dist/scripts/test-client.js +69 -0
  16. package/dist/scripts/test-sse-client.d.ts +1 -0
  17. package/dist/scripts/test-sse-client.js +221 -0
  18. package/dist/src/auth/credentials.d.ts +65 -0
  19. package/dist/src/auth/credentials.js +200 -0
  20. package/dist/src/auth/device-auth.d.ts +56 -0
  21. package/dist/src/auth/device-auth.js +147 -0
  22. package/dist/src/backend/client.d.ts +61 -0
  23. package/dist/src/backend/client.js +335 -0
  24. package/dist/src/index.d.ts +2 -0
  25. package/dist/src/index.js +98 -0
  26. package/dist/src/schema-packs/llm.d.ts +105 -0
  27. package/dist/src/schema-packs/llm.js +429 -0
  28. package/dist/src/server-sse.d.ts +1 -0
  29. package/dist/src/server-sse.js +264 -0
  30. package/dist/src/tools/auth.d.ts +1 -0
  31. package/dist/src/tools/auth.js +202 -0
  32. package/dist/src/tools/dev.d.ts +1 -0
  33. package/dist/src/tools/dev.js +195 -0
  34. package/dist/src/tools/index.d.ts +33 -0
  35. package/dist/src/tools/index.js +56 -0
  36. package/dist/src/tools/specialized/eclipse.d.ts +1 -0
  37. package/dist/src/tools/specialized/eclipse.js +53 -0
  38. package/dist/src/tools/specialized/electional.d.ts +1 -0
  39. package/dist/src/tools/specialized/electional.js +80 -0
  40. package/dist/src/tools/specialized/human_design.d.ts +1 -0
  41. package/dist/src/tools/specialized/human_design.js +54 -0
  42. package/dist/src/tools/specialized/moon.d.ts +1 -0
  43. package/dist/src/tools/specialized/moon.js +51 -0
  44. package/dist/src/tools/specialized/natal.d.ts +1 -0
  45. package/dist/src/tools/specialized/natal.js +80 -0
  46. package/dist/src/tools/specialized/relocation.d.ts +1 -0
  47. package/dist/src/tools/specialized/relocation.js +76 -0
  48. package/dist/src/tools/specialized/synastry.d.ts +1 -0
  49. package/dist/src/tools/specialized/synastry.js +73 -0
  50. package/dist/src/tools/specialized/transits.d.ts +1 -0
  51. package/dist/src/tools/specialized/transits.js +87 -0
  52. package/dist/test/allowlist-and-tools.test.d.ts +1 -0
  53. package/dist/test/allowlist-and-tools.test.js +96 -0
  54. package/dist/test/backend-client.test.d.ts +1 -0
  55. package/dist/test/backend-client.test.js +284 -0
  56. package/dist/test/credentials.test.d.ts +1 -0
  57. package/dist/test/credentials.test.js +143 -0
  58. package/package.json +27 -18
@@ -0,0 +1,264 @@
1
+ /**
2
+ * server-sse.ts — Remote SSE transport for the OpenEphemeris MCP server.
3
+ *
4
+ * This wraps the exact same tool registry used by the local stdio server
5
+ * but exposes it over HTTP + Server-Sent Events so that Claude Web,
6
+ * Claude Mobile (future), and any remote MCP client can connect via URL.
7
+ *
8
+ * Architecture:
9
+ * - The SSE server validates the user's API key at connection time by
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.
14
+ */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import express from "express";
19
+ import axios from "axios";
20
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
21
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
22
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
23
+ import { initTools, toolRegistry } from "./tools/index.js";
24
+ // ---------------------------------------------------------------------------
25
+ // Helpers
26
+ // ---------------------------------------------------------------------------
27
+ function resolveServerVersion() {
28
+ try {
29
+ const here = path.dirname(fileURLToPath(import.meta.url));
30
+ // Try multiple levels up — compiled JS is at dist/src/, source is at src/
31
+ for (const rel of ["..", "../.."]) {
32
+ const pkgPath = path.resolve(here, rel, "package.json");
33
+ try {
34
+ const raw = fs.readFileSync(pkgPath, "utf-8");
35
+ const parsed = JSON.parse(raw);
36
+ if (typeof parsed.version === "string" && parsed.version.trim()) {
37
+ return parsed.version.trim();
38
+ }
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ }
44
+ }
45
+ catch {
46
+ // fall through
47
+ }
48
+ return "0.0.0-unknown";
49
+ }
50
+ const PORT = parseInt(process.env.PORT || "3001", 10);
51
+ const BACKEND_URL = process.env.OPENEPHEMERIS_BACKEND_URL ||
52
+ process.env.ASTROMCP_BACKEND_URL ||
53
+ "https://api.openephemeris.com";
54
+ const version = resolveServerVersion();
55
+ /**
56
+ * Validate a user-supplied API key by hitting an auth-gated endpoint on
57
+ * the Go sidecar. Returns true if the key yields a 2xx, false on 401/403.
58
+ */
59
+ async function validateApiKey(apiKey) {
60
+ try {
61
+ const resp = await axios.get(`${BACKEND_URL}/ephemeris/moon/phase`, {
62
+ headers: { "X-API-Key": apiKey },
63
+ timeout: 5_000,
64
+ validateStatus: () => true, // don't throw on any status
65
+ });
66
+ // 2xx = valid key, 401/403 = invalid key, anything else = let through
67
+ if (resp.status === 401 || resp.status === 403)
68
+ return false;
69
+ return true;
70
+ }
71
+ catch {
72
+ // Network error — let them through; tool calls will fail if key is bad.
73
+ console.warn("Could not validate API key against backend, allowing connection.");
74
+ return true;
75
+ }
76
+ }
77
+ // ---------------------------------------------------------------------------
78
+ // MCP Server factory — one per SSE session
79
+ // ---------------------------------------------------------------------------
80
+ function createMcpServer() {
81
+ const server = new Server({
82
+ name: "openephemeris-mcp",
83
+ version,
84
+ icons: [
85
+ {
86
+ src: "https://mcp.openephemeris.com/icon.png",
87
+ mimeType: "image/png",
88
+ }
89
+ ]
90
+ }, { capabilities: { tools: {} } });
91
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
92
+ tools: Object.values(toolRegistry).map((tool) => ({
93
+ name: tool.name,
94
+ description: tool.description,
95
+ inputSchema: tool.inputSchema,
96
+ annotations: tool.annotations ?? {
97
+ title: tool.name,
98
+ readOnlyHint: true,
99
+ destructiveHint: false,
100
+ },
101
+ })),
102
+ }));
103
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
104
+ const toolName = request.params.name;
105
+ const tool = toolRegistry[toolName];
106
+ if (!tool) {
107
+ throw new Error(`Unknown tool: ${toolName}`);
108
+ }
109
+ try {
110
+ const result = await tool.handler(request.params.arguments ?? {});
111
+ return {
112
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
113
+ };
114
+ }
115
+ catch (error) {
116
+ const errorMessage = error instanceof Error ? error.message : String(error);
117
+ return {
118
+ content: [
119
+ {
120
+ type: "text",
121
+ text: JSON.stringify({ success: false, error: "TOOL_EXECUTION_ERROR", message: errorMessage }, null, 2),
122
+ },
123
+ ],
124
+ isError: true,
125
+ };
126
+ }
127
+ });
128
+ return server;
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // Express + SSE wiring
132
+ // ---------------------------------------------------------------------------
133
+ async function main() {
134
+ await initTools();
135
+ const app = express();
136
+ // NOTE: Do NOT use express.json() globally — SSEServerTransport.handlePostMessage
137
+ // reads the raw request stream itself. Pre-parsing with express.json() consumes it,
138
+ // causing "stream is not readable" errors.
139
+ // CORS — required for Claude Web cross-origin SSE connections
140
+ app.use((_req, res, next) => {
141
+ res.setHeader("Access-Control-Allow-Origin", "*");
142
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
143
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-Meridian-API-Key");
144
+ if (_req.method === "OPTIONS") {
145
+ res.status(204).end();
146
+ return;
147
+ }
148
+ next();
149
+ });
150
+ // Track active transports by session ID
151
+ const transports = new Map();
152
+ // Health check
153
+ app.get("/health", (_req, res) => {
154
+ res.json({ ok: true, version, transport: "sse", sessions: transports.size });
155
+ });
156
+ // Serve the MCP icon (earth + star)
157
+ // Compiled JS at dist/src/server-sse.js: ../../assets
158
+ // Source TS at src/server-sse.ts: ../assets
159
+ const here = path.dirname(fileURLToPath(import.meta.url));
160
+ const assetsDir = fs.existsSync(path.resolve(here, "..", "..", "assets"))
161
+ ? path.resolve(here, "..", "..", "assets")
162
+ : path.resolve(here, "..", "assets");
163
+ app.get("/icon.png", (_req, res) => {
164
+ const iconPath = path.join(assetsDir, "icon.png");
165
+ if (fs.existsSync(iconPath)) {
166
+ res.setHeader("Cache-Control", "public, max-age=86400");
167
+ res.sendFile(iconPath);
168
+ }
169
+ else {
170
+ res.status(404).end();
171
+ }
172
+ });
173
+ app.get("/favicon.ico", (_req, res) => {
174
+ const iconPath = path.join(assetsDir, "icon.png");
175
+ if (fs.existsSync(iconPath)) {
176
+ res.setHeader("Content-Type", "image/png");
177
+ res.setHeader("Cache-Control", "public, max-age=86400");
178
+ res.sendFile(iconPath);
179
+ }
180
+ else {
181
+ res.status(404).end();
182
+ }
183
+ });
184
+ // SSE endpoint — clients GET /sse to establish a stream
185
+ app.get("/sse", async (req, res) => {
186
+ // Extract API key from query param or header
187
+ const apiKey = req.query.apiKey ||
188
+ req.headers["x-api-key"] ||
189
+ req.headers["x-meridian-api-key"] ||
190
+ req.headers["authorization"]?.replace(/^Bearer\s+/i, "");
191
+ if (!apiKey) {
192
+ res.status(401).json({
193
+ error: "api_key_required",
194
+ message: "An OpenEphemeris API key is required. Generate one at https://openephemeris.com/dashboard?tab=apikeys",
195
+ });
196
+ return;
197
+ }
198
+ // Validate the user's key against the Go backend
199
+ const valid = await validateApiKey(apiKey);
200
+ if (!valid) {
201
+ res.status(403).json({
202
+ error: "invalid_api_key",
203
+ message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
204
+ });
205
+ return;
206
+ }
207
+ const transport = new SSEServerTransport("/message", res);
208
+ const sessionId = transport.sessionId;
209
+ transports.set(sessionId, transport);
210
+ const server = createMcpServer();
211
+ // Guard: prevent double-close from crashing the process
212
+ let closed = false;
213
+ const cleanup = () => {
214
+ if (closed)
215
+ return;
216
+ closed = true;
217
+ transports.delete(sessionId);
218
+ try {
219
+ server.close().catch(() => { });
220
+ }
221
+ catch { /* ignore */ }
222
+ };
223
+ transport.onclose = cleanup;
224
+ // Handle abrupt client disconnects (browser tab closed, etc.)
225
+ res.on("close", cleanup);
226
+ try {
227
+ // server.connect() calls transport.start() internally in SDK v1.27+
228
+ await server.connect(transport);
229
+ }
230
+ catch (err) {
231
+ console.error(`SSE session ${sessionId} failed to start:`, err);
232
+ cleanup();
233
+ }
234
+ });
235
+ // Message endpoint — clients POST /message?sessionId=xxx
236
+ app.post("/message", async (req, res) => {
237
+ const sessionId = req.query.sessionId;
238
+ const transport = transports.get(sessionId);
239
+ if (!transport) {
240
+ res.status(400).json({
241
+ error: "invalid_session",
242
+ message: "Unknown or expired session. Re-connect to /sse first.",
243
+ });
244
+ return;
245
+ }
246
+ await transport.handlePostMessage(req, res);
247
+ });
248
+ app.listen(PORT, "0.0.0.0", () => {
249
+ console.log(`OpenEphemeris MCP SSE server v${version} listening on port ${PORT}`);
250
+ });
251
+ }
252
+ // Prevent SDK recursive close bugs from crashing the process
253
+ process.on("uncaughtException", (err) => {
254
+ if (err instanceof RangeError && err.message.includes("call stack")) {
255
+ console.error("Caught stack overflow (likely SDK close recursion), ignoring.");
256
+ return;
257
+ }
258
+ console.error("Uncaught exception:", err);
259
+ process.exit(1);
260
+ });
261
+ main().catch((error) => {
262
+ console.error("Fatal error:", error);
263
+ process.exit(1);
264
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,202 @@
1
+ import { registerTool } from "./index.js";
2
+ import { CredentialManager } from "../auth/credentials.js";
3
+ import { DeviceAuthFlow } from "../auth/device-auth.js";
4
+ const credentialManager = new CredentialManager();
5
+ // ────────────────────────────────────────────────────────
6
+ // auth.login — Start the device authorization flow
7
+ // ────────────────────────────────────────────────────────
8
+ registerTool({
9
+ name: "auth_login",
10
+ description: "Start the device authorization flow to connect this MCP server to your OpenEphemeris account. " +
11
+ "Returns a verification URL and code for the user to enter in their browser. " +
12
+ "The MCP server will then automatically receive credentials and all API calls will be " +
13
+ "linked to the user's account (tier, credits, rate limits). " +
14
+ "Only needed if no OPENEPHEMERIS_API_KEY env var is set and no cached credentials exist.",
15
+ inputSchema: {
16
+ type: "object",
17
+ properties: {},
18
+ additionalProperties: false,
19
+ },
20
+ annotations: {
21
+ title: "Connect Account",
22
+ readOnlyHint: false,
23
+ destructiveHint: false,
24
+ },
25
+ handler: async () => {
26
+ // Check if already authenticated
27
+ const status = credentialManager.getStatus();
28
+ if (status.authenticated) {
29
+ return {
30
+ already_authenticated: true,
31
+ email: status.email,
32
+ user_id: status.userId,
33
+ expires_at: status.expiresAt,
34
+ message: `Already authenticated as ${status.email}. Use auth_logout to disconnect.`,
35
+ };
36
+ }
37
+ // Check if env var credentials are set
38
+ if (process.env.OPENEPHEMERIS_API_KEY ||
39
+ process.env.OPENEPHEMERIS_JWT ||
40
+ process.env.OPENEPHEMERIS_SERVICE_KEY) {
41
+ return {
42
+ already_authenticated: true,
43
+ method: "environment_variable",
44
+ message: "Credentials are configured via environment variables. " +
45
+ "Device auth is not needed.",
46
+ };
47
+ }
48
+ // Start the device auth flow
49
+ const flow = new DeviceAuthFlow();
50
+ const startResult = await flow.start();
51
+ // Begin polling in the background — this runs until the user authorizes
52
+ // or the code expires. We don't await it here because the tool should
53
+ // return immediately with the code for the user.
54
+ flow
55
+ .poll(startResult.device_code, (attempt) => {
56
+ if (attempt % 6 === 0) {
57
+ console.error(`Still waiting... Visit ${startResult.verification_uri} and enter code: ${startResult.user_code}`);
58
+ }
59
+ })
60
+ .then((result) => {
61
+ console.error(`✅ Authenticated as ${result.user?.email || "user"}`);
62
+ console.error(` Credentials saved to ${CredentialManager.credentialsPath}`);
63
+ })
64
+ .catch((err) => {
65
+ console.error(`❌ Device auth failed: ${err.message}`);
66
+ });
67
+ return {
68
+ action_required: true,
69
+ verification_uri: startResult.verification_uri,
70
+ user_code: startResult.user_code,
71
+ verification_uri_complete: startResult.verification_uri_complete,
72
+ expires_in: startResult.expires_in,
73
+ message: `Please visit ${startResult.verification_uri} and enter the code: ${startResult.user_code}. ` +
74
+ `Alternatively, open this direct link: ${startResult.verification_uri_complete}. ` +
75
+ `The server is polling in the background — once you authorize, all subsequent ` +
76
+ `tool calls will be linked to your account automatically.`,
77
+ instructions_for_agent: "Present the verification URL and code to the user clearly. " +
78
+ "They need to open the URL in a browser, log in to their OpenEphemeris account, " +
79
+ "and enter the code. After that, all API calls will work automatically. " +
80
+ "Wait 10-15 seconds after the user confirms they've entered the code, " +
81
+ "then try the original request again.",
82
+ };
83
+ },
84
+ });
85
+ // ────────────────────────────────────────────────────────
86
+ // auth.status — Check current authentication state
87
+ // ────────────────────────────────────────────────────────
88
+ registerTool({
89
+ name: "auth_status",
90
+ description: "Check the current authentication status of this MCP server. " +
91
+ "Shows whether the server is authenticated, which account it's linked to, " +
92
+ "the authentication method (API key, JWT, device auth), and token expiry.",
93
+ inputSchema: {
94
+ type: "object",
95
+ properties: {},
96
+ additionalProperties: false,
97
+ },
98
+ annotations: {
99
+ title: "Auth Status",
100
+ readOnlyHint: true,
101
+ destructiveHint: false,
102
+ },
103
+ handler: async () => {
104
+ // Check env var methods first
105
+ const hasApiKey = !!(process.env.OPENEPHEMERIS_API_KEY ||
106
+ process.env.ASTROMCP_API_KEY ||
107
+ process.env.MERIDIAN_API_KEY);
108
+ const hasJwt = !!(process.env.OPENEPHEMERIS_JWT ||
109
+ process.env.ASTROMCP_JWT ||
110
+ process.env.MERIDIAN_AUTH_TOKEN);
111
+ const hasServiceKey = !!(process.env.OPENEPHEMERIS_SERVICE_KEY ||
112
+ process.env.ASTROMCP_SERVICE_KEY ||
113
+ process.env.MERIDIAN_SERVICE_KEY);
114
+ if (hasServiceKey) {
115
+ return {
116
+ authenticated: true,
117
+ method: "service_key",
118
+ message: "Authenticated via service key (admin/internal access).",
119
+ };
120
+ }
121
+ if (hasApiKey) {
122
+ return {
123
+ authenticated: true,
124
+ method: "api_key",
125
+ message: "Authenticated via API key environment variable.",
126
+ };
127
+ }
128
+ if (hasJwt) {
129
+ return {
130
+ authenticated: true,
131
+ method: "jwt_env_var",
132
+ message: "Authenticated via JWT environment variable.",
133
+ };
134
+ }
135
+ // Check cached credentials
136
+ const status = credentialManager.getStatus();
137
+ if (status.authenticated) {
138
+ return {
139
+ authenticated: true,
140
+ method: "device_auth",
141
+ email: status.email,
142
+ user_id: status.userId,
143
+ expires_at: status.expiresAt,
144
+ credentials_path: CredentialManager.credentialsPath,
145
+ message: `Authenticated as ${status.email} via device authorization.`,
146
+ };
147
+ }
148
+ // Check if credentials exist but are expired
149
+ const creds = credentialManager.load();
150
+ if (creds) {
151
+ return {
152
+ authenticated: false,
153
+ method: "device_auth_expired",
154
+ email: creds.user_email,
155
+ expired_at: creds.expires_at,
156
+ message: "Device auth credentials exist but are expired. " +
157
+ "The server will attempt to refresh automatically on the next API call, " +
158
+ "or you can run auth_login to re-authenticate.",
159
+ };
160
+ }
161
+ return {
162
+ authenticated: false,
163
+ method: "none",
164
+ message: "Not authenticated. Set OPENEPHEMERIS_API_KEY in your MCP server config, " +
165
+ "or run auth_login to connect your account interactively.",
166
+ };
167
+ },
168
+ });
169
+ // ────────────────────────────────────────────────────────
170
+ // auth.logout — Clear cached credentials
171
+ // ────────────────────────────────────────────────────────
172
+ registerTool({
173
+ name: "auth_logout",
174
+ description: "Disconnect this MCP server from your OpenEphemeris account by clearing " +
175
+ "cached credentials. Does NOT revoke the API key if one is set via environment " +
176
+ "variable — only clears device-auth cached credentials.",
177
+ inputSchema: {
178
+ type: "object",
179
+ properties: {},
180
+ additionalProperties: false,
181
+ },
182
+ annotations: {
183
+ title: "Disconnect Account",
184
+ readOnlyHint: false,
185
+ destructiveHint: true,
186
+ },
187
+ handler: async () => {
188
+ const status = credentialManager.getStatus();
189
+ if (!status.authenticated && !credentialManager.load()) {
190
+ return {
191
+ success: true,
192
+ message: "No cached credentials to clear.",
193
+ };
194
+ }
195
+ credentialManager.clear();
196
+ return {
197
+ success: true,
198
+ previous_email: status.email,
199
+ message: `Disconnected. Cached credentials for ${status.email || "unknown user"} have been removed.`,
200
+ };
201
+ },
202
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,195 @@
1
+ import { registerTool, validateRequired } from "./index.js";
2
+ import { backendClient } from "../backend/client.js";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ function getAllowlistPath() {
7
+ const envPath = process.env.OPENEPHEMERIS_DEV_ALLOWLIST_PATH || process.env.ASTROMCP_DEV_ALLOWLIST_PATH;
8
+ if (envPath && envPath.trim())
9
+ return envPath;
10
+ // dist lives at mcp-server/dist; src lives at mcp-server/src.
11
+ // We resolve relative to current file location for both dev+prod.
12
+ const here = path.dirname(fileURLToPath(import.meta.url));
13
+ let mcpServerRoot = path.resolve(here, "..", "..");
14
+ if (path.basename(mcpServerRoot) === "dist") {
15
+ mcpServerRoot = path.resolve(mcpServerRoot, "..");
16
+ }
17
+ return path.join(mcpServerRoot, "config", "dev-allowlist.json");
18
+ }
19
+ function loadAllowlist() {
20
+ const allowlistPath = getAllowlistPath();
21
+ const raw = fs.readFileSync(allowlistPath, "utf-8");
22
+ const parsed = JSON.parse(raw);
23
+ if (!parsed || parsed.schema !== "astromcp-dev-allowlist-v1" || !Array.isArray(parsed.allow)) {
24
+ throw new Error(`Invalid allowlist file at ${allowlistPath}. Expected schema astromcp-dev-allowlist-v1.`);
25
+ }
26
+ return parsed;
27
+ }
28
+ function isDeniedByPrefix(pathname, prefixes) {
29
+ return prefixes.some((p) => pathname.startsWith(p));
30
+ }
31
+ function isAllowedOperation(method, pathname, allow) {
32
+ return allow.some((e) => e.method === method && e.path === pathname);
33
+ }
34
+ registerTool({
35
+ name: "dev_call",
36
+ description: "Call any allowlisted Open Ephemeris API endpoint directly. This is the power-user escape hatch " +
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" +
40
+ "CREDIT COSTS:\n" +
41
+ " • Standard chart math (natal, progressed, bazi, HD): 1 credit\n" +
42
+ " • Visualization rendering (chart-wheel, bi-wheel, charts/*): 2 credits\n" +
43
+ " • Comparative math (synastry, composite, overlay): 3 credits\n" +
44
+ " • Predictive ops (transits, returns, transit-chart): 5 credits\n" +
45
+ " • ACG / astrocartography: 5 credits\n" +
46
+ " • Catalog / metadata / health endpoints: 0 credits\n" +
47
+ " • format=llm (token-optimized output): available on all tiers\n\n" +
48
+ "COMMON CALLS:\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" +
50
+ " POST /ephemeris/natal/batch — Up to 50 natal charts in one request\n" +
51
+ " POST /ephemeris/relocation — Relocated chart (same natal, new location)\n" +
52
+ " POST /predictive/transits/search — Transit event search over a date range\n" +
53
+ " POST /predictive/returns/solar — Solar return chart\n" +
54
+ " POST /predictive/returns/lunar — Lunar return chart\n" +
55
+ " POST /comparative/synastry — Two-person synastry chart\n" +
56
+ " POST /comparative/composite — Composite (midpoint) chart\n" +
57
+ " POST /human-design/chart — Full HD bodygraph (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" +
59
+ " GET /ephemeris/moon/phase — Current/queried moon phase\n" +
60
+ " GET /ephemeris/moon/void-of-course — Next void-of-course period\n" +
61
+ " GET /ephemeris/agro/daily — Biodynamic farming day quality\n" +
62
+ " GET /ephemeris/agro/calendar — Multi-day biodynamic calendar\n" +
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" +
65
+ " GET /eclipse/solar/global — Next global solar eclipse (query: date=YYYY-MM-DD)\n" +
66
+ " GET /eclipse/solar/local — Local solar eclipse (query: lat, lon)\n" +
67
+ " GET /tidal/forcing — Gravitational tidal forcing index\n" +
68
+ " GET /tidal/forcing/deep-time — Extended tidal deep-time analysis\n" +
69
+ " POST /acg/power-lines — Astrocartography power lines (lat/lon GeoJSON)\n" +
70
+ " POST /acg/hits — ACG power at a specific location\n" +
71
+ " GET /calendar/astrology/moon-phases — Moon phase calendar for a date range\n" +
72
+ " GET /location/autocomplete — Geocode a place name (query: q=City Name)\n" +
73
+ " POST /timezone/lookup — Resolve timezone + UTC offset for a location\n" +
74
+ " POST /chinese/bazi — Chinese Ba Zi (Four Pillars) chart (body: {year, month, day, hour})\n" +
75
+ " GET /chinese/zodiac — Chinese zodiac year element/animal\n" +
76
+ " POST /vedic/chart — Vedic (Jyotish) natal chart (body: {datetime_utc, latitude, longitude})\n" +
77
+ " GET /catalogs/bodies — List all supported celestial bodies\n\n" +
78
+ "BINARY RESPONSES:\n" +
79
+ " • Binary/image endpoints return {content_type, content_length, encoding, data_base64}\n" +
80
+ " so callers can decode bytes deterministically.\n\n" +
81
+ "ECLIPSE NOTE: Eclipse endpoints accept format=llm via the query param like other endpoints.\n\n" +
82
+ "format=llm NOTE: Add query: {format: 'llm'} to natal/synastry/composite/HD endpoints for " +
83
+ "compact columnar output optimized for LLM token budgets (availability depends on your current plan).",
84
+ inputSchema: {
85
+ type: "object",
86
+ properties: {
87
+ method: {
88
+ type: "string",
89
+ enum: ["GET", "POST", "PUT", "PATCH", "DELETE"],
90
+ description: "HTTP method",
91
+ },
92
+ path: {
93
+ type: "string",
94
+ description: "Absolute API path (e.g., /ephemeris/natal-chart)",
95
+ },
96
+ query: {
97
+ type: "object",
98
+ additionalProperties: true,
99
+ description: "Query params for GET requests (optional)",
100
+ },
101
+ body: {
102
+ description: "JSON body for POST/PUT/PATCH (optional)",
103
+ },
104
+ preset: {
105
+ type: "string",
106
+ enum: ["full", "simple"],
107
+ description: "Convenience: if provided, set query.preset (optional).",
108
+ },
109
+ format: {
110
+ type: "string",
111
+ enum: ["json", "llm", "llm_v2"],
112
+ description: "Convenience: if provided, set query.format (optional). 'llm' is canonical; 'llm_v2' is accepted as a legacy alias.",
113
+ },
114
+ output_mode: {
115
+ type: "string",
116
+ enum: ["full", "simple", "llm", "llm_v2"],
117
+ description: "Legacy convenience (deprecated): if provided, set query.output_mode and also map to query.preset/query.format when possible.",
118
+ },
119
+ },
120
+ required: ["method", "path"],
121
+ additionalProperties: false,
122
+ },
123
+ handler: async (args) => {
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);
131
+ if (!pathname.startsWith("/")) {
132
+ throw new Error("path must start with '/'");
133
+ }
134
+ const allowlist = loadAllowlist();
135
+ const denyPrefixes = allowlist.deny?.path_prefixes ?? [];
136
+ if (denyPrefixes.length > 0 && isDeniedByPrefix(pathname, denyPrefixes)) {
137
+ throw new Error(`Endpoint denied by policy: ${pathname}`);
138
+ }
139
+ if (!isAllowedOperation(method, pathname, allowlist.allow)) {
140
+ throw new Error(`Endpoint not allowlisted: ${method} ${pathname}`);
141
+ }
142
+ const queryBase = args.query && typeof args.query === "object" ? args.query : {};
143
+ const query = { ...queryBase };
144
+ const body = args.body;
145
+ if (args.preset) {
146
+ query.preset = args.preset;
147
+ }
148
+ if (args.format) {
149
+ const rawFormat = String(args.format).trim().toLowerCase();
150
+ // Canonical: "llm". Normalize legacy alias "llm_v2" → "llm".
151
+ query.format = rawFormat === "llm_v2" ? "llm" : rawFormat;
152
+ }
153
+ // Legacy: output_mode used to overload both compute preset and output projection.
154
+ // Keep forwarding it for older endpoints, but also map it to preset/format for new endpoints.
155
+ if (args.output_mode) {
156
+ const rawMode = String(args.output_mode).trim().toLowerCase();
157
+ query.output_mode = rawMode;
158
+ if ((rawMode === "full" || rawMode === "simple") && query.preset == null) {
159
+ query.preset = rawMode;
160
+ }
161
+ if ((rawMode === "llm" || rawMode === "llm_v2") && query.format == null) {
162
+ query.format = "llm";
163
+ }
164
+ }
165
+ return await backendClient.request(method, pathname, {
166
+ params: query,
167
+ data: body,
168
+ });
169
+ },
170
+ });
171
+ registerTool({
172
+ name: "dev_list_allowed",
173
+ description: "List all API operations (method + path) that this MCP instance is authorized to call. " +
174
+ "Returns endpoint entries grouped by method, plus the active deny rules. " +
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.",
178
+ inputSchema: {
179
+ type: "object",
180
+ properties: {},
181
+ additionalProperties: false,
182
+ },
183
+ handler: async () => {
184
+ const allowlist = loadAllowlist();
185
+ return {
186
+ schema: allowlist.schema,
187
+ generated_from: allowlist.generated_from,
188
+ deny: allowlist.deny ?? null,
189
+ allow: allowlist.allow,
190
+ counts: {
191
+ allow: allowlist.allow.length,
192
+ },
193
+ };
194
+ },
195
+ });