@openephemeris/mcp-server 3.17.0 → 3.20.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,83 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [3.20.0] — 2026-07-09
11
+
12
+ Second wave of the transport/iframe review: legacy transport retirement, resumability, and remaining hardening/quality items.
13
+
14
+ ### Removed
15
+ - **Legacy SSE transport retired.** `GET /sse` + `POST /message` (pre-2025 spec, SDK-deprecated, no cross-machine replay, zero live sessions) now return `410 Gone` pointing at `/mcp`. `scripts/test-sse-client.ts` and the `test:sse` script removed; docs updated.
16
+
17
+ ### Added
18
+ - **`Last-Event-ID` resumability.** Bounded in-memory `EventStore` (200 events/stream, 2h TTL) on the Streamable HTTP transport — a reconnecting SSE stream replays missed events instead of losing them. Per-process is safe because sessions are machine-pinned via fly-replay.
19
+ - **Server-computed natal aspects.** `explore_natal_chart` was discarding the `aspects` array the natal endpoint already returns (with true ephemeris-derived `is_applying`) and re-deriving aspects client-side with an approximate heuristic — `computeAspects` deleted, server aspects mapped through. Bi-wheel cross-aspects stay client-side (two charts, no single endpoint) but the applying/separating heuristic now uses real longitude speeds instead of defaulted guesses.
20
+ - **CSP + keyboard accessibility** on the four iframe apps: inline-only `Content-Security-Policy` meta on each shell; interactive SVG elements (planets, houses, aspects, centers/gates/channels) get `tabindex`/`role`/`aria-label`, Enter/Space activation, and visible focus indicators.
21
+ - **Real-handler test coverage.** `createSseApp()` is now exported and `test/server-sse-real-app.test.ts` drives the actual Express app: 401/WWW-Authenticate contract, expired-JWT refresh signal, session issuance with machine prefix, cross-machine `fly-replay` 307 + loop-guard 404, resume-auth requirement, Host allowlist, and the 410 legacy responses.
22
+ - **`check:plugin` release gate** (`scripts/plugin-audit.ts`): plugin.json version must match the package (it had rotted at 3.1.0) and `openephemeris-plugin.zip` must match the plugin tree by content; `regen:plugin` rebuilds both. Wired into `verify:release`.
23
+ - **CI:** `validate:visual` (Playwright + axe over the app harness) added to the validate workflow; the live canary now sends a **Telegram alert on failure** (needs `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID` secrets; no-ops without them).
24
+
25
+ ### Changed
26
+ - Resume-path auth now runs **before** the session lookup so an unauthenticated probe cannot learn whether a session id exists.
27
+
28
+ ---
29
+
30
+ ## [3.19.0] — 2026-07-09
31
+
32
+ Transport hardening + E2E release verification, from a full review of the streaming HTTP transport and iframe app capability.
33
+
34
+ ### Fixed
35
+ - **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.
36
+ - **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.
37
+
38
+ ### Added
39
+ - **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.
40
+ - **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.
41
+ - **Tool-call timeout** (120s) so a hung backend request can't pin a session forever.
42
+ - **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).
43
+ - **Host-header allowlist** on `/mcp`, `/sse`, `/message` as DNS-rebinding defense (Origin was already validated; Host was not). Extend via `MCP_ALLOWED_HOSTS`.
44
+ - **Fly HTTP health check** on `/health` (previously TCP-only, so a hung process stayed in rotation).
45
+ - **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.
46
+ - **Tool-count regression test** (84) keeping the marketing SSOT honest.
47
+
48
+ ### Removed
49
+ - **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.
50
+ - Leftover one-off codemods (`fix.cjs`, `fix.mjs`, `fix2.mjs`) and the empty `dev_card.json`.
51
+ - 7 byte-identical copies of `singlefile-plugin.ts` (now shared from `src/ui/shared/`); debug `console.log` noise stripped from the chart-wheel bundle.
52
+
53
+ ### Changed
54
+ - Runtime Docker image now prunes dev dependencies before the final stage.
55
+
56
+ ---
57
+
58
+ ## [3.18.0] — 2026-07-06
59
+
60
+ Launch-reliability hardening for the remote SSE/HTTP server (`oe-mcp-live`).
61
+
62
+ ### Fixed
63
+ - **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.
64
+ - **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.
65
+ - **`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.
66
+ - **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.
67
+ - **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).
68
+ - **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.
69
+ - **`dev_read_api` reference text** corrected: `/location/autocomplete` takes `query=`, not `q=`.
70
+
71
+ ### Changed
72
+ - Added a `process.on('unhandledRejection')` log-and-continue handler so a single rejected request can't take down every live session.
73
+ - 402 credit-exhausted message annotated with a pointer to the pricing SSOT (`apps/web/config/billing-plans.ts`).
74
+ - `fly.toml` VM memory raised 512MB → 1024MB for the launch window.
75
+
76
+ ---
77
+
78
+ ## [3.17.0] — 2026-07-06
79
+
80
+ Docs-only release (this is the version live on npm while 3.18.0 awaited publish).
81
+
82
+ ### Changed
83
+ - README: embedded the hero demo GIF and refreshed the quick-start copy for the launch window (PR #258). No runtime changes.
84
+
85
+ ---
86
+
10
87
  ## [3.16.0] — 2026-07-05
11
88
 
12
89
  ### 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
@@ -187,7 +187,7 @@ The server is hosted at `https://mcp.openephemeris.com/mcp` with full Streamable
187
187
 
188
188
  - **Claude Web**: Add `https://mcp.openephemeris.com/mcp` as a custom connector URL — leave OAuth Client ID and Secret **blank**. The server uses OAuth 2.1 + PKCE (Dynamic Client Registration), so Claude handles authentication via a browser popup automatically.
189
189
  - **Via Smithery**: Use the [Smithery listing](https://smithery.ai/servers/open-ephemeris/openephemeris) for managed connections with any client
190
- - **Legacy SSE**: `https://mcp.openephemeris.com/sse` remains available for SSE-only clients
190
+ - **Legacy SSE**: retired in 3.20.0 use Streamable HTTP at `/mcp`
191
191
 
192
192
  ### Auth and upgrade behavior in MCP clients
193
193
 
@@ -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.
@@ -253,14 +270,18 @@ export class BackendClient {
253
270
  `OpenEphemeris connector to re-authorize.`, 401, "auth_required", false, LOGIN_SIGNUP_URL);
254
271
  }
255
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.
256
278
  const dataObj = data && typeof data === "object" ? data : {};
257
279
  const walletUrl = dataObj.upgrade?.wallets_url || "https://openephemeris.com/wallet";
258
280
  const currentTier = dataObj.upgrade?.current_tier || dataObj.tier || "explorer";
259
- const isPro = currentTier === "developer" || currentTier === "pro" || currentTier === "startup" || currentTier === "scale";
260
281
  let upsellMsg = "";
261
282
  if (currentTier === 'explorer') {
262
283
  upsellMsg =
263
- `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. ` +
264
285
  `They can top up their wallet starting at $5 for 150 credits at ` +
265
286
  `${walletUrl} — or upgrade to Pro ($29/mo) ` +
266
287
  `for 75,000 credits/month at ${UPGRADE_URL}`;
@@ -361,8 +382,14 @@ export class BackendClient {
361
382
  }
362
383
  catch (err) {
363
384
  if (err instanceof AxiosError) {
364
- const isRetryable = (err.response && RETRYABLE_STATUSES.has(err.response.status)) ||
365
- (!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)));
366
393
  if (isRetryable && attempt < RETRY_DELAYS_MS.length) {
367
394
  const jitter = Math.random() * 500;
368
395
  await sleep(RETRY_DELAYS_MS[attempt] + jitter);
@@ -0,0 +1,11 @@
1
+ import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
2
+ import type { EventStore, EventId, StreamId } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ export declare class InMemoryEventStore implements EventStore {
4
+ private streams;
5
+ storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId>;
6
+ replayEventsAfter(lastEventId: EventId, { send }: {
7
+ send: (eventId: EventId, message: JSONRPCMessage) => Promise<void>;
8
+ }): Promise<StreamId>;
9
+ /** Drop streams idle past the TTL so abandoned sessions don't leak memory. */
10
+ private reapStale;
11
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * event-store.ts — bounded in-memory EventStore for Streamable HTTP
3
+ * resumability (Last-Event-ID).
4
+ *
5
+ * Without an event store the transport cannot replay server→client messages
6
+ * missed across an SSE reconnect — a dropped connection silently loses any
7
+ * buffered events. This store keeps a small per-session ring of recent events
8
+ * so a client reconnecting with Last-Event-ID resumes cleanly.
9
+ *
10
+ * Deliberately in-memory and per-process: sessions are already machine-pinned
11
+ * (fly-replay routes a session's requests to its owning machine), so the
12
+ * events for a session always live on the machine handling its reconnect.
13
+ */
14
+ import { randomUUID } from "node:crypto";
15
+ const MAX_EVENTS_PER_STREAM = 200;
16
+ const STREAM_TTL_MS = 2 * 60 * 60 * 1000; // matches the session idle TTL
17
+ export class InMemoryEventStore {
18
+ streams = new Map();
19
+ async storeEvent(streamId, message) {
20
+ // Event IDs embed the stream ID so replay can locate the stream without a
21
+ // secondary index: "<streamId>__<uuid>".
22
+ const eventId = `${streamId}__${randomUUID()}`;
23
+ let stream = this.streams.get(streamId);
24
+ if (!stream) {
25
+ stream = { events: [], lastTouched: Date.now() };
26
+ this.streams.set(streamId, stream);
27
+ }
28
+ stream.events.push({ eventId, message });
29
+ stream.lastTouched = Date.now();
30
+ if (stream.events.length > MAX_EVENTS_PER_STREAM) {
31
+ stream.events.splice(0, stream.events.length - MAX_EVENTS_PER_STREAM);
32
+ }
33
+ this.reapStale();
34
+ return eventId;
35
+ }
36
+ async replayEventsAfter(lastEventId, { send }) {
37
+ const sep = lastEventId.lastIndexOf("__");
38
+ const streamId = sep > 0 ? lastEventId.slice(0, sep) : "";
39
+ const stream = this.streams.get(streamId);
40
+ if (!stream)
41
+ return streamId;
42
+ const idx = stream.events.findIndex((e) => e.eventId === lastEventId);
43
+ // If the event aged out of the ring, replay nothing rather than
44
+ // duplicating from an unknown point — the client re-requests as needed.
45
+ if (idx === -1)
46
+ return streamId;
47
+ for (const event of stream.events.slice(idx + 1)) {
48
+ await send(event.eventId, event.message);
49
+ }
50
+ stream.lastTouched = Date.now();
51
+ return streamId;
52
+ }
53
+ /** Drop streams idle past the TTL so abandoned sessions don't leak memory. */
54
+ reapStale() {
55
+ const cutoff = Date.now() - STREAM_TTL_MS;
56
+ for (const [id, stream] of this.streams) {
57
+ if (stream.lastTouched < cutoff)
58
+ this.streams.delete(id);
59
+ }
60
+ }
61
+ }
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
+ }
@@ -1 +1,7 @@
1
- export {};
1
+ import express from "express";
2
+ /**
3
+ * Build the full Express app (auth gate, OAuth routes, /mcp Streamable HTTP,
4
+ * health, resources). Exported so tests can drive the REAL handlers with
5
+ * supertest instead of a re-implemented stub. Does not listen.
6
+ */
7
+ export declare function createSseApp(): Promise<express.Application>;