@openephemeris/mcp-server 3.16.0 → 3.19.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.
@@ -22,7 +22,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
22
22
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
23
23
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
24
24
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
25
- import { initTools, toolRegistry, formatToolResponse, modelVisibleTools } from "./tools/index.js";
25
+ import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools } from "./tools/index.js";
26
26
  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";
@@ -34,6 +34,7 @@ import { oauthDcrRouter } from "./oauth/dcr.js";
34
34
  import { oauthTokenRouter } from "./oauth/token.js";
35
35
  import { OAuthStore } from "./oauth/store.js";
36
36
  import { createRateLimiter } from "./oauth/rate-limit.js";
37
+ import { isJwtExpired, encodeSessionId, replayTargetMachine, } from "./oauth/session-utils.js";
37
38
  // ---------------------------------------------------------------------------
38
39
  // Helpers
39
40
  // ---------------------------------------------------------------------------
@@ -73,9 +74,14 @@ const version = resolveServerVersion();
73
74
  */
74
75
  async function validateApiKey(apiKey) {
75
76
  try {
76
- const resp = await axios.get(`${BACKEND_URL}/ephemeris/moon/phase`, {
77
+ // Use a genuinely free, auth-gated endpoint. /catalogs/bodies is metered at
78
+ // 0 credits (usage_meter.go EstimateUsageUnits) yet still requires auth (NOT
79
+ // in middleware.go IsPublicPath), so an invalid key returns 401/403 while a
80
+ // valid key is validated without charging a credit on every session init.
81
+ // (The previous /ephemeris/moon/phase probe fell through to the default
82
+ // 1-credit charge — connecting cost the user a credit.)
83
+ const resp = await axios.get(`${BACKEND_URL}/catalogs/bodies`, {
77
84
  headers: { "X-API-Key": apiKey },
78
- params: { datetime: new Date().toISOString() }, // required param per OpenAPI spec
79
85
  timeout: 5_000,
80
86
  validateStatus: () => true, // don't throw on any status
81
87
  });
@@ -279,25 +285,22 @@ function createMcpServer() {
279
285
  try {
280
286
  const startTime = Date.now();
281
287
  console.error(`[MCP] Executing tool: ${toolName}`);
282
- const result = await tool.handler(request.params.arguments ?? {});
288
+ // Bound execution so a hung backend request can't pin the session forever.
289
+ // 120s comfortably covers the slowest known endpoint (cold heatmap
290
+ // composite ~9.3s) with a wide margin.
291
+ const result = await withTimeout(tool.handler(request.params.arguments ?? {}), TOOL_CALL_TIMEOUT_MS, toolName);
283
292
  const durationMs = Date.now() - startTime;
284
293
  return formatToolResponse(toolName, result, durationMs);
285
294
  }
286
295
  catch (error) {
287
- const startTime = Date.now(); // Not strictly accurate for errors but acceptable for logging structure if needed
288
- const durationMs = Date.now() - startTime;
289
296
  const errorMessage = error instanceof Error ? error.message : String(error);
290
- const isRetryable = error.retryable === true || error.status === 401 || error.code === "auth_required";
291
- console.error(`[MCP] Failed: ${toolName} (${durationMs}ms) - ${errorMessage}`);
292
- return {
293
- content: [
294
- {
295
- type: "text",
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).`,
297
- },
298
- ],
299
- isError: !isRetryable,
300
- };
297
+ console.error(`[MCP] Failed: ${toolName} - ${errorMessage}`);
298
+ // All failures surface as isError: true so the host can recover
299
+ // (retry / OAuth refresh). The remote transport has no device-auth flow,
300
+ // so formatToolError's device-auth exception never fires here — a 401
301
+ // (including the "disconnect and reconnect" message) renders as a real
302
+ // error and prompts the host's OAuth refresh.
303
+ return formatToolError(error);
301
304
  }
302
305
  });
303
306
  // --- Resource handlers ---
@@ -370,6 +373,51 @@ function createMcpServer() {
370
373
  return server;
371
374
  }
372
375
  const httpSessions = new Map();
376
+ // Sessions are held in memory and only freed on explicit DELETE / transport
377
+ // close. Hosts that vanish without teardown (tab closed, network drop) would
378
+ // otherwise leak sessions until process restart, so reap anything idle past
379
+ // the TTL. A reaped session 404s on its next request and the client
380
+ // re-initializes per the MCP spec.
381
+ const SESSION_IDLE_TTL_MS = 2 * 60 * 60 * 1000; // 2h
382
+ const TOOL_CALL_TIMEOUT_MS = 120_000;
383
+ function withTimeout(promise, ms, label) {
384
+ return new Promise((resolve, reject) => {
385
+ const timer = setTimeout(() => reject(new Error(`Tool '${label}' timed out after ${ms / 1000}s`)), ms);
386
+ promise.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
387
+ });
388
+ }
389
+ function reapIdleSessions() {
390
+ const cutoff = Date.now() - SESSION_IDLE_TTL_MS;
391
+ for (const [id, session] of httpSessions) {
392
+ if (session.lastSeen < cutoff) {
393
+ httpSessions.delete(id);
394
+ session.server.close().catch(() => { });
395
+ console.error(`[HTTP] Session reaped (idle > ${SESSION_IDLE_TTL_MS / 60000}m): ${id}`);
396
+ }
397
+ }
398
+ }
399
+ /**
400
+ * Keep a long-lived SSE response alive through Fly's proxy and any
401
+ * intermediaries: disable the socket idle timeout and emit an SSE comment
402
+ * frame every 25s (comment frames are ignored by EventSource parsers).
403
+ * Call only after the transport has sent the SSE headers.
404
+ */
405
+ function startSseKeepalive(res) {
406
+ res.socket?.setTimeout(0);
407
+ const ping = setInterval(() => {
408
+ if (res.writableEnded || res.destroyed) {
409
+ clearInterval(ping);
410
+ return;
411
+ }
412
+ try {
413
+ res.write(":ping\n\n");
414
+ }
415
+ catch {
416
+ clearInterval(ping);
417
+ }
418
+ }, 25_000);
419
+ res.on("close", () => clearInterval(ping));
420
+ }
373
421
  async function main() {
374
422
  await initTools();
375
423
  const app = express();
@@ -391,6 +439,36 @@ async function main() {
391
439
  function isOriginAllowed(origin) {
392
440
  return ORIGIN_ALLOWLIST.some((m) => typeof m === "string" ? m === origin : m.test(origin));
393
441
  }
442
+ // DNS-rebinding defense: Origin validation alone doesn't cover the Host
443
+ // header, which is the vector rebinding attacks use. Restrict the MCP
444
+ // endpoints to the hosts we actually serve. Override/extend with
445
+ // MCP_ALLOWED_HOSTS (comma-separated) if the deployment hostname changes.
446
+ const HOST_ALLOWLIST = [
447
+ /^mcp\.openephemeris\.com$/i,
448
+ /^oe-mcp-live\.fly\.dev$/i,
449
+ /^localhost(:\d+)?$/i,
450
+ /^127\.0\.0\.1(:\d+)?$/i,
451
+ /^\[::1\](:\d+)?$/i,
452
+ ...(process.env.MCP_ALLOWED_HOSTS ?? "")
453
+ .split(",")
454
+ .map((h) => h.trim())
455
+ .filter(Boolean)
456
+ .map((h) => new RegExp(`^${h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(:\\d+)?$`, "i")),
457
+ ];
458
+ const MCP_ROUTES = new Set(["/mcp", "/sse", "/message"]);
459
+ app.use((req, res, next) => {
460
+ if (MCP_ROUTES.has(req.path)) {
461
+ const host = req.headers.host;
462
+ if (host && !HOST_ALLOWLIST.some((m) => m.test(host))) {
463
+ res.status(403).json({
464
+ error: "host_not_allowed",
465
+ error_description: `Host '${host}' is not permitted.`,
466
+ });
467
+ return;
468
+ }
469
+ }
470
+ next();
471
+ });
394
472
  app.use((req, res, next) => {
395
473
  const origin = req.headers.origin;
396
474
  // Browser request → must match allowlist. Native client (no Origin) → allow.
@@ -515,12 +593,53 @@ async function main() {
515
593
  if (sessionId) {
516
594
  const session = httpSessions.get(sessionId);
517
595
  if (!session) {
596
+ // Cross-machine session recovery (Fly): min_machines_running >= 2 but
597
+ // httpSessions is per-process. If the session id encodes a DIFFERENT
598
+ // machine, ask Fly's proxy to replay this request to the owning machine
599
+ // — unless it was already replayed once (loop guard), in which case we
600
+ // fall through to 404 so the client re-initializes per the MCP spec.
601
+ const target = replayTargetMachine(sessionId, {
602
+ alreadyReplayed: typeof req.headers["fly-replay-src"] === "string",
603
+ });
604
+ if (target) {
605
+ res.set("fly-replay", `instance=${target}`);
606
+ res.status(307).end();
607
+ return;
608
+ }
518
609
  res.status(404).json({
519
610
  error: "session_not_found",
520
611
  message: "Unknown or expired MCP session. Re-initialize to start a new one.",
521
612
  });
522
613
  return;
523
614
  }
615
+ // A session id alone is not a credential: require the caller to still
616
+ // present a key/JWT on every resume, and reject an expired JWT with the
617
+ // OAuth-refresh signal (same semantics as the init gate below).
618
+ const resumedAuth = extractAuth(req);
619
+ if (!resumedAuth.apiKey && !resumedAuth.jwt) {
620
+ res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
621
+ res.status(401).json({
622
+ error: "auth_required",
623
+ message: "Authentication required on every request. Pass an API key via X-API-Key header, or a Bearer token via Authorization header.",
624
+ });
625
+ return;
626
+ }
627
+ if (resumedAuth.jwt && isJwtExpired(resumedAuth.jwt)) {
628
+ res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", error_description="The access token expired", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
629
+ res.status(401).json({
630
+ error: "invalid_token",
631
+ message: "Your session token has expired. Re-authorize to continue.",
632
+ });
633
+ return;
634
+ }
635
+ // Refresh the session's Bearer JWT if the host rotated it. claude.ai
636
+ // refreshes tokens via /oauth/token roughly hourly (self-signed Supabase
637
+ // JWTs expire in 1h); the frozen JWT captured at init would otherwise 401
638
+ // forever. Re-read the header and push the fresh token into the client.
639
+ if (resumedAuth.jwt) {
640
+ session.client.setJwt(resumedAuth.jwt);
641
+ }
642
+ session.lastSeen = Date.now();
524
643
  await runWithClient(session.client, () => session.transport.handleRequest(req, res, req.body));
525
644
  return;
526
645
  }
@@ -538,6 +657,19 @@ async function main() {
538
657
  });
539
658
  return;
540
659
  }
660
+ // Reject already-expired JWTs at the gate with an OAuth-refresh signal.
661
+ // Signature is validated downstream by the Go API — here we only decode the
662
+ // `exp` claim (no verification) so an expired token triggers the host's
663
+ // token refresh (401 invalid_token) instead of dying as a confusing tool
664
+ // error mid-session.
665
+ if (jwt && isJwtExpired(jwt)) {
666
+ res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", error_description="The access token expired", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
667
+ res.status(401).json({
668
+ error: "invalid_token",
669
+ message: "Your session token has expired. Re-authorize to continue.",
670
+ });
671
+ return;
672
+ }
541
673
  // Validate API keys against the Go backend; JWTs are validated downstream
542
674
  // by the Go sidecar's ValidateSupabaseJWT when tool calls are proxied.
543
675
  if (apiKey) {
@@ -570,9 +702,12 @@ async function main() {
570
702
  }
571
703
  // Create per-session transport, server, and client
572
704
  const transport = new StreamableHTTPServerTransport({
573
- sessionIdGenerator: randomUUID,
705
+ // Embed the Fly machine id so a resume request landing on another machine
706
+ // can be replayed to the owner (see the resume branch above). No-op off
707
+ // Fly (FLY_MACHINE_ID unset) — the raw UUID is used unchanged.
708
+ sessionIdGenerator: () => encodeSessionId(randomUUID()),
574
709
  onsessioninitialized: (id) => {
575
- httpSessions.set(id, { server, transport, client });
710
+ httpSessions.set(id, { server, transport, client, lastSeen: Date.now() });
576
711
  console.error(`[HTTP] Session initialized: ${id}`);
577
712
  },
578
713
  });
@@ -599,12 +734,24 @@ async function main() {
599
734
  }
600
735
  const session = httpSessions.get(sessionId);
601
736
  if (!session) {
737
+ // Cross-machine replay for the SSE leg (same scheme as POST /mcp resume).
738
+ const target = replayTargetMachine(sessionId, {
739
+ alreadyReplayed: typeof req.headers["fly-replay-src"] === "string",
740
+ });
741
+ if (target) {
742
+ res.set("fly-replay", `instance=${target}`);
743
+ res.status(307).end();
744
+ return;
745
+ }
602
746
  res.status(404).json({
603
747
  error: "session_not_found",
604
748
  message: "Unknown or expired MCP session.",
605
749
  });
606
750
  return;
607
751
  }
752
+ session.lastSeen = Date.now();
753
+ // The SSE leg stays open indefinitely — keep it alive through proxies.
754
+ startSseKeepalive(res);
608
755
  await session.transport.handleRequest(req, res);
609
756
  });
610
757
  // DELETE /mcp — client-initiated session teardown
@@ -715,6 +862,8 @@ async function main() {
715
862
  try {
716
863
  // server.connect() calls transport.start() internally in SDK v1.27+
717
864
  await server.connect(transport);
865
+ // Stream is established — keep it alive through Fly's proxy.
866
+ startSseKeepalive(res);
718
867
  }
719
868
  catch (err) {
720
869
  console.error(`SSE session ${sessionId} failed to start:`, err);
@@ -742,9 +891,17 @@ async function main() {
742
891
  await transport.handlePostMessage(req, res);
743
892
  }
744
893
  });
745
- app.listen(PORT, "0.0.0.0", () => {
894
+ // Reap abandoned sessions (no DELETE, no transport close) every 10 minutes.
895
+ setInterval(reapIdleSessions, 10 * 60 * 1000).unref();
896
+ const httpServer = app.listen(PORT, "0.0.0.0", () => {
746
897
  console.log(`OpenEphemeris MCP SSE server v${version} listening on port ${PORT}`);
747
898
  });
899
+ // SSE streams are long-lived by design; Node 18+ closes idle connections
900
+ // after 5s keep-alive by default at the server level — the per-response
901
+ // socket timeout is disabled in startSseKeepalive, but also raise the
902
+ // server-wide headers/request timeouts so slow proxies don't get cut.
903
+ httpServer.keepAliveTimeout = 65_000;
904
+ httpServer.headersTimeout = 70_000;
748
905
  }
749
906
  // Prevent SDK recursive close bugs from crashing the process
750
907
  process.on("uncaughtException", (err) => {
@@ -755,6 +912,13 @@ process.on("uncaughtException", (err) => {
755
912
  console.error("Uncaught exception:", err);
756
913
  process.exit(1);
757
914
  });
915
+ // Log-and-continue on unhandled promise rejections. A rejected promise from a
916
+ // single request (e.g. a client that disconnected mid-stream) must never take
917
+ // down the whole server and kill every other live session. Log for triage and
918
+ // keep serving.
919
+ process.on("unhandledRejection", (reason) => {
920
+ console.error("Unhandled promise rejection:", reason);
921
+ });
758
922
  main().catch((error) => {
759
923
  console.error("Fatal error:", error);
760
924
  process.exit(1);
@@ -6,7 +6,8 @@
6
6
  * • explore_human_design_transit — natal + transit overlay (premium)
7
7
  * • explore_human_design_connection — two-person connection/synastry overlay (premium)
8
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.
9
+ * hd_on_channel_click, hd_on_planet_click, hd_on_variable_click,
10
+ * hd_on_connection_channel_click, hd_on_transit_channel_click (overlay, Phase 5).
10
11
  * The transit + connection tools reuse this bodygraph iframe resource; their
11
12
  * overlay SVG is returned inline by /human-design/transit-chart and
12
13
  * /human-design/composite (include_visual) rather than a separate render call.
@@ -6,7 +6,8 @@
6
6
  * • explore_human_design_transit — natal + transit overlay (premium)
7
7
  * • explore_human_design_connection — two-person connection/synastry overlay (premium)
8
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.
9
+ * hd_on_channel_click, hd_on_planet_click, hd_on_variable_click,
10
+ * hd_on_connection_channel_click, hd_on_transit_channel_click (overlay, Phase 5).
10
11
  * The transit + connection tools reuse this bodygraph iframe resource; their
11
12
  * overlay SVG is returned inline by /human-design/transit-chart and
12
13
  * /human-design/composite (include_visual) rather than a separate render call.
@@ -704,6 +705,148 @@ registerTool({
704
705
  };
705
706
  },
706
707
  });
708
+ const CONNECTION_TYPE_DATA = {
709
+ electromagnetic: {
710
+ label: "Electromagnetic",
711
+ tagline: "attraction — opposites complete the channel",
712
+ body: "Each of you holds one different gate of this channel; together you complete a current neither runs alone. This is the classic draw — and, on the same wiring, the friction. What the channel does comes alive only in the meeting; apart, each of you is back to half. The gift is chemistry and mutual completion. The edge is that the completeness depends on the other, and the same difference that pulls you together can push you apart. Each of you stays with your own strategy and inner authority — the connection is information about the interplay, not an instruction.",
713
+ },
714
+ companionship: {
715
+ label: "Companionship",
716
+ tagline: "sameness — you both already run the whole channel",
717
+ body: "You each already define this whole channel on your own, so meeting here is recognition rather than completion. Nothing has to be negotiated; you simply operate the same way. Friendship and ease live here. The edge is that there is no charge to cross — sameness can go flat, or become the place you take each other for granted. Ease is not the same as aliveness.",
718
+ },
719
+ dominance: {
720
+ label: "Dominance",
721
+ tagline: "one defines the whole channel, the other is fully open here",
722
+ body: "One of you defines this entire channel; the other holds neither gate and is completely open here. The defined partner conditions the open one steadily and consistently, and the open partner takes the current in fully, unfiltered by any definition of their own — seeing the defined one clearly, for exactly who they are. It is meant to be accepted and surrendered to, not fixed or matched. The gift is consistency and clear sight. The edge is the not-self reading of \"I always have to do it your way\": the open partner can lose themselves to the steady conditioning if they drift from their own authority. Surrender here is a choice made from authority, not a disappearance.",
723
+ },
724
+ compromise: {
725
+ label: "Compromise",
726
+ tagline: "one defines the whole channel, the other holds a single gate",
727
+ body: "One of you defines this whole channel; the other holds exactly one of its two gates. Push and pull land on that shared gate — the half-definition keeps getting overridden by the full one, so the single-gate partner is consistently compromised here. This is the spot where \"you do it your way, I do it mine\" surfaces most. Met consciously, the recurring friction on that one gate names exactly where the work is and can sharpen both of you. Unwatched, it calcifies into a standing grievance instead of a workable edge.",
728
+ },
729
+ };
730
+ const TRANSIT_CLASS_DATA = {
731
+ transit_completed: {
732
+ label: "Transit completes this channel",
733
+ body: "For a window, a passing transit completes this channel: one of its gates is defined in your own design, the other is switched on by the sky. While that holds, the current runs in you the way it does for someone built this way full-time — except you are not, and that is the whole point. It is a gift on loan: a real taste of standing definition here, without your having become someone else for it. The pull is to grab it and decide this is now who you are; when the transit separates and the channel opens back up, that reading only leaves you grieving. Nothing about your type, strategy, or authority has changed. Use the borrowed current while it lasts, keep deciding from your own authority, and let it go cleanly when the sky moves on.",
734
+ },
735
+ transit_only: {
736
+ label: "Transit defines this channel on its own",
737
+ body: "For a window, a passing transit lights both gates of this channel — neither is yours natally. The channel runs entirely on borrowed weather; it is not part of your design at all, and it switches off completely when the transit moves on. Notice the temporary current for what it is, and keep deciding from your own authority rather than mistaking it for a new capacity.",
738
+ },
739
+ reinforced: {
740
+ label: "Transit reinforces your definition here",
741
+ body: "You already define this channel, and a passing transit also lands on one of its gates today — your standing definition, amplified for a window. Nothing new is added; the current you already run simply gets louder. When the transit moves on, it settles back to its usual level.",
742
+ },
743
+ natal: {
744
+ label: "Your standing definition",
745
+ body: "Both gates of this channel are defined in your own design. This is part of your fixed wiring, not transit weather — the passing sky does not change it. It reads here for context alongside the temporary activations.",
746
+ },
747
+ };
748
+ // ── Tool: hd_on_connection_channel_click [app-only] ────────────────────────
749
+ // Fired when the user clicks a connection (synastry) channel in the overlay
750
+ // bodygraph. Serves the connection-type dynamic for that specific channel.
751
+ registerTool({
752
+ name: "hd_on_connection_channel_click",
753
+ description: "Event handler called when the user clicks a connected channel in a Human Design connection (synastry) overlay bodygraph. " +
754
+ "Returns the channel identity and the relationship dynamic for its connection type " +
755
+ "(electromagnetic / companionship / dominance / compromise). " +
756
+ "This tool is called automatically by the bodygraph UI; you do not need to call it directly.",
757
+ inputSchema: {
758
+ type: "object",
759
+ properties: {
760
+ channel: { type: "string", description: "Channel identifier string (e.g. '1-8', '20-34')." },
761
+ connection_type: {
762
+ type: "string",
763
+ description: "Connection type: electromagnetic | companionship | dominance | compromise.",
764
+ },
765
+ gate1: { type: "number", description: "First gate number." },
766
+ gate2: { type: "number", description: "Second gate number." },
767
+ },
768
+ required: ["channel"],
769
+ },
770
+ outputSchema: OUTPUT_SCHEMA_JSON,
771
+ annotations: { title: "Connection Channel Interpretation", readOnlyHint: true, openWorldHint: false },
772
+ _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
773
+ handler: async (args) => {
774
+ const channelStr = String(args.channel ?? "");
775
+ const g1 = args.gate1 != null ? Number(args.gate1) : null;
776
+ const g2 = args.gate2 != null ? Number(args.gate2) : null;
777
+ const typeKey = String(args.connection_type ?? "").toLowerCase();
778
+ const key = channelStr.includes("-") ? channelStr : g1 != null && g2 != null ? `${g1}-${g2}` : channelStr;
779
+ const chan = HD_CHANNEL_DATA[key] ?? HD_CHANNEL_DATA[key.split("-").reverse().join("-")];
780
+ const conn = CONNECTION_TYPE_DATA[typeKey];
781
+ const chanName = chan?.name ?? "this channel";
782
+ const gatesLine = `**Gates:** ${g1 ?? channelStr.split("-")[0] ?? "?"} ↔ ${g2 ?? channelStr.split("-")[1] ?? "?"}`;
783
+ if (!conn) {
784
+ return {
785
+ content: [{
786
+ type: "text",
787
+ text: `**Channel ${channelStr} — ${chanName}**\n\n${gatesLine}\n\n` +
788
+ `This channel is defined across both people. ${chan?.description ?? ""}`.trim(),
789
+ }],
790
+ };
791
+ }
792
+ return {
793
+ content: [{
794
+ type: "text",
795
+ text: `**${conn.label} Connection — Channel of ${chanName} (${channelStr})**\n\n` +
796
+ `${gatesLine}\n\n` +
797
+ `*${conn.tagline}*\n\n` +
798
+ `${conn.body}` +
799
+ (chan?.description ? `\n\n**This channel:** ${chan.description}` : ""),
800
+ }],
801
+ };
802
+ },
803
+ });
804
+ // ── Tool: hd_on_transit_channel_click [app-only] ──────────────────────────
805
+ // Fired when the user clicks a channel in a transit overlay bodygraph. Serves
806
+ // the meaning of the channel's transit class (esp. transit_completed).
807
+ registerTool({
808
+ name: "hd_on_transit_channel_click",
809
+ description: "Event handler called when the user clicks a channel in a Human Design transit overlay bodygraph. " +
810
+ "Returns the channel identity and what its transit class means — especially transit_completed, where a transit " +
811
+ "temporarily completes a channel (one natal gate + one transit gate). " +
812
+ "This tool is called automatically by the bodygraph UI; you do not need to call it directly.",
813
+ inputSchema: {
814
+ type: "object",
815
+ properties: {
816
+ channel: { type: "string", description: "Channel identifier string (e.g. '64-47')." },
817
+ transit_class: {
818
+ type: "string",
819
+ description: "Transit class: natal | reinforced | transit_completed | transit_only.",
820
+ },
821
+ gate1: { type: "number", description: "First gate number." },
822
+ gate2: { type: "number", description: "Second gate number." },
823
+ },
824
+ required: ["channel"],
825
+ },
826
+ outputSchema: OUTPUT_SCHEMA_JSON,
827
+ annotations: { title: "Transit Channel Interpretation", readOnlyHint: true, openWorldHint: false },
828
+ _meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
829
+ handler: async (args) => {
830
+ const channelStr = String(args.channel ?? "");
831
+ const g1 = args.gate1 != null ? Number(args.gate1) : null;
832
+ const g2 = args.gate2 != null ? Number(args.gate2) : null;
833
+ const cls = String(args.transit_class ?? "transit_completed").toLowerCase();
834
+ const key = channelStr.includes("-") ? channelStr : g1 != null && g2 != null ? `${g1}-${g2}` : channelStr;
835
+ const chan = HD_CHANNEL_DATA[key] ?? HD_CHANNEL_DATA[key.split("-").reverse().join("-")];
836
+ const info = TRANSIT_CLASS_DATA[cls] ?? TRANSIT_CLASS_DATA.transit_completed;
837
+ const chanName = chan?.name ?? "this channel";
838
+ const gatesLine = `**Gates:** ${g1 ?? channelStr.split("-")[0] ?? "?"} ↔ ${g2 ?? channelStr.split("-")[1] ?? "?"}`;
839
+ return {
840
+ content: [{
841
+ type: "text",
842
+ text: `**Channel of ${chanName} (${channelStr}) — ${info.label}**\n\n` +
843
+ `${gatesLine}\n\n` +
844
+ `${info.body}` +
845
+ (chan?.description ? `\n\n**This channel:** ${chan.description}` : ""),
846
+ }],
847
+ };
848
+ },
849
+ });
707
850
  const HD_CENTER_DATA = {
708
851
  Head: {
709
852
  keynote: "Inspiration, mental pressure, questioning",
package/dist/tools/dev.js CHANGED
@@ -73,7 +73,7 @@ const DEV_API_REFERENCE = "Target API: Open Ephemeris REST API (https://api.open
73
73
  " POST /acg/power-lines — Astrocartography power lines (lat/lon GeoJSON)\n" +
74
74
  " POST /acg/hits — ACG power at a specific location\n" +
75
75
  " GET /calendar/astrology/moon-phases — Moon phase calendar for a date range\n" +
76
- " GET /location/autocomplete — Geocode a place name (query: q=City Name)\n" +
76
+ " GET /location/autocomplete — Geocode a place name (query: query=City Name)\n" +
77
77
  " POST /timezone/lookup — Resolve timezone + UTC offset for a location\n" +
78
78
  " POST /chinese/bazi — Chinese Ba Zi (Four Pillars) chart (body: {year, month, day, hour})\n" +
79
79
  " GET /chinese/zodiac — Chinese zodiac year element/animal\n" +
@@ -73,3 +73,28 @@ export declare function validateCoordinates(args: any, latKey: string, lonKey: s
73
73
  * Applies a safety limit to JSON payloads to prevent crashing LLM contexts.
74
74
  */
75
75
  export declare function formatToolResponse(toolName: string, result: any, durationMs: number): any;
76
+ /**
77
+ * Decide whether a thrown tool error is the stdio device-auth-pending case,
78
+ * which must NOT be flagged isError so the model relays the verification link.
79
+ *
80
+ * Everything else — 429, 5xx, network, plain auth failures — is a real error
81
+ * and MUST report isError: true so the host recovers (retry / OAuth refresh)
82
+ * instead of treating a failure as a silent success.
83
+ *
84
+ * Device-auth-pending is uniquely identified by a BackendError with
85
+ * code === "auth_required" AND retryable === true (set only when the background
86
+ * device-auth flow is live and its message carries a verification link). The
87
+ * plain "disconnect and reconnect" 401 is retryable === false → a real error.
88
+ */
89
+ export declare function isDeviceAuthPendingError(error: unknown): boolean;
90
+ /**
91
+ * Build the MCP error content block for a failed tool call. Shared by the stdio
92
+ * and SSE/HTTP CallTool handlers so the isError policy stays consistent.
93
+ */
94
+ export declare function formatToolError(error: unknown): {
95
+ content: {
96
+ type: "text";
97
+ text: string;
98
+ }[];
99
+ isError: boolean;
100
+ };
@@ -216,3 +216,36 @@ export function formatToolResponse(toolName, result, durationMs) {
216
216
  ],
217
217
  };
218
218
  }
219
+ /**
220
+ * Decide whether a thrown tool error is the stdio device-auth-pending case,
221
+ * which must NOT be flagged isError so the model relays the verification link.
222
+ *
223
+ * Everything else — 429, 5xx, network, plain auth failures — is a real error
224
+ * and MUST report isError: true so the host recovers (retry / OAuth refresh)
225
+ * instead of treating a failure as a silent success.
226
+ *
227
+ * Device-auth-pending is uniquely identified by a BackendError with
228
+ * code === "auth_required" AND retryable === true (set only when the background
229
+ * device-auth flow is live and its message carries a verification link). The
230
+ * plain "disconnect and reconnect" 401 is retryable === false → a real error.
231
+ */
232
+ export function isDeviceAuthPendingError(error) {
233
+ const e = error;
234
+ return !!e && e.code === "auth_required" && e.retryable === true;
235
+ }
236
+ /**
237
+ * Build the MCP error content block for a failed tool call. Shared by the stdio
238
+ * and SSE/HTTP CallTool handlers so the isError policy stays consistent.
239
+ */
240
+ export function formatToolError(error) {
241
+ const errorMessage = error instanceof Error ? error.message : String(error);
242
+ return {
243
+ content: [
244
+ {
245
+ type: "text",
246
+ 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).`,
247
+ },
248
+ ],
249
+ isError: !isDeviceAuthPendingError(error),
250
+ };
251
+ }
@@ -2,7 +2,7 @@
2
2
  * output-schemas.ts — Shared JSON Schema output schema definitions for MCP tools.
3
3
  *
4
4
  * MCP spec 2025-11-25 supports `outputSchema` on tool definitions. Smithery
5
- * uses this field to score the "Output schemas" quality criterion (10.37pt / 57 tools).
5
+ * uses this field to score the "Output schemas" quality criterion (scored per registered tool; count guarded by test/allowlist-and-tools.test.ts).
6
6
  *
7
7
  * All tools return an MCP content block response. Most return a single JSON text
8
8
  * block; visual tools return an image block or an image + text block.
@@ -2,7 +2,7 @@
2
2
  * output-schemas.ts — Shared JSON Schema output schema definitions for MCP tools.
3
3
  *
4
4
  * MCP spec 2025-11-25 supports `outputSchema` on tool definitions. Smithery
5
- * uses this field to score the "Output schemas" quality criterion (10.37pt / 57 tools).
5
+ * uses this field to score the "Output schemas" quality criterion (scored per registered tool; count guarded by test/allowlist-and-tools.test.ts).
6
6
  *
7
7
  * All tools return an MCP content block response. Most return a single JSON text
8
8
  * block; visual tools return an image block or an image + text block.
@@ -24,7 +24,9 @@ registerTool({
24
24
  properties: {
25
25
  birth_datetime: {
26
26
  type: "string",
27
- description: "ISO 8601 natal birth date/time.",
27
+ description: "ISO 8601 natal birth date/time. MUST include a UTC offset (or 'Z'), " +
28
+ "e.g. '1990-05-15T14:30:00-05:00'. A naive datetime (no offset) is " +
29
+ "interpreted as UTC and will place the ACG lines in the wrong location.",
28
30
  },
29
31
  birth_latitude: {
30
32
  type: "number",
@@ -114,7 +116,9 @@ registerTool({
114
116
  properties: {
115
117
  birth_datetime: {
116
118
  type: "string",
117
- description: "ISO 8601 natal birth date/time.",
119
+ description: "ISO 8601 natal birth date/time. MUST include a UTC offset (or 'Z'), " +
120
+ "e.g. '1990-05-15T14:30:00-05:00'. A naive datetime (no offset) is " +
121
+ "interpreted as UTC and will place the ACG lines in the wrong location.",
118
122
  },
119
123
  birth_latitude: {
120
124
  type: "number",
@@ -15,7 +15,9 @@ registerTool({
15
15
  properties: {
16
16
  birth_datetime: {
17
17
  type: "string",
18
- description: "ISO 8601 natal birth date/time.",
18
+ description: "ISO 8601 natal birth date/time. MUST include a UTC offset (or 'Z'), " +
19
+ "e.g. '1990-05-15T14:30:00-05:00'. A naive datetime (no offset) is " +
20
+ "interpreted as UTC and will shift the progressed positions.",
19
21
  },
20
22
  birth_latitude: {
21
23
  type: "number",