@openephemeris/mcp-server 3.13.10 → 3.14.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 (55) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +3 -3
  3. package/dist/index.js +5 -43
  4. package/dist/oauth/discovery.d.ts +1 -0
  5. package/dist/oauth/discovery.js +8 -3
  6. package/dist/oauth/store.d.ts +20 -0
  7. package/dist/oauth/store.js +117 -3
  8. package/dist/oauth/token.js +4 -2
  9. package/dist/prompts.js +9 -0
  10. package/dist/server-sse.js +71 -64
  11. package/dist/tools/apps/bazi-app.js +2 -0
  12. package/dist/tools/apps/bi-wheel-app.js +55 -6
  13. package/dist/tools/apps/bodygraph-app.js +68 -10
  14. package/dist/tools/apps/chart-wheel-app.js +53 -4
  15. package/dist/tools/apps/location-tools.js +3 -0
  16. package/dist/tools/apps/moon-phase-app.js +75 -21
  17. package/dist/tools/apps/transit-timeline-app.js +75 -23
  18. package/dist/tools/apps/vedic-chart-app.js +2 -0
  19. package/dist/tools/auth.js +4 -0
  20. package/dist/tools/dev.js +3 -0
  21. package/dist/tools/index.d.ts +6 -0
  22. package/dist/tools/index.js +8 -7
  23. package/dist/tools/output-schemas.d.ts +37 -0
  24. package/dist/tools/output-schemas.js +130 -0
  25. package/dist/tools/specialized/acg.js +3 -0
  26. package/dist/tools/specialized/bazi.js +8 -0
  27. package/dist/tools/specialized/bi_wheel.js +2 -0
  28. package/dist/tools/specialized/chart_wheel.js +2 -0
  29. package/dist/tools/specialized/comparative.js +5 -0
  30. package/dist/tools/specialized/eclipse.js +2 -0
  31. package/dist/tools/specialized/electional.js +5 -0
  32. package/dist/tools/specialized/ephemeris_core.js +3 -0
  33. package/dist/tools/specialized/ephemeris_extended.js +9 -0
  34. package/dist/tools/specialized/hd_bodygraph.js +2 -0
  35. package/dist/tools/specialized/hd_cycles.js +3 -0
  36. package/dist/tools/specialized/hd_group.js +3 -0
  37. package/dist/tools/specialized/human_design.js +2 -0
  38. package/dist/tools/specialized/moon.js +3 -0
  39. package/dist/tools/specialized/natal.js +2 -0
  40. package/dist/tools/specialized/progressed.js +2 -0
  41. package/dist/tools/specialized/relocation.js +2 -0
  42. package/dist/tools/specialized/returns.js +4 -0
  43. package/dist/tools/specialized/synastry.js +2 -0
  44. package/dist/tools/specialized/transits.js +52 -43
  45. package/dist/tools/specialized/vedic.js +2 -0
  46. package/dist/tools/specialized/venus_star_points.js +7 -0
  47. package/dist/ui/bazi.html +1 -1
  48. package/dist/ui/bi-wheel.html +3514 -3121
  49. package/dist/ui/bodygraph.html +1308 -1261
  50. package/dist/ui/chart-wheel.html +3404 -3116
  51. package/dist/ui/moon-phase.html +2622 -2616
  52. package/dist/ui/transit-timeline.html +2695 -2656
  53. package/dist/ui/vedic-chart.html +1 -1
  54. package/package.json +14 -11
  55. package/smithery.yaml +16 -1
@@ -27,11 +27,9 @@ import { BackendClient, runWithClient } from "./backend/client.js";
27
27
  import { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
28
28
  import { BODYGRAPH_RESOURCE_URI, BODYGRAPH_MIME_TYPE, getBodygraphBundle, } from "./tools/apps/bodygraph-app.js";
29
29
  import { BI_WHEEL_RESOURCE_URI, BI_WHEEL_MIME_TYPE, getBiWheelBundle, } from "./tools/apps/bi-wheel-app.js";
30
- import { TRANSIT_TIMELINE_RESOURCE_URI, TRANSIT_TIMELINE_MIME_TYPE, getTransitTimelineBundle, } from "./tools/apps/transit-timeline-app.js";
30
+ // NOTE: transit-timeline, bazi, vedic-chart imports removed tools disabled.
31
31
  import { MOON_PHASE_RESOURCE_URI, MOON_PHASE_MIME_TYPE, getMoonPhaseBundle, } from "./tools/apps/moon-phase-app.js";
32
- import { BAZI_RESOURCE_URI, BAZI_MIME_TYPE, getBaziBundle, } from "./tools/apps/bazi-app.js";
33
- import { VEDIC_CHART_RESOURCE_URI, VEDIC_CHART_MIME_TYPE, getVedicChartBundle, } from "./tools/apps/vedic-chart-app.js";
34
- import { oauthDiscoveryRouter } from "./oauth/discovery.js";
32
+ import { oauthDiscoveryRouter, PROTECTED_RESOURCE_METADATA_URL } from "./oauth/discovery.js";
35
33
  import { oauthDcrRouter } from "./oauth/dcr.js";
36
34
  import { oauthTokenRouter } from "./oauth/token.js";
37
35
  import { OAuthStore } from "./oauth/store.js";
@@ -232,7 +230,15 @@ function createMcpServer() {
232
230
  annotations: buildAnnotations(tool),
233
231
  // Expose MCP Apps UI linkage so Claude Desktop can prefetch the resource
234
232
  ...(tool._meta?.ui?.resourceUri
235
- ? { _meta: { ui: { resourceUri: tool._meta.ui.resourceUri, visibility: tool._meta.ui.visibility ?? ["model", "app"] } } }
233
+ ? {
234
+ _meta: {
235
+ "ui/resourceUri": tool._meta.ui.resourceUri,
236
+ ui: {
237
+ resourceUri: tool._meta.ui.resourceUri,
238
+ visibility: tool._meta.ui.visibility ?? ["model", "app"],
239
+ },
240
+ },
241
+ }
236
242
  : {}),
237
243
  })),
238
244
  }));
@@ -264,16 +270,18 @@ function createMcpServer() {
264
270
  }
265
271
  catch (error) {
266
272
  const startTime = Date.now(); // Not strictly accurate for errors but acceptable for logging structure if needed
273
+ const durationMs = Date.now() - startTime;
267
274
  const errorMessage = error instanceof Error ? error.message : String(error);
268
- console.error(`[MCP] \u274c Failed: ${toolName} - ${errorMessage}`);
275
+ const isRetryable = error.retryable === true || error.status === 401 || error.code === "auth_required";
276
+ console.error(`[MCP] ❌ Failed: ${toolName} (${durationMs}ms) - ${errorMessage}`);
269
277
  return {
270
278
  content: [
271
279
  {
272
280
  type: "text",
273
- text: JSON.stringify({ success: false, error: "TOOL_EXECUTION_ERROR", message: errorMessage }, null, 2),
281
+ text: `Tool execution failed:\n\n${errorMessage}\n\nPlease read the error above carefully and assist the user (e.g. by providing them the auth link or explaining the issue).`,
274
282
  },
275
283
  ],
276
- isError: true,
284
+ isError: !isRetryable,
277
285
  };
278
286
  }
279
287
  });
@@ -304,14 +312,7 @@ function createMcpServer() {
304
312
  mimeType: BI_WHEEL_MIME_TYPE,
305
313
  });
306
314
  }
307
- if (getTransitTimelineBundle()) {
308
- resources.push({
309
- uri: TRANSIT_TIMELINE_RESOURCE_URI,
310
- name: "Transit Timeline Explorer",
311
- description: "Interactive Gantt-style transit timeline showing planetary aspects over time.",
312
- mimeType: TRANSIT_TIMELINE_MIME_TYPE,
313
- });
314
- }
315
+ // NOTE: transit-timeline, bazi, vedic-chart resources omitted — tools disabled.
315
316
  if (getMoonPhaseBundle()) {
316
317
  resources.push({
317
318
  uri: MOON_PHASE_RESOURCE_URI,
@@ -320,22 +321,6 @@ function createMcpServer() {
320
321
  mimeType: MOON_PHASE_MIME_TYPE,
321
322
  });
322
323
  }
323
- if (getBaziBundle()) {
324
- resources.push({
325
- uri: BAZI_RESOURCE_URI,
326
- name: "BaZi Four Pillars Explorer",
327
- description: "Interactive BaZi chart with pillars, Ten Gods, hidden stems, and Wu Xing balance.",
328
- mimeType: BAZI_MIME_TYPE,
329
- });
330
- }
331
- if (getVedicChartBundle()) {
332
- resources.push({
333
- uri: VEDIC_CHART_RESOURCE_URI,
334
- name: "Vedic Jyotish Chart",
335
- description: "Interactive South Indian Rashi grid with planet placements, Lagna marker, and Nakshatra detail.",
336
- mimeType: VEDIC_CHART_MIME_TYPE,
337
- });
338
- }
339
324
  return { resources };
340
325
  });
341
326
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
@@ -358,30 +343,13 @@ function createMcpServer() {
358
343
  throw new Error("Bi-Wheel UI bundle not found. Run `npm run build:ui` to build it.");
359
344
  return { contents: [{ uri: BI_WHEEL_RESOURCE_URI, mimeType: BI_WHEEL_MIME_TYPE, text: bundle }] };
360
345
  }
361
- if (uri === TRANSIT_TIMELINE_RESOURCE_URI) {
362
- const bundle = getTransitTimelineBundle();
363
- if (!bundle)
364
- throw new Error("Transit Timeline UI bundle not found. Run `npm run build:ui` to build it.");
365
- return { contents: [{ uri: TRANSIT_TIMELINE_RESOURCE_URI, mimeType: TRANSIT_TIMELINE_MIME_TYPE, text: bundle }] };
366
- }
346
+ // NOTE: transit-timeline, bazi, vedic-chart ReadResource handlers removed.
367
347
  if (uri === MOON_PHASE_RESOURCE_URI) {
368
348
  const bundle = getMoonPhaseBundle();
369
349
  if (!bundle)
370
350
  throw new Error("Moon Phase UI bundle not found. Run `npm run build:ui` to build it.");
371
351
  return { contents: [{ uri: MOON_PHASE_RESOURCE_URI, mimeType: MOON_PHASE_MIME_TYPE, text: bundle }] };
372
352
  }
373
- if (uri === BAZI_RESOURCE_URI) {
374
- const bundle = getBaziBundle();
375
- if (!bundle)
376
- throw new Error("BaZi UI bundle not found. Run `npm run build:ui` to build it.");
377
- return { contents: [{ uri: BAZI_RESOURCE_URI, mimeType: BAZI_MIME_TYPE, text: bundle }] };
378
- }
379
- if (uri === VEDIC_CHART_RESOURCE_URI) {
380
- const bundle = getVedicChartBundle();
381
- if (!bundle)
382
- throw new Error("Vedic Chart UI bundle not found. Run `npm run build:ui` to build it.");
383
- return { contents: [{ uri: VEDIC_CHART_RESOURCE_URI, mimeType: VEDIC_CHART_MIME_TYPE, text: bundle }] };
384
- }
385
353
  throw new Error(`Unknown resource: ${uri}`);
386
354
  });
387
355
  return server;
@@ -393,12 +361,38 @@ async function main() {
393
361
  // NOTE: Do NOT use express.json() globally — SSEServerTransport.handlePostMessage
394
362
  // reads the raw request stream itself. Pre-parsing with express.json() consumes it,
395
363
  // causing "stream is not readable" errors.
396
- // CORS required for Claude Web cross-origin SSE connections
397
- app.use((_req, res, next) => {
398
- res.setHeader("Access-Control-Allow-Origin", "*");
364
+ // CORS + Origin allowlist
365
+ // Per Anthropic remote-MCP review criteria, browser-originated requests must
366
+ // be validated against an allowlist. Native (no-Origin) clients like Claude
367
+ // Desktop and CLI tooling are permitted; unknown browser origins are rejected.
368
+ const ORIGIN_ALLOWLIST = [
369
+ /^https:\/\/([a-z0-9-]+\.)*claude\.ai$/i,
370
+ /^https:\/\/([a-z0-9-]+\.)*claude\.com$/i,
371
+ /^https:\/\/([a-z0-9-]+\.)*anthropic\.com$/i,
372
+ /^https:\/\/([a-z0-9-]+\.)*openephemeris\.com$/i,
373
+ /^http:\/\/localhost(:\d+)?$/i,
374
+ /^http:\/\/127\.0\.0\.1(:\d+)?$/i,
375
+ ];
376
+ function isOriginAllowed(origin) {
377
+ return ORIGIN_ALLOWLIST.some((m) => typeof m === "string" ? m === origin : m.test(origin));
378
+ }
379
+ app.use((req, res, next) => {
380
+ const origin = req.headers.origin;
381
+ // Browser request → must match allowlist. Native client (no Origin) → allow.
382
+ if (origin) {
383
+ if (!isOriginAllowed(origin)) {
384
+ res.status(403).json({
385
+ error: "origin_not_allowed",
386
+ error_description: `Origin '${origin}' is not permitted.`,
387
+ });
388
+ return;
389
+ }
390
+ res.setHeader("Access-Control-Allow-Origin", origin);
391
+ res.setHeader("Vary", "Origin");
392
+ }
399
393
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
400
394
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-OpenEphemeris-API-Key, mcp-session-id");
401
- if (_req.method === "OPTIONS") {
395
+ if (req.method === "OPTIONS") {
402
396
  res.status(204).end();
403
397
  return;
404
398
  }
@@ -417,7 +411,13 @@ async function main() {
417
411
  maxRequests: 10,
418
412
  trustProxy: true,
419
413
  });
414
+ const dcrRateLimiter = createRateLimiter({
415
+ windowMs: 60_000,
416
+ maxRequests: 5,
417
+ trustProxy: true,
418
+ });
420
419
  app.use(oauthDiscoveryRouter);
420
+ app.use("/oauth/register", dcrRateLimiter);
421
421
  app.use(express.json({ limit: "1mb" }), oauthDcrRouter);
422
422
  app.use("/oauth/token", tokenRateLimiter);
423
423
  app.use(express.urlencoded({ extended: false }), oauthTokenRouter(oauthStore));
@@ -441,6 +441,7 @@ async function main() {
441
441
  name: tool.name,
442
442
  description: tool.description,
443
443
  inputSchema: tool.inputSchema,
444
+ ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
444
445
  annotations: buildAnnotations(tool),
445
446
  }));
446
447
  res.json({
@@ -490,17 +491,14 @@ async function main() {
490
491
  await runWithClient(session.client, () => session.transport.handleRequest(req, res, req.body));
491
492
  return;
492
493
  }
493
- // New sessionmust be an initialize request
494
- if (!isInitializeRequest(req.body)) {
495
- res.status(400).json({
496
- error: "expected_initialize",
497
- message: "First request to /mcp must be an MCP Initialize request.",
498
- });
499
- return;
500
- }
501
- // Auth — required on Initialize (supports both API keys and OAuth JWTs)
494
+ // Auth check FIRST before validating request shape. RFC 9728 requires
495
+ // any unauthenticated POST to /mcp (even a probe with an empty/invalid
496
+ // body) to return 401 + WWW-Authenticate so OAuth clients (Claude Web)
497
+ // can bootstrap discovery. Returning 400 before auth causes the ofid_
498
+ // "Authorization with MCP server failed" error in Claude's connector UI.
502
499
  const { apiKey, jwt } = extractAuth(req);
503
500
  if (!apiKey && !jwt) {
501
+ res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
504
502
  res.status(401).json({
505
503
  error: "auth_required",
506
504
  message: "Authentication required. Pass an API key via X-API-Key header, or a Bearer token via Authorization header. Get a free key at https://openephemeris.com/dashboard",
@@ -512,13 +510,22 @@ async function main() {
512
510
  if (apiKey) {
513
511
  const valid = await validateApiKey(apiKey);
514
512
  if (!valid) {
515
- res.status(403).json({
513
+ res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
514
+ res.status(401).json({
516
515
  error: "invalid_api_key",
517
516
  message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
518
517
  });
519
518
  return;
520
519
  }
521
520
  }
521
+ // New session — must be an initialize request
522
+ if (!isInitializeRequest(req.body)) {
523
+ res.status(400).json({
524
+ error: "expected_initialize",
525
+ message: "First request to /mcp must be an MCP Initialize request.",
526
+ });
527
+ return;
528
+ }
522
529
  // Create per-session transport, server, and client
523
530
  const transport = new StreamableHTTPServerTransport({
524
531
  sessionIdGenerator: randomUUID,
@@ -20,6 +20,7 @@ import path from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { registerTool, SERVER_VERSION } from "../index.js";
22
22
  import { getActiveClient } from "../../backend/client.js";
23
+ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
23
24
  // ── Constants ─────────────────────────────────────────────────────────────────
24
25
  export const BAZI_RESOURCE_URI = "ui://openephemeris/bazi";
25
26
  export const BAZI_MIME_TYPE = "text/html;profile=mcp-app";
@@ -160,6 +161,7 @@ registerTool({
160
161
  required: [],
161
162
  additionalProperties: false,
162
163
  },
164
+ outputSchema: OUTPUT_SCHEMA_JSON,
163
165
  annotations: {
164
166
  title: "Interactive BaZi Four Pillars Explorer",
165
167
  readOnlyHint: true,
@@ -25,6 +25,7 @@ import path from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { registerTool, SERVER_VERSION } from "../index.js";
27
27
  import { getActiveClient } from "../../backend/client.js";
28
+ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
28
29
  // ── Constants ─────────────────────────────────────────────────────────────────
29
30
  export const BI_WHEEL_RESOURCE_URI = "ui://openephemeris/bi-wheel";
30
31
  export const BI_WHEEL_MIME_TYPE = "text/html;profile=mcp-app";
@@ -100,7 +101,7 @@ const HOUSE_SYSTEM_MAP = {
100
101
  placidus: "P", whole_sign: "W", equal: "E", koch: "K",
101
102
  campanus: "C", regiomontanus: "R",
102
103
  };
103
- function buildNatalBody(datetime, lat, lon, timezone) {
104
+ function buildNatalBody(datetime, lat, lon, timezone, houseSystem = "placidus") {
104
105
  const body = {
105
106
  subject: {
106
107
  name: "MCP Request",
@@ -111,7 +112,7 @@ function buildNatalBody(datetime, lat, lon, timezone) {
111
112
  },
112
113
  },
113
114
  configuration: {
114
- house_system: HOUSE_SYSTEM_MAP["placidus"],
115
+ house_system: HOUSE_SYSTEM_MAP[houseSystem] ?? HOUSE_SYSTEM_MAP["placidus"],
115
116
  },
116
117
  };
117
118
  if (timezone) {
@@ -275,19 +276,39 @@ export function computeCrossAspects(planets1, planets2) {
275
276
  return results.sort((a, b) => a.orb - b.orb).slice(0, 30);
276
277
  }
277
278
  // ── Planet normalisation ───────────────────────────────────────────────────────
279
+ // Canonical underscore names accepted by the renderer.
278
280
  const CLASSICAL_PLANETS = new Set([
279
281
  "sun", "moon", "mercury", "venus", "mars",
280
282
  "jupiter", "saturn", "uranus", "neptune", "pluto",
281
283
  "chiron", "north_node", "south_node", "true_node", "asc", "mc",
282
284
  ]);
285
+ /**
286
+ * Normalise the Go backend's verbose planet key (e.g. "North Node (Mean)")
287
+ * to the canonical lowercase underscore form the renderer expects.
288
+ */
289
+ function canonicalizePlanetName(raw) {
290
+ const lower = raw.toLowerCase();
291
+ // Various node/chiron aliases the Go PlanetName() function emits
292
+ if (lower === "north node (mean)" || lower === "north node (true)" || lower === "mean node" || lower === "true node")
293
+ return "north_node";
294
+ if (lower === "south node (mean)" || lower === "south node (true)")
295
+ return "south_node";
296
+ if (lower === "mean apogee" || lower === "black moon lilith" || lower === "lilith (mean)" || lower === "lilith (true)")
297
+ return "lilith";
298
+ // Strip parenthetical qualifiers for any other bodies just in case
299
+ return lower.replace(/\s*\(.*?\)/g, "").replace(/\s+/g, "_");
300
+ }
283
301
  function normalizePlanets(raw) {
284
302
  if (Array.isArray(raw))
285
303
  return raw;
286
304
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
287
305
  return Object.entries(raw)
288
- .filter(([k]) => CLASSICAL_PLANETS.has(k.toLowerCase()))
306
+ .filter(([k]) => {
307
+ const canonical = canonicalizePlanetName(k);
308
+ return CLASSICAL_PLANETS.has(canonical);
309
+ })
289
310
  .map(([name, p]) => ({
290
- name: name.toLowerCase(),
311
+ name: canonicalizePlanetName(name),
291
312
  longitude: (p.longitude ?? p.lon ?? 0),
292
313
  speed: (p.longitude_speed ?? p.speed),
293
314
  retrograde: Boolean(p.is_retrograde ?? p.retrograde),
@@ -309,14 +330,30 @@ function normalizeHouses(raw) {
309
330
  return [];
310
331
  }
311
332
  // ── Payload builder ────────────────────────────────────────────────────────────
333
+ function extractAngles(data) {
334
+ const rawAngles = data.angles;
335
+ const rawHouseObj = data.houses;
336
+ const ascendant = rawAngles?.ascendant ??
337
+ data.ascendant ??
338
+ rawHouseObj?.cusps?.[0] ??
339
+ 0;
340
+ const midheaven = rawAngles?.midheaven ??
341
+ data.midheaven ??
342
+ rawHouseObj?.cusps?.[9] ??
343
+ 0;
344
+ return { ascendant, midheaven };
345
+ }
312
346
  function buildBiWheelPayload(innerData, outerData, mode, params1, params2) {
313
347
  const innerPlanets = normalizePlanets(innerData.planets);
314
348
  const outerPlanets = normalizePlanets(outerData.planets);
315
349
  const innerHouses = normalizeHouses(innerData.houses);
316
350
  const outerHouses = normalizeHouses(outerData.houses);
317
351
  const crossAspects = computeCrossAspects(innerPlanets, outerPlanets);
352
+ const { ascendant, midheaven } = extractAngles(innerData);
318
353
  return {
319
354
  mode,
355
+ ascendant,
356
+ midheaven,
320
357
  inner: innerPlanets,
321
358
  outer: outerPlanets,
322
359
  inner_houses: innerHouses,
@@ -413,6 +450,7 @@ registerTool({
413
450
  required: ["person1_datetime", "person2_datetime",
414
451
  "person1_latitude", "person1_longitude"],
415
452
  },
453
+ outputSchema: OUTPUT_SCHEMA_JSON,
416
454
  annotations: {
417
455
  title: "Interactive Bi-Wheel Explorer",
418
456
  readOnlyHint: true,
@@ -487,14 +525,21 @@ registerTool({
487
525
  person2_timezone: { type: "string" },
488
526
  person2_name: { type: "string" },
489
527
  location: { type: "string" },
528
+ house_system: {
529
+ type: "string",
530
+ enum: Object.keys(HOUSE_SYSTEM_MAP),
531
+ description: "House system for the inner (natal) wheel. Defaults to placidus.",
532
+ },
490
533
  },
491
534
  required: ["mode", "person1_datetime", "person1_latitude", "person1_longitude", "person2_datetime"],
492
535
  },
536
+ outputSchema: OUTPUT_SCHEMA_JSON,
493
537
  annotations: { title: "Recalculate Bi-Wheel", readOnlyHint: true, openWorldHint: false },
494
538
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
495
539
  handler: async (args) => {
496
540
  const client = getActiveClient();
497
541
  const mode = args.mode ?? "synastry";
542
+ const houseSystem = args.house_system ?? "placidus";
498
543
  const dt1 = String(args.person1_datetime);
499
544
  const lat1 = args.person1_latitude;
500
545
  const lon1 = args.person1_longitude;
@@ -506,14 +551,14 @@ registerTool({
506
551
  const loc1 = String(args.person1_name ?? args.location ?? `${lat1 ?? "?"},${lon1 ?? "?"}`);
507
552
  const loc2 = MODE_META[mode].outerLabel(dt2, args.person2_name);
508
553
  const [innerData, outerData] = await Promise.all([
509
- client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1)),
554
+ client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1, houseSystem)),
510
555
  fetchOuterChart(client, mode, dt1, lat1, lon1, tz1, dt2, lat2, lon2, tz2),
511
556
  ]);
512
557
  const params1 = { datetime: dt1, timezone: tz1 ?? null, latitude: lat1 ?? null, longitude: lon1 ?? null, location: loc1 };
513
558
  const params2 = { datetime: dt2, timezone: tz2 ?? null, latitude: lat2 ?? null, longitude: lon2 ?? null, location: loc2 };
514
559
  const payload = buildBiWheelPayload(innerData, outerData, mode, params1, params2);
515
560
  return {
516
- content: [{ type: "text", text: JSON.stringify({ ...payload, server_version: SERVER_VERSION }) }],
561
+ content: [{ type: "text", text: JSON.stringify({ ...payload, house_system: houseSystem, server_version: SERVER_VERSION }) }],
517
562
  };
518
563
  },
519
564
  });
@@ -537,6 +582,7 @@ registerTool({
537
582
  },
538
583
  required: ["planet1", "planet2", "aspect_type"],
539
584
  },
585
+ outputSchema: OUTPUT_SCHEMA_JSON,
540
586
  annotations: { title: "Cross-Aspect Interpretation", readOnlyHint: true, openWorldHint: false },
541
587
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
542
588
  handler: async (args) => {
@@ -575,6 +621,7 @@ registerTool({
575
621
  },
576
622
  required: ["planet", "wheel", "longitude"],
577
623
  },
624
+ outputSchema: OUTPUT_SCHEMA_JSON,
578
625
  annotations: { title: "Bi-Wheel Planet Interpretation", readOnlyHint: true, openWorldHint: false },
579
626
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
580
627
  handler: async (args) => {
@@ -775,6 +822,7 @@ registerTool({
775
822
  },
776
823
  required: ["house_number"],
777
824
  },
825
+ outputSchema: OUTPUT_SCHEMA_JSON,
778
826
  annotations: { title: "Bi-Wheel House Interpretation", readOnlyHint: true, openWorldHint: false },
779
827
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
780
828
  handler: async (args) => {
@@ -834,6 +882,7 @@ registerTool({
834
882
  },
835
883
  required: ["mode"],
836
884
  },
885
+ outputSchema: OUTPUT_SCHEMA_JSON,
837
886
  annotations: { title: "Bi-Wheel Synopsis", readOnlyHint: true, openWorldHint: false },
838
887
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app", "model"] } },
839
888
  handler: async (args) => {
@@ -19,6 +19,7 @@ import path from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { registerTool, SERVER_VERSION } from "../index.js";
21
21
  import { getActiveClient } from "../../backend/client.js";
22
+ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
22
23
  // ── Constants ─────────────────────────────────────────────────────────────
23
24
  export const BODYGRAPH_RESOURCE_URI = "ui://openephemeris/bodygraph";
24
25
  export const BODYGRAPH_MIME_TYPE = "text/html;profile=mcp-app";
@@ -65,6 +66,40 @@ function ensureTimezone(dt) {
65
66
  return dt;
66
67
  return dt + "Z";
67
68
  }
69
+ /**
70
+ * Fetch the canonical bodygraph SVG from the Go renderer.
71
+ *
72
+ * The Go engine is the source of truth for bodygraph visuals (Phase 4 of the
73
+ * renderer unification). This SVG is inlined directly into the iframe and
74
+ * bound to interaction handlers via its data-* attributes (data-center,
75
+ * data-gate, data-channel, data-hanging, data-highlighted, etc.).
76
+ *
77
+ * Returns null on failure so the caller can fall back to the legacy client-side
78
+ * renderer in bodygraph-renderer.ts. Non-fatal: a missing SVG should never
79
+ * block the tool result.
80
+ */
81
+ async function fetchGoBodygraphSVG(body) {
82
+ try {
83
+ const client = getActiveClient();
84
+ // /visualization/bodygraph is registered as binary in BINARY_ENDPOINT_PREFIXES.
85
+ // The client wraps it as { content_type, encoding: "base64", data_base64 } —
86
+ // we decode back to UTF-8 SVG text for inlining.
87
+ const resp = await client.post("/visualization/bodygraph?format=svg&size=800", body);
88
+ if (typeof resp === "string")
89
+ return resp;
90
+ if (resp && typeof resp === "object") {
91
+ const r = resp;
92
+ if (typeof r.data_base64 === "string") {
93
+ return Buffer.from(r.data_base64, "base64").toString("utf-8");
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+ catch (err) {
99
+ console.warn("[bodygraph] Go SVG fetch failed, will fall back to client render:", err);
100
+ return null;
101
+ }
102
+ }
68
103
  /**
69
104
  * Convert a local datetime string (no offset) to a UTC ISO 8601 string.
70
105
  * Uses the IANA timezone to compute the correct UTC offset.
@@ -349,6 +384,7 @@ registerTool({
349
384
  },
350
385
  required: ["datetime"],
351
386
  },
387
+ outputSchema: OUTPUT_SCHEMA_JSON,
352
388
  annotations: {
353
389
  title: "Interactive Human Design Bodygraph Explorer",
354
390
  readOnlyHint: true,
@@ -376,9 +412,18 @@ registerTool({
376
412
  body.latitude = lat;
377
413
  if (lon != null)
378
414
  body.longitude = lon;
379
- const chartData = await client.request("POST", "/human-design/chart", {
380
- data: body,
381
- });
415
+ const bundleAvailable = Boolean(getBodygraphBundle());
416
+ // Fetch chart data and Go-rendered SVG in parallel. SVG only fetched when
417
+ // the iframe bundle is available (text-fallback hosts have no use for it,
418
+ // and the visualization endpoint costs a credit reservation).
419
+ const [chartData, svg] = await Promise.all([
420
+ client.request("POST", "/human-design/chart", {
421
+ data: body,
422
+ }),
423
+ bundleAvailable
424
+ ? fetchGoBodygraphSVG(body)
425
+ : Promise.resolve(null),
426
+ ]);
382
427
  // API returns { chart: {...}, metadata: {...} } — unwrap the inner chart object
383
428
  const chart = chartData.chart ?? chartData;
384
429
  // Build payload once — summary and model data both derive from the same object
@@ -389,8 +434,9 @@ registerTool({
389
434
  latitude: lat ?? null,
390
435
  longitude: lon ?? null,
391
436
  });
437
+ if (svg)
438
+ modelPayload._svg = svg;
392
439
  const summary = buildHdSummary(modelPayload, location);
393
- const bundleAvailable = Boolean(getBodygraphBundle());
394
440
  if (bundleAvailable) {
395
441
  return {
396
442
  content: [
@@ -425,6 +471,7 @@ registerTool({
425
471
  },
426
472
  required: ["datetime"],
427
473
  },
474
+ outputSchema: OUTPUT_SCHEMA_JSON,
428
475
  annotations: { title: "Recalculate Bodygraph", readOnlyHint: true, openWorldHint: false },
429
476
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
430
477
  handler: async (args) => {
@@ -441,9 +488,13 @@ registerTool({
441
488
  body.latitude = lat;
442
489
  if (lon != null)
443
490
  body.longitude = lon;
444
- const chartData = await client.request("POST", "/human-design/chart", {
445
- data: body,
446
- });
491
+ // Recalculate is iframe-only (visibility: ["app"]) — always co-fetch the SVG.
492
+ const [chartData, svg] = await Promise.all([
493
+ client.request("POST", "/human-design/chart", {
494
+ data: body,
495
+ }),
496
+ fetchGoBodygraphSVG(body),
497
+ ]);
447
498
  const chart = chartData.chart ?? chartData;
448
499
  const modelPayload = buildHdModelPayload(chart, {
449
500
  datetime,
@@ -452,6 +503,8 @@ registerTool({
452
503
  latitude: lat ?? null,
453
504
  longitude: lon ?? null,
454
505
  });
506
+ if (svg)
507
+ modelPayload._svg = svg;
455
508
  return {
456
509
  content: [{ type: "text", text: JSON.stringify({ ...modelPayload, server_version: SERVER_VERSION }) }],
457
510
  };
@@ -478,6 +531,7 @@ registerTool({
478
531
  },
479
532
  required: ["center", "defined"],
480
533
  },
534
+ outputSchema: OUTPUT_SCHEMA_JSON,
481
535
  annotations: { title: "Center Interpretation", readOnlyHint: true, openWorldHint: false },
482
536
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
483
537
  handler: async (args) => {
@@ -535,6 +589,7 @@ registerTool({
535
589
  },
536
590
  required: ["gate"],
537
591
  },
592
+ outputSchema: OUTPUT_SCHEMA_JSON,
538
593
  annotations: { title: "Gate Interpretation", readOnlyHint: true, openWorldHint: false },
539
594
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
540
595
  handler: async (args) => {
@@ -589,6 +644,7 @@ registerTool({
589
644
  },
590
645
  required: ["channel"],
591
646
  },
647
+ outputSchema: OUTPUT_SCHEMA_JSON,
592
648
  annotations: { title: "Channel Interpretation", readOnlyHint: true, openWorldHint: false },
593
649
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
594
650
  handler: async (args) => {
@@ -734,7 +790,7 @@ const HD_GATE_DATA = {
734
790
  38: { name: "The Fighter", hexagram: "Hex 38 — Opposition", keynote: "Struggle in the search for purpose", gift: "Perseverance — fighting for what is meaningful", shadow: "Struggle — fighting against everything without purpose", circuit: "Individual (Knowing)" },
735
791
  39: { name: "Provocation", hexagram: "Hex 39 — Obstruction", keynote: "Freeing the spirit through provocation", gift: "Dynamism — provocateur that liberates", shadow: "Provocation — stirring trouble unconsciously", circuit: "Individual (Knowing)" },
736
792
  40: { name: "Aloneness", hexagram: "Hex 40 — Deliverance", keynote: "The demand for aloneness as restoration", gift: "Resolve — willpower renewed in solitude", shadow: "Exhaustion — depleted by giving without replenishment", circuit: "Tribal (Ego)" },
737
- 41: { name: "Contraction", hexagram: "Hex 41 — Decrease", keynote: "The beginning of all experience — fantasy and desire", gift: "Fantasy — imagination that seeds new experience", shadow: "Fantasy misused — escapism", circuit: "Collective (Abstract)" },
793
+ 41: { name: "Contraction", hexagram: "Hex 41 — Decrease", keynote: "The beginning of all experience — fantasy and desire", gift: "Fantasy — imagination that seeds new experience", shadow: "Escapism — escapism", circuit: "Collective (Abstract)" },
738
794
  42: { name: "Growth", hexagram: "Hex 42 — Increase", keynote: "The ability to perpetuate and complete growth cycles", gift: "Detachment — completing cycles with grace", shadow: "Incompletion — abandoning cycles before they end", circuit: "Collective (Abstract)" },
739
795
  43: { name: "Insight", hexagram: "Hex 43 — Breakthrough", keynote: "Breakthrough from within", gift: "Insight — inner knowing that arrives as genius", shadow: "Deafness — genius unrecognised or unheard", circuit: "Individual (Knowing)" },
740
796
  44: { name: "Alertness", hexagram: "Hex 44 — Coming to Meet", keynote: "Instinctive memory and pattern recognition", gift: "Teamwork — bringing the right people together", shadow: "Interference — driven by karmic patterns from the past", circuit: "Tribal (Defence)" },
@@ -848,8 +904,8 @@ const HD_PLANET_HD_THEMES = {
848
904
  "to act before the mind has a chance to deliberate. This energy can surprise even yourself.",
849
905
  },
850
906
  Jupiter: {
851
- conscious: "**Jupiter** represents the law of your Personality — the karmic gifts and the areas of life where " +
852
- "higher wisdom, abundance, and justice flow. It shows where and how you expand beyond the self-limiting " +
907
+ conscious: "**Jupiter** represents the law of your Personality — the karmic gifts and the principled framework through which you expand beyond " +
908
+ "higher wisdom and justice flow. It shows where you move beyond the self-limiting " +
853
909
  "patterns of your conditioning.",
854
910
  unconscious: "**Design Jupiter** shows the unconscious grace embedded in your design — the karmic correction " +
855
911
  "your body naturally applies when you are living true to your nature.",
@@ -932,6 +988,7 @@ registerTool({
932
988
  },
933
989
  required: ["planet", "column"],
934
990
  },
991
+ outputSchema: OUTPUT_SCHEMA_JSON,
935
992
  annotations: { title: "Planet Sidebar Interpretation", readOnlyHint: true, openWorldHint: false },
936
993
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
937
994
  handler: async (args) => {
@@ -1000,6 +1057,7 @@ registerTool({
1000
1057
  },
1001
1058
  required: ["variable", "direction"],
1002
1059
  },
1060
+ outputSchema: OUTPUT_SCHEMA_JSON,
1003
1061
  annotations: { title: "Variable Arrow Interpretation", readOnlyHint: true, openWorldHint: false },
1004
1062
  _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
1005
1063
  handler: async (args) => {