@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 +57 -0
- package/LICENSE +21 -21
- package/README.md +6 -4
- package/dist/backend/client.d.ts +9 -0
- package/dist/backend/client.js +35 -6
- 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/oauth/token.js +54 -7
- package/dist/server-sse.js +184 -20
- package/dist/tools/apps/bodygraph-app.d.ts +2 -1
- package/dist/tools/apps/bodygraph-app.js +144 -1
- 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/README.md
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@openephemeris/mcp-server)
|
|
5
5
|
[](https://status.openephemeris.com/)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+

|
|
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
|
-
│
|
|
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`): **
|
|
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
|
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.
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/oauth/token.js
CHANGED
|
@@ -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
|
-
//
|
|
117
|
-
|
|
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
|
|
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
|
-
|
|
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({
|