@openephemeris/mcp-server 3.19.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 +20 -0
- package/README.md +1 -1
- package/dist/event-store.d.ts +11 -0
- package/dist/event-store.js +61 -0
- package/dist/server-sse.d.ts +7 -1
- package/dist/server-sse.js +63 -131
- package/dist/tools/apps/bi-wheel-app.js +8 -6
- package/dist/tools/apps/chart-wheel-app.js +36 -51
- package/dist/ui/bi-wheel.html +5 -2
- package/dist/ui/bodygraph.html +30 -3
- package/dist/ui/chart-wheel.html +5 -2
- package/dist/ui/moon-phase.html +13 -6
- package/package.json +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ 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
|
+
|
|
10
30
|
## [3.19.0] — 2026-07-09
|
|
11
31
|
|
|
12
32
|
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**:
|
|
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,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/server-sse.d.ts
CHANGED
|
@@ -1 +1,7 @@
|
|
|
1
|
-
|
|
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>;
|
package/dist/server-sse.js
CHANGED
|
@@ -14,13 +14,13 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import fs from "node:fs";
|
|
16
16
|
import path from "node:path";
|
|
17
|
-
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
18
|
import { randomUUID } from "node:crypto";
|
|
19
19
|
import express from "express";
|
|
20
20
|
import axios from "axios";
|
|
21
21
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
22
|
-
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
23
22
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
23
|
+
import { InMemoryEventStore } from "./event-store.js";
|
|
24
24
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
|
|
25
25
|
import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools } from "./tools/index.js";
|
|
26
26
|
import { BackendClient, runWithClient } from "./backend/client.js";
|
|
@@ -418,12 +418,17 @@ function startSseKeepalive(res) {
|
|
|
418
418
|
}, 25_000);
|
|
419
419
|
res.on("close", () => clearInterval(ping));
|
|
420
420
|
}
|
|
421
|
-
|
|
421
|
+
/**
|
|
422
|
+
* Build the full Express app (auth gate, OAuth routes, /mcp Streamable HTTP,
|
|
423
|
+
* health, resources). Exported so tests can drive the REAL handlers with
|
|
424
|
+
* supertest instead of a re-implemented stub. Does not listen.
|
|
425
|
+
*/
|
|
426
|
+
export async function createSseApp() {
|
|
422
427
|
await initTools();
|
|
423
428
|
const app = express();
|
|
424
|
-
// NOTE:
|
|
425
|
-
//
|
|
426
|
-
//
|
|
429
|
+
// NOTE: express.json() is applied per-route (jsonParser on /mcp POST), not
|
|
430
|
+
// globally — the OAuth token route needs urlencoded, and transports that
|
|
431
|
+
// read the raw request stream break if the body is pre-consumed.
|
|
427
432
|
// CORS + Origin allowlist
|
|
428
433
|
// Per Anthropic remote-MCP review criteria, browser-originated requests must
|
|
429
434
|
// be validated against an allowlist. Native (no-Origin) clients like Claude
|
|
@@ -455,7 +460,7 @@ async function main() {
|
|
|
455
460
|
.filter(Boolean)
|
|
456
461
|
.map((h) => new RegExp(`^${h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(:\\d+)?$`, "i")),
|
|
457
462
|
];
|
|
458
|
-
const MCP_ROUTES = new Set(["/mcp"
|
|
463
|
+
const MCP_ROUTES = new Set(["/mcp"]);
|
|
459
464
|
app.use((req, res, next) => {
|
|
460
465
|
if (MCP_ROUTES.has(req.path)) {
|
|
461
466
|
const host = req.headers.host;
|
|
@@ -491,10 +496,6 @@ async function main() {
|
|
|
491
496
|
}
|
|
492
497
|
next();
|
|
493
498
|
});
|
|
494
|
-
// Track active transports by session ID
|
|
495
|
-
const transports = new Map();
|
|
496
|
-
// Per-session BackendClient — each connection authenticates independently.
|
|
497
|
-
const sessionClients = new Map();
|
|
498
499
|
// ---------------------------------------------------------------------------
|
|
499
500
|
// OAuth 2.1 routes — must be mounted BEFORE JSON body parser
|
|
500
501
|
// ---------------------------------------------------------------------------
|
|
@@ -531,14 +532,12 @@ async function main() {
|
|
|
531
532
|
sessionRateLimiter(req, res, next);
|
|
532
533
|
};
|
|
533
534
|
app.use("/mcp", throttleSessionCreation);
|
|
534
|
-
app.use("/sse", sessionRateLimiter);
|
|
535
535
|
// Health check
|
|
536
536
|
app.get("/health", (_req, res) => {
|
|
537
537
|
res.json({
|
|
538
538
|
ok: true,
|
|
539
539
|
version,
|
|
540
|
-
transports: ["
|
|
541
|
-
sse_sessions: transports.size,
|
|
540
|
+
transports: ["streamable-http"],
|
|
542
541
|
http_sessions: httpSessions.size,
|
|
543
542
|
});
|
|
544
543
|
});
|
|
@@ -591,6 +590,27 @@ async function main() {
|
|
|
591
590
|
const sessionId = req.headers["mcp-session-id"];
|
|
592
591
|
// Resume existing session
|
|
593
592
|
if (sessionId) {
|
|
593
|
+
// A session id alone is not a credential: require a key/JWT BEFORE the
|
|
594
|
+
// session lookup so an unauthenticated probe can't learn whether a
|
|
595
|
+
// session id exists, and reject an expired JWT with the OAuth-refresh
|
|
596
|
+
// signal (same semantics as the init gate below).
|
|
597
|
+
const resumedAuth = extractAuth(req);
|
|
598
|
+
if (!resumedAuth.apiKey && !resumedAuth.jwt) {
|
|
599
|
+
res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
|
|
600
|
+
res.status(401).json({
|
|
601
|
+
error: "auth_required",
|
|
602
|
+
message: "Authentication required on every request. Pass an API key via X-API-Key header, or a Bearer token via Authorization header.",
|
|
603
|
+
});
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (resumedAuth.jwt && isJwtExpired(resumedAuth.jwt)) {
|
|
607
|
+
res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", error_description="The access token expired", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
|
|
608
|
+
res.status(401).json({
|
|
609
|
+
error: "invalid_token",
|
|
610
|
+
message: "Your session token has expired. Re-authorize to continue.",
|
|
611
|
+
});
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
594
614
|
const session = httpSessions.get(sessionId);
|
|
595
615
|
if (!session) {
|
|
596
616
|
// Cross-machine session recovery (Fly): min_machines_running >= 2 but
|
|
@@ -612,26 +632,6 @@ async function main() {
|
|
|
612
632
|
});
|
|
613
633
|
return;
|
|
614
634
|
}
|
|
615
|
-
// A session id alone is not a credential: require the caller to still
|
|
616
|
-
// present a key/JWT on every resume, and reject an expired JWT with the
|
|
617
|
-
// OAuth-refresh signal (same semantics as the init gate below).
|
|
618
|
-
const resumedAuth = extractAuth(req);
|
|
619
|
-
if (!resumedAuth.apiKey && !resumedAuth.jwt) {
|
|
620
|
-
res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
|
|
621
|
-
res.status(401).json({
|
|
622
|
-
error: "auth_required",
|
|
623
|
-
message: "Authentication required on every request. Pass an API key via X-API-Key header, or a Bearer token via Authorization header.",
|
|
624
|
-
});
|
|
625
|
-
return;
|
|
626
|
-
}
|
|
627
|
-
if (resumedAuth.jwt && isJwtExpired(resumedAuth.jwt)) {
|
|
628
|
-
res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", error_description="The access token expired", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
|
|
629
|
-
res.status(401).json({
|
|
630
|
-
error: "invalid_token",
|
|
631
|
-
message: "Your session token has expired. Re-authorize to continue.",
|
|
632
|
-
});
|
|
633
|
-
return;
|
|
634
|
-
}
|
|
635
635
|
// Refresh the session's Bearer JWT if the host rotated it. claude.ai
|
|
636
636
|
// refreshes tokens via /oauth/token roughly hourly (self-signed Supabase
|
|
637
637
|
// JWTs expire in 1h); the frozen JWT captured at init would otherwise 401
|
|
@@ -706,6 +706,10 @@ async function main() {
|
|
|
706
706
|
// can be replayed to the owner (see the resume branch above). No-op off
|
|
707
707
|
// Fly (FLY_MACHINE_ID unset) — the raw UUID is used unchanged.
|
|
708
708
|
sessionIdGenerator: () => encodeSessionId(randomUUID()),
|
|
709
|
+
// Last-Event-ID resumability: a reconnecting SSE stream replays the
|
|
710
|
+
// events it missed instead of silently losing them. Per-process is fine
|
|
711
|
+
// — the session is machine-pinned via fly-replay.
|
|
712
|
+
eventStore: new InMemoryEventStore(),
|
|
709
713
|
onsessioninitialized: (id) => {
|
|
710
714
|
httpSessions.set(id, { server, transport, client, lastSeen: Date.now() });
|
|
711
715
|
console.error(`[HTTP] Session initialized: ${id}`);
|
|
@@ -796,101 +800,23 @@ async function main() {
|
|
|
796
800
|
}
|
|
797
801
|
});
|
|
798
802
|
// ---------------------------------------------------------------------------
|
|
799
|
-
// Legacy SSE transport —
|
|
800
|
-
//
|
|
801
|
-
//
|
|
803
|
+
// Legacy SSE transport — RETIRED in 3.20.0.
|
|
804
|
+
// The pre-2025 HTTP+SSE transport (/sse + /message) was SDK-deprecated, had
|
|
805
|
+
// no cross-machine replay (broken with min_machines_running >= 2), and live
|
|
806
|
+
// metrics showed zero sessions. 410 Gone points stragglers at /mcp.
|
|
802
807
|
// ---------------------------------------------------------------------------
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
if (!apiKey && !jwt) {
|
|
808
|
-
res.status(401).json({
|
|
809
|
-
error: "auth_required",
|
|
810
|
-
message: "Authentication required. Pass an API key via X-API-Key header or query param, or a Bearer JWT via Authorization header. Get a free key at https://openephemeris.com/dashboard?tab=apikeys",
|
|
811
|
-
});
|
|
812
|
-
return;
|
|
813
|
-
}
|
|
814
|
-
// Validate API keys against the Go backend; JWTs validated downstream.
|
|
815
|
-
if (apiKey) {
|
|
816
|
-
const keyStatus = await validateApiKey(apiKey);
|
|
817
|
-
if (keyStatus === "invalid") {
|
|
818
|
-
res.status(403).json({
|
|
819
|
-
error: "invalid_api_key",
|
|
820
|
-
message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
|
|
821
|
-
});
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
|
-
// Fail closed for browser clients when validation is unavailable (APP-6).
|
|
825
|
-
if (keyStatus === "unknown" && isBrowserOrigin(req)) {
|
|
826
|
-
res.set("Retry-After", "5");
|
|
827
|
-
res.status(503).json({
|
|
828
|
-
error: "validation_unavailable",
|
|
829
|
-
message: "Could not verify your API key right now. Please retry shortly.",
|
|
830
|
-
});
|
|
831
|
-
return;
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
const transport = new SSEServerTransport("/message", res);
|
|
835
|
-
const sessionId = transport.sessionId;
|
|
836
|
-
transports.set(sessionId, transport);
|
|
837
|
-
// Create a per-session BackendClient so concurrent SSE connections
|
|
838
|
-
// don't overwrite each other's API key on the shared singleton.
|
|
839
|
-
const sessionClient = new BackendClient({
|
|
840
|
-
baseURL: BACKEND_URL,
|
|
841
|
-
apiKey,
|
|
842
|
-
jwt,
|
|
808
|
+
const legacySseGone = (_req, res) => {
|
|
809
|
+
res.status(410).json({
|
|
810
|
+
error: "transport_retired",
|
|
811
|
+
message: "The legacy SSE transport has been retired. Connect via Streamable HTTP at https://mcp.openephemeris.com/mcp instead.",
|
|
843
812
|
});
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
closed = true;
|
|
852
|
-
transports.delete(sessionId);
|
|
853
|
-
sessionClients.delete(sessionId);
|
|
854
|
-
try {
|
|
855
|
-
server.close().catch(() => { });
|
|
856
|
-
}
|
|
857
|
-
catch { /* ignore */ }
|
|
858
|
-
};
|
|
859
|
-
transport.onclose = cleanup;
|
|
860
|
-
// Handle abrupt client disconnects (browser tab closed, etc.)
|
|
861
|
-
res.on("close", cleanup);
|
|
862
|
-
try {
|
|
863
|
-
// server.connect() calls transport.start() internally in SDK v1.27+
|
|
864
|
-
await server.connect(transport);
|
|
865
|
-
// Stream is established — keep it alive through Fly's proxy.
|
|
866
|
-
startSseKeepalive(res);
|
|
867
|
-
}
|
|
868
|
-
catch (err) {
|
|
869
|
-
console.error(`SSE session ${sessionId} failed to start:`, err);
|
|
870
|
-
cleanup();
|
|
871
|
-
}
|
|
872
|
-
});
|
|
873
|
-
// Message endpoint — clients POST /message?sessionId=xxx
|
|
874
|
-
app.post("/message", async (req, res) => {
|
|
875
|
-
const sessionId = req.query.sessionId;
|
|
876
|
-
const transport = transports.get(sessionId);
|
|
877
|
-
const sessionClient = sessionClients.get(sessionId);
|
|
878
|
-
if (!transport) {
|
|
879
|
-
res.status(400).json({
|
|
880
|
-
error: "invalid_session",
|
|
881
|
-
message: "Unknown or expired session. Re-connect to /sse first.",
|
|
882
|
-
});
|
|
883
|
-
return;
|
|
884
|
-
}
|
|
885
|
-
// Run handlePostMessage inside the session's client context so all tool
|
|
886
|
-
// handlers reach getActiveClient() and get this user's BackendClient.
|
|
887
|
-
if (sessionClient) {
|
|
888
|
-
await runWithClient(sessionClient, () => transport.handlePostMessage(req, res));
|
|
889
|
-
}
|
|
890
|
-
else {
|
|
891
|
-
await transport.handlePostMessage(req, res);
|
|
892
|
-
}
|
|
893
|
-
});
|
|
813
|
+
};
|
|
814
|
+
app.get("/sse", legacySseGone);
|
|
815
|
+
app.post("/message", legacySseGone);
|
|
816
|
+
return app;
|
|
817
|
+
}
|
|
818
|
+
async function main() {
|
|
819
|
+
const app = await createSseApp();
|
|
894
820
|
// Reap abandoned sessions (no DELETE, no transport close) every 10 minutes.
|
|
895
821
|
setInterval(reapIdleSessions, 10 * 60 * 1000).unref();
|
|
896
822
|
const httpServer = app.listen(PORT, "0.0.0.0", () => {
|
|
@@ -919,7 +845,13 @@ process.on("uncaughtException", (err) => {
|
|
|
919
845
|
process.on("unhandledRejection", (reason) => {
|
|
920
846
|
console.error("Unhandled promise rejection:", reason);
|
|
921
847
|
});
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
848
|
+
// Only listen when run as the entry point — importing createSseApp from
|
|
849
|
+
// tests must not bind a port or install the reaper interval.
|
|
850
|
+
const isDirectRun = process.argv[1] !== undefined &&
|
|
851
|
+
import.meta.url.toLowerCase() === pathToFileURL(process.argv[1]).href.toLowerCase();
|
|
852
|
+
if (isDirectRun) {
|
|
853
|
+
main().catch((error) => {
|
|
854
|
+
console.error("Fatal error:", error);
|
|
855
|
+
process.exit(1);
|
|
856
|
+
});
|
|
857
|
+
}
|
|
@@ -248,15 +248,17 @@ export function computeCrossAspects(planets1, planets2) {
|
|
|
248
248
|
const results = [];
|
|
249
249
|
for (const p1 of planets1) {
|
|
250
250
|
for (const p2 of planets2) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
251
|
+
// Signed separation of p2 ahead of p1 (0–360), folded to shortest arc.
|
|
252
|
+
const delta = (((p2.longitude - p1.longitude) % 360) + 360) % 360;
|
|
253
|
+
const diff = delta > 180 ? 360 - delta : delta;
|
|
254
|
+
// Rate of change of the shortest-arc separation, from real daily motion.
|
|
255
|
+
const rel = (p2.speed ?? 0) - (p1.speed ?? 0);
|
|
256
|
+
const dDiff = delta > 180 ? -rel : rel;
|
|
254
257
|
for (const asp of CROSS_ASPECT_DEFS) {
|
|
255
258
|
const orb = Math.abs(diff - asp.angle);
|
|
256
259
|
if (orb <= asp.orb) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
: diff > asp.angle;
|
|
260
|
+
// Applying = the separation is moving toward the exact aspect angle.
|
|
261
|
+
const applying = (diff - asp.angle) * dDiff < 0;
|
|
260
262
|
results.push({
|
|
261
263
|
planet1: p1.name,
|
|
262
264
|
wheel1: "inner",
|
|
@@ -189,7 +189,7 @@ registerTool({
|
|
|
189
189
|
const chartData = await client.post("/ephemeris/natal-chart", natalBody);
|
|
190
190
|
// Build human-readable summary for the LLM context
|
|
191
191
|
const summary = buildChartSummary(chartData, String(args.location ?? `${lat}, ${lon}`));
|
|
192
|
-
// Build the UI payload (planets normalised to array, aspects
|
|
192
|
+
// Build the UI payload (planets normalised to array, server aspects mapped)
|
|
193
193
|
const modelPayload = buildModelPayload(chartData, {
|
|
194
194
|
datetime,
|
|
195
195
|
timezone: args.timezone ?? null,
|
|
@@ -453,52 +453,39 @@ const ALL_KNOWN_BODIES = new Set([...CLASSICAL_PLANETS, ...EXTENDED_BODIES]);
|
|
|
453
453
|
* Tokens saved vs. full uiPayload: ~85–95% reduction (no 100KB SVG, no asteroid flood).
|
|
454
454
|
*/
|
|
455
455
|
/**
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
456
|
+
* Map the server-computed aspects array from /ephemeris/natal-chart (and the
|
|
457
|
+
* solar-return / progressed responses) to the AspectData shape the chart-wheel
|
|
458
|
+
* UI consumes: { planet1, planet2, type, angle, orb, applying }.
|
|
459
|
+
*
|
|
460
|
+
* The Go API computes aspects by default (options.include_aspects = true),
|
|
461
|
+
* including a proper ephemeris-based is_applying flag — so no client-side
|
|
462
|
+
* longitude heuristic is needed. Aspects are optionally filtered to the set of
|
|
463
|
+
* bodies actually displayed so lines never reference a hidden planet.
|
|
459
464
|
*/
|
|
460
|
-
function
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
{ type: "opposition", angle: 180, orb: 8 },
|
|
464
|
-
{ type: "trine", angle: 120, orb: 8 },
|
|
465
|
-
{ type: "square", angle: 90, orb: 7 },
|
|
466
|
-
{ type: "sextile", angle: 60, orb: 6 },
|
|
467
|
-
{ type: "quincunx", angle: 150, orb: 3 },
|
|
468
|
-
{ type: "semisquare", angle: 45, orb: 2 },
|
|
469
|
-
];
|
|
465
|
+
function mapServerAspects(rawAspects, allowedNames) {
|
|
466
|
+
if (!Array.isArray(rawAspects))
|
|
467
|
+
return [];
|
|
470
468
|
const results = [];
|
|
471
|
-
for (
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
planet1: p1.name,
|
|
492
|
-
planet2: p2.name,
|
|
493
|
-
type: asp.type,
|
|
494
|
-
angle: asp.angle,
|
|
495
|
-
orb: Math.round(orb * 100) / 100,
|
|
496
|
-
applying,
|
|
497
|
-
});
|
|
498
|
-
break; // Each pair only matches one aspect type
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
}
|
|
469
|
+
for (const a of rawAspects) {
|
|
470
|
+
if (!a || typeof a !== "object")
|
|
471
|
+
continue;
|
|
472
|
+
const p1 = canonicalizeBodyName(String(a.planet1 ?? ""));
|
|
473
|
+
const p2 = canonicalizeBodyName(String(a.planet2 ?? ""));
|
|
474
|
+
if (!p1 || !p2)
|
|
475
|
+
continue;
|
|
476
|
+
if (allowedNames && (!allowedNames.has(p1) || !allowedNames.has(p2)))
|
|
477
|
+
continue;
|
|
478
|
+
results.push({
|
|
479
|
+
planet1: p1,
|
|
480
|
+
planet2: p2,
|
|
481
|
+
type: String(a.aspect_type ?? a.type ?? "aspect").toLowerCase(),
|
|
482
|
+
angle: Number(a.exact_angle ?? a.angle ?? 0),
|
|
483
|
+
orb: Math.round(Number(a.orb ?? 0) * 100) / 100,
|
|
484
|
+
// is_applying may be null when the server cannot determine it
|
|
485
|
+
applying: typeof a.is_applying === "boolean" ? a.is_applying
|
|
486
|
+
: typeof a.applying === "boolean" ? a.applying
|
|
487
|
+
: undefined,
|
|
488
|
+
});
|
|
502
489
|
}
|
|
503
490
|
return results;
|
|
504
491
|
}
|
|
@@ -536,8 +523,8 @@ function buildModelPayload(chartData, birthParams, houseSystem, svgBase, bodyFil
|
|
|
536
523
|
// Already normalised (e.g. house system recalculate path)
|
|
537
524
|
planetsArray = chartData.planets;
|
|
538
525
|
}
|
|
539
|
-
// ── 2.
|
|
540
|
-
const aspects =
|
|
526
|
+
// ── 2. Map server-computed aspects (filtered to displayed bodies) ─────────
|
|
527
|
+
const aspects = mapServerAspects(chartData.aspects, new Set(planetsArray.map((p) => p.name)));
|
|
541
528
|
// ── 3. Extract angles — API nests ascendant/MC under an `angles` key ──────
|
|
542
529
|
const rawAngles = chartData.angles;
|
|
543
530
|
const rawHouseObj = chartData.houses;
|
|
@@ -626,10 +613,8 @@ function buildChartSummary(data, location) {
|
|
|
626
613
|
elementCount[el]++;
|
|
627
614
|
}
|
|
628
615
|
const domElement = Object.entries(elementCount).sort((a, b) => b[1] - a[1])[0];
|
|
629
|
-
// Strongest aspect by tightest orb (
|
|
630
|
-
const aspects =
|
|
631
|
-
.filter((p) => p.longitude != null)
|
|
632
|
-
.map((p) => ({ name: p.name, longitude: p.longitude, speed: p.longitude_speed })));
|
|
616
|
+
// Strongest aspect by tightest orb (server-computed aspects)
|
|
617
|
+
const aspects = mapServerAspects(data.aspects);
|
|
633
618
|
const strongest = aspects.sort((a, b) => a.orb - b.orb)[0];
|
|
634
619
|
const strongestNote = strongest
|
|
635
620
|
? `Closest aspect: ${capitalize(strongest.planet1)} ${strongest.type} ${capitalize(strongest.planet2)} (${strongest.orb.toFixed(1)}° orb)`
|