@openephemeris/mcp-server 3.13.10 → 3.15.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 +53 -0
  2. package/README.md +6 -6
  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 +25 -16
  10. package/dist/server-sse.js +136 -78
  11. package/dist/tools/apps/bazi-app.js +2 -0
  12. package/dist/tools/apps/bi-wheel-app.js +65 -7
  13. package/dist/tools/apps/bodygraph-app.js +85 -11
  14. package/dist/tools/apps/chart-wheel-app.js +80 -7
  15. package/dist/tools/apps/location-tools.js +41 -5
  16. package/dist/tools/apps/moon-phase-app.js +82 -22
  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 +104 -67
  21. package/dist/tools/index.d.ts +16 -0
  22. package/dist/tools/index.js +23 -8
  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 +14 -6
  27. package/dist/tools/specialized/bi_wheel.js +5 -2
  28. package/dist/tools/specialized/chart_wheel.js +5 -2
  29. package/dist/tools/specialized/comparative.js +7 -2
  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 +6 -3
  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 +22 -22
  48. package/dist/ui/bodygraph.html +1981 -1904
  49. package/dist/ui/chart-wheel.html +3148 -2806
  50. package/dist/ui/vedic-chart.html +22 -22
  51. package/package.json +18 -11
  52. package/smithery.yaml +16 -1
  53. package/dist/ui/bi-wheel.html +0 -7485
  54. package/dist/ui/moon-phase.html +0 -6758
  55. package/dist/ui/transit-timeline.html +0 -6835
@@ -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";
@@ -68,8 +66,10 @@ const BACKEND_URL = process.env.OPENEPHEMERIS_BACKEND_URL ||
68
66
  "https://api.openephemeris.com";
69
67
  const version = resolveServerVersion();
70
68
  /**
71
- * Validate a user-supplied API key by hitting an auth-gated endpoint on
72
- * the Go sidecar. Returns true if the key yields a 2xx, false on 401/403.
69
+ * Validate a user-supplied API key by hitting an auth-gated endpoint on the Go
70
+ * sidecar. Tri-state so callers can fail closed when validation is unavailable
71
+ * (APP-6): "valid" (2xx), "invalid" (401/403), or "unknown" (network error or
72
+ * non-auth status — could not confirm).
73
73
  */
74
74
  async function validateApiKey(apiKey) {
75
75
  try {
@@ -79,17 +79,26 @@ async function validateApiKey(apiKey) {
79
79
  timeout: 5_000,
80
80
  validateStatus: () => true, // don't throw on any status
81
81
  });
82
- // 2xx = valid key, 401/403 = invalid key, anything else = let through
83
82
  if (resp.status === 401 || resp.status === 403)
84
- return false;
85
- return true;
83
+ return "invalid";
84
+ if (resp.status >= 200 && resp.status < 300)
85
+ return "valid";
86
+ return "unknown"; // 5xx etc. — can't confirm
86
87
  }
87
88
  catch {
88
- // Network error let them through; tool calls will fail if key is bad.
89
- console.warn("Could not validate API key against backend, allowing connection.");
90
- return true;
89
+ console.warn("Could not validate API key against backend.");
90
+ return "unknown";
91
91
  }
92
92
  }
93
+ /**
94
+ * Browser-originated requests carry an Origin header (the MCP apps run in a
95
+ * cross-origin iframe). Server-to-server MCP clients generally do not. We use
96
+ * this to fail closed for browsers when key validation is unavailable, while
97
+ * still letting trusted server clients through (APP-6).
98
+ */
99
+ function isBrowserOrigin(req) {
100
+ return typeof req.headers.origin === "string" && req.headers.origin.length > 0;
101
+ }
93
102
  /**
94
103
  * Extract authentication from a request, distinguishing JWTs from API keys.
95
104
  *
@@ -214,8 +223,12 @@ function createMcpServer() {
214
223
  prompts: {},
215
224
  resources: {},
216
225
  experimental: {
217
- // Declare MCP Apps extension (spec: io.modelcontextprotocol/ui, 2026-01-26)
218
- "io.modelcontextprotocol/ui": {},
226
+ // Declare MCP Apps extension (spec: io.modelcontextprotocol/ui, 2026-01-26).
227
+ // Advertise the supported UI MIME type so Directory-compliant hosts know
228
+ // this server returns renderable HTML resources.
229
+ "io.modelcontextprotocol/ui": {
230
+ mimeTypes: ["text/html;profile=mcp-app"],
231
+ },
219
232
  },
220
233
  },
221
234
  instructions: "Open Ephemeris is a precision astronomical computation engine. " +
@@ -232,7 +245,15 @@ function createMcpServer() {
232
245
  annotations: buildAnnotations(tool),
233
246
  // Expose MCP Apps UI linkage so Claude Desktop can prefetch the resource
234
247
  ...(tool._meta?.ui?.resourceUri
235
- ? { _meta: { ui: { resourceUri: tool._meta.ui.resourceUri, visibility: tool._meta.ui.visibility ?? ["model", "app"] } } }
248
+ ? {
249
+ _meta: {
250
+ "ui/resourceUri": tool._meta.ui.resourceUri,
251
+ ui: {
252
+ resourceUri: tool._meta.ui.resourceUri,
253
+ visibility: tool._meta.ui.visibility ?? ["model", "app"],
254
+ },
255
+ },
256
+ }
236
257
  : {}),
237
258
  })),
238
259
  }));
@@ -264,16 +285,18 @@ function createMcpServer() {
264
285
  }
265
286
  catch (error) {
266
287
  const startTime = Date.now(); // Not strictly accurate for errors but acceptable for logging structure if needed
288
+ const durationMs = Date.now() - startTime;
267
289
  const errorMessage = error instanceof Error ? error.message : String(error);
268
- console.error(`[MCP] \u274c Failed: ${toolName} - ${errorMessage}`);
290
+ const isRetryable = error.retryable === true || error.status === 401 || error.code === "auth_required";
291
+ console.error(`[MCP] ❌ Failed: ${toolName} (${durationMs}ms) - ${errorMessage}`);
269
292
  return {
270
293
  content: [
271
294
  {
272
295
  type: "text",
273
- text: JSON.stringify({ success: false, error: "TOOL_EXECUTION_ERROR", message: errorMessage }, null, 2),
296
+ 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
297
  },
275
298
  ],
276
- isError: true,
299
+ isError: !isRetryable,
277
300
  };
278
301
  }
279
302
  });
@@ -304,14 +327,7 @@ function createMcpServer() {
304
327
  mimeType: BI_WHEEL_MIME_TYPE,
305
328
  });
306
329
  }
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
- }
330
+ // NOTE: transit-timeline, bazi, vedic-chart resources omitted — tools disabled.
315
331
  if (getMoonPhaseBundle()) {
316
332
  resources.push({
317
333
  uri: MOON_PHASE_RESOURCE_URI,
@@ -320,22 +336,6 @@ function createMcpServer() {
320
336
  mimeType: MOON_PHASE_MIME_TYPE,
321
337
  });
322
338
  }
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
339
  return { resources };
340
340
  });
341
341
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
@@ -358,30 +358,13 @@ function createMcpServer() {
358
358
  throw new Error("Bi-Wheel UI bundle not found. Run `npm run build:ui` to build it.");
359
359
  return { contents: [{ uri: BI_WHEEL_RESOURCE_URI, mimeType: BI_WHEEL_MIME_TYPE, text: bundle }] };
360
360
  }
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
- }
361
+ // NOTE: transit-timeline, bazi, vedic-chart ReadResource handlers removed.
367
362
  if (uri === MOON_PHASE_RESOURCE_URI) {
368
363
  const bundle = getMoonPhaseBundle();
369
364
  if (!bundle)
370
365
  throw new Error("Moon Phase UI bundle not found. Run `npm run build:ui` to build it.");
371
366
  return { contents: [{ uri: MOON_PHASE_RESOURCE_URI, mimeType: MOON_PHASE_MIME_TYPE, text: bundle }] };
372
367
  }
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
368
  throw new Error(`Unknown resource: ${uri}`);
386
369
  });
387
370
  return server;
@@ -393,12 +376,38 @@ async function main() {
393
376
  // NOTE: Do NOT use express.json() globally — SSEServerTransport.handlePostMessage
394
377
  // reads the raw request stream itself. Pre-parsing with express.json() consumes it,
395
378
  // 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", "*");
379
+ // CORS + Origin allowlist
380
+ // Per Anthropic remote-MCP review criteria, browser-originated requests must
381
+ // be validated against an allowlist. Native (no-Origin) clients like Claude
382
+ // Desktop and CLI tooling are permitted; unknown browser origins are rejected.
383
+ const ORIGIN_ALLOWLIST = [
384
+ /^https:\/\/([a-z0-9-]+\.)*claude\.ai$/i,
385
+ /^https:\/\/([a-z0-9-]+\.)*claude\.com$/i,
386
+ /^https:\/\/([a-z0-9-]+\.)*anthropic\.com$/i,
387
+ /^https:\/\/([a-z0-9-]+\.)*openephemeris\.com$/i,
388
+ /^http:\/\/localhost(:\d+)?$/i,
389
+ /^http:\/\/127\.0\.0\.1(:\d+)?$/i,
390
+ ];
391
+ function isOriginAllowed(origin) {
392
+ return ORIGIN_ALLOWLIST.some((m) => typeof m === "string" ? m === origin : m.test(origin));
393
+ }
394
+ app.use((req, res, next) => {
395
+ const origin = req.headers.origin;
396
+ // Browser request → must match allowlist. Native client (no Origin) → allow.
397
+ if (origin) {
398
+ if (!isOriginAllowed(origin)) {
399
+ res.status(403).json({
400
+ error: "origin_not_allowed",
401
+ error_description: `Origin '${origin}' is not permitted.`,
402
+ });
403
+ return;
404
+ }
405
+ res.setHeader("Access-Control-Allow-Origin", origin);
406
+ res.setHeader("Vary", "Origin");
407
+ }
399
408
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
400
409
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-OpenEphemeris-API-Key, mcp-session-id");
401
- if (_req.method === "OPTIONS") {
410
+ if (req.method === "OPTIONS") {
402
411
  res.status(204).end();
403
412
  return;
404
413
  }
@@ -417,10 +426,34 @@ async function main() {
417
426
  maxRequests: 10,
418
427
  trustProxy: true,
419
428
  });
429
+ const dcrRateLimiter = createRateLimiter({
430
+ windowMs: 60_000,
431
+ maxRequests: 5,
432
+ trustProxy: true,
433
+ });
420
434
  app.use(oauthDiscoveryRouter);
435
+ app.use("/oauth/register", dcrRateLimiter);
421
436
  app.use(express.json({ limit: "1mb" }), oauthDcrRouter);
422
437
  app.use("/oauth/token", tokenRateLimiter);
423
438
  app.use(express.urlencoded({ extended: false }), oauthTokenRouter(oauthStore));
439
+ // Per-IP throttle on MCP session creation to bound unauthenticated session
440
+ // churn / volumetric abuse (APP-7). Only NEW sessions are limited — requests
441
+ // carrying an mcp-session-id are existing sessions (ongoing tool calls) and
442
+ // must never be throttled.
443
+ const sessionRateLimiter = createRateLimiter({
444
+ windowMs: 60_000,
445
+ maxRequests: 30,
446
+ trustProxy: true,
447
+ });
448
+ const throttleSessionCreation = (req, res, next) => {
449
+ if (req.headers["mcp-session-id"]) {
450
+ next();
451
+ return;
452
+ }
453
+ sessionRateLimiter(req, res, next);
454
+ };
455
+ app.use("/mcp", throttleSessionCreation);
456
+ app.use("/sse", sessionRateLimiter);
424
457
  // Health check
425
458
  app.get("/health", (_req, res) => {
426
459
  res.json({
@@ -441,6 +474,7 @@ async function main() {
441
474
  name: tool.name,
442
475
  description: tool.description,
443
476
  inputSchema: tool.inputSchema,
477
+ ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
444
478
  annotations: buildAnnotations(tool),
445
479
  }));
446
480
  res.json({
@@ -490,17 +524,14 @@ async function main() {
490
524
  await runWithClient(session.client, () => session.transport.handleRequest(req, res, req.body));
491
525
  return;
492
526
  }
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)
527
+ // Auth check FIRST before validating request shape. RFC 9728 requires
528
+ // any unauthenticated POST to /mcp (even a probe with an empty/invalid
529
+ // body) to return 401 + WWW-Authenticate so OAuth clients (Claude Web)
530
+ // can bootstrap discovery. Returning 400 before auth causes the ofid_
531
+ // "Authorization with MCP server failed" error in Claude's connector UI.
502
532
  const { apiKey, jwt } = extractAuth(req);
503
533
  if (!apiKey && !jwt) {
534
+ res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
504
535
  res.status(401).json({
505
536
  error: "auth_required",
506
537
  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",
@@ -510,14 +541,32 @@ async function main() {
510
541
  // Validate API keys against the Go backend; JWTs are validated downstream
511
542
  // by the Go sidecar's ValidateSupabaseJWT when tool calls are proxied.
512
543
  if (apiKey) {
513
- const valid = await validateApiKey(apiKey);
514
- if (!valid) {
515
- res.status(403).json({
544
+ const keyStatus = await validateApiKey(apiKey);
545
+ if (keyStatus === "invalid") {
546
+ res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
547
+ res.status(401).json({
516
548
  error: "invalid_api_key",
517
549
  message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
518
550
  });
519
551
  return;
520
552
  }
553
+ // Fail closed for browser clients when validation is unavailable (APP-6).
554
+ if (keyStatus === "unknown" && isBrowserOrigin(req)) {
555
+ res.set("Retry-After", "5");
556
+ res.status(503).json({
557
+ error: "validation_unavailable",
558
+ message: "Could not verify your API key right now. Please retry shortly.",
559
+ });
560
+ return;
561
+ }
562
+ }
563
+ // New session — must be an initialize request
564
+ if (!isInitializeRequest(req.body)) {
565
+ res.status(400).json({
566
+ error: "expected_initialize",
567
+ message: "First request to /mcp must be an MCP Initialize request.",
568
+ });
569
+ return;
521
570
  }
522
571
  // Create per-session transport, server, and client
523
572
  const transport = new StreamableHTTPServerTransport({
@@ -617,14 +666,23 @@ async function main() {
617
666
  }
618
667
  // Validate API keys against the Go backend; JWTs validated downstream.
619
668
  if (apiKey) {
620
- const valid = await validateApiKey(apiKey);
621
- if (!valid) {
669
+ const keyStatus = await validateApiKey(apiKey);
670
+ if (keyStatus === "invalid") {
622
671
  res.status(403).json({
623
672
  error: "invalid_api_key",
624
673
  message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
625
674
  });
626
675
  return;
627
676
  }
677
+ // Fail closed for browser clients when validation is unavailable (APP-6).
678
+ if (keyStatus === "unknown" && isBrowserOrigin(req)) {
679
+ res.set("Retry-After", "5");
680
+ res.status(503).json({
681
+ error: "validation_unavailable",
682
+ message: "Could not verify your API key right now. Please retry shortly.",
683
+ });
684
+ return;
685
+ }
628
686
  }
629
687
  const transport = new SSEServerTransport("/message", res);
630
688
  const sessionId = transport.sessionId;
@@ -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,43 @@ 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
+ // Keep mean vs true DISTINCT so the UI can offer a node toggle; collapsing
293
+ // both to "north_node" (the old behaviour) produced two overlapping ☊ glyphs.
294
+ if (lower === "north node (true)" || lower === "true node")
295
+ return "true_node";
296
+ if (lower === "north node (mean)" || lower === "mean node" || lower === "north node")
297
+ return "north_node";
298
+ if (lower === "south node (mean)" || lower === "south node (true)")
299
+ return "south_node";
300
+ if (lower === "mean apogee" || lower === "black moon lilith" || lower === "lilith (mean)" || lower === "lilith (true)")
301
+ return "lilith";
302
+ // Strip parenthetical qualifiers for any other bodies just in case
303
+ return lower.replace(/\s*\(.*?\)/g, "").replace(/\s+/g, "_");
304
+ }
283
305
  function normalizePlanets(raw) {
284
306
  if (Array.isArray(raw))
285
307
  return raw;
286
308
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
287
309
  return Object.entries(raw)
288
- .filter(([k]) => CLASSICAL_PLANETS.has(k.toLowerCase()))
310
+ .filter(([k]) => {
311
+ const canonical = canonicalizePlanetName(k);
312
+ return CLASSICAL_PLANETS.has(canonical);
313
+ })
289
314
  .map(([name, p]) => ({
290
- name: name.toLowerCase(),
315
+ name: canonicalizePlanetName(name),
291
316
  longitude: (p.longitude ?? p.lon ?? 0),
292
317
  speed: (p.longitude_speed ?? p.speed),
293
318
  retrograde: Boolean(p.is_retrograde ?? p.retrograde),
@@ -309,14 +334,30 @@ function normalizeHouses(raw) {
309
334
  return [];
310
335
  }
311
336
  // ── Payload builder ────────────────────────────────────────────────────────────
337
+ function extractAngles(data) {
338
+ const rawAngles = data.angles;
339
+ const rawHouseObj = data.houses;
340
+ const ascendant = rawAngles?.ascendant ??
341
+ data.ascendant ??
342
+ rawHouseObj?.cusps?.[0] ??
343
+ 0;
344
+ const midheaven = rawAngles?.midheaven ??
345
+ data.midheaven ??
346
+ rawHouseObj?.cusps?.[9] ??
347
+ 0;
348
+ return { ascendant, midheaven };
349
+ }
312
350
  function buildBiWheelPayload(innerData, outerData, mode, params1, params2) {
313
351
  const innerPlanets = normalizePlanets(innerData.planets);
314
352
  const outerPlanets = normalizePlanets(outerData.planets);
315
353
  const innerHouses = normalizeHouses(innerData.houses);
316
354
  const outerHouses = normalizeHouses(outerData.houses);
317
355
  const crossAspects = computeCrossAspects(innerPlanets, outerPlanets);
356
+ const { ascendant, midheaven } = extractAngles(innerData);
318
357
  return {
319
358
  mode,
359
+ ascendant,
360
+ midheaven,
320
361
  inner: innerPlanets,
321
362
  outer: outerPlanets,
322
363
  inner_houses: innerHouses,
@@ -413,6 +454,7 @@ registerTool({
413
454
  required: ["person1_datetime", "person2_datetime",
414
455
  "person1_latitude", "person1_longitude"],
415
456
  },
457
+ outputSchema: OUTPUT_SCHEMA_JSON,
416
458
  annotations: {
417
459
  title: "Interactive Bi-Wheel Explorer",
418
460
  readOnlyHint: true,
@@ -453,11 +495,16 @@ registerTool({
453
495
  const payload = buildBiWheelPayload(innerData, outerData, mode, params1, params2);
454
496
  const bundleAvailable = Boolean(getBiWheelBundle());
455
497
  if (bundleAvailable) {
498
+ // MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
499
+ // delivered through resources/read — NOT as a content block. A URI-only
500
+ // `type: "resource"` block lacks the inline text/blob the base MCP schema
501
+ // requires and fails strict client validation. structuredContent
502
+ // preserves the full payload for hosts that don't render the iframe.
456
503
  return {
457
504
  content: [
458
505
  { type: "text", text: summary },
459
- { type: "text", text: JSON.stringify({ ...payload, server_version: SERVER_VERSION }) },
460
506
  ],
507
+ structuredContent: { ...payload, server_version: SERVER_VERSION },
461
508
  _meta: {
462
509
  "ui/resourceUri": BI_WHEEL_RESOURCE_URI,
463
510
  ui: { resourceUri: BI_WHEEL_RESOURCE_URI },
@@ -487,14 +534,21 @@ registerTool({
487
534
  person2_timezone: { type: "string" },
488
535
  person2_name: { type: "string" },
489
536
  location: { type: "string" },
537
+ house_system: {
538
+ type: "string",
539
+ enum: Object.keys(HOUSE_SYSTEM_MAP),
540
+ description: "House system for the inner (natal) wheel. Defaults to placidus.",
541
+ },
490
542
  },
491
543
  required: ["mode", "person1_datetime", "person1_latitude", "person1_longitude", "person2_datetime"],
492
544
  },
545
+ outputSchema: OUTPUT_SCHEMA_JSON,
493
546
  annotations: { title: "Recalculate Bi-Wheel", readOnlyHint: true, openWorldHint: false },
494
547
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
495
548
  handler: async (args) => {
496
549
  const client = getActiveClient();
497
550
  const mode = args.mode ?? "synastry";
551
+ const houseSystem = args.house_system ?? "placidus";
498
552
  const dt1 = String(args.person1_datetime);
499
553
  const lat1 = args.person1_latitude;
500
554
  const lon1 = args.person1_longitude;
@@ -506,14 +560,14 @@ registerTool({
506
560
  const loc1 = String(args.person1_name ?? args.location ?? `${lat1 ?? "?"},${lon1 ?? "?"}`);
507
561
  const loc2 = MODE_META[mode].outerLabel(dt2, args.person2_name);
508
562
  const [innerData, outerData] = await Promise.all([
509
- client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1)),
563
+ client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1, houseSystem)),
510
564
  fetchOuterChart(client, mode, dt1, lat1, lon1, tz1, dt2, lat2, lon2, tz2),
511
565
  ]);
512
566
  const params1 = { datetime: dt1, timezone: tz1 ?? null, latitude: lat1 ?? null, longitude: lon1 ?? null, location: loc1 };
513
567
  const params2 = { datetime: dt2, timezone: tz2 ?? null, latitude: lat2 ?? null, longitude: lon2 ?? null, location: loc2 };
514
568
  const payload = buildBiWheelPayload(innerData, outerData, mode, params1, params2);
515
569
  return {
516
- content: [{ type: "text", text: JSON.stringify({ ...payload, server_version: SERVER_VERSION }) }],
570
+ content: [{ type: "text", text: JSON.stringify({ ...payload, house_system: houseSystem, server_version: SERVER_VERSION }) }],
517
571
  };
518
572
  },
519
573
  });
@@ -537,6 +591,7 @@ registerTool({
537
591
  },
538
592
  required: ["planet1", "planet2", "aspect_type"],
539
593
  },
594
+ outputSchema: OUTPUT_SCHEMA_JSON,
540
595
  annotations: { title: "Cross-Aspect Interpretation", readOnlyHint: true, openWorldHint: false },
541
596
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
542
597
  handler: async (args) => {
@@ -575,6 +630,7 @@ registerTool({
575
630
  },
576
631
  required: ["planet", "wheel", "longitude"],
577
632
  },
633
+ outputSchema: OUTPUT_SCHEMA_JSON,
578
634
  annotations: { title: "Bi-Wheel Planet Interpretation", readOnlyHint: true, openWorldHint: false },
579
635
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
580
636
  handler: async (args) => {
@@ -775,6 +831,7 @@ registerTool({
775
831
  },
776
832
  required: ["house_number"],
777
833
  },
834
+ outputSchema: OUTPUT_SCHEMA_JSON,
778
835
  annotations: { title: "Bi-Wheel House Interpretation", readOnlyHint: true, openWorldHint: false },
779
836
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
780
837
  handler: async (args) => {
@@ -834,6 +891,7 @@ registerTool({
834
891
  },
835
892
  required: ["mode"],
836
893
  },
894
+ outputSchema: OUTPUT_SCHEMA_JSON,
837
895
  annotations: { title: "Bi-Wheel Synopsis", readOnlyHint: true, openWorldHint: false },
838
896
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app", "model"] } },
839
897
  handler: async (args) => {