@openephemeris/mcp-server 3.17.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 +57 -0
- package/LICENSE +21 -21
- package/dist/backend/client.d.ts +9 -0
- package/dist/backend/client.js +32 -5
- package/dist/index.js +12 -11
- package/dist/oauth/session-utils.d.ts +59 -0
- package/dist/oauth/session-utils.js +110 -0
- package/dist/server-sse.js +184 -20
- package/dist/tools/dev.js +1 -1
- package/dist/tools/index.d.ts +25 -0
- package/dist/tools/index.js +33 -0
- package/dist/tools/output-schemas.d.ts +1 -1
- package/dist/tools/output-schemas.js +1 -1
- package/dist/tools/specialized/acg.js +6 -2
- package/dist/tools/specialized/progressed.js +3 -1
- package/dist/ui/chart-wheel.html +15 -20
- package/dist/ui/moon-phase.html +28 -28
- package/package.json +4 -2
- package/dist/tools/apps/bazi-app.d.ts +0 -23
- package/dist/tools/apps/bazi-app.js +0 -226
- package/dist/tools/apps/transit-timeline-app.d.ts +0 -20
- package/dist/tools/apps/transit-timeline-app.js +0 -295
- package/dist/tools/apps/vedic-chart-app.d.ts +0 -17
- package/dist/tools/apps/vedic-chart-app.js +0 -228
- package/dist/ui/bazi.html +0 -213
- package/dist/ui/transit-timeline.html +0 -6874
- package/dist/ui/vedic-chart.html +0 -210
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/dist/backend/client.d.ts
CHANGED
|
@@ -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.
|
package/dist/backend/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
365
|
-
|
|
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);
|
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
|
-
|
|
182
|
-
|
|
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
|
+
}
|