@openephemeris/mcp-server 3.1.0 → 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.
Files changed (51) hide show
  1. package/README.md +30 -21
  2. package/dist/index.js +0 -0
  3. package/dist/scripts/dev-allowlist.d.ts +1 -0
  4. package/dist/scripts/dev-allowlist.js +287 -0
  5. package/dist/scripts/pack-audit.d.ts +1 -0
  6. package/dist/scripts/pack-audit.js +45 -0
  7. package/dist/scripts/schema-packs.d.ts +1 -0
  8. package/dist/scripts/schema-packs.js +150 -0
  9. package/dist/scripts/smoke-dev-profile.d.ts +1 -0
  10. package/dist/scripts/smoke-dev-profile.js +25 -0
  11. package/dist/scripts/sync-readme.d.ts +1 -0
  12. package/dist/scripts/sync-readme.js +141 -0
  13. package/dist/src/auth/credentials.d.ts +65 -0
  14. package/dist/src/auth/credentials.js +200 -0
  15. package/dist/src/auth/device-auth.d.ts +56 -0
  16. package/dist/src/auth/device-auth.js +144 -0
  17. package/dist/src/backend/client.d.ts +61 -0
  18. package/dist/src/backend/client.js +335 -0
  19. package/dist/src/index.d.ts +2 -0
  20. package/dist/src/index.js +92 -0
  21. package/dist/src/schema-packs/llm.d.ts +105 -0
  22. package/dist/src/schema-packs/llm.js +429 -0
  23. package/dist/src/tools/auth.d.ts +1 -0
  24. package/dist/src/tools/auth.js +202 -0
  25. package/dist/src/tools/dev.d.ts +1 -0
  26. package/dist/src/tools/dev.js +187 -0
  27. package/dist/src/tools/index.d.ts +25 -0
  28. package/dist/src/tools/index.js +33 -0
  29. package/dist/src/tools/specialized/eclipse.d.ts +1 -0
  30. package/dist/src/tools/specialized/eclipse.js +56 -0
  31. package/dist/src/tools/specialized/electional.d.ts +1 -0
  32. package/dist/src/tools/specialized/electional.js +79 -0
  33. package/dist/src/tools/specialized/human_design.d.ts +1 -0
  34. package/dist/src/tools/specialized/human_design.js +53 -0
  35. package/dist/src/tools/specialized/moon.d.ts +1 -0
  36. package/dist/src/tools/specialized/moon.js +50 -0
  37. package/dist/src/tools/specialized/natal.d.ts +1 -0
  38. package/dist/src/tools/specialized/natal.js +71 -0
  39. package/dist/src/tools/specialized/relocation.d.ts +1 -0
  40. package/dist/src/tools/specialized/relocation.js +71 -0
  41. package/dist/src/tools/specialized/synastry.d.ts +1 -0
  42. package/dist/src/tools/specialized/synastry.js +61 -0
  43. package/dist/src/tools/specialized/transits.d.ts +1 -0
  44. package/dist/src/tools/specialized/transits.js +80 -0
  45. package/dist/test/allowlist-and-tools.test.d.ts +1 -0
  46. package/dist/test/allowlist-and-tools.test.js +96 -0
  47. package/dist/test/backend-client.test.d.ts +1 -0
  48. package/dist/test/backend-client.test.js +286 -0
  49. package/dist/test/credentials.test.d.ts +1 -0
  50. package/dist/test/credentials.test.js +143 -0
  51. package/package.json +21 -18
package/README.md CHANGED
@@ -7,7 +7,7 @@ Model Context Protocol server for OpenEphemeris. This package exposes typed astr
7
7
  ### One-click install (Cursor)
8
8
 
9
9
  <!-- GENERATED:CURSOR_INSTALL:BEGIN -->
10
- [![Install in Cursor](https://img.shields.io/badge/Install%20in-Cursor-1f6feb)](cursor://anysphere.cursor-deeplink/mcp/install?name=openephemeris&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBvcGVuZXBoZW1lcmlzL21jcC1zZXJ2ZXIiXSwiZW52Ijp7IkFTVFJPTUNQX1BST0ZJTEUiOiJkZXYiLCJBU1RST01DUF9CQUNLRU5EX1VSTCI6Imh0dHBzOi8vYXBpLm9wZW5lcGhlbWVyaXMuY29tIiwiQVNUUk9NQ1BfQVBJX0tFWSI6IllPVVJfQVBJX0tFWV9IRVJFIn19)
10
+ [![Install in Cursor](https://img.shields.io/badge/Install%20in-Cursor-1f6feb)](cursor://anysphere.cursor-deeplink/mcp/install?name=openephemeris&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBvcGVuZXBoZW1lcmlzL21jcC1zZXJ2ZXIiXSwiZW52Ijp7Ik9QRU5FUEhFTUVSSVNfUFJPRklMRSI6ImRldiIsIk9QRU5FUEhFTUVSSVNfQkFDS0VORF9VUkwiOiJodHRwczovL2FwaS5vcGVuZXBoZW1lcmlzLmNvbSIsIk9QRU5FUEhFTUVSSVNfQVBJX0tFWSI6IllPVVJfQVBJX0tFWV9IRVJFIn19)
11
11
 
12
12
  > Replace `YOUR_API_KEY_HERE` in Cursor MCP settings with your API key from https://openephemeris.com/dashboard.
13
13
 
@@ -20,9 +20,9 @@ Cursor deeplink payload:
20
20
  "@openephemeris/mcp-server"
21
21
  ],
22
22
  "env": {
23
- "ASTROMCP_PROFILE": "dev",
24
- "ASTROMCP_BACKEND_URL": "https://api.openephemeris.com",
25
- "ASTROMCP_API_KEY": "YOUR_API_KEY_HERE"
23
+ "OPENEPHEMERIS_PROFILE": "dev",
24
+ "OPENEPHEMERIS_BACKEND_URL": "https://api.openephemeris.com",
25
+ "OPENEPHEMERIS_API_KEY": "YOUR_API_KEY_HERE"
26
26
  }
27
27
  }
28
28
  ```
@@ -37,9 +37,9 @@ Cursor deeplink payload:
37
37
  "command": "npx",
38
38
  "args": ["-y", "@openephemeris/mcp-server"],
39
39
  "env": {
40
- "ASTROMCP_PROFILE": "dev",
41
- "ASTROMCP_BACKEND_URL": "https://api.openephemeris.com",
42
- "ASTROMCP_API_KEY": "YOUR_API_KEY_HERE"
40
+ "OPENEPHEMERIS_PROFILE": "dev",
41
+ "OPENEPHEMERIS_BACKEND_URL": "https://api.openephemeris.com",
42
+ "OPENEPHEMERIS_API_KEY": "YOUR_API_KEY_HERE"
43
43
  }
44
44
  }
45
45
  }
@@ -48,6 +48,8 @@ Cursor deeplink payload:
48
48
 
49
49
  ### Platform guide
50
50
 
51
+ > **Detailed setup walkthroughs** for each platform are in [SETUP.md](./SETUP.md).
52
+
51
53
  | Client | Install mode | Config location |
52
54
  |---|---|---|
53
55
  | Cursor | One-click deeplink or manual | `~/.cursor/mcp.json` |
@@ -112,15 +114,16 @@ This package runs as a local stdio MCP server. Remote-only clients require a hos
112
114
 
113
115
  | Variable | Required | Description |
114
116
  |---|---|---|
115
- | `ASTROMCP_API_KEY` | Yes (unless service key/JWT used) | API key for OpenEphemeris |
116
- | `ASTROMCP_BACKEND_URL` | No | Defaults to `https://api.openephemeris.com` |
117
- | `ASTROMCP_PROFILE` | No | `dev` by default |
118
- | `ASTROMCP_SERVICE_KEY` | No | Internal service auth |
119
- | `ASTROMCP_JWT` | No | Bearer token auth |
120
- | `ASTROMCP_DEV_ALLOWLIST_PATH` | No | Override allowlist file path |
117
+ | `OPENEPHEMERIS_API_KEY` | Yes (unless service key/JWT used) | API key for OpenEphemeris |
118
+ | `ASTROMCP_API_KEY` | No | Legacy alias for `OPENEPHEMERIS_API_KEY` (checked as fallback) |
119
+ | `OPENEPHEMERIS_BACKEND_URL` | No | Defaults to `https://api.openephemeris.com` |
120
+ | `OPENEPHEMERIS_PROFILE` | No | `dev` by default |
121
+ | `OPENEPHEMERIS_SERVICE_KEY` | No | Internal service auth |
122
+ | `OPENEPHEMERIS_JWT` | No | Bearer token auth |
123
+ | `OPENEPHEMERIS_DEV_ALLOWLIST_PATH` | No | Override allowlist file path |
121
124
  | `MCP_USER_ID` | No | Per-instance user identifier |
122
125
 
123
- Legacy aliases (`MERIDIAN_*`) remain supported.
126
+ Legacy aliases (`ASTROMCP_*`, `MERIDIAN_*`) remain supported.
124
127
 
125
128
  ## Development
126
129
 
@@ -161,25 +164,31 @@ OpenEphemeris API
161
164
 
162
165
  Generated by `npm run sync:readme` from `config/dev-allowlist.json` and the live tool registry.
163
166
 
164
- - Allowlisted operations: **67**
165
- - Methods: `GET=22`, `POST=45`, `PUT=0`, `PATCH=0`, `DELETE=0`
166
- - Registered tools (`ASTROMCP_PROFILE=dev`): **10**
167
- - Typed tools: `ephemeris.electional`, `ephemeris.moon_phase`, `ephemeris.natal_chart`, `ephemeris.next_eclipse`, `ephemeris.relocation`, `ephemeris.synastry`, `ephemeris.transits`, `human_design.chart`
167
+ - Allowlisted operations: **97**
168
+ - Methods: `GET=41`, `POST=56`, `PUT=0`, `PATCH=0`, `DELETE=0`
169
+ - Registered tools (`OPENEPHEMERIS_PROFILE=dev`): **13**
170
+ - Typed tools: `auth.login`, `auth.logout`, `auth.status`, `ephemeris.electional`, `ephemeris.moon_phase`, `ephemeris.natal_chart`, `ephemeris.next_eclipse`, `ephemeris.relocation`, `ephemeris.synastry`, `ephemeris.transits`, `human_design.chart`
168
171
  - Generic tools: `dev.call`, `dev.list_allowed`
169
172
 
170
173
  ### Allowlist Families
171
174
 
172
175
  | Family | Operations | Example |
173
176
  |---|---:|---|
174
- | `acg` | 4 | `POST /acg/ccg`, `GET /acg/health` |
177
+ | `acg` | 12 | `POST /acg/aspects`, `POST /acg/ccg` |
178
+ | `calendar` | 3 | `GET /calendar/astrology/cross-quarter`, `GET /calendar/astrology/lunar-standstill` |
175
179
  | `catalogs` | 3 | `GET /catalogs/bodies`, `GET /catalogs/fixed-stars` |
180
+ | `chinese` | 2 | `POST /chinese/bazi`, `GET /chinese/zodiac` |
176
181
  | `comparative` | 5 | `POST /comparative/composite`, `POST /comparative/composite/midpoint` |
182
+ | `eclipse` | 5 | `GET /eclipse/besselian-elements`, `GET /eclipse/lunar/global` |
177
183
  | `electional` | 5 | `GET /electional/aspect-search`, `GET /electional/find-window` |
178
- | `ephemeris` | 29 | `POST /ephemeris/angles-points`, `POST /ephemeris/aspect-check` |
184
+ | `ephemeris` | 33 | `GET /ephemeris/agro/calendar`, `GET /ephemeris/agro/daily` |
179
185
  | `health` | 1 | `GET /health` |
180
- | `human-design` | 6 | `POST /human-design/bodygraph`, `POST /human-design/chart` |
186
+ | `human-design` | 5 | `POST /human-design/chart`, `POST /human-design/composite` |
181
187
  | `location` | 2 | `GET /location/autocomplete`, `GET /location/reverse` |
182
188
  | `predictive` | 8 | `POST /predictive/events`, `POST /predictive/returns` |
189
+ | `tidal` | 2 | `GET /tidal/forcing`, `GET /tidal/forcing/deep-time` |
190
+ | `time` | 6 | `GET /time/delta-t`, `GET /time/equation-of-time` |
183
191
  | `timezone` | 2 | `POST /timezone/lookup`, `POST /timezone/offset` |
192
+ | `vedic` | 1 | `POST /vedic/chart` |
184
193
  | `visualization` | 2 | `POST /visualization/bi-wheel`, `POST /visualization/chart-wheel` |
185
194
  <!-- GENERATED:RUNTIME_SNAPSHOT:END -->
package/dist/index.js CHANGED
File without changes
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,287 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { fileURLToPath } from "node:url";
5
+ import axios from "axios";
6
+ function normalizeTier(raw) {
7
+ if (typeof raw !== "string")
8
+ return null;
9
+ const value = raw.trim().toLowerCase();
10
+ if (value === "free" ||
11
+ value === "all" ||
12
+ value === "free/all" ||
13
+ value === "anonymous" ||
14
+ value === "anonymous/all" ||
15
+ value === "public" ||
16
+ value === "public/all")
17
+ return "free";
18
+ if (value === "dev" || value === "developer")
19
+ return "developer";
20
+ if (value === "pro")
21
+ return "pro";
22
+ if (value === "production")
23
+ return "production";
24
+ if (value === "admin" || value === "internal")
25
+ return "admin";
26
+ return null;
27
+ }
28
+ function isCommercialTier(tierNormalized) {
29
+ // Dev MCP should expose all commercially available tiers up through production.
30
+ return tierNormalized === "free" || tierNormalized === "developer" || tierNormalized === "pro" || tierNormalized === "production";
31
+ }
32
+ function sha256(text) {
33
+ return crypto.createHash("sha256").update(text).digest("hex");
34
+ }
35
+ function readJsonFile(filePath) {
36
+ const raw = fs.readFileSync(filePath, "utf-8");
37
+ return { raw, json: JSON.parse(raw) };
38
+ }
39
+ function writeJsonFile(filePath, obj) {
40
+ fs.writeFileSync(filePath, JSON.stringify(obj, null, 2) + "\n", "utf-8");
41
+ }
42
+ function normalizeTag(tag) {
43
+ return tag.trim().toLowerCase();
44
+ }
45
+ function isDeniedByPrefix(pathname, prefixes) {
46
+ return prefixes.some((p) => pathname.startsWith(p));
47
+ }
48
+ function hasDeniedTag(tags, denyTags) {
49
+ const deny = new Set(denyTags.map(normalizeTag));
50
+ return tags.some((t) => deny.has(normalizeTag(t)));
51
+ }
52
+ function getDefaults() {
53
+ const here = path.dirname(fileURLToPath(import.meta.url)); // .../mcp-server/scripts
54
+ const mcpServerRoot = path.resolve(here, "..");
55
+ const repoRoot = path.resolve(mcpServerRoot, "..");
56
+ return {
57
+ mcpServerRoot,
58
+ repoRoot,
59
+ allowlistPath: path.join(mcpServerRoot, "config", "dev-allowlist.json"),
60
+ openapiPath: path.join(repoRoot, "go-sidecar", "openapi.json"),
61
+ };
62
+ }
63
+ function parseArgs(argv) {
64
+ const args = {
65
+ openapiPath: undefined,
66
+ allowlistPath: undefined,
67
+ write: false,
68
+ check: false,
69
+ };
70
+ for (let i = 0; i < argv.length; i++) {
71
+ const a = argv[i];
72
+ if (a === "--openapi")
73
+ args.openapiPath = argv[++i];
74
+ else if (a === "--allowlist")
75
+ args.allowlistPath = argv[++i];
76
+ else if (a === "--write")
77
+ args.write = true;
78
+ else if (a === "--check")
79
+ args.check = true;
80
+ else if (a === "--help" || a === "-h") {
81
+ console.log([
82
+ "Dev allowlist helper (astromcp-dev-allowlist-v1)",
83
+ "",
84
+ "Usage:",
85
+ " tsx scripts/dev-allowlist.ts --check",
86
+ " tsx scripts/dev-allowlist.ts --write",
87
+ "",
88
+ "Options:",
89
+ " --openapi <path> Path to OpenAPI JSON (default: ../go-sidecar/openapi.json)",
90
+ " --allowlist <path> Path to allowlist JSON (default: config/dev-allowlist.json)",
91
+ " --check Validate allowlist against OpenAPI + deny rules (exit 1 on failure)",
92
+ " --write Update allowlist metadata + candidates + auto-allow commercial-tier operations",
93
+ ].join("\n"));
94
+ process.exit(0);
95
+ }
96
+ }
97
+ return args;
98
+ }
99
+ function asHttpMethod(m) {
100
+ const up = m.toUpperCase();
101
+ if (up === "GET" || up === "POST" || up === "PUT" || up === "PATCH" || up === "DELETE")
102
+ return up;
103
+ return null;
104
+ }
105
+ function buildCandidates(openapi, denyPrefixes, denyTags) {
106
+ const candidatesGet = [];
107
+ const candidatesNonGet = [];
108
+ const index = new Map();
109
+ for (const [pathname, pathItem] of Object.entries(openapi.paths || {})) {
110
+ // OpenAPI keys can include templated params; we keep as-is.
111
+ if (denyPrefixes.length && isDeniedByPrefix(pathname, denyPrefixes))
112
+ continue;
113
+ for (const [methodKey, operation] of Object.entries(pathItem || {})) {
114
+ const method = asHttpMethod(methodKey);
115
+ if (!method)
116
+ continue;
117
+ const tags = Array.isArray(operation?.tags) ? operation.tags : [];
118
+ if (denyTags.length && hasDeniedTag(tags, denyTags))
119
+ continue;
120
+ const tier = typeof operation?.["x-meridian-tier"] === "string" ? String(operation["x-meridian-tier"]) : undefined;
121
+ const tier_normalized = tier ? normalizeTier(tier) ?? undefined : undefined;
122
+ const candidate = {
123
+ method,
124
+ path: pathname,
125
+ tags,
126
+ tier,
127
+ tier_normalized,
128
+ operationId: typeof operation?.operationId === "string" ? operation.operationId : undefined,
129
+ summary: typeof operation?.summary === "string" ? operation.summary : undefined,
130
+ };
131
+ const key = `${method} ${pathname}`;
132
+ index.set(key, candidate);
133
+ if (method === "GET")
134
+ candidatesGet.push(candidate);
135
+ else
136
+ candidatesNonGet.push(candidate);
137
+ }
138
+ }
139
+ candidatesGet.sort((a, b) => (a.path + a.method).localeCompare(b.path + b.method));
140
+ candidatesNonGet.sort((a, b) => (a.path + a.method).localeCompare(b.path + b.method));
141
+ return { candidatesGet, candidatesNonGet, index };
142
+ }
143
+ function buildCommercialAllow(candidates, existingAllow) {
144
+ // Build a lookup of existing notes/metadata to preserve across regen
145
+ const existingMeta = new Map();
146
+ for (const e of existingAllow) {
147
+ if (e.note)
148
+ existingMeta.set(`${e.method} ${e.path}`, { note: e.note });
149
+ }
150
+ const allow = candidates
151
+ .filter((c) => isCommercialTier(c.tier_normalized))
152
+ .map((c) => {
153
+ const key = `${c.method} ${c.path}`;
154
+ const entry = { method: c.method, path: c.path };
155
+ const meta = existingMeta.get(key);
156
+ if (meta?.note)
157
+ entry.note = meta.note;
158
+ return entry;
159
+ });
160
+ // Deterministic ordering for diffs.
161
+ allow.sort((a, b) => (a.path + a.method).localeCompare(b.path + b.method));
162
+ return allow;
163
+ }
164
+ function validateAllowlist(allowlist, index, denyPrefixes, denyTags) {
165
+ const errors = [];
166
+ const warnings = [];
167
+ for (const entry of allowlist.allow) {
168
+ if (!entry || typeof entry !== "object") {
169
+ errors.push(`Invalid allow entry (not an object): ${JSON.stringify(entry)}`);
170
+ continue;
171
+ }
172
+ const method = entry.method;
173
+ const pathname = entry.path;
174
+ if (!asHttpMethod(method)) {
175
+ errors.push(`Invalid method in allow entry: ${JSON.stringify(entry)}`);
176
+ continue;
177
+ }
178
+ if (typeof pathname !== "string" || !pathname.startsWith("/")) {
179
+ errors.push(`Invalid path in allow entry: ${JSON.stringify(entry)}`);
180
+ continue;
181
+ }
182
+ if (denyPrefixes.length && isDeniedByPrefix(pathname, denyPrefixes)) {
183
+ errors.push(`Allowlisted entry violates deny prefix rule: ${method} ${pathname}`);
184
+ continue;
185
+ }
186
+ const key = `${method.toUpperCase()} ${pathname}`;
187
+ const candidate = index.get(key);
188
+ if (!candidate) {
189
+ errors.push(`Allowlisted entry not found in OpenAPI: ${key}`);
190
+ continue;
191
+ }
192
+ if (!isCommercialTier(candidate.tier_normalized)) {
193
+ errors.push(`Allowlisted entry is not a commercial-tier operation: ${key} (x-meridian-tier=${candidate.tier ?? "<missing>"})`);
194
+ continue;
195
+ }
196
+ if (denyTags.length && hasDeniedTag(candidate.tags, denyTags)) {
197
+ errors.push(`Allowlisted entry violates deny tag rule: ${key} (tags=${candidate.tags.join(",")})`);
198
+ continue;
199
+ }
200
+ // Method gating reminder: non-GET is allowed only by explicit allowlist (which it is),
201
+ // but we keep a warning so reviewers notice.
202
+ if (method.toUpperCase() !== "GET") {
203
+ warnings.push(`Non-GET allowlisted: ${key}`);
204
+ }
205
+ }
206
+ return { errors, warnings };
207
+ }
208
+ async function getOpenApiDocument(inputPathOrUrl) {
209
+ if (inputPathOrUrl.startsWith("http")) {
210
+ console.log(`Fetching OpenAPI from URL: ${inputPathOrUrl}`);
211
+ const response = await axios.get(inputPathOrUrl);
212
+ const json = response.data;
213
+ return { raw: JSON.stringify(json), json };
214
+ }
215
+ const raw = fs.readFileSync(inputPathOrUrl, "utf-8");
216
+ return { raw, json: JSON.parse(raw) };
217
+ }
218
+ async function main() {
219
+ const defaults = getDefaults();
220
+ const args = parseArgs(process.argv.slice(2));
221
+ const allowlistPath = path.resolve(args.allowlistPath || defaults.allowlistPath);
222
+ const openapiPathOrUrl = args.openapiPath || process.env.ASTROMCP_OPENAPI_URL || defaults.openapiPath;
223
+ const { raw: allowRaw, json: allowlist } = readJsonFile(allowlistPath);
224
+ if (!allowlist || allowlist.schema !== "astromcp-dev-allowlist-v1" || !Array.isArray(allowlist.allow)) {
225
+ throw new Error(`Invalid allowlist schema at ${allowlistPath}`);
226
+ }
227
+ const denyPrefixes = allowlist.deny?.path_prefixes ?? [];
228
+ const denyTags = allowlist.deny?.tags ?? [];
229
+ const { raw: openapiRaw, json: openapi } = await getOpenApiDocument(openapiPathOrUrl);
230
+ if (!openapi || typeof openapi !== "object" || !openapi.paths) {
231
+ throw new Error(`Invalid OpenAPI document at ${openapiPathOrUrl}`);
232
+ }
233
+ const openapiHash = sha256(openapiRaw);
234
+ const allowlistHash = sha256(allowRaw);
235
+ const { candidatesGet, candidatesNonGet, index } = buildCandidates(openapi, denyPrefixes, denyTags);
236
+ const computedAllow = buildCommercialAllow([...candidatesGet, ...candidatesNonGet], allowlist.allow);
237
+ const allowlistForValidation = args.write ? { ...allowlist, allow: computedAllow } : allowlist;
238
+ const { errors, warnings } = validateAllowlist(allowlistForValidation, index, denyPrefixes, denyTags);
239
+ const report = {
240
+ ok: errors.length === 0,
241
+ allowlistPath,
242
+ openapiPath: openapiPathOrUrl,
243
+ openapi_sha256: openapiHash,
244
+ allowlist_sha256: allowlistHash,
245
+ counts: {
246
+ allow: allowlistForValidation.allow.length,
247
+ candidates_get: candidatesGet.length,
248
+ candidates_non_get: candidatesNonGet.length,
249
+ errors: errors.length,
250
+ warnings: warnings.length,
251
+ },
252
+ errors,
253
+ warnings,
254
+ };
255
+ if (args.write) {
256
+ const now = new Date().toISOString();
257
+ const updated = {
258
+ ...allowlist,
259
+ generated_from: openapiPathOrUrl,
260
+ last_generated_at: now,
261
+ openapi_sha256: openapiHash,
262
+ allow: computedAllow,
263
+ candidates_get: candidatesGet.map((c) => ({ method: c.method, path: c.path, tags: c.tags, operationId: c.operationId })),
264
+ candidates_non_get: candidatesNonGet.map((c) => ({ method: c.method, path: c.path, tags: c.tags, operationId: c.operationId })),
265
+ validation: {
266
+ ok: errors.length === 0,
267
+ errors,
268
+ warnings,
269
+ },
270
+ };
271
+ writeJsonFile(allowlistPath, updated);
272
+ }
273
+ if (args.check) {
274
+ if (!report.ok) {
275
+ console.error(JSON.stringify(report, null, 2));
276
+ process.exit(1);
277
+ }
278
+ // Print a small success line for CI.
279
+ console.log(JSON.stringify(report, null, 2));
280
+ return;
281
+ }
282
+ console.log(JSON.stringify(report, null, 2));
283
+ }
284
+ main().catch((err) => {
285
+ console.error(err instanceof Error ? err.message : String(err));
286
+ process.exit(1);
287
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import { execSync } from "node:child_process";
2
+ function main() {
3
+ const raw = execSync("npm pack --dry-run --json", {
4
+ encoding: "utf-8",
5
+ stdio: ["ignore", "pipe", "inherit"],
6
+ });
7
+ const parsed = JSON.parse(raw);
8
+ if (!Array.isArray(parsed) || parsed.length === 0) {
9
+ throw new Error("Unable to parse npm pack --dry-run --json output.");
10
+ }
11
+ const pack = parsed[0];
12
+ const filePaths = new Set(pack.files.map((f) => f.path));
13
+ const required = ["dist/index.js", "config/dev-allowlist.json", "README.md", "package.json"];
14
+ const missing = required.filter((f) => !filePaths.has(f));
15
+ if (missing.length > 0) {
16
+ throw new Error(`Pack audit failed: required files missing: ${missing.join(", ")}`);
17
+ }
18
+ const bannedPatterns = [
19
+ /^src\//,
20
+ /^test\//,
21
+ /^scripts\//,
22
+ /^test-output/,
23
+ /^diff\.txt$/,
24
+ /^tsconfig(?:\.test)?\.json$/,
25
+ /^package-lock\.json$/,
26
+ /^node_modules\//,
27
+ ];
28
+ const banned = pack.files
29
+ .map((f) => f.path)
30
+ .filter((p) => bannedPatterns.some((re) => re.test(p)));
31
+ if (banned.length > 0) {
32
+ throw new Error(`Pack audit failed: unexpected files in tarball: ${banned.join(", ")}`);
33
+ }
34
+ const distFiles = pack.files.filter((f) => f.path.startsWith("dist/"));
35
+ if (distFiles.length === 0) {
36
+ throw new Error("Pack audit failed: no dist/* files present in tarball.");
37
+ }
38
+ console.log(JSON.stringify({
39
+ ok: true,
40
+ filename: pack.filename,
41
+ file_count: pack.files.length,
42
+ dist_file_count: distFiles.length,
43
+ }, null, 2));
44
+ }
45
+ main();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,150 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { LLM_V2_ASPECTS_SCHEMA, LLM_V2_DICT, LLM_V2_POINTS_SCHEMA, LlmV2PayloadSchema, llmV2JsonSchema, } from "../src/schema-packs/llm.js";
6
+ function parseMode(argv) {
7
+ if (argv.includes("--write"))
8
+ return "write";
9
+ if (argv.includes("--check"))
10
+ return "check";
11
+ throw new Error("Usage: tsx scripts/schema-packs.ts --write|--check");
12
+ }
13
+ function ensureDir(dirPath) {
14
+ fs.mkdirSync(dirPath, { recursive: true });
15
+ }
16
+ function readJsonFile(filePath) {
17
+ const raw = fs.readFileSync(filePath, "utf-8");
18
+ return JSON.parse(raw);
19
+ }
20
+ function writeJsonFile(filePath, obj) {
21
+ const raw = JSON.stringify(obj, null, 2) + "\n";
22
+ fs.writeFileSync(filePath, raw, "utf-8");
23
+ }
24
+ function stableStringify(obj) {
25
+ // Deterministic enough for our use: we always write with JSON.stringify(..., null, 2)
26
+ // and compare against the same formatting.
27
+ return JSON.stringify(obj, null, 2) + "\n";
28
+ }
29
+ function validateFixture(filePath) {
30
+ const payload = readJsonFile(filePath);
31
+ const parsed = LlmV2PayloadSchema.safeParse(payload);
32
+ if (!parsed.success) {
33
+ const msg = parsed.error.issues
34
+ .slice(0, 25)
35
+ .map((i) => `${i.path.join(".")}: ${i.message}`)
36
+ .join("\n");
37
+ throw new Error(`Invalid llm fixture: ${filePath}\n${msg}`);
38
+ }
39
+ }
40
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
41
+ const MCP_ROOT = path.resolve(SCRIPT_DIR, "..");
42
+ const PACK_DIR = path.join(MCP_ROOT, "schema-packs", "llm");
43
+ const FIXTURES_DIR = path.join(PACK_DIR, "fixtures");
44
+ const SCHEMA_PATH = path.join(PACK_DIR, "schema.json");
45
+ const REPO_ROOT = path.resolve(MCP_ROOT, "..");
46
+ const CANDIDATE_FIXTURES = [
47
+ path.join(REPO_ROOT, "tmp", "llm_fe_sample.json"),
48
+ path.join(REPO_ROOT, "tmp", "llm_payload_user_2025-12-21.json"),
49
+ ];
50
+ function copyFirstExistingFixture() {
51
+ for (const candidate of CANDIDATE_FIXTURES) {
52
+ if (!fs.existsSync(candidate))
53
+ continue;
54
+ const dest = path.join(FIXTURES_DIR, path.basename(candidate));
55
+ fs.copyFileSync(candidate, dest);
56
+ return dest;
57
+ }
58
+ return null;
59
+ }
60
+ function writeFallbackFixture() {
61
+ const fallbackPath = path.join(FIXTURES_DIR, "minimal-natal.json");
62
+ const minimalPayload = {
63
+ output_mode: "llm",
64
+ chart: {
65
+ chart_type: "natal",
66
+ subject: {
67
+ datetime: "2026-01-01T00:00:00Z",
68
+ jd: null,
69
+ lat: 0,
70
+ lon: 0,
71
+ },
72
+ },
73
+ dict: LLM_V2_DICT,
74
+ present: {
75
+ point_count: 1,
76
+ kinds: {
77
+ planet: 1,
78
+ },
79
+ },
80
+ points_schema: LLM_V2_POINTS_SCHEMA,
81
+ points: [["sun", "planet", 0, 280, 0, 1, 0, 9, 10, null, 23, 0, "none", null, null, 0]],
82
+ aspects_schema: LLM_V2_ASPECTS_SCHEMA,
83
+ aspects: [],
84
+ counts: {
85
+ point_count: 1,
86
+ aspect_count: 0,
87
+ },
88
+ };
89
+ writeJsonFile(fallbackPath, minimalPayload);
90
+ return fallbackPath;
91
+ }
92
+ function listFixtures() {
93
+ if (!fs.existsSync(FIXTURES_DIR))
94
+ return [];
95
+ return fs
96
+ .readdirSync(FIXTURES_DIR)
97
+ .filter((f) => f.toLowerCase().endsWith(".json"))
98
+ .map((f) => path.join(FIXTURES_DIR, f));
99
+ }
100
+ function checkSchemaFile() {
101
+ if (!fs.existsSync(SCHEMA_PATH)) {
102
+ throw new Error(`Missing schema file: ${SCHEMA_PATH} (run --write)`);
103
+ }
104
+ const disk = readJsonFile(SCHEMA_PATH);
105
+ const expected = llmV2JsonSchema;
106
+ const diskRaw = fs.readFileSync(SCHEMA_PATH, "utf-8");
107
+ const expectedRaw = stableStringify(expected);
108
+ if (diskRaw !== expectedRaw) {
109
+ throw new Error(`schema.json drift detected (run --write): ${SCHEMA_PATH}`);
110
+ }
111
+ }
112
+ function main() {
113
+ const mode = parseMode(process.argv.slice(2));
114
+ if (mode === "write") {
115
+ ensureDir(FIXTURES_DIR);
116
+ ensureDir(PACK_DIR);
117
+ writeJsonFile(SCHEMA_PATH, llmV2JsonSchema);
118
+ if (listFixtures().length === 0) {
119
+ const copied = copyFirstExistingFixture();
120
+ if (copied) {
121
+ validateFixture(copied);
122
+ // eslint-disable-next-line no-console
123
+ console.log(`Copied fixture: ${copied}`);
124
+ }
125
+ else {
126
+ const fallback = writeFallbackFixture();
127
+ validateFixture(fallback);
128
+ // eslint-disable-next-line no-console
129
+ console.log(`Wrote fallback fixture: ${fallback}`);
130
+ }
131
+ }
132
+ for (const fixture of listFixtures()) {
133
+ validateFixture(fixture);
134
+ }
135
+ // eslint-disable-next-line no-console
136
+ console.log(`Wrote schema pack: ${PACK_DIR}`);
137
+ return;
138
+ }
139
+ // check
140
+ checkSchemaFile();
141
+ const fixtures = listFixtures();
142
+ if (fixtures.length === 0) {
143
+ throw new Error(`No fixtures found in: ${FIXTURES_DIR}`);
144
+ }
145
+ for (const fixture of fixtures)
146
+ validateFixture(fixture);
147
+ // eslint-disable-next-line no-console
148
+ console.log(`Schema pack OK (fixtures=${fixtures.length})`);
149
+ }
150
+ main();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ import { initTools, toolRegistry } from "../src/tools/index.js";
2
+ async function main() {
3
+ await initTools("dev");
4
+ const list = toolRegistry["dev.list_allowed"];
5
+ const call = toolRegistry["dev.call"];
6
+ if (!list || !call) {
7
+ throw new Error("Dev tools not registered (expected dev.list_allowed and dev.call)");
8
+ }
9
+ const allowed = await list.handler({});
10
+ console.log("ALLOWED_TOOLS:");
11
+ console.log(JSON.stringify(Object.keys(toolRegistry), null, 2));
12
+ console.log("\nALLOWLIST:");
13
+ console.log(JSON.stringify(allowed, null, 2));
14
+ // Smoke call a simple endpoint that should exist without extra payload.
15
+ const result = await call.handler({
16
+ method: "GET",
17
+ path: "/health",
18
+ });
19
+ console.log("\nDEV_CALL_RESULT:");
20
+ console.log(JSON.stringify(result, null, 2));
21
+ }
22
+ main().catch((err) => {
23
+ console.error(err instanceof Error ? err.stack || err.message : String(err));
24
+ process.exit(1);
25
+ });
@@ -0,0 +1 @@
1
+ export {};