@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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [3.16.0] — 2026-07-05
11
+
12
+ ### Added
13
+ - **`explore_human_design_transit`** — personalized Human Design transit overlay. Overlays a transiting moment on a person's natal bodygraph and highlights the channels the transit temporarily completes plus any newly-defined centers. Premium (Developer tier); renders an interactive overlay bodygraph in MCP Apps hosts.
14
+ - **`explore_human_design_connection`** — two-person Human Design connection (synastry) overlay. Classifies every connected channel as electromagnetic / companionship / dominance / compromise and renders a two-person overlay bodygraph. Premium (Developer tier).
15
+
16
+ ### Changed
17
+ - **`human_design_composite` is now Developer-tier** (was Explorer) and returns real connection-channel scoring — the previously-stubbed relationship endpoint (`/human-design/composite`) is now live end-to-end.
18
+
19
+ ---
20
+
21
+ ## [3.15.0] — 2026-06-15
22
+
23
+ ### Changed
24
+ - **`dev_call` split into `dev_read_api` (GET) and `dev_write_api` (POST/PUT/PATCH/DELETE).** Read and write now live in separate tools so a safe read surface never shares a tool with state-changing calls — required for MCP connector-directory compliance. `dev_read_api` carries `readOnlyHint: true`; `dev_write_api` carries `readOnlyHint: false`. Both name their target API explicitly.
25
+
26
+ ### Fixed
27
+ - **Brand-gold WCAG AA contrast pass** across the chart-wheel, bi-wheel, and bodygraph iframes — gold accents darkened to clear AA against their backgrounds; all three apps are axe-clean.
28
+ - **Moon-phase color cue** now encodes speed/proximity, and aspect-label contrast corrected.
29
+
30
+ ### Added
31
+ - **Collapsible glyph legend** in the interactive chart iframes — keeps the wheel uncluttered while remaining one tap from a full symbol key.
32
+
33
+ ---
34
+
10
35
  ## [3.14.0] — 2026-05-24
11
36
 
12
37
  ### Added
package/README.md CHANGED
@@ -229,7 +229,9 @@ The server is hosted at `https://mcp.openephemeris.com/mcp` with full Streamable
229
229
  | Station tracker | `electional_station_tracker` | Developer |
230
230
  | Aspect search | `electional_aspect_search` | Developer |
231
231
  | Human Design chart | `human_design_chart` | Explorer |
232
- | HD composite | `human_design_composite` | Explorer |
232
+ | HD composite | `human_design_composite` | Developer |
233
+ | HD transit overlay | `explore_human_design_transit` | Developer |
234
+ | HD connection (synastry) | `explore_human_design_connection` | Developer |
233
235
  | HD penta | `human_design_penta` | Explorer |
234
236
  | HD return / opposition | `hd_planetary_return`, `hd_opposition` | Explorer |
235
237
  | Vedic chart | `vedic_chart` | Explorer |
@@ -251,15 +253,15 @@ The server is hosted at `https://mcp.openephemeris.com/mcp` with full Streamable
251
253
  ## Tooling Model
252
254
 
253
255
  - Typed tools are preferred for common workflows (natal, transits, moon phase, eclipse, synastry, relocation, electional, Human Design).
254
- - Generic tools: `dev.list_allowed` returns all currently allowlisted operations, and `dev.call` invokes any allowlisted operation by `method + path`.
256
+ - Generic tools: `dev_list_allowed` returns all currently allowlisted operations; `dev_read_api` invokes allowlisted **GET** (read) operations and `dev_write_api` invokes allowlisted **POST/PUT/PATCH/DELETE** (write/compute) operations, each by `method + path`. Read and write are kept as separate tools so a safe read surface never shares a tool with state-changing writes.
255
257
  - Security model: default-deny with explicit allowlist in `config/dev-allowlist.json`.
256
258
  - Deny prefixes block sensitive route families (`/auth`, `/billing`, `/admin`, etc.).
257
259
 
258
- ### `dev.call` input
260
+ ### `dev_read_api` / `dev_write_api` input
259
261
 
260
262
  | Parameter | Type | Required | Description |
261
263
  |---|---|---|---|
262
- | `method` | `GET\|POST\|PUT\|PATCH\|DELETE` | Yes | HTTP method |
264
+ | `method` | `dev_read_api`: `GET` · `dev_write_api`: `POST\|PUT\|PATCH\|DELETE` | No | HTTP method (defaults to the tool's natural method) |
263
265
  | `path` | `string` | Yes | Absolute API path, e.g. `/ephemeris/natal-chart` |
264
266
  | `query` | `object` | No | Query parameters |
265
267
  | `body` | `object` | No | JSON body for non-GET requests |
@@ -355,8 +357,8 @@ Generated by `npm run sync:readme` from `config/dev-allowlist.json` and the live
355
357
 
356
358
  - Allowlisted operations: **28**
357
359
  - Methods: `GET=4`, `POST=24`, `PUT=0`, `PATCH=0`, `DELETE=0`
358
- - Registered tools (`OPENEPHEMERIS_PROFILE=dev`): **79**
359
- - Typed tools: `acg_hits`, `acg_power_lines`, `auth_login`, `auth_logout`, `auth_status`, `bazi_annual_pillar`, `bazi_chart`, `bazi_compatibility`, `bazi_element_balance`, `bazi_luck_pillars`, `bazi_ten_gods`, `bi_wheel_on_cross_aspect_click`, `bi_wheel_on_house_click`, `bi_wheel_on_planet_click`, `bi_wheel_recalculate`, `bi_wheel_synopsis`, `bodygraph_recalculate`, `chart_wheel_on_aspect_click`, `chart_wheel_on_house_click`, `chart_wheel_on_planet_click`, `chart_wheel_recalculate`, `chinese_bazi`, `dev_call`, `dev_list_allowed`, `electional_aspect_search`, `electional_moment_analysis`, `electional_station_tracker`, `ephemeris_angles_points`, `ephemeris_aspect_check`, `ephemeris_bi_wheel`, `ephemeris_chart_wheel`, `ephemeris_composite`, `ephemeris_composite_midpoint`, `ephemeris_dignities`, `ephemeris_electional`, `ephemeris_fixed_stars`, `ephemeris_hermetic_lots`, `ephemeris_house_cusps`, `ephemeris_lunar_return`, `ephemeris_midpoints`, `ephemeris_moon_phase`, `ephemeris_natal_batch`, `ephemeris_natal_chart`, `ephemeris_natal_transits`, `ephemeris_next_eclipse`, `ephemeris_next_lunar_phase`, `ephemeris_overlay`, `ephemeris_planet_position`, `ephemeris_planetary_return`, `ephemeris_progressed_chart`, `ephemeris_relocation`, `ephemeris_retrograde_status`, `ephemeris_solar_return`, `ephemeris_synastry`, `ephemeris_transits`, `explore_bi_wheel`, `explore_human_design`, `explore_moon_phase`, `explore_natal_chart`, `hd_on_center_click`, `hd_on_channel_click`, `hd_on_gate_click`, `hd_on_planet_click`, `hd_on_variable_click`, `hd_opposition`, `hd_planetary_return`, `human_design_bodygraph`, `human_design_chart`, `human_design_composite`, `human_design_penta`, `location_search`, `timezone_resolve`, `vedic_chart`, `venus_eight_year_star`, `venus_elongations`, `venus_phase`, `venus_star_points`, `venus_star_points_conjunctions`, `venus_stations`
360
+ - Registered tools (`OPENEPHEMERIS_PROFILE=dev`): **82**
361
+ - Typed tools: `acg_hits`, `acg_power_lines`, `auth_login`, `auth_logout`, `auth_status`, `bazi_annual_pillar`, `bazi_chart`, `bazi_compatibility`, `bazi_element_balance`, `bazi_luck_pillars`, `bazi_ten_gods`, `bi_wheel_on_cross_aspect_click`, `bi_wheel_on_house_click`, `bi_wheel_on_planet_click`, `bi_wheel_recalculate`, `bi_wheel_synopsis`, `bodygraph_recalculate`, `chart_wheel_on_aspect_click`, `chart_wheel_on_house_click`, `chart_wheel_on_planet_click`, `chart_wheel_recalculate`, `chinese_bazi`, `dev_list_allowed`, `dev_read_api`, `dev_write_api`, `electional_aspect_search`, `electional_moment_analysis`, `electional_station_tracker`, `ephemeris_angles_points`, `ephemeris_aspect_check`, `ephemeris_bi_wheel`, `ephemeris_chart_wheel`, `ephemeris_composite`, `ephemeris_composite_midpoint`, `ephemeris_dignities`, `ephemeris_electional`, `ephemeris_fixed_stars`, `ephemeris_hermetic_lots`, `ephemeris_house_cusps`, `ephemeris_lunar_return`, `ephemeris_midpoints`, `ephemeris_moon_phase`, `ephemeris_natal_batch`, `ephemeris_natal_chart`, `ephemeris_natal_transits`, `ephemeris_next_eclipse`, `ephemeris_next_lunar_phase`, `ephemeris_overlay`, `ephemeris_planet_position`, `ephemeris_planetary_return`, `ephemeris_progressed_chart`, `ephemeris_relocation`, `ephemeris_retrograde_status`, `ephemeris_solar_return`, `ephemeris_synastry`, `ephemeris_transits`, `explore_bi_wheel`, `explore_human_design`, `explore_human_design_connection`, `explore_human_design_transit`, `explore_moon_phase`, `explore_natal_chart`, `hd_on_center_click`, `hd_on_channel_click`, `hd_on_gate_click`, `hd_on_planet_click`, `hd_on_variable_click`, `hd_opposition`, `hd_planetary_return`, `human_design_bodygraph`, `human_design_chart`, `human_design_composite`, `human_design_penta`, `location_search`, `timezone_resolve`, `vedic_chart`, `venus_eight_year_star`, `venus_elongations`, `venus_phase`, `venus_star_points`, `venus_star_points_conjunctions`, `venus_stations`
360
362
  - Generic tools:
361
363
 
362
364
  ### Allowlist Families
package/dist/prompts.js CHANGED
@@ -68,9 +68,9 @@ export const PROMPTS = [
68
68
  " Default to Placidus if they're unsure.\n\n" +
69
69
  "## Step 2 — Resolve Coordinates and Timezone (MANDATORY)\n" +
70
70
  "**Do NOT guess coordinates or timezones from memory.** Use the API tools to resolve them:\n\n" +
71
- "**A) Geocode** — call `dev_call` with `method: 'GET'`, `path: '/location/autocomplete'`, " +
71
+ "**A) Geocode** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
72
72
  "`query: { q: 'City Name, Country' }`. This returns verified decimal lat/lon.\n\n" +
73
- "**B) Timezone** — call `dev_call` with `method: 'POST'`, `path: '/timezone/lookup'`, " +
73
+ "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
74
74
  "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`. " +
75
75
  "This returns the correct IANA timezone and UTC offset for the birth year, including historical DST.\n\n" +
76
76
  "- Format: pass `datetime` as local ISO 8601 (e.g. `1990-04-15T14:30:00`) " +
@@ -148,9 +148,9 @@ export const PROMPTS = [
148
148
  "4. **Any major events coming up** they already know about — reframes the transits around real context\n\n" +
149
149
  "## Step 2 — Resolve Coordinates and Timezone (MANDATORY)\n" +
150
150
  "**Do NOT guess coordinates or timezones from memory.** Use the API tools:\n\n" +
151
- "**A) Geocode** — call `dev_call` with `method: 'GET'`, `path: '/location/autocomplete'`, " +
151
+ "**A) Geocode** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
152
152
  "`query: { q: 'Birth City, Country' }`. Use the returned decimal lat/lon.\n\n" +
153
- "**B) Timezone** — call `dev_call` with `method: 'POST'`, `path: '/timezone/lookup'`, " +
153
+ "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
154
154
  "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n\n" +
155
155
  "- Set `start_date` to today and `end_date` to the end of the forecast window\n\n" +
156
156
  "## Step 3 — Tool Call Sequence\n" +
@@ -211,9 +211,9 @@ export const PROMPTS = [
211
211
  "7. **House system**: Placidus (default), Whole Sign, Koch, Equal?\n\n" +
212
212
  "## Step 2 — Resolve Both Sets of Coordinates (MANDATORY)\n" +
213
213
  "**Do NOT guess coordinates or timezones.** For EACH person:\n\n" +
214
- "**A) Geocode** — call `dev_call` with `method: 'GET'`, `path: '/location/autocomplete'`, " +
214
+ "**A) Geocode** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
215
215
  "`query: { q: 'Birth City, Country' }`.\n\n" +
216
- "**B) Timezone** — call `dev_call` with `method: 'POST'`, `path: '/timezone/lookup'`, " +
216
+ "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
217
217
  "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n\n" +
218
218
  "- Format: local ISO 8601 datetime (no Z) + IANA timezone name separately for each person\n" +
219
219
  "- Note if either birth time is unknown — flag this upfront before running tools\n\n" +
@@ -283,9 +283,9 @@ export const PROMPTS = [
283
283
  "sometimes it explains why a place felt 'off'\n\n" +
284
284
  "## Step 2 — Resolve Coordinates (MANDATORY)\n" +
285
285
  "**Do NOT guess coordinates or timezones.** Use the API tools:\n\n" +
286
- "**A) Geocode birth city** — call `dev_call` with `method: 'GET'`, `path: '/location/autocomplete'`, " +
286
+ "**A) Geocode birth city** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
287
287
  "`query: { q: 'Birth City, Country' }`.\n\n" +
288
- "**B) Timezone** — call `dev_call` with `method: 'POST'`, `path: '/timezone/lookup'`, " +
288
+ "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
289
289
  "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n\n" +
290
290
  "**C) Geocode each target city** — repeat the autocomplete call for each destination city.\n\n" +
291
291
  "- Format birth datetime as local ISO 8601 (no Z), pass IANA timezone separately\n\n" +
@@ -372,9 +372,9 @@ export const PROMPTS = [
372
372
  "4. **Birth city and country** — needed for timezone conversion\n\n" +
373
373
  "## Step 2 — CRITICAL: Convert to UTC\n" +
374
374
  "Human Design requires **UTC datetime**. The tool rejects local times without a UTC offset.\n\n" +
375
- "**A) Geocode** — call `dev_call` with `method: 'GET'`, `path: '/location/autocomplete'`, " +
375
+ "**A) Geocode** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
376
376
  "`query: { q: 'Birth City, Country' }`.\n\n" +
377
- "**B) Timezone** — call `dev_call` with `method: 'POST'`, `path: '/timezone/lookup'`, " +
377
+ "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
378
378
  "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n" +
379
379
  "This returns the UTC offset for the birth year, including historical DST.\n\n" +
380
380
  "- Use the returned offset to convert local birth time → UTC\n" +
@@ -458,7 +458,7 @@ export const PROMPTS = [
458
458
  "5. **Any personal chart data?** If yes, run natal chart first and look for dates when the " +
459
459
  "moon triggers benefic natal planets.\n\n" +
460
460
  "## Step 1.5 — Resolve Event Location (MANDATORY)\n" +
461
- "**Do NOT guess coordinates.** Call `dev_call` with `method: 'GET'`, `path: '/location/autocomplete'`, " +
461
+ "**Do NOT guess coordinates.** Call `dev_read_api` with `path: '/location/autocomplete'`, " +
462
462
  "`query: { q: 'Event City, Country' }` to get verified decimal lat/lon for the event location.\n\n" +
463
463
  "## Step 2 — Check the Planetary Weather First\n" +
464
464
  "Before scanning for windows:\n\n" +
@@ -545,9 +545,9 @@ export const PROMPTS = [
545
545
  " Default to Lahiri if unsure.\n\n" +
546
546
  "## Step 2 — Convert to UTC (MANDATORY)\n" +
547
547
  "The `vedic_chart` tool requires a UTC datetime (with Z suffix):\n\n" +
548
- "**A) Geocode** — call `dev_call` with `method: 'GET'`, `path: '/location/autocomplete'`, " +
548
+ "**A) Geocode** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
549
549
  "`query: { q: 'Birth City, Country' }`.\n\n" +
550
- "**B) Timezone** — call `dev_call` with `method: 'POST'`, `path: '/timezone/lookup'`, " +
550
+ "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
551
551
  "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n" +
552
552
  "This returns the correct IANA timezone and UTC offset for the birth year, including historical DST.\n\n" +
553
553
  "- Use the returned offset to convert local birth time → UTC\n" +
@@ -629,7 +629,7 @@ export const PROMPTS = [
629
629
  "this difference is small, but it can shift the hour animal by one block for cities far " +
630
630
  "from their timezone meridian (e.g. western Xinjiang in China UTC+8, western Spain in CET).\n" +
631
631
  "Pass individual date/time components — do NOT convert to UTC.\n\n" +
632
- "**Geocode the birth city** — call `dev_call` with `method: 'GET'`, `path: '/location/autocomplete'`, " +
632
+ "**Geocode the birth city** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
633
633
  "`query: { q: 'Birth City, Country' }` to get verified coordinates for longitude offset calculation.\n\n" +
634
634
  "## Step 3 — Call the Tool\n" +
635
635
  "Call `chinese_bazi` with:\n" +
@@ -770,9 +770,9 @@ export const PROMPTS = [
770
770
  "4. **House system**: Placidus (default), Whole Sign, Koch, Equal?\n\n" +
771
771
  "## Step 2 — Resolve Data (MANDATORY)\n" +
772
772
  "**Do NOT guess coordinates or timezones.** Use the API tools:\n\n" +
773
- "**A) Geocode birth city** — call `dev_call` with `method: 'GET'`, `path: '/location/autocomplete'`, " +
773
+ "**A) Geocode birth city** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
774
774
  "`query: { q: 'Birth City, Country' }`.\n\n" +
775
- "**B) Timezone** — call `dev_call` with `method: 'POST'`, `path: '/timezone/lookup'`, " +
775
+ "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
776
776
  "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n\n" +
777
777
  "- For `birth_datetime`, include the UTC offset from the timezone lookup: `1985-06-21T14:00:00-05:00`\n" +
778
778
  " (or convert to UTC with Z: `1985-06-21T19:00:00Z`)\n\n" +
@@ -66,8 +66,10 @@ const BACKEND_URL = process.env.OPENEPHEMERIS_BACKEND_URL ||
66
66
  "https://api.openephemeris.com";
67
67
  const version = resolveServerVersion();
68
68
  /**
69
- * Validate a user-supplied API key by hitting an auth-gated endpoint on
70
- * 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).
71
73
  */
72
74
  async function validateApiKey(apiKey) {
73
75
  try {
@@ -77,17 +79,26 @@ async function validateApiKey(apiKey) {
77
79
  timeout: 5_000,
78
80
  validateStatus: () => true, // don't throw on any status
79
81
  });
80
- // 2xx = valid key, 401/403 = invalid key, anything else = let through
81
82
  if (resp.status === 401 || resp.status === 403)
82
- return false;
83
- return true;
83
+ return "invalid";
84
+ if (resp.status >= 200 && resp.status < 300)
85
+ return "valid";
86
+ return "unknown"; // 5xx etc. — can't confirm
84
87
  }
85
88
  catch {
86
- // Network error let them through; tool calls will fail if key is bad.
87
- console.warn("Could not validate API key against backend, allowing connection.");
88
- return true;
89
+ console.warn("Could not validate API key against backend.");
90
+ return "unknown";
89
91
  }
90
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
+ }
91
102
  /**
92
103
  * Extract authentication from a request, distinguishing JWTs from API keys.
93
104
  *
@@ -212,8 +223,12 @@ function createMcpServer() {
212
223
  prompts: {},
213
224
  resources: {},
214
225
  experimental: {
215
- // Declare MCP Apps extension (spec: io.modelcontextprotocol/ui, 2026-01-26)
216
- "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
+ },
217
232
  },
218
233
  },
219
234
  instructions: "Open Ephemeris is a precision astronomical computation engine. " +
@@ -421,6 +436,24 @@ async function main() {
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({
@@ -508,8 +541,8 @@ async function main() {
508
541
  // Validate API keys against the Go backend; JWTs are validated downstream
509
542
  // by the Go sidecar's ValidateSupabaseJWT when tool calls are proxied.
510
543
  if (apiKey) {
511
- const valid = await validateApiKey(apiKey);
512
- if (!valid) {
544
+ const keyStatus = await validateApiKey(apiKey);
545
+ if (keyStatus === "invalid") {
513
546
  res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
514
547
  res.status(401).json({
515
548
  error: "invalid_api_key",
@@ -517,6 +550,15 @@ async function main() {
517
550
  });
518
551
  return;
519
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
+ }
520
562
  }
521
563
  // New session — must be an initialize request
522
564
  if (!isInitializeRequest(req.body)) {
@@ -624,14 +666,23 @@ async function main() {
624
666
  }
625
667
  // Validate API keys against the Go backend; JWTs validated downstream.
626
668
  if (apiKey) {
627
- const valid = await validateApiKey(apiKey);
628
- if (!valid) {
669
+ const keyStatus = await validateApiKey(apiKey);
670
+ if (keyStatus === "invalid") {
629
671
  res.status(403).json({
630
672
  error: "invalid_api_key",
631
673
  message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
632
674
  });
633
675
  return;
634
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
+ }
635
686
  }
636
687
  const transport = new SSEServerTransport("/message", res);
637
688
  const sessionId = transport.sessionId;
@@ -288,8 +288,12 @@ const CLASSICAL_PLANETS = new Set([
288
288
  */
289
289
  function canonicalizePlanetName(raw) {
290
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")
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")
293
297
  return "north_node";
294
298
  if (lower === "south node (mean)" || lower === "south node (true)")
295
299
  return "south_node";
@@ -491,11 +495,16 @@ registerTool({
491
495
  const payload = buildBiWheelPayload(innerData, outerData, mode, params1, params2);
492
496
  const bundleAvailable = Boolean(getBiWheelBundle());
493
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.
494
503
  return {
495
504
  content: [
496
505
  { type: "text", text: summary },
497
- { type: "text", text: JSON.stringify({ ...payload, server_version: SERVER_VERSION }) },
498
506
  ],
507
+ structuredContent: { ...payload, server_version: SERVER_VERSION },
499
508
  _meta: {
500
509
  "ui/resourceUri": BI_WHEEL_RESOURCE_URI,
501
510
  ui: { resourceUri: BI_WHEEL_RESOURCE_URI },
@@ -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