@openephemeris/mcp-server 3.0.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -22
- package/config/dev-allowlist.json +1319 -1165
- package/dist/backend/client.d.ts +12 -0
- package/dist/backend/client.js +99 -35
- package/dist/index.js +5 -0
- package/dist/schema-packs/llm.d.ts +1 -1
- package/dist/schema-packs/llm.js +1 -1
- package/dist/scripts/dev-allowlist.d.ts +1 -0
- package/dist/scripts/dev-allowlist.js +287 -0
- package/dist/scripts/pack-audit.d.ts +1 -0
- package/dist/scripts/pack-audit.js +45 -0
- package/dist/scripts/schema-packs.d.ts +1 -0
- package/dist/scripts/schema-packs.js +150 -0
- package/dist/scripts/smoke-dev-profile.d.ts +1 -0
- package/dist/scripts/smoke-dev-profile.js +25 -0
- package/dist/scripts/sync-readme.d.ts +1 -0
- package/dist/scripts/sync-readme.js +141 -0
- package/dist/src/auth/credentials.d.ts +65 -0
- package/dist/src/auth/credentials.js +200 -0
- package/dist/src/auth/device-auth.d.ts +56 -0
- package/dist/src/auth/device-auth.js +144 -0
- package/dist/src/backend/client.d.ts +61 -0
- package/dist/src/backend/client.js +335 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +92 -0
- package/dist/src/schema-packs/llm.d.ts +105 -0
- package/dist/src/schema-packs/llm.js +429 -0
- package/dist/src/tools/auth.d.ts +1 -0
- package/dist/src/tools/auth.js +202 -0
- package/dist/src/tools/dev.d.ts +1 -0
- package/dist/src/tools/dev.js +187 -0
- package/dist/src/tools/index.d.ts +25 -0
- package/dist/src/tools/index.js +33 -0
- package/dist/src/tools/specialized/eclipse.d.ts +1 -0
- package/dist/src/tools/specialized/eclipse.js +56 -0
- package/dist/src/tools/specialized/electional.d.ts +1 -0
- package/dist/src/tools/specialized/electional.js +79 -0
- package/dist/src/tools/specialized/human_design.d.ts +1 -0
- package/dist/src/tools/specialized/human_design.js +53 -0
- package/dist/src/tools/specialized/moon.d.ts +1 -0
- package/dist/src/tools/specialized/moon.js +50 -0
- package/dist/src/tools/specialized/natal.d.ts +1 -0
- package/dist/src/tools/specialized/natal.js +71 -0
- package/dist/src/tools/specialized/relocation.d.ts +1 -0
- package/dist/src/tools/specialized/relocation.js +71 -0
- package/dist/src/tools/specialized/synastry.d.ts +1 -0
- package/dist/src/tools/specialized/synastry.js +61 -0
- package/dist/src/tools/specialized/transits.d.ts +1 -0
- package/dist/src/tools/specialized/transits.js +80 -0
- package/dist/test/allowlist-and-tools.test.d.ts +1 -0
- package/dist/test/allowlist-and-tools.test.js +96 -0
- package/dist/test/backend-client.test.d.ts +1 -0
- package/dist/test/backend-client.test.js +286 -0
- package/dist/test/credentials.test.d.ts +1 -0
- package/dist/test/credentials.test.js +143 -0
- package/dist/tools/dev.js +7 -3
- package/dist/tools/index.d.ts +7 -0
- package/package.json +3 -3
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { BackendClient, BackendError } from "../src/backend/client.js";
|
|
4
|
+
async function startJsonServer(handler) {
|
|
5
|
+
const server = http.createServer(handler);
|
|
6
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
7
|
+
const { port } = server.address();
|
|
8
|
+
return {
|
|
9
|
+
baseURL: `http://127.0.0.1:${port}`,
|
|
10
|
+
close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
describe("BackendClient", () => {
|
|
14
|
+
async function getErrorMessage(run) {
|
|
15
|
+
try {
|
|
16
|
+
await run();
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
return error instanceof Error ? error.message : String(error);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
it("injects service key with highest priority", async () => {
|
|
24
|
+
const srv = await startJsonServer((req, res) => {
|
|
25
|
+
res.setHeader("Content-Type", "application/json");
|
|
26
|
+
res.end(JSON.stringify({ headers: req.headers }));
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
const client = new BackendClient({
|
|
30
|
+
baseURL: srv.baseURL,
|
|
31
|
+
serviceKey: "svc-key",
|
|
32
|
+
apiKey: "api-key",
|
|
33
|
+
jwt: "jwt-token",
|
|
34
|
+
});
|
|
35
|
+
const out = await client.get("/headers");
|
|
36
|
+
expect(out.headers["x-service-key"]).toBe("svc-key");
|
|
37
|
+
expect(out.headers["x-api-key"]).toBeUndefined();
|
|
38
|
+
expect(out.headers.authorization).toBeUndefined();
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await srv.close();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
it("respects explicit request auth headers over injected credentials", async () => {
|
|
45
|
+
const srv = await startJsonServer((req, res) => {
|
|
46
|
+
res.setHeader("Content-Type", "application/json");
|
|
47
|
+
res.end(JSON.stringify({ headers: req.headers }));
|
|
48
|
+
});
|
|
49
|
+
try {
|
|
50
|
+
const client = new BackendClient({
|
|
51
|
+
baseURL: srv.baseURL,
|
|
52
|
+
serviceKey: "svc-key",
|
|
53
|
+
});
|
|
54
|
+
const out = await client.request("GET", "/headers", {
|
|
55
|
+
headers: {
|
|
56
|
+
Authorization: "Bearer explicit-token",
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
expect(out.headers.authorization).toBe("Bearer explicit-token");
|
|
60
|
+
expect(out.headers["x-service-key"]).toBeUndefined();
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await srv.close();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
it("maps backend 404 into an MCP-friendly error", async () => {
|
|
67
|
+
const srv = await startJsonServer((_req, res) => {
|
|
68
|
+
res.statusCode = 404;
|
|
69
|
+
res.setHeader("Content-Type", "application/json");
|
|
70
|
+
res.end(JSON.stringify({ message: "missing resource" }));
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
const client = new BackendClient({
|
|
74
|
+
baseURL: srv.baseURL,
|
|
75
|
+
});
|
|
76
|
+
await expect(client.get("/missing")).rejects.toThrow("Resource not found: missing resource");
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await srv.close();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
it("maps backend 401 into signup and API key guidance", async () => {
|
|
83
|
+
const srv = await startJsonServer((_req, res) => {
|
|
84
|
+
res.statusCode = 401;
|
|
85
|
+
res.setHeader("Content-Type", "application/json");
|
|
86
|
+
res.end(JSON.stringify({
|
|
87
|
+
detail: "Missing credentials. Provide X-API-Key or Authorization bearer token.",
|
|
88
|
+
}));
|
|
89
|
+
});
|
|
90
|
+
try {
|
|
91
|
+
const client = new BackendClient({
|
|
92
|
+
baseURL: srv.baseURL,
|
|
93
|
+
});
|
|
94
|
+
const message = await getErrorMessage(() => client.get("/secure"));
|
|
95
|
+
expect(message).toContain("Authentication required:");
|
|
96
|
+
expect(message).toContain("auth.login");
|
|
97
|
+
expect(message).toContain("OPENEPHEMERIS_API_KEY");
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
await srv.close();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
it("maps backend 402 into quota and upgrade guidance", async () => {
|
|
104
|
+
const srv = await startJsonServer((_req, res) => {
|
|
105
|
+
res.statusCode = 402;
|
|
106
|
+
res.setHeader("Content-Type", "application/json");
|
|
107
|
+
res.end(JSON.stringify({
|
|
108
|
+
detail: "Monthly quota exceeded for this account.",
|
|
109
|
+
}));
|
|
110
|
+
});
|
|
111
|
+
try {
|
|
112
|
+
const client = new BackendClient({
|
|
113
|
+
baseURL: srv.baseURL,
|
|
114
|
+
});
|
|
115
|
+
const message = await getErrorMessage(() => client.get("/quota"));
|
|
116
|
+
expect(message).toContain("Usage quota exceeded:");
|
|
117
|
+
expect(message).toContain("https://openephemeris.com/pay");
|
|
118
|
+
expect(message).toContain("https://openephemeris.com/dashboard?tab=account");
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
await srv.close();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
it("maps backend 403 into tier-upgrade guidance", async () => {
|
|
125
|
+
const srv = await startJsonServer((_req, res) => {
|
|
126
|
+
res.statusCode = 403;
|
|
127
|
+
res.setHeader("Content-Type", "application/json");
|
|
128
|
+
res.end(JSON.stringify({
|
|
129
|
+
detail: "This endpoint requires at least the Developer tier.",
|
|
130
|
+
}));
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
const client = new BackendClient({
|
|
134
|
+
baseURL: srv.baseURL,
|
|
135
|
+
});
|
|
136
|
+
const message = await getErrorMessage(() => client.get("/tier-gated"));
|
|
137
|
+
expect(message).toContain("Access blocked by plan or policy:");
|
|
138
|
+
expect(message).toContain("https://openephemeris.com/pay");
|
|
139
|
+
expect(message).toContain("https://openephemeris.com/dashboard?tab=account");
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
await srv.close();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
it("maps backend 429 into dashboard usage guidance", async () => {
|
|
146
|
+
const srv = await startJsonServer((_req, res) => {
|
|
147
|
+
res.statusCode = 429;
|
|
148
|
+
res.setHeader("Content-Type", "application/json");
|
|
149
|
+
res.end(JSON.stringify({
|
|
150
|
+
detail: "Rate limit exceeded for authenticated user tier.",
|
|
151
|
+
}));
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
const client = new BackendClient({
|
|
155
|
+
baseURL: srv.baseURL,
|
|
156
|
+
});
|
|
157
|
+
const message = await getErrorMessage(() => client.get("/rate-limit"));
|
|
158
|
+
expect(message).toContain("Rate limit exceeded.");
|
|
159
|
+
expect(message).toContain("https://openephemeris.com/dashboard?tab=account");
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
await srv.close();
|
|
163
|
+
}
|
|
164
|
+
}, 15_000); // Extended timeout: 3 retries with backoff + jitter
|
|
165
|
+
it("retries on 503 and succeeds on second attempt", async () => {
|
|
166
|
+
let attempt = 0;
|
|
167
|
+
const srv = await startJsonServer((_req, res) => {
|
|
168
|
+
attempt++;
|
|
169
|
+
if (attempt === 1) {
|
|
170
|
+
res.statusCode = 503;
|
|
171
|
+
res.setHeader("Content-Type", "application/json");
|
|
172
|
+
res.end(JSON.stringify({ detail: "Service unavailable" }));
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
res.statusCode = 200;
|
|
176
|
+
res.setHeader("Content-Type", "application/json");
|
|
177
|
+
res.end(JSON.stringify({ ok: true }));
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
try {
|
|
181
|
+
const client = new BackendClient({ baseURL: srv.baseURL });
|
|
182
|
+
const result = await client.get("/transient");
|
|
183
|
+
expect(result.ok).toBe(true);
|
|
184
|
+
expect(attempt).toBe(2);
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
await srv.close();
|
|
188
|
+
}
|
|
189
|
+
}, 10_000);
|
|
190
|
+
it("BackendError exposes status, code, and retryable", async () => {
|
|
191
|
+
const srv = await startJsonServer((_req, res) => {
|
|
192
|
+
res.statusCode = 403;
|
|
193
|
+
res.setHeader("Content-Type", "application/json");
|
|
194
|
+
res.end(JSON.stringify({ detail: "Tier upgrade required" }));
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
const client = new BackendClient({ baseURL: srv.baseURL });
|
|
198
|
+
try {
|
|
199
|
+
await client.get("/gated");
|
|
200
|
+
expect.unreachable("Should have thrown");
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
expect(err).toBeInstanceOf(BackendError);
|
|
204
|
+
const be = err;
|
|
205
|
+
expect(be.status).toBe(403);
|
|
206
|
+
expect(be.code).toBe("tier_required");
|
|
207
|
+
expect(be.retryable).toBe(false);
|
|
208
|
+
expect(be.upgradeUrl).toContain("openephemeris.com");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
await srv.close();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
it("returns a base64 payload envelope for chart-wheel binary endpoints", async () => {
|
|
216
|
+
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02]);
|
|
217
|
+
const srv = await startJsonServer((_req, res) => {
|
|
218
|
+
res.statusCode = 200;
|
|
219
|
+
res.setHeader("Content-Type", "image/png");
|
|
220
|
+
res.end(pngBytes);
|
|
221
|
+
});
|
|
222
|
+
try {
|
|
223
|
+
const client = new BackendClient({
|
|
224
|
+
baseURL: srv.baseURL,
|
|
225
|
+
});
|
|
226
|
+
const payload = await client.request("POST", "/visualization/chart-wheel", {
|
|
227
|
+
params: { format: "png" },
|
|
228
|
+
data: {},
|
|
229
|
+
});
|
|
230
|
+
expect(payload.content_type).toBe("image/png");
|
|
231
|
+
expect(payload.content_length).toBe(pngBytes.length);
|
|
232
|
+
expect(payload.encoding).toBe("base64");
|
|
233
|
+
expect(Buffer.from(payload.data_base64, "base64").equals(pngBytes)).toBe(true);
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
await srv.close();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
it("treats chart-wheel as binary without explicit format query", async () => {
|
|
240
|
+
const svg = "<svg></svg>";
|
|
241
|
+
const srv = await startJsonServer((_req, res) => {
|
|
242
|
+
res.statusCode = 200;
|
|
243
|
+
res.setHeader("Content-Type", "image/svg+xml");
|
|
244
|
+
res.end(svg);
|
|
245
|
+
});
|
|
246
|
+
try {
|
|
247
|
+
const client = new BackendClient({
|
|
248
|
+
baseURL: srv.baseURL,
|
|
249
|
+
});
|
|
250
|
+
const payload = await client.request("POST", "/visualization/chart-wheel", {
|
|
251
|
+
data: {},
|
|
252
|
+
});
|
|
253
|
+
expect(payload.content_type).toBe("image/svg+xml");
|
|
254
|
+
expect(payload.content_length).toBe(Buffer.byteLength(svg));
|
|
255
|
+
expect(payload.encoding).toBe("base64");
|
|
256
|
+
expect(Buffer.from(payload.data_base64, "base64").toString("utf8")).toBe(svg);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
await srv.close();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
it("preserves structured 403 detail for binary-endpoint requests", async () => {
|
|
263
|
+
const srv = await startJsonServer((_req, res) => {
|
|
264
|
+
res.statusCode = 403;
|
|
265
|
+
res.setHeader("Content-Type", "application/json");
|
|
266
|
+
res.end(JSON.stringify({
|
|
267
|
+
detail: "This endpoint requires at least the Startup (Pro) tier.",
|
|
268
|
+
}));
|
|
269
|
+
});
|
|
270
|
+
try {
|
|
271
|
+
const client = new BackendClient({
|
|
272
|
+
baseURL: srv.baseURL,
|
|
273
|
+
});
|
|
274
|
+
const message = await getErrorMessage(() => client.request("POST", "/visualization/bi-wheel", {
|
|
275
|
+
params: { format: "png" },
|
|
276
|
+
data: {},
|
|
277
|
+
}));
|
|
278
|
+
expect(message).toContain("Access blocked by plan or policy:");
|
|
279
|
+
expect(message).toContain("Startup (Pro) tier");
|
|
280
|
+
expect(message).toContain("https://openephemeris.com/pay");
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
await srv.close();
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { CredentialManager } from "../src/auth/credentials.js";
|
|
5
|
+
// Use a temp directory to avoid polluting the real ~/.openephemeris
|
|
6
|
+
const TEMP_DIR = path.join(os.tmpdir(), `oe-cred-test-${Date.now()}`);
|
|
7
|
+
const TEMP_CREDS_FILE = path.join(TEMP_DIR, "credentials.json");
|
|
8
|
+
// We can't easily override the private constant, so we'll test the public API
|
|
9
|
+
// using a real CredentialManager and clean up after ourselves.
|
|
10
|
+
function makeCredentials(overrides = {}) {
|
|
11
|
+
return {
|
|
12
|
+
access_token: "test-access-token-" + Date.now(),
|
|
13
|
+
refresh_token: "test-refresh-token-" + Date.now(),
|
|
14
|
+
expires_at: new Date(Date.now() + 3600 * 1000).toISOString(),
|
|
15
|
+
user_id: "test-user-id",
|
|
16
|
+
user_email: "test@example.com",
|
|
17
|
+
updated_at: new Date().toISOString(),
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
describe("CredentialManager", () => {
|
|
22
|
+
let manager;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
manager = new CredentialManager();
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
// Clean up the default credentials file if it was created by tests
|
|
28
|
+
manager.clear();
|
|
29
|
+
});
|
|
30
|
+
it("returns null when no credentials file exists", () => {
|
|
31
|
+
manager.clear(); // Ensure clean state
|
|
32
|
+
const creds = manager.load();
|
|
33
|
+
// It's either null (no file) or returns existing credentials
|
|
34
|
+
// For a fresh test, it should be null after clear()
|
|
35
|
+
expect(creds).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
it("saves and loads credentials", () => {
|
|
38
|
+
const testCreds = makeCredentials();
|
|
39
|
+
manager.save(testCreds);
|
|
40
|
+
// Create a new manager to test loading from disk
|
|
41
|
+
const freshManager = new CredentialManager();
|
|
42
|
+
const loaded = freshManager.load();
|
|
43
|
+
expect(loaded).not.toBeNull();
|
|
44
|
+
expect(loaded.access_token).toBe(testCreds.access_token);
|
|
45
|
+
expect(loaded.refresh_token).toBe(testCreds.refresh_token);
|
|
46
|
+
expect(loaded.user_email).toBe("test@example.com");
|
|
47
|
+
expect(loaded.user_id).toBe("test-user-id");
|
|
48
|
+
// Cleanup
|
|
49
|
+
freshManager.clear();
|
|
50
|
+
});
|
|
51
|
+
it("detects non-expired credentials", () => {
|
|
52
|
+
const creds = makeCredentials({
|
|
53
|
+
expires_at: new Date(Date.now() + 3600 * 1000).toISOString(), // 1 hour from now
|
|
54
|
+
});
|
|
55
|
+
expect(manager.isExpired(creds)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
it("detects expired credentials", () => {
|
|
58
|
+
const creds = makeCredentials({
|
|
59
|
+
expires_at: new Date(Date.now() - 1000).toISOString(), // 1 second ago
|
|
60
|
+
});
|
|
61
|
+
expect(manager.isExpired(creds)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
it("treats credentials expiring within 2 minutes as expired", () => {
|
|
64
|
+
const creds = makeCredentials({
|
|
65
|
+
expires_at: new Date(Date.now() + 60 * 1000).toISOString(), // 60 seconds from now
|
|
66
|
+
});
|
|
67
|
+
// Within the 120-second buffer, should be considered expired
|
|
68
|
+
expect(manager.isExpired(creds)).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
it("treats null/missing credentials as expired", () => {
|
|
71
|
+
expect(manager.isExpired(null)).toBe(true);
|
|
72
|
+
expect(manager.isExpired(undefined)).toBe(true);
|
|
73
|
+
expect(manager.isExpired({ ...makeCredentials(), expires_at: "" })).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
it("clear removes credentials", () => {
|
|
76
|
+
manager.save(makeCredentials());
|
|
77
|
+
expect(manager.load()).not.toBeNull();
|
|
78
|
+
manager.clear();
|
|
79
|
+
expect(manager.load()).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
it("clear is idempotent (doesn't throw on missing file)", () => {
|
|
82
|
+
manager.clear();
|
|
83
|
+
expect(() => manager.clear()).not.toThrow();
|
|
84
|
+
});
|
|
85
|
+
it("getStatus returns authenticated: false when no credentials", () => {
|
|
86
|
+
manager.clear();
|
|
87
|
+
const status = manager.getStatus();
|
|
88
|
+
expect(status.authenticated).toBe(false);
|
|
89
|
+
expect(status.email).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
it("getStatus returns authenticated: true for valid credentials", () => {
|
|
92
|
+
const creds = makeCredentials({
|
|
93
|
+
expires_at: new Date(Date.now() + 3600 * 1000).toISOString(),
|
|
94
|
+
user_email: "status@example.com",
|
|
95
|
+
});
|
|
96
|
+
manager.save(creds);
|
|
97
|
+
const status = manager.getStatus();
|
|
98
|
+
expect(status.authenticated).toBe(true);
|
|
99
|
+
expect(status.email).toBe("status@example.com");
|
|
100
|
+
expect(status.expiresAt).toBe(creds.expires_at);
|
|
101
|
+
manager.clear();
|
|
102
|
+
});
|
|
103
|
+
it("getStatus returns authenticated: false for expired credentials", () => {
|
|
104
|
+
const creds = makeCredentials({
|
|
105
|
+
expires_at: new Date(Date.now() - 1000).toISOString(),
|
|
106
|
+
});
|
|
107
|
+
manager.save(creds);
|
|
108
|
+
const status = manager.getStatus();
|
|
109
|
+
expect(status.authenticated).toBe(false);
|
|
110
|
+
manager.clear();
|
|
111
|
+
});
|
|
112
|
+
it("saves with updated_at timestamp", () => {
|
|
113
|
+
const before = Date.now();
|
|
114
|
+
manager.save(makeCredentials());
|
|
115
|
+
const loaded = manager.load();
|
|
116
|
+
expect(loaded).not.toBeNull();
|
|
117
|
+
const updatedAt = new Date(loaded.updated_at).getTime();
|
|
118
|
+
expect(updatedAt).toBeGreaterThanOrEqual(before);
|
|
119
|
+
expect(updatedAt).toBeLessThanOrEqual(Date.now());
|
|
120
|
+
manager.clear();
|
|
121
|
+
});
|
|
122
|
+
it("getValidToken returns null when no credentials", async () => {
|
|
123
|
+
manager.clear();
|
|
124
|
+
const token = await manager.getValidToken();
|
|
125
|
+
expect(token).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
it("getValidToken returns token when not expired", async () => {
|
|
128
|
+
const creds = makeCredentials({
|
|
129
|
+
access_token: "valid-token-123",
|
|
130
|
+
expires_at: new Date(Date.now() + 3600 * 1000).toISOString(),
|
|
131
|
+
});
|
|
132
|
+
manager.save(creds);
|
|
133
|
+
const token = await manager.getValidToken();
|
|
134
|
+
expect(token).toBe("valid-token-123");
|
|
135
|
+
manager.clear();
|
|
136
|
+
});
|
|
137
|
+
it("credentialsPath is a string in the home directory", () => {
|
|
138
|
+
const credPath = CredentialManager.credentialsPath;
|
|
139
|
+
expect(typeof credPath).toBe("string");
|
|
140
|
+
expect(credPath).toContain(".openephemeris");
|
|
141
|
+
expect(credPath).toContain("credentials.json");
|
|
142
|
+
});
|
|
143
|
+
});
|
package/dist/tools/dev.js
CHANGED
|
@@ -36,10 +36,11 @@ registerTool({
|
|
|
36
36
|
"Call dev.list_allowed to see all currently available endpoint paths.\n\n" +
|
|
37
37
|
"AUTH: Set ASTROMCP_API_KEY in your environment. See openephemeris.com/dashboard for active plan limits.\n\n" +
|
|
38
38
|
"CREDIT COSTS:\n" +
|
|
39
|
-
" • Standard chart (natal,
|
|
39
|
+
" • Standard chart math (natal, progressed, bazi, HD): 1 credit\n" +
|
|
40
|
+
" • Visualization rendering (chart-wheel, bi-wheel, charts/*): 2 credits\n" +
|
|
41
|
+
" • Comparative math (synastry, composite, overlay): 3 credits\n" +
|
|
40
42
|
" • Predictive ops (transits, returns, transit-chart): 5 credits\n" +
|
|
41
43
|
" • ACG / astrocartography: 5 credits\n" +
|
|
42
|
-
" • Human Design chart: 1 credit\n" +
|
|
43
44
|
" • Catalog / metadata / health endpoints: 0 credits\n" +
|
|
44
45
|
" • format=llm (token-optimized output): requires Pro tier ($49+/mo)\n\n" +
|
|
45
46
|
"COMMON CALLS:\n" +
|
|
@@ -62,7 +63,7 @@ registerTool({
|
|
|
62
63
|
" GET /eclipse/next-visible — Next eclipse visible from a location\n" +
|
|
63
64
|
" GET /tidal/forcing — Gravitational tidal forcing index\n" +
|
|
64
65
|
" GET /tidal/forcing/deep-time — Extended tidal deep-time analysis\n" +
|
|
65
|
-
" POST /acg/
|
|
66
|
+
" POST /acg/power-lines — Astrocartography power lines (lat/lon GeoJSON)\n" +
|
|
66
67
|
" POST /acg/hits — ACG power at a specific location\n" +
|
|
67
68
|
" GET /calendar/astrology/moon-phases — Moon phase calendar for a date range\n" +
|
|
68
69
|
" GET /location/autocomplete — Geocode a place name (query: q=City Name)\n" +
|
|
@@ -71,6 +72,9 @@ registerTool({
|
|
|
71
72
|
" GET /chinese/zodiac — Chinese zodiac year element/animal\n" +
|
|
72
73
|
" POST /vedic/chart — Vedic (Jyotish) natal chart\n" +
|
|
73
74
|
" GET /catalogs/bodies — List all supported celestial bodies\n\n" +
|
|
75
|
+
"BINARY RESPONSES:\n" +
|
|
76
|
+
" • Binary/image endpoints return {content_type, content_length, encoding, data_base64}\n" +
|
|
77
|
+
" so callers can decode bytes deterministically.\n\n" +
|
|
74
78
|
"ECLIPSE NOTE: Eclipse endpoints accept format=llm via the query param like other endpoints.\n\n" +
|
|
75
79
|
"format=llm NOTE: Add query: {format: 'llm'} to natal/synastry/composite/HD endpoints for " +
|
|
76
80
|
"compact columnar output optimized for LLM token budgets (availability depends on your current plan).",
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -3,6 +3,13 @@ export interface ToolDefinition {
|
|
|
3
3
|
name: string;
|
|
4
4
|
description: string;
|
|
5
5
|
inputSchema: z.ZodType<any> | Record<string, unknown>;
|
|
6
|
+
annotations?: {
|
|
7
|
+
title?: string;
|
|
8
|
+
readOnlyHint?: boolean;
|
|
9
|
+
destructiveHint?: boolean;
|
|
10
|
+
idempotentHint?: boolean;
|
|
11
|
+
openWorldHint?: boolean;
|
|
12
|
+
};
|
|
6
13
|
handler: (args: any) => Promise<any>;
|
|
7
14
|
}
|
|
8
15
|
export declare const toolRegistry: Record<string, ToolDefinition>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openephemeris/mcp-server",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Model Context Protocol server for the Open Ephemeris astronomical computation API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"build": "tsc",
|
|
20
20
|
"dev": "tsx watch src/index.ts",
|
|
21
21
|
"start": "node dist/index.js",
|
|
22
|
-
"test": "vitest run",
|
|
23
|
-
"test:watch": "vitest",
|
|
22
|
+
"test": "vitest run --exclude \"dist/**\"",
|
|
23
|
+
"test:watch": "vitest --exclude \"dist/**\"",
|
|
24
24
|
"typecheck": "tsc --noEmit",
|
|
25
25
|
"regen:dev-allowlist": "tsx scripts/dev-allowlist.ts --write",
|
|
26
26
|
"check:dev-allowlist": "tsx scripts/dev-allowlist.ts --check",
|