@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 +45 -0
- package/README.md +1 -1
- package/dist/analytics.d.ts +3 -0
- package/dist/analytics.js +44 -0
- package/dist/backend/client.js +17 -8
- package/dist/event-store.d.ts +11 -0
- package/dist/event-store.js +61 -0
- package/dist/oauth/dcr.js +25 -0
- package/dist/oauth/rate-limit.js +10 -11
- package/dist/prompts.js +7 -5
- package/dist/server-sse.d.ts +7 -1
- package/dist/server-sse.js +98 -157
- package/dist/tools/apps/bi-wheel-app.js +11 -7
- package/dist/tools/apps/bodygraph-app.js +6 -3
- package/dist/tools/apps/chart-wheel-app.js +40 -53
- package/dist/tools/apps/location-tools.js +2 -2
- package/dist/tools/apps/moon-phase-app.js +1 -0
- package/dist/tools/auth.js +6 -1
- package/dist/tools/index.d.ts +11 -2
- package/dist/tools/index.js +5 -2
- package/dist/tools/specialized/ephemeris_extended.js +4 -2
- package/dist/tools/specialized/natal.js +2 -1
- package/dist/ui/bi-wheel.html +16 -11
- package/dist/ui/bodygraph.html +33 -6
- package/dist/ui/chart-wheel.html +8 -5
- package/dist/ui/moon-phase.html +15 -8
- package/package.json +5 -3
package/dist/server-sse.js
CHANGED
|
@@ -14,13 +14,14 @@
|
|
|
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
|
+
import { captureEvent, distinctIdFor } from "./analytics.js";
|
|
24
25
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
|
|
25
26
|
import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools } from "./tools/index.js";
|
|
26
27
|
import { BackendClient, runWithClient } from "./backend/client.js";
|
|
@@ -179,30 +180,26 @@ const WELCOME_PROMPT_CONTENT = [
|
|
|
179
180
|
role: "assistant",
|
|
180
181
|
content: {
|
|
181
182
|
type: "text",
|
|
182
|
-
text: "🌟 **Welcome to Open Ephemeris
|
|
183
|
-
"
|
|
184
|
-
"
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
"
|
|
188
|
-
"
|
|
189
|
-
"
|
|
190
|
-
"
|
|
191
|
-
"
|
|
192
|
-
"
|
|
193
|
-
"
|
|
194
|
-
"
|
|
195
|
-
"• **Time Lords** — Firdaria, Profections, Zodiacal Releasing\n\n" +
|
|
196
|
-
"**Tips:** Use `format=\"llm\"` on any tool to get ~50% smaller, token-optimized output. " +
|
|
197
|
-
"All computations are sub-arcsecond precision using JPL DE440 ephemerides.\n\n" +
|
|
198
|
-
"Just tell me what you'd like to explore!",
|
|
183
|
+
text: "🌟 **Welcome!** You're connected to Open Ephemeris — every chart here is computed " +
|
|
184
|
+
"from NASA JPL data, not guessed.\n\n" +
|
|
185
|
+
"Two great ways to start:\n\n" +
|
|
186
|
+
"1. **\"What's in the sky right now?\"** — instant, no birth details needed " +
|
|
187
|
+
"(live planets, moon phase, retrogrades).\n" +
|
|
188
|
+
"2. **\"Draw my birth chart\"** — an interactive wheel you can click and explore. " +
|
|
189
|
+
"Just share your birth date, exact time, and city.\n\n" +
|
|
190
|
+
"From there we can go deep: relationship synastry, Human Design bodygraphs, " +
|
|
191
|
+
"astrocartography (where on Earth your chart comes alive), Vedic, BaZi, transits, " +
|
|
192
|
+
"eclipses, and more.\n\n" +
|
|
193
|
+
"_(For the model: prefer explore_natal_chart / explore_moon_phase and the other " +
|
|
194
|
+
"explore_* tools for anything the user should SEE — they render interactive visuals. " +
|
|
195
|
+
"Use format=\"llm\" on data tools for compact output.)_",
|
|
199
196
|
},
|
|
200
197
|
},
|
|
201
198
|
];
|
|
202
199
|
// ---------------------------------------------------------------------------
|
|
203
200
|
// MCP Server factory — one per SSE/HTTP session
|
|
204
201
|
// ---------------------------------------------------------------------------
|
|
205
|
-
function createMcpServer() {
|
|
202
|
+
function createMcpServer(analyticsId = "anonymous") {
|
|
206
203
|
const server = new Server({
|
|
207
204
|
name: "openephemeris-mcp",
|
|
208
205
|
title: "Open Ephemeris",
|
|
@@ -237,14 +234,18 @@ function createMcpServer() {
|
|
|
237
234
|
},
|
|
238
235
|
},
|
|
239
236
|
},
|
|
240
|
-
instructions: "Open Ephemeris
|
|
241
|
-
"
|
|
242
|
-
"
|
|
243
|
-
"
|
|
237
|
+
instructions: "Open Ephemeris computes real astronomy (JPL DE440, sub-arcsecond) — never guess " +
|
|
238
|
+
"or approximate positions yourself; always call a tool. " +
|
|
239
|
+
"For anything the user should SEE (natal chart, bi-wheel, bodygraph, moon phase), " +
|
|
240
|
+
"prefer the explore_* tools — they render interactive visuals inline. " +
|
|
241
|
+
"ephemeris_* tools return data; use format='llm' on them for compact output. " +
|
|
242
|
+
"If the user has no birth data handy, start with the sky right now — " +
|
|
243
|
+
"explore_moon_phase and ephemeris_retrograde_status need none. " +
|
|
244
|
+
"See the 'welcome_to_open_ephemeris' prompt for orientation.",
|
|
244
245
|
});
|
|
245
246
|
// --- Tool handlers ---
|
|
246
247
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
247
|
-
tools: modelVisibleTools().map((tool) => ({
|
|
248
|
+
tools: modelVisibleTools("http").map((tool) => ({
|
|
248
249
|
name: tool.name,
|
|
249
250
|
description: tool.description,
|
|
250
251
|
inputSchema: tool.inputSchema,
|
|
@@ -279,7 +280,9 @@ function createMcpServer() {
|
|
|
279
280
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
280
281
|
const toolName = request.params.name;
|
|
281
282
|
const tool = toolRegistry[toolName];
|
|
282
|
-
if (!tool) {
|
|
283
|
+
if (!tool || tool.stdioOnly) {
|
|
284
|
+
// stdioOnly tools (device auth) mutate process-global state and must not
|
|
285
|
+
// be callable on the multi-tenant HTTP transport, even by name.
|
|
283
286
|
throw new Error(`Unknown tool: ${toolName}`);
|
|
284
287
|
}
|
|
285
288
|
try {
|
|
@@ -290,11 +293,15 @@ function createMcpServer() {
|
|
|
290
293
|
// composite ~9.3s) with a wide margin.
|
|
291
294
|
const result = await withTimeout(tool.handler(request.params.arguments ?? {}), TOOL_CALL_TIMEOUT_MS, toolName);
|
|
292
295
|
const durationMs = Date.now() - startTime;
|
|
296
|
+
captureEvent("mcp_tool_call", analyticsId, { tool: toolName, duration_ms: durationMs });
|
|
293
297
|
return formatToolResponse(toolName, result, durationMs);
|
|
294
298
|
}
|
|
295
299
|
catch (error) {
|
|
296
300
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
297
301
|
console.error(`[MCP] ❌ Failed: ${toolName} - ${errorMessage}`);
|
|
302
|
+
// status makes 402 (paywall) and 429 (rate limit) countable in PostHog.
|
|
303
|
+
const status = error?.response?.status;
|
|
304
|
+
captureEvent("mcp_tool_error", analyticsId, { tool: toolName, status: status ?? "unknown" });
|
|
298
305
|
// All failures surface as isError: true so the host can recover
|
|
299
306
|
// (retry / OAuth refresh). The remote transport has no device-auth flow,
|
|
300
307
|
// so formatToolError's device-auth exception never fires here — a 401
|
|
@@ -418,12 +425,17 @@ function startSseKeepalive(res) {
|
|
|
418
425
|
}, 25_000);
|
|
419
426
|
res.on("close", () => clearInterval(ping));
|
|
420
427
|
}
|
|
421
|
-
|
|
428
|
+
/**
|
|
429
|
+
* Build the full Express app (auth gate, OAuth routes, /mcp Streamable HTTP,
|
|
430
|
+
* health, resources). Exported so tests can drive the REAL handlers with
|
|
431
|
+
* supertest instead of a re-implemented stub. Does not listen.
|
|
432
|
+
*/
|
|
433
|
+
export async function createSseApp() {
|
|
422
434
|
await initTools();
|
|
423
435
|
const app = express();
|
|
424
|
-
// NOTE:
|
|
425
|
-
//
|
|
426
|
-
//
|
|
436
|
+
// NOTE: express.json() is applied per-route (jsonParser on /mcp POST), not
|
|
437
|
+
// globally — the OAuth token route needs urlencoded, and transports that
|
|
438
|
+
// read the raw request stream break if the body is pre-consumed.
|
|
427
439
|
// CORS + Origin allowlist
|
|
428
440
|
// Per Anthropic remote-MCP review criteria, browser-originated requests must
|
|
429
441
|
// be validated against an allowlist. Native (no-Origin) clients like Claude
|
|
@@ -455,7 +467,7 @@ async function main() {
|
|
|
455
467
|
.filter(Boolean)
|
|
456
468
|
.map((h) => new RegExp(`^${h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(:\\d+)?$`, "i")),
|
|
457
469
|
];
|
|
458
|
-
const MCP_ROUTES = new Set(["/mcp"
|
|
470
|
+
const MCP_ROUTES = new Set(["/mcp"]);
|
|
459
471
|
app.use((req, res, next) => {
|
|
460
472
|
if (MCP_ROUTES.has(req.path)) {
|
|
461
473
|
const host = req.headers.host;
|
|
@@ -491,10 +503,6 @@ async function main() {
|
|
|
491
503
|
}
|
|
492
504
|
next();
|
|
493
505
|
});
|
|
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
506
|
// ---------------------------------------------------------------------------
|
|
499
507
|
// OAuth 2.1 routes — must be mounted BEFORE JSON body parser
|
|
500
508
|
// ---------------------------------------------------------------------------
|
|
@@ -531,14 +539,12 @@ async function main() {
|
|
|
531
539
|
sessionRateLimiter(req, res, next);
|
|
532
540
|
};
|
|
533
541
|
app.use("/mcp", throttleSessionCreation);
|
|
534
|
-
app.use("/sse", sessionRateLimiter);
|
|
535
542
|
// Health check
|
|
536
543
|
app.get("/health", (_req, res) => {
|
|
537
544
|
res.json({
|
|
538
545
|
ok: true,
|
|
539
546
|
version,
|
|
540
|
-
transports: ["
|
|
541
|
-
sse_sessions: transports.size,
|
|
547
|
+
transports: ["streamable-http"],
|
|
542
548
|
http_sessions: httpSessions.size,
|
|
543
549
|
});
|
|
544
550
|
});
|
|
@@ -548,7 +554,7 @@ async function main() {
|
|
|
548
554
|
// Spec: https://smithery.ai/docs/build/publish#server-scanning
|
|
549
555
|
// ---------------------------------------------------------------------------
|
|
550
556
|
app.get("/.well-known/mcp/server-card.json", (_req, res) => {
|
|
551
|
-
const tools = modelVisibleTools().map((tool) => ({
|
|
557
|
+
const tools = modelVisibleTools("http").map((tool) => ({
|
|
552
558
|
name: tool.name,
|
|
553
559
|
description: tool.description,
|
|
554
560
|
inputSchema: tool.inputSchema,
|
|
@@ -591,6 +597,27 @@ async function main() {
|
|
|
591
597
|
const sessionId = req.headers["mcp-session-id"];
|
|
592
598
|
// Resume existing session
|
|
593
599
|
if (sessionId) {
|
|
600
|
+
// A session id alone is not a credential: require a key/JWT BEFORE the
|
|
601
|
+
// session lookup so an unauthenticated probe can't learn whether a
|
|
602
|
+
// session id exists, and reject an expired JWT with the OAuth-refresh
|
|
603
|
+
// signal (same semantics as the init gate below).
|
|
604
|
+
const resumedAuth = extractAuth(req);
|
|
605
|
+
if (!resumedAuth.apiKey && !resumedAuth.jwt) {
|
|
606
|
+
res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
|
|
607
|
+
res.status(401).json({
|
|
608
|
+
error: "auth_required",
|
|
609
|
+
message: "Authentication required on every request. Pass an API key via X-API-Key header, or a Bearer token via Authorization header.",
|
|
610
|
+
});
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (resumedAuth.jwt && isJwtExpired(resumedAuth.jwt)) {
|
|
614
|
+
res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", error_description="The access token expired", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
|
|
615
|
+
res.status(401).json({
|
|
616
|
+
error: "invalid_token",
|
|
617
|
+
message: "Your session token has expired. Re-authorize to continue.",
|
|
618
|
+
});
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
594
621
|
const session = httpSessions.get(sessionId);
|
|
595
622
|
if (!session) {
|
|
596
623
|
// Cross-machine session recovery (Fly): min_machines_running >= 2 but
|
|
@@ -612,26 +639,6 @@ async function main() {
|
|
|
612
639
|
});
|
|
613
640
|
return;
|
|
614
641
|
}
|
|
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
642
|
// Refresh the session's Bearer JWT if the host rotated it. claude.ai
|
|
636
643
|
// refreshes tokens via /oauth/token roughly hourly (self-signed Supabase
|
|
637
644
|
// JWTs expire in 1h); the frozen JWT captured at init would otherwise 401
|
|
@@ -706,13 +713,19 @@ async function main() {
|
|
|
706
713
|
// can be replayed to the owner (see the resume branch above). No-op off
|
|
707
714
|
// Fly (FLY_MACHINE_ID unset) — the raw UUID is used unchanged.
|
|
708
715
|
sessionIdGenerator: () => encodeSessionId(randomUUID()),
|
|
716
|
+
// Last-Event-ID resumability: a reconnecting SSE stream replays the
|
|
717
|
+
// events it missed instead of silently losing them. Per-process is fine
|
|
718
|
+
// — the session is machine-pinned via fly-replay.
|
|
719
|
+
eventStore: new InMemoryEventStore(),
|
|
709
720
|
onsessioninitialized: (id) => {
|
|
710
721
|
httpSessions.set(id, { server, transport, client, lastSeen: Date.now() });
|
|
711
722
|
console.error(`[HTTP] Session initialized: ${id}`);
|
|
712
723
|
},
|
|
713
724
|
});
|
|
714
725
|
const client = new BackendClient({ baseURL: BACKEND_URL, apiKey, jwt });
|
|
715
|
-
const
|
|
726
|
+
const analyticsId = distinctIdFor(apiKey ?? jwt);
|
|
727
|
+
captureEvent("mcp_session_init", analyticsId, { auth: apiKey ? "api_key" : "oauth" });
|
|
728
|
+
const server = createMcpServer(analyticsId);
|
|
716
729
|
transport.onclose = () => {
|
|
717
730
|
if (transport.sessionId) {
|
|
718
731
|
httpSessions.delete(transport.sessionId);
|
|
@@ -796,101 +809,23 @@ async function main() {
|
|
|
796
809
|
}
|
|
797
810
|
});
|
|
798
811
|
// ---------------------------------------------------------------------------
|
|
799
|
-
// Legacy SSE transport —
|
|
800
|
-
//
|
|
801
|
-
//
|
|
812
|
+
// Legacy SSE transport — RETIRED in 3.20.0.
|
|
813
|
+
// The pre-2025 HTTP+SSE transport (/sse + /message) was SDK-deprecated, had
|
|
814
|
+
// no cross-machine replay (broken with min_machines_running >= 2), and live
|
|
815
|
+
// metrics showed zero sessions. 410 Gone points stragglers at /mcp.
|
|
802
816
|
// ---------------------------------------------------------------------------
|
|
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,
|
|
817
|
+
const legacySseGone = (_req, res) => {
|
|
818
|
+
res.status(410).json({
|
|
819
|
+
error: "transport_retired",
|
|
820
|
+
message: "The legacy SSE transport has been retired. Connect via Streamable HTTP at https://mcp.openephemeris.com/mcp instead.",
|
|
843
821
|
});
|
|
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
|
-
});
|
|
822
|
+
};
|
|
823
|
+
app.get("/sse", legacySseGone);
|
|
824
|
+
app.post("/message", legacySseGone);
|
|
825
|
+
return app;
|
|
826
|
+
}
|
|
827
|
+
async function main() {
|
|
828
|
+
const app = await createSseApp();
|
|
894
829
|
// Reap abandoned sessions (no DELETE, no transport close) every 10 minutes.
|
|
895
830
|
setInterval(reapIdleSessions, 10 * 60 * 1000).unref();
|
|
896
831
|
const httpServer = app.listen(PORT, "0.0.0.0", () => {
|
|
@@ -919,7 +854,13 @@ process.on("uncaughtException", (err) => {
|
|
|
919
854
|
process.on("unhandledRejection", (reason) => {
|
|
920
855
|
console.error("Unhandled promise rejection:", reason);
|
|
921
856
|
});
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
857
|
+
// Only listen when run as the entry point — importing createSseApp from
|
|
858
|
+
// tests must not bind a port or install the reaper interval.
|
|
859
|
+
const isDirectRun = process.argv[1] !== undefined &&
|
|
860
|
+
import.meta.url.toLowerCase() === pathToFileURL(process.argv[1]).href.toLowerCase();
|
|
861
|
+
if (isDirectRun) {
|
|
862
|
+
main().catch((error) => {
|
|
863
|
+
console.error("Fatal error:", error);
|
|
864
|
+
process.exit(1);
|
|
865
|
+
});
|
|
866
|
+
}
|
|
@@ -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",
|
|
@@ -404,7 +406,9 @@ function buildBiWheelSummary(innerPlanets, outerPlanets, crossAspects, mode, loc
|
|
|
404
406
|
// ── Tool: explore_bi_wheel ─────────────────────────────────────────────────────
|
|
405
407
|
registerTool({
|
|
406
408
|
name: "explore_bi_wheel",
|
|
407
|
-
description: "Generate an interactive bi-wheel chart comparing two astrological chart positions
|
|
409
|
+
description: "Generate an interactive bi-wheel chart comparing two astrological chart positions.\n\n" +
|
|
410
|
+
"CREDIT COST: 2 credits per call for most modes (1 per wheel computed); " +
|
|
411
|
+
"solar_return/lunar_return modes cost 6 (5 for the return + 1 for the natal wheel).\n\n" +
|
|
408
412
|
"The inner wheel is always Person 1's natal chart. The outer ring depends on mode:\n" +
|
|
409
413
|
"• synastry — Person 2's natal chart (two-person compatibility). 1 credit.\n" +
|
|
410
414
|
"• transit — Transiting planets for a given date. 1 credit.\n" +
|
|
@@ -356,7 +356,8 @@ function buildHdSummary(payload, location) {
|
|
|
356
356
|
// ── Tool: explore_human_design ────────────────────────────────────────────
|
|
357
357
|
registerTool({
|
|
358
358
|
name: "explore_human_design",
|
|
359
|
-
description: "Generate an interactive Human Design Bodygraph with clickable centers, gates, and channels
|
|
359
|
+
description: "Generate an interactive Human Design Bodygraph with clickable centers, gates, and channels.\n\n" +
|
|
360
|
+
"CREDIT COST: 2 credits per call.\n\n" +
|
|
360
361
|
"Returns an embedded visual bodygraph explorer that lets you click any center or gate " +
|
|
361
362
|
"for instant Human Design interpretation. " +
|
|
362
363
|
"Shows defined/undefined centers, active gates, channels, Type, Profile, Authority, " +
|
|
@@ -1325,7 +1326,8 @@ registerTool({
|
|
|
1325
1326
|
// Premium (Developer tier). Calls POST /human-design/transit-chart.
|
|
1326
1327
|
registerTool({
|
|
1327
1328
|
name: "explore_human_design_transit",
|
|
1328
|
-
description: "Overlay the current (or a chosen) planetary transit on a person's natal Human Design bodygraph
|
|
1329
|
+
description: "Overlay the current (or a chosen) planetary transit on a person's natal Human Design bodygraph.\n\n" +
|
|
1330
|
+
"CREDIT COST: 3 credits per call.\n\n" +
|
|
1329
1331
|
"Highlights the channels a transit temporarily COMPLETES with the natal chart and any centers it " +
|
|
1330
1332
|
"newly defines — the core of a Human Design transit reading. " +
|
|
1331
1333
|
"Returns an interactive overlay bodygraph in MCP Apps-capable hosts (Claude Desktop), with a text " +
|
|
@@ -1415,7 +1417,8 @@ registerTool({
|
|
|
1415
1417
|
registerTool({
|
|
1416
1418
|
name: "explore_human_design_connection",
|
|
1417
1419
|
description: "Compare two people's Human Design charts and classify every connected channel by HD connection " +
|
|
1418
|
-
"theory
|
|
1420
|
+
"theory. CREDIT COST: 3 credits per call. Classifications: " +
|
|
1421
|
+
"electromagnetic (attraction), companionship (sameness), dominance (one defines), and " +
|
|
1419
1422
|
"compromise (friction). Returns an interactive two-person overlay bodygraph in MCP Apps-capable " +
|
|
1420
1423
|
"hosts, with a text summary fallback elsewhere. Premium (Developer tier). NASA JPL DE440 ephemerides.",
|
|
1421
1424
|
inputSchema: {
|
|
@@ -104,9 +104,11 @@ function buildNatalBody(datetime, lat, lon, houseSystem, timezone) {
|
|
|
104
104
|
// ── Tool: explore_natal_chart ──────────────────────────────────────────────
|
|
105
105
|
registerTool({
|
|
106
106
|
name: "explore_natal_chart",
|
|
107
|
-
description: "
|
|
107
|
+
description: "PRIMARY tool for any natal/birth-chart request. Renders an interactive, clickable chart " +
|
|
108
|
+
"wheel — prefer this over ephemeris_natal_chart when the user wants to SEE a chart. " +
|
|
108
109
|
"Returns an embedded visual chart explorer that lets you click any planet, house, or aspect " +
|
|
109
|
-
"line for instant astrological interpretation
|
|
110
|
+
"line for instant astrological interpretation.\n\n" +
|
|
111
|
+
"CREDIT COST: 1 credit per call.\n\n" +
|
|
110
112
|
"Supports house system switching (Placidus, Whole Sign, Equal, Koch). " +
|
|
111
113
|
"The chart is computed using NASA JPL DE440 ephemerides for sub-arcsecond precision. " +
|
|
112
114
|
"Use this instead of ephemeris_chart_wheel for a richer, interactive experience in " +
|
|
@@ -189,7 +191,7 @@ registerTool({
|
|
|
189
191
|
const chartData = await client.post("/ephemeris/natal-chart", natalBody);
|
|
190
192
|
// Build human-readable summary for the LLM context
|
|
191
193
|
const summary = buildChartSummary(chartData, String(args.location ?? `${lat}, ${lon}`));
|
|
192
|
-
// Build the UI payload (planets normalised to array, aspects
|
|
194
|
+
// Build the UI payload (planets normalised to array, server aspects mapped)
|
|
193
195
|
const modelPayload = buildModelPayload(chartData, {
|
|
194
196
|
datetime,
|
|
195
197
|
timezone: args.timezone ?? null,
|
|
@@ -453,52 +455,39 @@ const ALL_KNOWN_BODIES = new Set([...CLASSICAL_PLANETS, ...EXTENDED_BODIES]);
|
|
|
453
455
|
* Tokens saved vs. full uiPayload: ~85–95% reduction (no 100KB SVG, no asteroid flood).
|
|
454
456
|
*/
|
|
455
457
|
/**
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
458
|
+
* Map the server-computed aspects array from /ephemeris/natal-chart (and the
|
|
459
|
+
* solar-return / progressed responses) to the AspectData shape the chart-wheel
|
|
460
|
+
* UI consumes: { planet1, planet2, type, angle, orb, applying }.
|
|
461
|
+
*
|
|
462
|
+
* The Go API computes aspects by default (options.include_aspects = true),
|
|
463
|
+
* including a proper ephemeris-based is_applying flag — so no client-side
|
|
464
|
+
* longitude heuristic is needed. Aspects are optionally filtered to the set of
|
|
465
|
+
* bodies actually displayed so lines never reference a hidden planet.
|
|
459
466
|
*/
|
|
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
|
-
];
|
|
467
|
+
function mapServerAspects(rawAspects, allowedNames) {
|
|
468
|
+
if (!Array.isArray(rawAspects))
|
|
469
|
+
return [];
|
|
470
470
|
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
|
-
}
|
|
471
|
+
for (const a of rawAspects) {
|
|
472
|
+
if (!a || typeof a !== "object")
|
|
473
|
+
continue;
|
|
474
|
+
const p1 = canonicalizeBodyName(String(a.planet1 ?? ""));
|
|
475
|
+
const p2 = canonicalizeBodyName(String(a.planet2 ?? ""));
|
|
476
|
+
if (!p1 || !p2)
|
|
477
|
+
continue;
|
|
478
|
+
if (allowedNames && (!allowedNames.has(p1) || !allowedNames.has(p2)))
|
|
479
|
+
continue;
|
|
480
|
+
results.push({
|
|
481
|
+
planet1: p1,
|
|
482
|
+
planet2: p2,
|
|
483
|
+
type: String(a.aspect_type ?? a.type ?? "aspect").toLowerCase(),
|
|
484
|
+
angle: Number(a.exact_angle ?? a.angle ?? 0),
|
|
485
|
+
orb: Math.round(Number(a.orb ?? 0) * 100) / 100,
|
|
486
|
+
// is_applying may be null when the server cannot determine it
|
|
487
|
+
applying: typeof a.is_applying === "boolean" ? a.is_applying
|
|
488
|
+
: typeof a.applying === "boolean" ? a.applying
|
|
489
|
+
: undefined,
|
|
490
|
+
});
|
|
502
491
|
}
|
|
503
492
|
return results;
|
|
504
493
|
}
|
|
@@ -536,8 +525,8 @@ function buildModelPayload(chartData, birthParams, houseSystem, svgBase, bodyFil
|
|
|
536
525
|
// Already normalised (e.g. house system recalculate path)
|
|
537
526
|
planetsArray = chartData.planets;
|
|
538
527
|
}
|
|
539
|
-
// ── 2.
|
|
540
|
-
const aspects =
|
|
528
|
+
// ── 2. Map server-computed aspects (filtered to displayed bodies) ─────────
|
|
529
|
+
const aspects = mapServerAspects(chartData.aspects, new Set(planetsArray.map((p) => p.name)));
|
|
541
530
|
// ── 3. Extract angles — API nests ascendant/MC under an `angles` key ──────
|
|
542
531
|
const rawAngles = chartData.angles;
|
|
543
532
|
const rawHouseObj = chartData.houses;
|
|
@@ -626,10 +615,8 @@ function buildChartSummary(data, location) {
|
|
|
626
615
|
elementCount[el]++;
|
|
627
616
|
}
|
|
628
617
|
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 })));
|
|
618
|
+
// Strongest aspect by tightest orb (server-computed aspects)
|
|
619
|
+
const aspects = mapServerAspects(data.aspects);
|
|
633
620
|
const strongest = aspects.sort((a, b) => a.orb - b.orb)[0];
|
|
634
621
|
const strongestNote = strongest
|
|
635
622
|
? `Closest aspect: ${capitalize(strongest.planet1)} ${strongest.type} ${capitalize(strongest.planet2)} (${strongest.orb.toFixed(1)}° orb)`
|
|
@@ -3,7 +3,7 @@ import { getActiveClient } from "../../backend/client.js";
|
|
|
3
3
|
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
4
4
|
registerTool({
|
|
5
5
|
name: "location_search",
|
|
6
|
-
description: "App-only tool for getting location autocomplete suggestions. Returns display name, region (state/province), latitude, longitude, and IANA timezone. Optional bias params (country/region/near) improve ranking; a trailing \"City, ST\" qualifier in the query is also honored.",
|
|
6
|
+
description: "App-only tool for getting location autocomplete suggestions. CREDIT COST: 1 credit per call. Returns display name, region (state/province), latitude, longitude, and IANA timezone. Optional bias params (country/region/near) improve ranking; a trailing \"City, ST\" qualifier in the query is also honored.",
|
|
7
7
|
inputSchema: {
|
|
8
8
|
type: "object",
|
|
9
9
|
properties: {
|
|
@@ -63,7 +63,7 @@ registerTool({
|
|
|
63
63
|
});
|
|
64
64
|
registerTool({
|
|
65
65
|
name: "timezone_resolve",
|
|
66
|
-
description: "App-only tool for resolving IANA timezone by latitude and longitude.",
|
|
66
|
+
description: "App-only tool for resolving IANA timezone by latitude and longitude. CREDIT COST: 1 credit per call.",
|
|
67
67
|
inputSchema: {
|
|
68
68
|
type: "object",
|
|
69
69
|
properties: {
|
|
@@ -98,6 +98,7 @@ registerTool({
|
|
|
98
98
|
" • Void-of-Course status with timing details\n" +
|
|
99
99
|
" • Lunar age (days in the synodic cycle)\n" +
|
|
100
100
|
" • Upcoming New Moon and Full Moon dates\n\n" +
|
|
101
|
+
"CREDIT COST: 3 credits per call (phase + void-of-course + aspects, 1 each).\n\n" +
|
|
101
102
|
"Use this for a rich, interactive lunar phase experience in MCP Apps-capable hosts (Claude Desktop). " +
|
|
102
103
|
"Falls back to a text summary in other hosts.",
|
|
103
104
|
inputSchema: {
|