@openephemeris/mcp-server 3.14.0 → 3.16.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.
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * bodygraph-app.ts — MCP App tool registration for the Human Design Bodygraph Explorer.
3
3
  *
4
- * Registers 4 tools:
5
- * • explore_human_design — primary entry point, returns data + UI resource [model]
6
- * • hd_on_center_click — center click handler [app-only]
7
- * • hd_on_gate_click — gate click handler [app-only]
8
- * • hd_on_channel_click — channel click handler [app-only]
4
+ * Entry tools [model + app]:
5
+ * • explore_human_design — natal bodygraph, data + UI resource
6
+ * • explore_human_design_transit — natal + transit overlay (premium)
7
+ * • explore_human_design_connection — two-person connection/synastry overlay (premium)
8
+ * Click handlers [app-only]: hd_on_center_click, hd_on_gate_click,
9
+ * hd_on_channel_click, hd_on_planet_click, hd_on_variable_click.
10
+ * The transit + connection tools reuse this bodygraph iframe resource; their
11
+ * overlay SVG is returned inline by /human-design/transit-chart and
12
+ * /human-design/composite (include_visual) rather than a separate render call.
9
13
  *
10
14
  * The bodygraph is rendered client-side from structured JSON — no SVG endpoint
11
15
  * is called, keeping latency at zero and the architecture consistent with
@@ -78,13 +82,18 @@ function ensureTimezone(dt) {
78
82
  * renderer in bodygraph-renderer.ts. Non-fatal: a missing SVG should never
79
83
  * block the tool result.
80
84
  */
81
- async function fetchGoBodygraphSVG(body) {
85
+ async function fetchGoBodygraphSVG(body, theme = "dark") {
82
86
  try {
83
87
  const client = getActiveClient();
84
88
  // /visualization/bodygraph is registered as binary in BINARY_ENDPOINT_PREFIXES.
85
89
  // The client wraps it as { content_type, encoding: "base64", data_base64 } —
86
90
  // we decode back to UTF-8 SVG text for inlining.
87
- const resp = await client.post("/visualization/bodygraph?format=svg&size=800", body);
91
+ //
92
+ // style=light|dark drives the Go renderer's palette. The endpoint defaults
93
+ // to "light" when style is omitted, which mismatches dark MCP hosts — so we
94
+ // always pass an explicit theme (default "dark" to match the app shell; the
95
+ // iframe re-fetches with the host-detected theme on load / theme change).
96
+ const resp = await client.post(`/visualization/bodygraph?format=svg&size=800&style=${theme}`, body);
88
97
  if (typeof resp === "string")
89
98
  return resp;
90
99
  if (resp && typeof resp === "object") {
@@ -438,11 +447,16 @@ registerTool({
438
447
  modelPayload._svg = svg;
439
448
  const summary = buildHdSummary(modelPayload, location);
440
449
  if (bundleAvailable) {
450
+ // MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
451
+ // delivered through resources/read — NOT as a content block. A URI-only
452
+ // `type: "resource"` block lacks the inline text/blob the base MCP schema
453
+ // requires and fails strict client validation. structuredContent carries
454
+ // the data for non-rendering hosts.
441
455
  return {
442
456
  content: [
443
457
  { type: "text", text: summary },
444
- { type: "text", text: JSON.stringify({ ...modelPayload, server_version: SERVER_VERSION }) },
445
458
  ],
459
+ structuredContent: { ...modelPayload, server_version: SERVER_VERSION },
446
460
  _meta: {
447
461
  "ui/resourceUri": BODYGRAPH_RESOURCE_URI,
448
462
  ui: { resourceUri: BODYGRAPH_RESOURCE_URI },
@@ -468,6 +482,11 @@ registerTool({
468
482
  type: "string",
469
483
  description: "IANA timezone name (e.g. 'America/New_York') for accurate UTC conversion.",
470
484
  },
485
+ theme: {
486
+ type: "string",
487
+ enum: ["light", "dark"],
488
+ description: "Render palette for the bodygraph SVG. Mirrors the MCP host's light/dark color scheme.",
489
+ },
471
490
  },
472
491
  required: ["datetime"],
473
492
  },
@@ -481,6 +500,7 @@ registerTool({
481
500
  const datetime = localToUtcIso(String(args.datetime), timezone);
482
501
  const lat = args.latitude;
483
502
  const lon = args.longitude;
503
+ const theme = args.theme === "light" ? "light" : "dark";
484
504
  const body = {
485
505
  birth_datetime_utc: datetime,
486
506
  };
@@ -493,7 +513,7 @@ registerTool({
493
513
  client.request("POST", "/human-design/chart", {
494
514
  data: body,
495
515
  }),
496
- fetchGoBodygraphSVG(body),
516
+ fetchGoBodygraphSVG(body, theme),
497
517
  ]);
498
518
  const chart = chartData.chart ?? chartData;
499
519
  const modelPayload = buildHdModelPayload(chart, {
@@ -1157,3 +1177,181 @@ registerTool({
1157
1177
  };
1158
1178
  },
1159
1179
  });
1180
+ // ── Tool: explore_human_design_transit ───────────────────────────────────────
1181
+ // Personalized Human Design transit overlay (natal chart + a transiting moment).
1182
+ // Premium (Developer tier). Calls POST /human-design/transit-chart.
1183
+ registerTool({
1184
+ name: "explore_human_design_transit",
1185
+ description: "Overlay the current (or a chosen) planetary transit on a person's natal Human Design bodygraph. " +
1186
+ "Highlights the channels a transit temporarily COMPLETES with the natal chart and any centers it " +
1187
+ "newly defines — the core of a Human Design transit reading. " +
1188
+ "Returns an interactive overlay bodygraph in MCP Apps-capable hosts (Claude Desktop), with a text " +
1189
+ "summary fallback elsewhere. Premium (Developer tier). Calculated with NASA JPL DE440 ephemerides.",
1190
+ inputSchema: {
1191
+ type: "object",
1192
+ properties: {
1193
+ datetime: {
1194
+ type: "string",
1195
+ description: "Natal birth datetime, ISO 8601 (e.g. '1990-04-15T19:30:00Z'). Include 'Z' or an offset, " +
1196
+ "or supply timezone for local time. HD is time-sensitive to the minute.",
1197
+ },
1198
+ latitude: { type: "number", description: "Natal birth latitude (decimal degrees, +N). Optional." },
1199
+ longitude: { type: "number", description: "Natal birth longitude (decimal degrees, +E). Optional." },
1200
+ location: { type: "string", description: "Natal location name, display only." },
1201
+ timezone: { type: "string", description: "IANA timezone for the birth location (e.g. 'America/New_York')." },
1202
+ transit_datetime: {
1203
+ type: "string",
1204
+ description: "Transit moment, ISO 8601. Defaults to now (UTC) when omitted.",
1205
+ },
1206
+ },
1207
+ required: ["datetime"],
1208
+ },
1209
+ outputSchema: OUTPUT_SCHEMA_JSON,
1210
+ annotations: {
1211
+ title: "Human Design Transit Overlay",
1212
+ readOnlyHint: true,
1213
+ destructiveHint: false,
1214
+ idempotentHint: false,
1215
+ openWorldHint: false,
1216
+ },
1217
+ _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["model", "app"] } },
1218
+ handler: async (args) => {
1219
+ const client = getActiveClient();
1220
+ const timezone = args.timezone;
1221
+ const natalIso = localToUtcIso(String(args.datetime), timezone);
1222
+ const lat = args.latitude ?? 0;
1223
+ const lon = args.longitude ?? 0;
1224
+ // SubjectRequest wire shape: name (required), birth_datetime.iso, and
1225
+ // birth_location with struct-typed coordinate/timezone inputs (matches the
1226
+ // live bi-wheel/transit-timeline tools — bare scalars are rejected).
1227
+ const body = {
1228
+ subject: {
1229
+ name: args.location ?? "Subject",
1230
+ birth_datetime: { iso: natalIso },
1231
+ birth_location: {
1232
+ latitude: { decimal: lat },
1233
+ longitude: { decimal: lon },
1234
+ ...(timezone ? { timezone: { iana_name: timezone } } : {}),
1235
+ },
1236
+ },
1237
+ };
1238
+ if (args.transit_datetime) {
1239
+ body.transit_datetime = { iso: ensureTimezone(String(args.transit_datetime)) };
1240
+ }
1241
+ const bundleAvailable = Boolean(getBodygraphBundle());
1242
+ if (bundleAvailable)
1243
+ body.include_visual = true;
1244
+ const resp = await client.request("POST", "/human-design/transit-chart", { data: body });
1245
+ const transit = (resp?.transit ?? {});
1246
+ const completed = Array.isArray(transit.completed_channels) ? transit.completed_channels : [];
1247
+ const newCenters = Array.isArray(transit.newly_defined_centers) ? transit.newly_defined_centers : [];
1248
+ const natal = (resp?.natal ?? {});
1249
+ const svg = resp?.visual?.data;
1250
+ const summary = `**Human Design Transit Overlay**\n\n` +
1251
+ `Natal: ${natal.type ?? "—"} ${natal.profile ?? ""} · ${natal.authority ?? ""} authority.\n\n` +
1252
+ (completed.length
1253
+ ? `The transit **completes ${completed.length} channel${completed.length === 1 ? "" : "s"}**: ${completed.join(", ")}.`
1254
+ : `The transit completes no channels right now.`) +
1255
+ (newCenters.length
1256
+ ? `\n\nNewly-defined center${newCenters.length === 1 ? "" : "s"}: **${newCenters.join(", ")}** — a temporary shift while the transit holds.`
1257
+ : "");
1258
+ const payload = { ...resp, _svg: svg, server_version: SERVER_VERSION };
1259
+ if (bundleAvailable && svg) {
1260
+ return {
1261
+ content: [{ type: "text", text: summary }],
1262
+ structuredContent: payload,
1263
+ _meta: { "ui/resourceUri": BODYGRAPH_RESOURCE_URI, ui: { resourceUri: BODYGRAPH_RESOURCE_URI } },
1264
+ };
1265
+ }
1266
+ return { content: [{ type: "text", text: summary }], structuredContent: payload };
1267
+ },
1268
+ });
1269
+ // ── Tool: explore_human_design_connection ────────────────────────────────────
1270
+ // Two-person Human Design connection (synastry) overlay. Premium (Developer tier).
1271
+ // Calls POST /human-design/composite.
1272
+ registerTool({
1273
+ name: "explore_human_design_connection",
1274
+ description: "Compare two people's Human Design charts and classify every connected channel by HD connection " +
1275
+ "theory — electromagnetic (attraction), companionship (sameness), dominance (one defines), and " +
1276
+ "compromise (friction). Returns an interactive two-person overlay bodygraph in MCP Apps-capable " +
1277
+ "hosts, with a text summary fallback elsewhere. Premium (Developer tier). NASA JPL DE440 ephemerides.",
1278
+ inputSchema: {
1279
+ type: "object",
1280
+ properties: {
1281
+ person_a: {
1282
+ type: "object",
1283
+ description: "First person's birth data.",
1284
+ properties: {
1285
+ datetime: { type: "string", description: "Birth datetime, ISO 8601 (Z/offset, or local with timezone)." },
1286
+ latitude: { type: "number", description: "Birth latitude (decimal degrees, +N). Optional." },
1287
+ longitude: { type: "number", description: "Birth longitude (decimal degrees, +E). Optional." },
1288
+ timezone: { type: "string", description: "IANA timezone (e.g. 'America/New_York')." },
1289
+ },
1290
+ required: ["datetime"],
1291
+ },
1292
+ person_b: {
1293
+ type: "object",
1294
+ description: "Second person's birth data.",
1295
+ properties: {
1296
+ datetime: { type: "string", description: "Birth datetime, ISO 8601 (Z/offset, or local with timezone)." },
1297
+ latitude: { type: "number", description: "Birth latitude (decimal degrees, +N). Optional." },
1298
+ longitude: { type: "number", description: "Birth longitude (decimal degrees, +E). Optional." },
1299
+ timezone: { type: "string", description: "IANA timezone (e.g. 'America/New_York')." },
1300
+ },
1301
+ required: ["datetime"],
1302
+ },
1303
+ },
1304
+ required: ["person_a", "person_b"],
1305
+ },
1306
+ outputSchema: OUTPUT_SCHEMA_JSON,
1307
+ annotations: {
1308
+ title: "Human Design Connection (Synastry)",
1309
+ readOnlyHint: true,
1310
+ destructiveHint: false,
1311
+ idempotentHint: true,
1312
+ openWorldHint: false,
1313
+ },
1314
+ _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["model", "app"] } },
1315
+ handler: async (args) => {
1316
+ const client = getActiveClient();
1317
+ const toSubject = (p) => {
1318
+ const tz = p?.timezone;
1319
+ return {
1320
+ birth_datetime_utc: localToUtcIso(String(p?.datetime), tz),
1321
+ latitude: p?.latitude ?? 0,
1322
+ longitude: p?.longitude ?? 0,
1323
+ };
1324
+ };
1325
+ const body = {
1326
+ subject_1: toSubject(args.person_a),
1327
+ subject_2: toSubject(args.person_b),
1328
+ };
1329
+ const bundleAvailable = Boolean(getBodygraphBundle());
1330
+ if (bundleAvailable)
1331
+ body.include_visual = true;
1332
+ const resp = await client.request("POST", "/human-design/composite", { data: body });
1333
+ const connections = Array.isArray(resp?.connections) ? resp.connections : [];
1334
+ const svg = resp?.visual?.data;
1335
+ const byType = (t) => connections.filter((c) => c?.type === t).map((c) => c.id);
1336
+ const line = (label, t) => {
1337
+ const ids = byType(t);
1338
+ return ids.length ? `\n- **${label}** (${ids.length}): ${ids.join(", ")}` : "";
1339
+ };
1340
+ const summary = `**Human Design Connection**\n\n` +
1341
+ `${connections.length} connected channel${connections.length === 1 ? "" : "s"}:` +
1342
+ line("Electromagnetic — attraction", "electromagnetic") +
1343
+ line("Companionship — sameness", "companionship") +
1344
+ line("Dominance — one defines", "dominance") +
1345
+ line("Compromise — friction", "compromise") +
1346
+ (connections.length ? "" : " none.");
1347
+ const payload = { ...resp, _svg: svg, server_version: SERVER_VERSION };
1348
+ if (bundleAvailable && svg) {
1349
+ return {
1350
+ content: [{ type: "text", text: summary }],
1351
+ structuredContent: payload,
1352
+ _meta: { "ui/resourceUri": BODYGRAPH_RESOURCE_URI, ui: { resourceUri: BODYGRAPH_RESOURCE_URI } },
1353
+ };
1354
+ }
1355
+ return { content: [{ type: "text", text: summary }], structuredContent: payload };
1356
+ },
1357
+ });
@@ -199,11 +199,17 @@ registerTool({
199
199
  }, houseSystem, undefined, requestedBodies);
200
200
  const bundleAvailable = Boolean(getChartWheelBundle());
201
201
  if (bundleAvailable) {
202
+ // MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
203
+ // delivered through resources/read — NOT as a content block. A URI-only
204
+ // `type: "resource"` block lacks the inline text/blob the base MCP schema
205
+ // requires and fails strict client validation. Keep `structuredContent`
206
+ // so non-rendering hosts still get the data, and a text summary so chat
207
+ // transcripts read coherently.
202
208
  return {
203
209
  content: [
204
210
  { type: "text", text: summary },
205
- { type: "text", text: JSON.stringify({ ...modelPayload, server_version: SERVER_VERSION }) },
206
211
  ],
212
+ structuredContent: { ...modelPayload, server_version: SERVER_VERSION },
207
213
  _meta: {
208
214
  "ui/resourceUri": CHART_WHEEL_RESOURCE_URI,
209
215
  ui: { resourceUri: CHART_WHEEL_RESOURCE_URI },
@@ -414,8 +420,26 @@ function zodSignFromLon(lon) {
414
420
  const CLASSICAL_PLANETS = new Set([
415
421
  "sun", "moon", "mercury", "venus", "mars",
416
422
  "jupiter", "saturn", "uranus", "neptune", "pluto",
417
- "chiron", "north_node", "south_node", "true_node", "asc", "mc",
423
+ // Nodes use space form to match the renderer's glyph table (chart-renderer.ts).
424
+ "chiron", "north node", "south node", "true node", "asc", "mc",
418
425
  ]);
426
+ /**
427
+ * Canonicalise the Go backend's verbose node keys ("North Node (Mean)" /
428
+ * "North Node (True)") to the space-form names the renderer expects, keeping
429
+ * mean vs true DISTINCT. Without this the natal wheel dropped the nodes entirely
430
+ * (the raw key never matched the classical-planet set). All other body names are
431
+ * returned lowercased and otherwise unchanged.
432
+ */
433
+ function canonicalizeBodyName(raw) {
434
+ const lower = raw.toLowerCase().trim();
435
+ if (lower === "north node (true)" || lower === "true node")
436
+ return "true node";
437
+ if (lower === "north node (mean)" || lower === "mean node" || lower === "north node")
438
+ return "north node";
439
+ if (lower === "south node (mean)" || lower === "south node (true)" || lower === "south node")
440
+ return "south node";
441
+ return lower;
442
+ }
419
443
  const EXTENDED_BODIES = new Set([
420
444
  "mean_lilith", "lilith", "true_lilith",
421
445
  "ceres", "juno", "vesta", "pallas", "pholus",
@@ -487,19 +511,19 @@ function buildModelPayload(chartData, birthParams, houseSystem, svgBase, bodyFil
487
511
  if (rawPlanets && typeof rawPlanets === "object" && !Array.isArray(rawPlanets)) {
488
512
  planetsArray = Object.entries(rawPlanets)
489
513
  .filter(([k]) => {
490
- const key = k.toLowerCase();
514
+ const key = canonicalizeBodyName(k);
491
515
  // If specific bodies were requested, use that filter
492
516
  if (bodyFilter) {
493
517
  const wantsAll = bodyFilter.some(b => b.toLowerCase() === "all");
494
518
  if (wantsAll)
495
519
  return ALL_KNOWN_BODIES.has(key);
496
- return bodyFilter.some(b => b.toLowerCase() === key);
520
+ return bodyFilter.some(b => canonicalizeBodyName(b) === key);
497
521
  }
498
522
  // Default: classical planets only
499
523
  return CLASSICAL_PLANETS.has(key);
500
524
  })
501
525
  .map(([name, p]) => ({
502
- name: name.toLowerCase(),
526
+ name: canonicalizeBodyName(name),
503
527
  longitude: (p.longitude ?? p.lon ?? 0),
504
528
  latitude: (p.latitude ?? p.lat),
505
529
  speed: (p.longitude_speed ?? p.speed),
@@ -3,13 +3,29 @@ import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
4
  registerTool({
5
5
  name: "location_search",
6
- description: "App-only tool for getting location autocomplete suggestions. Returns latitude, longitude, and IANA timezone.",
6
+ description: "App-only tool for getting location autocomplete suggestions. Returns display name, region (state/province), latitude, longitude, and IANA timezone. Optional bias params (country/region/near) improve ranking; a trailing \"City, ST\" qualifier in the query is also honored.",
7
7
  inputSchema: {
8
8
  type: "object",
9
9
  properties: {
10
10
  query: {
11
11
  type: "string",
12
- description: "Search query for location, e.g. 'Los Angeles'",
12
+ description: "Search query for location, e.g. 'Los Angeles' or 'Wakefield, MI'",
13
+ },
14
+ country: {
15
+ type: "string",
16
+ description: "Optional ISO 3166-1 alpha-2 country code (e.g. 'US') to bias ranking. Boost, not filter.",
17
+ },
18
+ region: {
19
+ type: "string",
20
+ description: "Optional admin1 (state/province) qualifier to bias ranking — full name ('Michigan'), ASCII name, or code ('MI'). Boost, not filter.",
21
+ },
22
+ admin1: {
23
+ type: "string",
24
+ description: "Alias for region (region wins if both supplied).",
25
+ },
26
+ near: {
27
+ type: "string",
28
+ description: "Optional 'lat,lon' proximity hint (e.g. '37.77,-122.42') to bias ranking toward nearby places.",
13
29
  },
14
30
  },
15
31
  required: ["query"],
@@ -23,9 +39,26 @@ registerTool({
23
39
  },
24
40
  handler: async (args) => {
25
41
  validateRequired(args, ["query"]);
26
- return await getActiveClient().request("GET", "/location/autocomplete", {
27
- params: { q: args.query },
28
- });
42
+ // NB: the endpoint param is `query` (only /geocode/search reads `q`).
43
+ const params = { query: args.query };
44
+ for (const k of ["country", "region", "admin1", "near"]) {
45
+ if (args[k] != null && String(args[k]).trim() !== "")
46
+ params[k] = String(args[k]);
47
+ }
48
+ const raw = (await getActiveClient().request("GET", "/location/autocomplete", { params }));
49
+ // Map the API's snake_case response to the camelCase shape the app UI consumes.
50
+ const list = Array.isArray(raw?.suggestions) ? raw.suggestions : [];
51
+ const suggestions = list.map((s) => ({
52
+ displayName: s.display_name,
53
+ shortName: s.short_name,
54
+ region: s.region,
55
+ countryCode: s.country_code,
56
+ placeId: s.place_id,
57
+ latitude: s.latitude,
58
+ longitude: s.longitude,
59
+ timezone: s.timezone,
60
+ }));
61
+ return { suggestions };
29
62
  },
30
63
  });
31
64
  registerTool({
@@ -227,11 +227,17 @@ registerTool({
227
227
  const summary = buildMoonSummary(mergedData);
228
228
  const bundleAvailable = Boolean(getMoonPhaseBundle());
229
229
  if (bundleAvailable) {
230
+ // MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
231
+ // delivered through resources/read — NOT as a content block. An embedded
232
+ // `type: "resource"` block must carry inline text/blob per the base MCP
233
+ // schema, so a URI-only reference fails strict client validation; the
234
+ // _meta keys below are what the host uses to render the iframe.
235
+ // structuredContent carries the data payload for non-rendering hosts.
230
236
  return {
231
237
  content: [
232
238
  { type: "text", text: summary },
233
- { type: "text", text: JSON.stringify(uiPayload) },
234
239
  ],
240
+ structuredContent: uiPayload,
235
241
  _meta: {
236
242
  "ui/resourceUri": MOON_PHASE_RESOURCE_URI,
237
243
  ui: { resourceUri: MOON_PHASE_RESOURCE_URI },
package/dist/tools/dev.js CHANGED
@@ -32,66 +32,72 @@ function isDeniedByPrefix(pathname, prefixes) {
32
32
  function isAllowedOperation(method, pathname, allow) {
33
33
  return allow.some((e) => e.method === method && e.path === pathname);
34
34
  }
35
- registerTool({
36
- name: "dev_call",
37
- description: "Call any allowlisted Open Ephemeris API endpoint directly. This is the power-user escape hatch " +
38
- "— use the typed tools (ephemeris_natal_chart, ephemeris_transits, etc.) first for common operations. " +
39
- "Call dev_list_allowed to see all currently available endpoint paths.\n\n" +
40
- "AUTH: Set OPENEPHEMERIS_API_KEY in your environment. See openephemeris.com/dashboard for active plan limits.\n\n" +
41
- "CREDIT COSTS:\n" +
42
- " • Standard chart math (natal, progressed, bazi, vedic, iching): 1 credit\n" +
43
- " • Human Design: 2 credits\n" +
44
- " • Visualization rendering (chart-wheel, bi-wheel, charts/*): 2 credits\n" +
45
- " • Comparative math (synastry, composite, overlay): 3 credits\n" +
46
- " • Predictive ops (transits/search, returns): 5 credits\n" +
47
- " • Predictive transit-chart: 1 credit\n" +
48
- " • ACG / astrocartography: 10 credits (acg/hits: 15 credits)\n" +
49
- " • Calendar endpoints: 10 credits\n" +
50
- " • Catalog / metadata / health endpoints: 0 credits\n" +
51
- " • Compute surcharge: requests > 30s add 1 credit per 30s (predictive, acg, calendar, electional)\n" +
52
- " • format=llm (token-optimized output): available on all tiers\n\n" +
53
- "COMMON CALLS:\n" +
54
- " 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" +
55
- " POST /ephemeris/natal/batch — Up to 50 natal charts in one request\n" +
56
- " POST /ephemeris/relocation — Relocated chart (same natal, new location)\n" +
57
- " POST /predictive/transits/search — Transit event search over a date range\n" +
58
- " POST /predictive/returns/solar — Solar return chart\n" +
59
- " POST /predictive/returns/lunar — Lunar return chart\n" +
60
- " POST /comparative/synastry — Two-person synastry chart\n" +
61
- " POST /comparative/composite — Composite (midpoint) chart\n" +
62
- " POST /human-design/chart — Full HD chart (body: {birth_datetime_utc: '1990-04-15T19:30:00Z'}) — lat/lon optional\n" +
63
- " POST /time/julian-day — Convert date to JD (body: {year: 1987, month: 7, day: 15, hour: 14, minute: 1})\n" +
64
- " GET /ephemeris/moon/phase — Current/queried moon phase\n" +
65
- " GET /ephemeris/moon/void-of-course — Next void-of-course period\n" +
66
- " GET /ephemeris/agro/daily — Biodynamic farming day quality\n" +
67
- " GET /ephemeris/agro/calendar — Multi-day biodynamic calendar\n" +
68
- " GET /ephemeris/agro/void-of-course — Biodynamic VoC periods\n" +
69
- " GET /eclipse/next-visible — Next eclipse visible from a location (query: lat, lon, type=solar|lunar)\n" +
70
- " GET /eclipse/solar/global — Next global solar eclipse (query: date=YYYY-MM-DD)\n" +
71
- " GET /eclipse/solar/local — Local solar eclipse (query: lat, lon)\n" +
72
- " GET /tidal/forcing — Gravitational tidal forcing index\n" +
73
- " POST /acg/power-lines — Astrocartography power lines (lat/lon GeoJSON)\n" +
74
- " POST /acg/hits — ACG power at a specific location\n" +
75
- " GET /calendar/astrology/moon-phases — Moon phase calendar for a date range\n" +
76
- " GET /location/autocomplete — Geocode a place name (query: q=City Name)\n" +
77
- " POST /timezone/lookup — Resolve timezone + UTC offset for a location\n" +
78
- " POST /chinese/bazi — Chinese Ba Zi (Four Pillars) chart (body: {year, month, day, hour})\n" +
79
- " GET /chinese/zodiac — Chinese zodiac year element/animal\n" +
80
- " POST /vedic/chart — Vedic (Jyotish) natal chart (body: {datetime_utc, latitude, longitude})\n" +
81
- " GET /catalogs/bodies — List all supported celestial bodies\n\n" +
82
- "BINARY RESPONSES:\n" +
83
- " • Binary/image endpoints return {content_type, content_length, encoding, data_base64}\n" +
84
- " so callers can decode bytes deterministically.\n\n" +
85
- "ECLIPSE NOTE: Eclipse endpoints accept format=llm via the query param like other endpoints.\n\n" +
86
- "format=llm NOTE: Add query: {format: 'llm'} to natal/synastry/composite/HD endpoints for " +
87
- "compact columnar output optimized for LLM token budgets (availability depends on your current plan).",
88
- inputSchema: {
35
+ // Shared reference block appended to both read/write proxy tools. Names the
36
+ // target API (Open Ephemeris) explicitly — required by the directory for
37
+ // freeform-path tools plus credit costs and common calls.
38
+ const DEV_API_REFERENCE = "Target API: Open Ephemeris REST API (https://api.openephemeris.com). " +
39
+ "Call dev_list_allowed to see all currently available endpoint paths.\n\n" +
40
+ "AUTH: Set OPENEPHEMERIS_API_KEY in your environment. See openephemeris.com/dashboard for active plan limits.\n\n" +
41
+ "CREDIT COSTS:\n" +
42
+ " • Standard chart math (natal, progressed, bazi, vedic, iching): 1 credit\n" +
43
+ " • Human Design: 2 credits\n" +
44
+ " • Visualization rendering (chart-wheel, bi-wheel, charts/*): 2 credits\n" +
45
+ " • Comparative math (synastry, composite, overlay): 3 credits\n" +
46
+ " • Predictive ops (transits/search, returns): 5 credits\n" +
47
+ " • Predictive transit-chart: 1 credit\n" +
48
+ " • ACG / astrocartography: 10 credits (acg/hits: 15 credits)\n" +
49
+ " • Calendar endpoints: 10 credits\n" +
50
+ " • Catalog / metadata / health endpoints: 0 credits\n" +
51
+ " • Compute surcharge: requests > 30s add 1 credit per 30s (predictive, acg, calendar, electional)\n" +
52
+ " • format=llm (token-optimized output): available on all tiers\n\n" +
53
+ "COMMON CALLS:\n" +
54
+ " 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" +
55
+ " POST /ephemeris/natal/batch — Up to 50 natal charts in one request\n" +
56
+ " POST /ephemeris/relocation — Relocated chart (same natal, new location)\n" +
57
+ " POST /predictive/transits/search — Transit event search over a date range\n" +
58
+ " POST /predictive/returns/solar — Solar return chart\n" +
59
+ " POST /predictive/returns/lunar — Lunar return chart\n" +
60
+ " POST /comparative/synastry — Two-person synastry chart\n" +
61
+ " POST /comparative/composite — Composite (midpoint) chart\n" +
62
+ " POST /human-design/chart — Full HD chart (body: {birth_datetime_utc: '1990-04-15T19:30:00Z'}) — lat/lon optional\n" +
63
+ " POST /time/julian-day — Convert date to JD (body: {year: 1987, month: 7, day: 15, hour: 14, minute: 1})\n" +
64
+ " GET /ephemeris/moon/phase — Current/queried moon phase\n" +
65
+ " GET /ephemeris/moon/void-of-course — Next void-of-course period\n" +
66
+ " GET /ephemeris/agro/daily — Biodynamic farming day quality\n" +
67
+ " GET /ephemeris/agro/calendar — Multi-day biodynamic calendar\n" +
68
+ " GET /ephemeris/agro/void-of-course — Biodynamic VoC periods\n" +
69
+ " GET /eclipse/next-visible — Next eclipse visible from a location (query: lat, lon, type=solar|lunar)\n" +
70
+ " GET /eclipse/solar/global — Next global solar eclipse (query: date=YYYY-MM-DD)\n" +
71
+ " GET /eclipse/solar/local — Local solar eclipse (query: lat, lon)\n" +
72
+ " GET /tidal/forcing — Gravitational tidal forcing index\n" +
73
+ " POST /acg/power-lines — Astrocartography power lines (lat/lon GeoJSON)\n" +
74
+ " POST /acg/hits — ACG power at a specific location\n" +
75
+ " GET /calendar/astrology/moon-phases — Moon phase calendar for a date range\n" +
76
+ " GET /location/autocomplete — Geocode a place name (query: q=City Name)\n" +
77
+ " POST /timezone/lookup — Resolve timezone + UTC offset for a location\n" +
78
+ " POST /chinese/bazi — Chinese Ba Zi (Four Pillars) chart (body: {year, month, day, hour})\n" +
79
+ " GET /chinese/zodiac — Chinese zodiac year element/animal\n" +
80
+ " POST /vedic/chart — Vedic (Jyotish) natal chart (body: {datetime_utc, latitude, longitude})\n" +
81
+ " GET /catalogs/bodies — List all supported celestial bodies\n\n" +
82
+ "BINARY RESPONSES:\n" +
83
+ " • Binary/image endpoints return {content_type, content_length, encoding, data_base64}\n" +
84
+ " so callers can decode bytes deterministically.\n\n" +
85
+ "ECLIPSE NOTE: Eclipse endpoints accept format=llm via the query param like other endpoints.\n\n" +
86
+ "format=llm NOTE: Add query: {format: 'llm'} to natal/synastry/composite/HD endpoints for " +
87
+ "compact columnar output optimized for LLM token budgets (availability depends on your current plan).";
88
+ // The read and write proxies are kept as separate tools (not one method-switching
89
+ // tool) so that safe GET reads never share a surface with state-changing writes —
90
+ // a hard requirement of the Anthropic connector directory.
91
+ const READ_METHODS = ["GET"];
92
+ const WRITE_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
93
+ function makeProxyInputSchema(methods) {
94
+ return {
89
95
  type: "object",
90
96
  properties: {
91
97
  method: {
92
98
  type: "string",
93
- enum: ["GET", "POST", "PUT", "PATCH", "DELETE"],
94
- description: "HTTP method",
99
+ enum: methods,
100
+ description: methods.length === 1 ? `HTTP method (always ${methods[0]})` : "HTTP method",
95
101
  },
96
102
  path: {
97
103
  type: "string",
@@ -100,10 +106,10 @@ registerTool({
100
106
  query: {
101
107
  type: "object",
102
108
  additionalProperties: true,
103
- description: "Query params for GET requests (optional)",
109
+ description: "Query params (optional)",
104
110
  },
105
111
  body: {
106
- description: "JSON body for POST/PUT/PATCH (optional)",
112
+ description: "JSON request body (optional)",
107
113
  },
108
114
  preset: {
109
115
  type: "string",
@@ -121,15 +127,16 @@ registerTool({
121
127
  description: "Legacy convenience (deprecated): if provided, set query.output_mode and also map to query.preset/query.format when possible.",
122
128
  },
123
129
  },
124
- required: ["method", "path"],
130
+ required: ["path"],
125
131
  additionalProperties: false,
126
- },
127
- outputSchema: OUTPUT_SCHEMA_JSON,
128
- handler: async (args) => {
132
+ };
133
+ }
134
+ function makeProxyHandler(allowedMethods, defaultMethod) {
135
+ return async (args) => {
129
136
  validateRequired(args, ["path"]);
130
- const methodStr = String(args.method || "GET").toUpperCase();
131
- if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(methodStr)) {
132
- throw new Error(`Invalid HTTP method: ${methodStr}`);
137
+ const methodStr = String(args.method || defaultMethod).toUpperCase();
138
+ if (!allowedMethods.includes(methodStr)) {
139
+ throw new Error(`Method ${methodStr} not permitted by this tool. Allowed: ${allowedMethods.join(", ")}.`);
133
140
  }
134
141
  const method = methodStr;
135
142
  const pathname = String(args.path);
@@ -177,21 +184,48 @@ registerTool({
177
184
  params: query,
178
185
  data: body,
179
186
  });
180
- },
187
+ };
188
+ }
189
+ registerTool({
190
+ name: "dev_read_api",
191
+ description: "Read from any allowlisted Open Ephemeris API endpoint via HTTP GET. This is the read-only " +
192
+ "power-user escape hatch — use the typed tools (ephemeris_natal_chart, ephemeris_transits, etc.) " +
193
+ "first for common operations. For endpoints that compute via POST (natal-chart, synastry, etc.), " +
194
+ "use dev_write_api.\n\n" +
195
+ DEV_API_REFERENCE,
196
+ inputSchema: makeProxyInputSchema(READ_METHODS),
197
+ outputSchema: OUTPUT_SCHEMA_JSON,
198
+ // GET-only: never mutates server state.
199
+ annotations: { title: "API Read", readOnlyHint: true, openWorldHint: true },
200
+ handler: makeProxyHandler(READ_METHODS, "GET"),
201
+ });
202
+ registerTool({
203
+ name: "dev_write_api",
204
+ description: "Send a request to any allowlisted Open Ephemeris API endpoint via POST/PUT/PATCH/DELETE. " +
205
+ "Most chart computations (natal-chart, synastry, composite, transits/search, returns, ACG) are " +
206
+ "POST endpoints and use this tool. Use the typed tools first for common operations; use dev_read_api " +
207
+ "for GET endpoints.\n\n" +
208
+ DEV_API_REFERENCE,
209
+ inputSchema: makeProxyInputSchema(WRITE_METHODS),
210
+ outputSchema: OUTPUT_SCHEMA_JSON,
211
+ // Issues POST/PUT/PATCH/DELETE — must not be advertised as read-only.
212
+ annotations: { title: "API Write", readOnlyHint: false, openWorldHint: true },
213
+ handler: makeProxyHandler(WRITE_METHODS, "POST"),
181
214
  });
182
215
  registerTool({
183
216
  name: "dev_list_allowed",
184
217
  description: "List all API operations (method + path) that this MCP instance is authorized to call. " +
185
218
  "Returns endpoint entries grouped by method, plus the active deny rules. " +
186
- "Use this to discover what's available before calling dev_call, or to verify an endpoint path. " +
219
+ "Use this to discover what's available before calling dev_read_api / dev_write_api, or to verify an endpoint path. " +
187
220
  "Typed shortcut tools (ephemeris_natal_chart, ephemeris_transits, etc.) cover the most common operations — " +
188
- "check those first before reaching for dev_call.",
221
+ "check those first before reaching for the generic proxies.",
189
222
  inputSchema: {
190
223
  type: "object",
191
224
  properties: {},
192
225
  additionalProperties: false,
193
226
  },
194
227
  outputSchema: OUTPUT_SCHEMA_JSON,
228
+ annotations: { title: "List Allowed API Operations", readOnlyHint: true, openWorldHint: false },
195
229
  handler: async () => {
196
230
  const allowlist = loadAllowlist();
197
231
  return {