@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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,63 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [3.19.0] — 2026-07-09
11
+
12
+ Transport hardening + E2E release verification, from a full review of the streaming HTTP transport and iframe app capability.
13
+
14
+ ### Fixed
15
+ - **stdio missing the MCP Apps capability flag.** The stdio entry (`src/index.ts`) never advertised `experimental["io.modelcontextprotocol/ui"]` (the SSE server did), so hosts gating iframe rendering on that capability would not render apps from a local install. Now declared on both transports.
16
+ - **Rotted integration scripts.** `scripts/test-client.ts` and `scripts/smoke-dev-profile.ts` still asserted pre-rename dotted tool names (`auth.login`, `dev.call`) and could never pass. Fixed and wired into CI so they can't rot silently again.
17
+
18
+ ### Added
19
+ - **SSE keepalive.** Long-lived streams (`GET /mcp` leg and legacy `/sse`) now emit an SSE comment frame every 25s and disable the per-socket idle timeout, preventing silent drops through Fly's proxy on idle sessions.
20
+ - **Idle session reaper.** In-memory HTTP sessions abandoned without a DELETE are reaped after 2h idle (they previously leaked until process restart); a reaped session 404s and the client re-initializes per spec.
21
+ - **Tool-call timeout** (120s) so a hung backend request can't pin a session forever.
22
+ - **Resume-path auth.** A session id alone is no longer sufficient to resume a Streamable HTTP session — every request must still carry a valid API key / non-expired JWT (expired JWTs get the standard 401 `invalid_token` refresh signal).
23
+ - **Host-header allowlist** on `/mcp`, `/sse`, `/message` as DNS-rebinding defense (Origin was already validated; Host was not). Extend via `MCP_ALLOWED_HOSTS`.
24
+ - **Fly HTTP health check** on `/health` (previously TCP-only, so a hung process stayed in rotation).
25
+ - **E2E release verification:** `test:published` installs the packed/published tarball into a clean dir and drives `dist/index.js` over stdio (initialize → tools/list → UI resource read → tool call); `test:mcp-http` drives the live Streamable HTTP endpoint end to end including an MCP Apps `resources/read`. Wired into the validate workflow, as a post-publish gate in the npm workflow, and as a 6-hourly production canary.
26
+ - **Tool-count regression test** (84) keeping the marketing SSOT honest.
27
+
28
+ ### Removed
29
+ - **Dormant UI apps deleted** (transit-timeline, bazi, vedic-chart): never imported/registered, two never functional; their trees, tool files, per-app lockfiles, and `build:ui` steps are gone. BaZi/Vedic data still ships via the typed `bazi_*`/`vedic_chart` tools.
30
+ - Leftover one-off codemods (`fix.cjs`, `fix.mjs`, `fix2.mjs`) and the empty `dev_card.json`.
31
+ - 7 byte-identical copies of `singlefile-plugin.ts` (now shared from `src/ui/shared/`); debug `console.log` noise stripped from the chart-wheel bundle.
32
+
33
+ ### Changed
34
+ - Runtime Docker image now prunes dev dependencies before the final stage.
35
+
36
+ ---
37
+
38
+ ## [3.18.0] — 2026-07-06
39
+
40
+ Launch-reliability hardening for the remote SSE/HTTP server (`oe-mcp-live`).
41
+
42
+ ### Fixed
43
+ - **Stale-JWT dead session.** Self-signed Supabase JWTs expire after 1h. A session's `BackendClient` captured the JWT once at init and the resume path never re-read it, so after the host refreshed the token every tool call kept sending the frozen expired JWT and 401'd forever (users were told to disconnect/reconnect). `BackendClient.setJwt()` now lets the resume branch push the freshly-rotated Bearer token into the session client. Additionally, the `/mcp` auth gate now decodes the JWT `exp` claim (no signature verification — the Go API is the trust boundary) and returns `401 invalid_token` + `WWW-Authenticate` for an already-expired token, triggering the host's OAuth refresh instead of a confusing downstream tool error.
44
+ - **POST retry double-charge.** The axios retry loop retried every method — including POST — on transient failures, and every expensive compute endpoint is POST (e.g. `acg_hits` = 15 credits) metered at reservation time, so one dropped response could be billed up to 4×. Only idempotent GETs are auto-retried now; POST/PUT/PATCH/DELETE are never retried. `429` is no longer retried at all — the rate-limit message surfaces immediately instead of hammering the limiter.
45
+ - **`isError` correctness.** Both CallTool handlers previously set `isError: !isRetryable`, so 429/5xx/network/auth failures arrived as `isError: false` "successes." All failures now report `isError: true` so hosts recover (retry / OAuth refresh), with the one exception of the stdio device-auth-pending case (whose message carries a verification link the model must relay). Extracted into a shared, unit-tested `formatToolError` helper.
46
+ - **Cross-machine session 404s.** `min_machines_running = 2` but the session store is a per-process Map, so a resume request landing on the other machine 404'd. Streamable-HTTP session IDs now embed `FLY_MACHINE_ID`; a request whose session ID targets a different machine is re-routed via the `fly-replay` header (with a `fly-replay-src` loop guard that falls through to 404 so the client re-initializes). No-op locally when `FLY_MACHINE_ID` is unset.
47
+ - **API-key validation no longer costs a credit.** Session-init key validation probed `/ephemeris/moon/phase`, which meters at 1 credit — so merely connecting charged the user. Switched to `/catalogs/bodies` (auth-gated but metered at 0 credits).
48
+ - **ACG / progressed datetime descriptions** now require a UTC offset (e.g. `1990-05-15T14:30:00-05:00`); a naive datetime was silently interpreted as UTC and mislocated ACG lines / shifted progressions.
49
+ - **`dev_read_api` reference text** corrected: `/location/autocomplete` takes `query=`, not `q=`.
50
+
51
+ ### Changed
52
+ - Added a `process.on('unhandledRejection')` log-and-continue handler so a single rejected request can't take down every live session.
53
+ - 402 credit-exhausted message annotated with a pointer to the pricing SSOT (`apps/web/config/billing-plans.ts`).
54
+ - `fly.toml` VM memory raised 512MB → 1024MB for the launch window.
55
+
56
+ ---
57
+
58
+ ## [3.17.0] — 2026-07-06
59
+
60
+ Docs-only release (this is the version live on npm while 3.18.0 awaited publish).
61
+
62
+ ### Changed
63
+ - README: embedded the hero demo GIF and refreshed the quick-start copy for the launch window (PR #258). No runtime changes.
64
+
65
+ ---
66
+
10
67
  ## [3.16.0] — 2026-07-05
11
68
 
12
69
  ### Added
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2025-2026 Open Ephemeris
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Open Ephemeris
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -4,7 +4,9 @@
4
4
  [![npm version](https://img.shields.io/npm/v/@openephemeris/mcp-server)](https://www.npmjs.com/package/@openephemeris/mcp-server)
5
5
  [![System Status](https://img.shields.io/badge/Status-Operational-brightgreen)](https://status.openephemeris.com/)
6
6
 
7
- Model Context Protocol server for OpenEphemeris 52 typed astrology tools powered by the NASA JPL DE440 ephemeris. Zero hallucination on planetary positions, dates, and degrees. Covers 1,100 years of astronomical data.
7
+ ![OpenEphemeris in Claudeask in plain language, get a real computed chart](https://raw.githubusercontent.com/openephemeris/openephemeris/main/docs/internal/PH-ASSETS/hero-demo.gif)
8
+
9
+ Model Context Protocol server for OpenEphemeris — typed astrology tools powered by the NASA JPL DE440 ephemeris. Zero hallucination on planetary positions, dates, and degrees. Covers 1,100 years of astronomical data.
8
10
 
9
11
  **Hosted endpoint:** `https://mcp.openephemeris.com/mcp` (Streamable HTTP, MCP 2025-11-25 spec)
10
12
 
@@ -337,7 +339,7 @@ When you update the MCP server logic (handlers, bug fixes, hardening), you shoul
337
339
  ┌────▼────────────────────┐
338
340
  │ openephemeris-mcp │
339
341
  │ Node.js MCP Server │
340
- 52 typed tools
342
+ │ typed tools
341
343
  │ auth: Key > JWT │
342
344
  └────────────┬────────────┘
343
345
  │ HTTPS
@@ -357,8 +359,8 @@ Generated by `npm run sync:readme` from `config/dev-allowlist.json` and the live
357
359
 
358
360
  - Allowlisted operations: **28**
359
361
  - Methods: `GET=4`, `POST=24`, `PUT=0`, `PATCH=0`, `DELETE=0`
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`
362
+ - Registered tools (`OPENEPHEMERIS_PROFILE=dev`): **84**
363
+ - 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_connection_channel_click`, `hd_on_gate_click`, `hd_on_planet_click`, `hd_on_transit_channel_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`
362
364
  - Generic tools:
363
365
 
364
366
  ### Allowlist Families
@@ -44,6 +44,15 @@ export declare class BackendClient {
44
44
  * metered against the correct user account.
45
45
  */
46
46
  setApiKey(key: string): void;
47
+ /**
48
+ * Override the stored JWT at runtime. Used by the SSE/HTTP server to refresh
49
+ * a session's Bearer token when the host (e.g. claude.ai) rotates it via
50
+ * /oauth/token. Self-signed Supabase JWTs expire after 1h, so without this a
51
+ * long-lived session would keep sending the frozen expired token and 401 on
52
+ * every tool call. Only takes effect when no API key / service key is set
53
+ * (JWT is the lowest-priority credential in the interceptor).
54
+ */
55
+ setJwt(jwt: string): void;
47
56
  /**
48
57
  * Start the device auth flow in the background if not already running.
49
58
  * Called automatically when no credentials are found in the interceptor.
@@ -4,7 +4,13 @@ import { CredentialManager } from "../auth/credentials.js";
4
4
  import { DeviceAuthFlow } from "../auth/device-auth.js";
5
5
  const DEFAULT_TIMEOUT_MS = 45_000;
6
6
  const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]; // Three retries with backoff
7
- const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
7
+ // NOTE: 429 is intentionally NOT retryable. Every expensive compute endpoint is
8
+ // POST and the Go API meters at reservation time, so retrying a rate-limited or
9
+ // dropped POST double-charges the user (e.g. acg_hits = 15 credits × retries).
10
+ // We only auto-retry idempotent GETs on transient transport/5xx failures; the
11
+ // 429 message is surfaced immediately so the caller backs off instead of
12
+ // hammering the limiter. See request() for the GET-only guard.
13
+ const RETRYABLE_STATUSES = new Set([502, 503, 504]);
8
14
  const RETRYABLE_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN"]);
9
15
  const BINARY_ENDPOINT_PREFIXES = [
10
16
  "/visualization/bi-wheel",
@@ -118,6 +124,17 @@ export class BackendClient {
118
124
  this.serviceKey = undefined;
119
125
  this.jwt = undefined;
120
126
  }
127
+ /**
128
+ * Override the stored JWT at runtime. Used by the SSE/HTTP server to refresh
129
+ * a session's Bearer token when the host (e.g. claude.ai) rotates it via
130
+ * /oauth/token. Self-signed Supabase JWTs expire after 1h, so without this a
131
+ * long-lived session would keep sending the frozen expired token and 401 on
132
+ * every tool call. Only takes effect when no API key / service key is set
133
+ * (JWT is the lowest-priority credential in the interceptor).
134
+ */
135
+ setJwt(jwt) {
136
+ this.jwt = jwt;
137
+ }
121
138
  /**
122
139
  * Start the device auth flow in the background if not already running.
123
140
  * Called automatically when no credentials are found in the interceptor.
@@ -248,17 +265,23 @@ export class BackendClient {
248
265
  `If you don't have an account, you can create one at the link above (free tier, no credit card needed).`, 401, "auth_required", true, authInfo.verification_uri_complete);
249
266
  }
250
267
  return new BackendError(`Authentication required: ${msg}. Run auth.login to connect your account, ` +
251
- `or set OPENEPHEMERIS_API_KEY in your MCP server config.`, 401, "auth_required", false, LOGIN_SIGNUP_URL);
268
+ `or set OPENEPHEMERIS_API_KEY in your MCP server config. If you connected ` +
269
+ `through Claude's connector settings, disconnect and reconnect the ` +
270
+ `OpenEphemeris connector to re-authorize.`, 401, "auth_required", false, LOGIN_SIGNUP_URL);
252
271
  }
253
272
  if (status === 402) {
273
+ // PRICING SSOT: apps/web/config/billing-plans.ts — Explorer grant
274
+ // (EXPLORER.creditsNumeric = 150, one-time), Pro/`developer`
275
+ // (monthlyPrice = 29, credits = 75,000), and CREDIT_TOPUPS
276
+ // ($5→150, $10→350, $20→900). Keep every number below in sync with
277
+ // that file; do NOT hand-edit these figures independently.
254
278
  const dataObj = data && typeof data === "object" ? data : {};
255
279
  const walletUrl = dataObj.upgrade?.wallets_url || "https://openephemeris.com/wallet";
256
280
  const currentTier = dataObj.upgrade?.current_tier || dataObj.tier || "explorer";
257
- const isPro = currentTier === "developer" || currentTier === "pro" || currentTier === "startup" || currentTier === "scale";
258
281
  let upsellMsg = "";
259
282
  if (currentTier === 'explorer') {
260
283
  upsellMsg =
261
- `Let the user know warmly: Their 150 free discovery credits are used up. ` +
284
+ `Let the user know warmly: Their 150 free credits (one-time grant) are used up. ` +
262
285
  `They can top up their wallet starting at $5 for 150 credits at ` +
263
286
  `${walletUrl} — or upgrade to Pro ($29/mo) ` +
264
287
  `for 75,000 credits/month at ${UPGRADE_URL}`;
@@ -359,8 +382,14 @@ export class BackendClient {
359
382
  }
360
383
  catch (err) {
361
384
  if (err instanceof AxiosError) {
362
- const isRetryable = (err.response && RETRYABLE_STATUSES.has(err.response.status)) ||
363
- (!err.response && err.code && RETRYABLE_CODES.has(err.code));
385
+ // Only idempotent GETs may be auto-retried. POST/PUT/PATCH/DELETE are
386
+ // never retried: expensive compute endpoints are POST and the Go API
387
+ // meters credits at reservation time, so a retried POST double-charges
388
+ // even when the original response was merely lost in transit.
389
+ const isIdempotent = method === "GET";
390
+ const isRetryable = isIdempotent &&
391
+ ((err.response && RETRYABLE_STATUSES.has(err.response.status)) ||
392
+ (!err.response && !!err.code && RETRYABLE_CODES.has(err.code)));
364
393
  if (isRetryable && attempt < RETRY_DELAYS_MS.length) {
365
394
  const jitter = Math.random() * 500;
366
395
  await sleep(RETRY_DELAYS_MS[attempt] + jitter);
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
7
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
8
- import { initTools, toolRegistry, formatToolResponse, modelVisibleTools } from "./tools/index.js";
8
+ import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools } from "./tools/index.js";
9
9
  import { listPrompts, getPromptContent } from "./prompts.js";
10
10
  // ── MCP App resource imports ────────────────────────────────────────────────
11
11
  import { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
@@ -42,6 +42,14 @@ const server = new Server({
42
42
  tools: {},
43
43
  prompts: {},
44
44
  resources: {},
45
+ experimental: {
46
+ // Declare MCP Apps extension (spec: io.modelcontextprotocol/ui, 2026-01-26).
47
+ // Must match the SSE server's capability block so stdio hosts (Claude
48
+ // Desktop) also know this server returns renderable HTML resources.
49
+ "io.modelcontextprotocol/ui": {
50
+ mimeTypes: ["text/html;profile=mcp-app"],
51
+ },
52
+ },
45
53
  },
46
54
  });
47
55
  // List available tools
@@ -176,17 +184,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
176
184
  catch (error) {
177
185
  const durationMs = Date.now() - startTime;
178
186
  const errorMessage = error instanceof Error ? error.message : String(error);
179
- const isRetryable = error.retryable === true || error.status === 401 || error.code === "auth_required";
180
187
  console.error(`[MCP] ❌ Failed: ${toolName} (${durationMs}ms) - ${errorMessage}`);
181
- return {
182
- content: [
183
- {
184
- type: "text",
185
- 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).`,
186
- },
187
- ],
188
- isError: !isRetryable,
189
- };
188
+ // isError: true for every failure so the host recovers, EXCEPT the stdio
189
+ // device-auth-pending case (model must relay the verification link).
190
+ return formatToolError(error);
190
191
  }
191
192
  });
192
193
  // Start server
@@ -0,0 +1,59 @@
1
+ /**
2
+ * src/oauth/session-utils.ts — Pure helpers for session reliability.
3
+ *
4
+ * Two concerns live here so they can be unit-tested without spinning up the
5
+ * full SSE server:
6
+ *
7
+ * 1. JWT expiry decoding — read the `exp` claim WITHOUT signature
8
+ * verification. The signature is validated downstream by the Go API's
9
+ * ValidateSupabaseJWT; here we only need to know whether a token is
10
+ * already expired so the /mcp gate can trigger the host's OAuth refresh
11
+ * (401 invalid_token) instead of letting it die as a confusing tool error.
12
+ *
13
+ * 2. Machine-scoped session IDs — Fly runs min_machines_running >= 2, but the
14
+ * HTTP/SSE session store is a per-process Map. A resume request can land on
15
+ * a different machine and 404. We embed the FLY_MACHINE_ID into generated
16
+ * session IDs so an incoming request can be re-routed to the owning machine
17
+ * via the `fly-replay` header. Local/dev (no FLY_MACHINE_ID) is a no-op.
18
+ */
19
+ /**
20
+ * Decode a JWT's payload without verifying the signature and return its `exp`
21
+ * claim (seconds since epoch), or null if the token is malformed / has no exp.
22
+ *
23
+ * Signature verification is intentionally skipped — the Go API is the trust
24
+ * boundary. This is only used to decide whether to prompt the host to refresh.
25
+ */
26
+ export declare function decodeJwtExp(token: string): number | null;
27
+ /**
28
+ * Returns true when the JWT is expired (or expires within `skewSeconds`).
29
+ * A token we cannot decode (or that carries no exp) is treated as NOT expired
30
+ * so we never block a token the Go API might still accept — the downstream
31
+ * validator remains the source of truth.
32
+ */
33
+ export declare function isJwtExpired(token: string, skewSeconds?: number, nowMs?: number): boolean;
34
+ /** The current Fly machine id, or "" when running locally / outside Fly. */
35
+ export declare function currentMachineId(): string;
36
+ /**
37
+ * Wrap a raw session ID with the current machine id when running on Fly.
38
+ * Local/dev (no FLY_MACHINE_ID) returns the raw id unchanged so behavior and
39
+ * existing session-id shapes are preserved off-platform.
40
+ */
41
+ export declare function encodeSessionId(rawId: string, machineId?: string): string;
42
+ /**
43
+ * Extract the machine id embedded in a session ID, or "" if none is present
44
+ * (locally-minted id, or a legacy id from before this scheme).
45
+ */
46
+ export declare function machineIdFromSessionId(sessionId: string): string;
47
+ /**
48
+ * Decide whether an incoming request for `sessionId` should be replayed to a
49
+ * different Fly machine.
50
+ *
51
+ * Returns the target machine id to replay to, or null to handle locally.
52
+ * Guards against replay loops: if the request was already replayed
53
+ * (fly-replay-src header present) we never replay again — the caller falls
54
+ * through to its normal 404 so the client re-initializes per the MCP spec.
55
+ */
56
+ export declare function replayTargetMachine(sessionId: string, opts: {
57
+ selfMachineId?: string;
58
+ alreadyReplayed: boolean;
59
+ }): string | null;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * src/oauth/session-utils.ts — Pure helpers for session reliability.
3
+ *
4
+ * Two concerns live here so they can be unit-tested without spinning up the
5
+ * full SSE server:
6
+ *
7
+ * 1. JWT expiry decoding — read the `exp` claim WITHOUT signature
8
+ * verification. The signature is validated downstream by the Go API's
9
+ * ValidateSupabaseJWT; here we only need to know whether a token is
10
+ * already expired so the /mcp gate can trigger the host's OAuth refresh
11
+ * (401 invalid_token) instead of letting it die as a confusing tool error.
12
+ *
13
+ * 2. Machine-scoped session IDs — Fly runs min_machines_running >= 2, but the
14
+ * HTTP/SSE session store is a per-process Map. A resume request can land on
15
+ * a different machine and 404. We embed the FLY_MACHINE_ID into generated
16
+ * session IDs so an incoming request can be re-routed to the owning machine
17
+ * via the `fly-replay` header. Local/dev (no FLY_MACHINE_ID) is a no-op.
18
+ */
19
+ /**
20
+ * Decode a JWT's payload without verifying the signature and return its `exp`
21
+ * claim (seconds since epoch), or null if the token is malformed / has no exp.
22
+ *
23
+ * Signature verification is intentionally skipped — the Go API is the trust
24
+ * boundary. This is only used to decide whether to prompt the host to refresh.
25
+ */
26
+ export function decodeJwtExp(token) {
27
+ if (typeof token !== "string" || !token)
28
+ return null;
29
+ const parts = token.split(".");
30
+ if (parts.length !== 3)
31
+ return null;
32
+ try {
33
+ const payloadJson = Buffer.from(parts[1], "base64url").toString("utf8");
34
+ const payload = JSON.parse(payloadJson);
35
+ if (typeof payload.exp === "number" && Number.isFinite(payload.exp)) {
36
+ return payload.exp;
37
+ }
38
+ return null;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /**
45
+ * Returns true when the JWT is expired (or expires within `skewSeconds`).
46
+ * A token we cannot decode (or that carries no exp) is treated as NOT expired
47
+ * so we never block a token the Go API might still accept — the downstream
48
+ * validator remains the source of truth.
49
+ */
50
+ export function isJwtExpired(token, skewSeconds = 0, nowMs = Date.now()) {
51
+ const exp = decodeJwtExp(token);
52
+ if (exp === null)
53
+ return false;
54
+ const nowSec = Math.floor(nowMs / 1000);
55
+ return exp <= nowSec + skewSeconds;
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // Machine-scoped session IDs (Fly request replay)
59
+ // ---------------------------------------------------------------------------
60
+ const MACHINE_PREFIX_SEP = "."; // "<machineId>.<rawSessionId>"
61
+ /** The current Fly machine id, or "" when running locally / outside Fly. */
62
+ export function currentMachineId() {
63
+ return process.env.FLY_MACHINE_ID || "";
64
+ }
65
+ /**
66
+ * Wrap a raw session ID with the current machine id when running on Fly.
67
+ * Local/dev (no FLY_MACHINE_ID) returns the raw id unchanged so behavior and
68
+ * existing session-id shapes are preserved off-platform.
69
+ */
70
+ export function encodeSessionId(rawId, machineId = currentMachineId()) {
71
+ if (!machineId)
72
+ return rawId;
73
+ return `${machineId}${MACHINE_PREFIX_SEP}${rawId}`;
74
+ }
75
+ /**
76
+ * Extract the machine id embedded in a session ID, or "" if none is present
77
+ * (locally-minted id, or a legacy id from before this scheme).
78
+ */
79
+ export function machineIdFromSessionId(sessionId) {
80
+ if (typeof sessionId !== "string")
81
+ return "";
82
+ const idx = sessionId.indexOf(MACHINE_PREFIX_SEP);
83
+ if (idx <= 0)
84
+ return "";
85
+ return sessionId.slice(0, idx);
86
+ }
87
+ /**
88
+ * Decide whether an incoming request for `sessionId` should be replayed to a
89
+ * different Fly machine.
90
+ *
91
+ * Returns the target machine id to replay to, or null to handle locally.
92
+ * Guards against replay loops: if the request was already replayed
93
+ * (fly-replay-src header present) we never replay again — the caller falls
94
+ * through to its normal 404 so the client re-initializes per the MCP spec.
95
+ */
96
+ export function replayTargetMachine(sessionId, opts) {
97
+ const selfMachineId = opts.selfMachineId ?? currentMachineId();
98
+ // Off-Fly (no machine identity) → never replay.
99
+ if (!selfMachineId)
100
+ return null;
101
+ // Already bounced once → don't loop; let the caller 404.
102
+ if (opts.alreadyReplayed)
103
+ return null;
104
+ const target = machineIdFromSessionId(sessionId);
105
+ if (!target)
106
+ return null; // legacy / local id — can't route
107
+ if (target === selfMachineId)
108
+ return null; // we own it
109
+ return target;
110
+ }
@@ -21,6 +21,20 @@ import { timingSafeEqual } from "node:crypto";
21
21
  import { verifyCodeChallenge } from "./pkce.js";
22
22
  import { issueSupabaseJWT, refreshSupabaseSession } from "./supabase-jwt.js";
23
23
  // ---------------------------------------------------------------------------
24
+ // Supabase config check (mirrors store.ts / supabase-jwt.ts)
25
+ // ---------------------------------------------------------------------------
26
+ /**
27
+ * True when Supabase env vars are configured, meaning callers expect a real
28
+ * JWT. In that case a JWT-issuance failure must be reported as an OAuth
29
+ * error rather than silently degrading to a placeholder token — a
30
+ * placeholder access_token is classified as an API key downstream
31
+ * (server-sse.ts extractAuth) and will 401 on every subsequent request,
32
+ * leaving the connector stuck in a "connected but broken" state.
33
+ */
34
+ function isSupabaseConfigured() {
35
+ return Boolean(process.env["SUPABASE_URL"] && process.env["SUPABASE_SERVICE_ROLE_KEY"]);
36
+ }
37
+ // ---------------------------------------------------------------------------
24
38
  // Helpers
25
39
  // ---------------------------------------------------------------------------
26
40
  /**
@@ -113,8 +127,10 @@ async function handleAuthorizationCode(req, res, store) {
113
127
  store.storeRefreshToken(oauthRefreshToken, {
114
128
  client_id: stored.client_id,
115
129
  user_id: stored.user_id,
116
- // Keep the Supabase refresh token for downstream refresh
117
- supabase_refresh_token: supabaseTokens.refresh_token,
130
+ // Real Supabase refresh tokens aren't available for self-signed JWTs
131
+ // (see supabase-jwt.ts) — storing null avoids a doomed Supabase
132
+ // refresh round-trip on the next /oauth/token refresh_token grant.
133
+ supabase_refresh_token: null,
118
134
  });
119
135
  console.info(`[token] Supabase JWT issued for user ${stored.user_id}`);
120
136
  res.json({
@@ -125,10 +141,23 @@ async function handleAuthorizationCode(req, res, store) {
125
141
  });
126
142
  return;
127
143
  }
128
- // Supabase JWT issuance failed log a warning but continue to placeholder
144
+ // Supabase JWT issuance failed. If Supabase is configured, the caller
145
+ // expects a real JWT — returning a placeholder here would leave the
146
+ // connector "connected" while every subsequent API call 401s (a
147
+ // placeholder access_token is classified as an API key, not a JWT, by
148
+ // server-sse.ts's extractAuth). Report a proper OAuth error instead so
149
+ // the client can retry or surface the failure.
150
+ if (isSupabaseConfigured()) {
151
+ console.error(`[token] Supabase JWT issuance failed for user ${stored.user_id}; returning server_error`);
152
+ res.status(500).json({
153
+ error: "server_error",
154
+ error_description: "Failed to issue an access token. Please try again.",
155
+ });
156
+ return;
157
+ }
129
158
  console.warn(`[token] Supabase JWT issuance failed for user ${stored.user_id}, falling back to placeholder`);
130
159
  }
131
- // Fallback: local placeholder tokens (test/dev path)
160
+ // Fallback: local placeholder tokens (test/dev path — Supabase not configured)
132
161
  const refreshToken = store.generateRefreshToken();
133
162
  store.storeRefreshToken(refreshToken, { client_id: stored.client_id });
134
163
  res.json({
@@ -166,7 +195,10 @@ async function handleRefreshToken(req, res, store) {
166
195
  // ---------------------------------------------------------------------------
167
196
  const userId = storedMeta.user_id;
168
197
  const supabaseRefresh = storedMeta.supabase_refresh_token;
169
- // Try Supabase refresh first
198
+ // Try Supabase refresh first — only meaningful if we ever obtained a real
199
+ // Supabase refresh token. Self-signed JWTs (see supabase-jwt.ts) never do,
200
+ // so supabase_refresh_token is stored as null and this is skipped in favor
201
+ // of going straight to the working issueSupabaseJWT(userId) fallback below.
170
202
  if (supabaseRefresh) {
171
203
  const refreshed = await refreshSupabaseSession(supabaseRefresh);
172
204
  if (refreshed) {
@@ -192,7 +224,9 @@ async function handleRefreshToken(req, res, store) {
192
224
  const newRefreshToken = store.generateRefreshToken();
193
225
  store.storeRefreshToken(newRefreshToken, {
194
226
  ...storedMeta,
195
- supabase_refresh_token: freshTokens.refresh_token,
227
+ // See note above — self-signed JWTs have no real Supabase refresh
228
+ // token, so store null rather than the JWT's own (also fake) one.
229
+ supabase_refresh_token: null,
196
230
  });
197
231
  res.json({
198
232
  access_token: freshTokens.access_token,
@@ -202,8 +236,21 @@ async function handleRefreshToken(req, res, store) {
202
236
  });
203
237
  return;
204
238
  }
239
+ // We have a user_id (Supabase-issued grant) but JWT issuance failed.
240
+ // Same reasoning as the authorization_code path: a placeholder token
241
+ // here would falsely appear "refreshed" while every subsequent request
242
+ // 401s. Report a proper OAuth error so the client can retry/reauth.
243
+ if (isSupabaseConfigured()) {
244
+ console.error(`[token] Refresh: Supabase JWT re-issuance failed for user ${userId}; returning server_error`);
245
+ res.status(500).json({
246
+ error: "server_error",
247
+ error_description: "Failed to refresh the access token. Please try again.",
248
+ });
249
+ return;
250
+ }
205
251
  }
206
- // Fallback: placeholder tokens
252
+ // Fallback: placeholder tokens (test/dev path — Supabase not configured,
253
+ // or this refresh token predates any user_id/Supabase association)
207
254
  const newRefreshToken = store.generateRefreshToken();
208
255
  store.storeRefreshToken(newRefreshToken, storedMeta);
209
256
  res.json({