@openephemeris/mcp-server 3.19.0 → 3.21.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,51 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [3.21.0] — 2026-07-09
11
+
12
+ Launch-readiness wave from the three-perspective master review (directory vetting, first-run UX, revenue red-team).
13
+
14
+ ### Security (directory-listing conditions)
15
+ - **`auth_*` device tools removed from the HTTP transport.** They mutate process-global state (shared credential file, env inspection) — on the multi-tenant remote server one user's `auth_logout` cleared shared credentials and `auth_status` could report the process service-key config to any tenant. Now `stdioOnly` and hidden/blocked on `/mcp`; unchanged for local stdio installs.
16
+ - **Rate limiters no longer trust `X-Forwarded-For`** (leftmost value is attacker-controlled and let callers rotate fake IPs past every throttle). Client IP now comes from Fly's trusted `Fly-Client-IP` hop, with a spoof-resistance test.
17
+ - **DCR redirect hardening:** `redirect_uris` must be https or http loopback (RFC 8252) — non-conforming registrations are rejected.
18
+
19
+ ### Conversion & instrumentation
20
+ - **PostHog events from the MCP server** (`src/analytics.ts`, fire-and-forget, no-ops without `POSTHOG_API_KEY`): `mcp_session_init` (auth type), `mcp_tool_call` (tool, duration), `mcp_tool_error` (tool, HTTP status — makes 402 paywall hits and 429s countable). Identity is a SHA-256 prefix of the credential, never the raw key; all events tagged `source: mcp`.
21
+ - **402 paywall fixed:** paid tiers are no longer told to "enable overages" (overage billing is switched off) — they get the next tier with exact price and a `/pricing?plan=…` deep link; free-tier 402s deep-link to the wallet top-up and `/pricing?plan=developer`.
22
+ - **Credit costs disclosed** on the five app tools that lacked them (verified against `usage_meter.go`); `ephemeris_natal_batch` now states its Startup-tier gating and warns that N subjects = N credits.
23
+ - **Return hooks:** `current_sky_snapshot` and `transit_forecast` prompts now end with when to check back (next Moon sign change / next exact transit).
24
+
25
+ ### First-run UX
26
+ - **Routing:** `explore_natal_chart` is now explicitly the PRIMARY tool for chart requests; `ephemeris_natal_chart` marks itself raw-JSON and defers to it; the `natal_chart_reading` prompt and the astrology-mcp landing page now point at the interactive explorers.
27
+ - **Welcome rewritten** from a 12-bullet brochure to a two-option hook (instant no-birth-data sky snapshot, or an interactive birth chart); server `instructions` now steer the model to `explore_*` tools for anything visual.
28
+ - **Loader copy de-jargoned** ("Computing ephemeris…" → "Mapping the sky at your birth moment…" etc.); every terminal iframe error now offers "Tap to try again" wired to a real retry; `auth_login` leads with "free tier, no credit card needed"; expired-OAuth copy tells users exactly where to reconnect.
29
+
30
+ ### Fixed
31
+ - **Local visual harness (`npm run harness`) was broken** by an orphaned vite process squatting port 5180: `visual-check.mjs` now kills the full process tree on Windows (`taskkill /T`), and the harness uses `strictPort` so a squatted port fails loudly instead of silently drifting. Harness index/fixtures cleaned of the deleted dormant apps; the dead bodygraph fixture was rebuilt with the real Go-SVG contract — which exposed and fixed two genuine a11y bugs (`role="img"` on an SVG containing buttons; hardcoded low-contrast gold on action buttons).
32
+
33
+ ---
34
+
35
+ ## [3.20.0] — 2026-07-09
36
+
37
+ Second wave of the transport/iframe review: legacy transport retirement, resumability, and remaining hardening/quality items.
38
+
39
+ ### Removed
40
+ - **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.
41
+
42
+ ### Added
43
+ - **`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.
44
+ - **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.
45
+ - **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.
46
+ - **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.
47
+ - **`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`.
48
+ - **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).
49
+
50
+ ### Changed
51
+ - Resume-path auth now runs **before** the session lookup so an unauthenticated probe cannot learn whether a session id exists.
52
+
53
+ ---
54
+
10
55
  ## [3.19.0] — 2026-07-09
11
56
 
12
57
  Transport hardening + E2E release verification, from a full review of the streaming HTTP transport and iframe app capability.
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
 
@@ -0,0 +1,3 @@
1
+ /** Stable, non-reversible identity from a credential. */
2
+ export declare function distinctIdFor(credential: string | undefined): string;
3
+ export declare function captureEvent(event: string, distinctId: string, properties?: Record<string, unknown>): void;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * analytics.ts — fire-and-forget PostHog events for the remote MCP server.
3
+ *
4
+ * The launch was previously unmeasurable from the MCP side: no way to see how
5
+ * many sessions initialized, which tools drove usage, or how many users hit
6
+ * the 402 paywall. These events close that gap. Everything is best-effort:
7
+ * analytics must never slow down or fail a tool call.
8
+ *
9
+ * Configuration (Fly secrets / env):
10
+ * POSTHOG_API_KEY — the project API key (phc_...); absent → all no-ops.
11
+ * POSTHOG_HOST — capture host, default https://us.i.posthog.com.
12
+ *
13
+ * Identity: we never send raw API keys or JWTs. The distinct_id is a SHA-256
14
+ * prefix of the credential, stable per user across sessions but useless for
15
+ * authentication.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+ const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com";
19
+ function apiKey() {
20
+ return process.env.POSTHOG_API_KEY || undefined;
21
+ }
22
+ /** Stable, non-reversible identity from a credential. */
23
+ export function distinctIdFor(credential) {
24
+ if (!credential)
25
+ return "anonymous";
26
+ return "mcp_" + createHash("sha256").update(credential).digest("hex").slice(0, 24);
27
+ }
28
+ export function captureEvent(event, distinctId, properties = {}) {
29
+ const key = apiKey();
30
+ if (!key)
31
+ return;
32
+ // Fire-and-forget: no await, swallow every failure.
33
+ fetch(`${POSTHOG_HOST}/capture/`, {
34
+ method: "POST",
35
+ headers: { "Content-Type": "application/json" },
36
+ body: JSON.stringify({
37
+ api_key: key,
38
+ event,
39
+ distinct_id: distinctId,
40
+ properties: { source: "mcp", ...properties },
41
+ timestamp: new Date().toISOString(),
42
+ }),
43
+ }).catch(() => { });
44
+ }
@@ -266,25 +266,33 @@ export class BackendClient {
266
266
  }
267
267
  return new BackendError(`Authentication required: ${msg}. Run auth.login to connect your account, ` +
268
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);
269
+ `through Claude's connector settings, your session expired: in Claude's connector ` +
270
+ `settings, toggle OpenEphemeris off and on to reconnect — this only takes a moment.`, 401, "auth_required", false, LOGIN_SIGNUP_URL);
271
271
  }
272
272
  if (status === 402) {
273
273
  // PRICING SSOT: apps/web/config/billing-plans.ts — Explorer grant
274
274
  // (EXPLORER.creditsNumeric = 150, one-time), Pro/`developer`
275
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.
276
+ // ($5→150, $10→350, $20→900), Startup ($79, 500k), Scale ($199, 2M).
277
+ // Overage billing is OFF (OVERAGE_BILLING_AVAILABLE=false) never
278
+ // suggest enabling overages; suggest the next tier instead. Keep every
279
+ // number below in sync with that file; do NOT hand-edit independently.
278
280
  const dataObj = data && typeof data === "object" ? data : {};
279
- const walletUrl = dataObj.upgrade?.wallets_url || "https://openephemeris.com/wallet";
281
+ const walletUrl = dataObj.upgrade?.wallets_url || "https://openephemeris.com/dashboard?tab=wallet";
280
282
  const currentTier = dataObj.upgrade?.current_tier || dataObj.tier || "explorer";
283
+ // Next-tier upgrade path (SSOT: BILLING_PLANS order in billing-plans.ts).
284
+ const NEXT_TIER = {
285
+ developer: { plan: "startup", label: "Startup ($79/mo, 500,000 credits)" },
286
+ startup: { plan: "scale", label: "Scale ($199/mo, 2,000,000 credits)" },
287
+ scale: { plan: "enterprise", label: "Enterprise (custom volume)" },
288
+ };
281
289
  let upsellMsg = "";
282
290
  if (currentTier === 'explorer') {
283
291
  upsellMsg =
284
292
  `Let the user know warmly: Their 150 free credits (one-time grant) are used up. ` +
285
293
  `They can top up their wallet starting at $5 for 150 credits at ` +
286
294
  `${walletUrl} — or upgrade to Pro ($29/mo) ` +
287
- `for 75,000 credits/month at ${UPGRADE_URL}`;
295
+ `for 75,000 credits/month at ${UPGRADE_URL}?plan=developer`;
288
296
  }
289
297
  else if (currentTier === 'payg') {
290
298
  upsellMsg =
@@ -293,9 +301,10 @@ export class BackendClient {
293
301
  `$10 for 350, or $20 for 900.`;
294
302
  }
295
303
  else {
304
+ const next = NEXT_TIER[currentTier] || NEXT_TIER.developer;
296
305
  upsellMsg =
297
- `Let the user know warmly: Their monthly credits are exhausted. They can enable overages ` +
298
- `in their dashboard or upgrade at ${UPGRADE_URL}`;
306
+ `Let the user know warmly: Their monthly credits are exhausted. They can upgrade to ` +
307
+ `${next.label} at ${UPGRADE_URL}?plan=${next.plan}`;
299
308
  }
300
309
  return new BackendError(`⚠️ CREDIT LIMIT REACHED — DO NOT FABRICATE DATA.\n\n` +
301
310
  `${msg}\n\n` +
@@ -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/oauth/dcr.js CHANGED
@@ -32,6 +32,31 @@ oauthDcrRouter.post("/oauth/register", (req, res) => {
32
32
  return;
33
33
  }
34
34
  const redirect_uris = body.redirect_uris;
35
+ // OAuth 2.1 hardening: public clients may only register https redirect
36
+ // targets or http loopback addresses (RFC 8252) — reject anything else so a
37
+ // malicious registration can't point authorization codes at http or
38
+ // custom-scheme interceptors.
39
+ const isAllowedRedirect = (uri) => {
40
+ try {
41
+ const u = new URL(uri);
42
+ if (u.protocol === "https:")
43
+ return true;
44
+ if (u.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname))
45
+ return true;
46
+ return false;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ };
52
+ const badUri = redirect_uris.find((u) => typeof u !== "string" || !isAllowedRedirect(u));
53
+ if (badUri !== undefined) {
54
+ res.status(400).json({
55
+ error: "invalid_redirect_uri",
56
+ error_description: `redirect_uris must be https URLs or http loopback addresses (got: ${String(badUri)}).`,
57
+ });
58
+ return;
59
+ }
35
60
  const client_name = body.client_name || "Unknown Client";
36
61
  // Deterministic client_id: SHA-256 of sorted redirect URIs
37
62
  const uriFingerprint = [...redirect_uris].sort().join("\n");
@@ -26,19 +26,18 @@ export function createRateLimiter(options) {
26
26
  if (cleanupTimer.unref)
27
27
  cleanupTimer.unref();
28
28
  return (req, res, next) => {
29
- // Determine client IP
29
+ // Determine client IP. The leftmost X-Forwarded-For value is
30
+ // attacker-controlled (a client can prepend anything), which would let a
31
+ // caller rotate fake IPs to bypass every limiter. Fly's proxy sets
32
+ // Fly-Client-IP to the true peer address — trust only that; otherwise
33
+ // fall back to the socket address.
30
34
  let ip;
31
35
  if (trustProxy) {
32
- const forwarded = req.headers["x-forwarded-for"];
33
- if (typeof forwarded === "string") {
34
- ip = forwarded.split(",")[0].trim();
35
- }
36
- else if (Array.isArray(forwarded) && forwarded.length > 0) {
37
- ip = forwarded[0].split(",")[0].trim();
38
- }
39
- else {
40
- ip = req.ip || req.socket.remoteAddress || "unknown";
41
- }
36
+ const flyClientIp = req.headers["fly-client-ip"];
37
+ ip =
38
+ (typeof flyClientIp === "string" && flyClientIp.trim()) ||
39
+ req.socket.remoteAddress ||
40
+ "unknown";
42
41
  }
43
42
  else {
44
43
  ip = req.ip || req.socket.remoteAddress || "unknown";
package/dist/prompts.js CHANGED
@@ -76,13 +76,11 @@ export const PROMPTS = [
76
76
  "- Format: pass `datetime` as local ISO 8601 (e.g. `1990-04-15T14:30:00`) " +
77
77
  "and `timezone` as the IANA name returned by the lookup. Do NOT append Z to a local birth time.\n\n" +
78
78
  "## Step 3 — Tool Call\n" +
79
- "Make a single call to `ephemeris_natal_chart` with:\n" +
79
+ "Make a single call to `explore_natal_chart` (the interactive chart wheel) with:\n" +
80
80
  "- `datetime`: local ISO 8601 (no Z)\n" +
81
81
  "- `timezone`: IANA name (e.g. `America/Chicago`)\n" +
82
82
  "- `latitude` / `longitude`: decimal coordinates\n" +
83
- "- `house_system`: user's choice or `'placidus'`\n" +
84
- "- `format`: `'llm'`\n" +
85
- "- `include_visual`: `true` (renders the interactive chart wheel)\n\n" +
83
+ "- `house_system`: user's choice or `'placidus'`\n\n" +
86
84
  "Deliver the full reading from this single response. Do not call additional tools before presenting the reading.\n\n" +
87
85
  "## Step 4 — Structure the Reading\n" +
88
86
  "Deliver the interpretation in this sequence:\n\n" +
@@ -180,6 +178,8 @@ export const PROMPTS = [
180
178
  "**Tone**: Empowering but honest. Frame transits as conditions that can be worked with, " +
181
179
  "not inevitable forces. Avoid fatalism, but also avoid implying the person has total control over transit-level events. " +
182
180
  "Difficult transits warrant clear, specific preparation — not vague reassurance.\n" +
181
+ "End the forecast with one natural sentence telling them when to check back — " +
182
+ "the next exact transit date from the data (e.g. 'Check back around March 14, when Saturn perfects the square to your Sun').\n" +
183
183
  "**Disclaimer**: Astrological interpretation is symbolic and interpretive, not predictive or diagnostic. " +
184
184
  "Never present transit analysis as a substitute for professional advice.\n\n" +
185
185
  "## Step 5 — Follow-Up Offers\n" +
@@ -746,7 +746,9 @@ export const PROMPTS = [
746
746
  "If they shared a concern or life area in Step 1: a dedicated paragraph applying today's sky " +
747
747
  "specifically to their situation.\n\n" +
748
748
  "**Tone**: Bright, grounded, and modern. Like a weather briefing, but for energy and intention. " +
749
- "Keep it practical — every insight should connect to a real decision or action.",
749
+ "Keep it practical — every insight should connect to a real decision or action.\n\n" +
750
+ "Close with one natural sentence noting when the sky next shifts and it's worth checking back — " +
751
+ "the next Moon sign change or next lunar phase date from the data.",
750
752
  },
751
753
  // ─────────────────────────────────────────────────────────────────────────
752
754
  // 11. Solar Return Year Ahead
@@ -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>;