@openephemeris/mcp-server 3.14.0 → 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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [3.15.0] — 2026-06-15
11
+
12
+ ### Changed
13
+ - **`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.
14
+
15
+ ### Fixed
16
+ - **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.
17
+ - **Moon-phase color cue** now encodes speed/proximity, and aspect-label contrast corrected.
18
+
19
+ ### Added
20
+ - **Collapsible glyph legend** in the interactive chart iframes — keeps the wheel uncluttered while remaining one tap from a full symbol key.
21
+
22
+ ---
23
+
10
24
  ## [3.14.0] — 2026-05-24
11
25
 
12
26
  ### Added
package/README.md CHANGED
@@ -251,15 +251,15 @@ The server is hosted at `https://mcp.openephemeris.com/mcp` with full Streamable
251
251
  ## Tooling Model
252
252
 
253
253
  - 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`.
254
+ - 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
255
  - Security model: default-deny with explicit allowlist in `config/dev-allowlist.json`.
256
256
  - Deny prefixes block sensitive route families (`/auth`, `/billing`, `/admin`, etc.).
257
257
 
258
- ### `dev.call` input
258
+ ### `dev_read_api` / `dev_write_api` input
259
259
 
260
260
  | Parameter | Type | Required | Description |
261
261
  |---|---|---|---|
262
- | `method` | `GET\|POST\|PUT\|PATCH\|DELETE` | Yes | HTTP method |
262
+ | `method` | `dev_read_api`: `GET` · `dev_write_api`: `POST\|PUT\|PATCH\|DELETE` | No | HTTP method (defaults to the tool's natural method) |
263
263
  | `path` | `string` | Yes | Absolute API path, e.g. `/ephemeris/natal-chart` |
264
264
  | `query` | `object` | No | Query parameters |
265
265
  | `body` | `object` | No | JSON body for non-GET requests |
@@ -355,8 +355,8 @@ Generated by `npm run sync:readme` from `config/dev-allowlist.json` and the live
355
355
 
356
356
  - Allowlisted operations: **28**
357
357
  - 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`
358
+ - Registered tools (`OPENEPHEMERIS_PROFILE=dev`): **80**
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_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_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
360
  - Generic tools:
361
361
 
362
362
  ### 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 },
@@ -78,13 +78,18 @@ function ensureTimezone(dt) {
78
78
  * renderer in bodygraph-renderer.ts. Non-fatal: a missing SVG should never
79
79
  * block the tool result.
80
80
  */
81
- async function fetchGoBodygraphSVG(body) {
81
+ async function fetchGoBodygraphSVG(body, theme = "dark") {
82
82
  try {
83
83
  const client = getActiveClient();
84
84
  // /visualization/bodygraph is registered as binary in BINARY_ENDPOINT_PREFIXES.
85
85
  // The client wraps it as { content_type, encoding: "base64", data_base64 } —
86
86
  // we decode back to UTF-8 SVG text for inlining.
87
- const resp = await client.post("/visualization/bodygraph?format=svg&size=800", body);
87
+ //
88
+ // style=light|dark drives the Go renderer's palette. The endpoint defaults
89
+ // to "light" when style is omitted, which mismatches dark MCP hosts — so we
90
+ // always pass an explicit theme (default "dark" to match the app shell; the
91
+ // iframe re-fetches with the host-detected theme on load / theme change).
92
+ const resp = await client.post(`/visualization/bodygraph?format=svg&size=800&style=${theme}`, body);
88
93
  if (typeof resp === "string")
89
94
  return resp;
90
95
  if (resp && typeof resp === "object") {
@@ -438,11 +443,16 @@ registerTool({
438
443
  modelPayload._svg = svg;
439
444
  const summary = buildHdSummary(modelPayload, location);
440
445
  if (bundleAvailable) {
446
+ // MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
447
+ // delivered through resources/read — NOT as a content block. A URI-only
448
+ // `type: "resource"` block lacks the inline text/blob the base MCP schema
449
+ // requires and fails strict client validation. structuredContent carries
450
+ // the data for non-rendering hosts.
441
451
  return {
442
452
  content: [
443
453
  { type: "text", text: summary },
444
- { type: "text", text: JSON.stringify({ ...modelPayload, server_version: SERVER_VERSION }) },
445
454
  ],
455
+ structuredContent: { ...modelPayload, server_version: SERVER_VERSION },
446
456
  _meta: {
447
457
  "ui/resourceUri": BODYGRAPH_RESOURCE_URI,
448
458
  ui: { resourceUri: BODYGRAPH_RESOURCE_URI },
@@ -468,6 +478,11 @@ registerTool({
468
478
  type: "string",
469
479
  description: "IANA timezone name (e.g. 'America/New_York') for accurate UTC conversion.",
470
480
  },
481
+ theme: {
482
+ type: "string",
483
+ enum: ["light", "dark"],
484
+ description: "Render palette for the bodygraph SVG. Mirrors the MCP host's light/dark color scheme.",
485
+ },
471
486
  },
472
487
  required: ["datetime"],
473
488
  },
@@ -481,6 +496,7 @@ registerTool({
481
496
  const datetime = localToUtcIso(String(args.datetime), timezone);
482
497
  const lat = args.latitude;
483
498
  const lon = args.longitude;
499
+ const theme = args.theme === "light" ? "light" : "dark";
484
500
  const body = {
485
501
  birth_datetime_utc: datetime,
486
502
  };
@@ -493,7 +509,7 @@ registerTool({
493
509
  client.request("POST", "/human-design/chart", {
494
510
  data: body,
495
511
  }),
496
- fetchGoBodygraphSVG(body),
512
+ fetchGoBodygraphSVG(body, theme),
497
513
  ]);
498
514
  const chart = chartData.chart ?? chartData;
499
515
  const modelPayload = buildHdModelPayload(chart, {
@@ -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 },