@openephemeris/mcp-server 3.17.0 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/LICENSE +21 -21
- package/dist/backend/client.d.ts +9 -0
- package/dist/backend/client.js +32 -5
- package/dist/index.js +12 -11
- package/dist/oauth/session-utils.d.ts +59 -0
- package/dist/oauth/session-utils.js +110 -0
- package/dist/server-sse.js +184 -20
- package/dist/tools/dev.js +1 -1
- package/dist/tools/index.d.ts +25 -0
- package/dist/tools/index.js +33 -0
- package/dist/tools/output-schemas.d.ts +1 -1
- package/dist/tools/output-schemas.js +1 -1
- package/dist/tools/specialized/acg.js +6 -2
- package/dist/tools/specialized/progressed.js +3 -1
- package/dist/ui/chart-wheel.html +15 -20
- package/dist/ui/moon-phase.html +28 -28
- package/package.json +4 -2
- package/dist/tools/apps/bazi-app.d.ts +0 -23
- package/dist/tools/apps/bazi-app.js +0 -226
- package/dist/tools/apps/transit-timeline-app.d.ts +0 -20
- package/dist/tools/apps/transit-timeline-app.js +0 -295
- package/dist/tools/apps/vedic-chart-app.d.ts +0 -17
- package/dist/tools/apps/vedic-chart-app.js +0 -228
- package/dist/ui/bazi.html +0 -213
- package/dist/ui/transit-timeline.html +0 -6874
- package/dist/ui/vedic-chart.html +0 -210
package/dist/server-sse.js
CHANGED
|
@@ -22,7 +22,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
22
22
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
23
23
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
24
24
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
|
|
25
|
-
import { initTools, toolRegistry, formatToolResponse, modelVisibleTools } from "./tools/index.js";
|
|
25
|
+
import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools } from "./tools/index.js";
|
|
26
26
|
import { BackendClient, runWithClient } from "./backend/client.js";
|
|
27
27
|
import { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
|
|
28
28
|
import { BODYGRAPH_RESOURCE_URI, BODYGRAPH_MIME_TYPE, getBodygraphBundle, } from "./tools/apps/bodygraph-app.js";
|
|
@@ -34,6 +34,7 @@ import { oauthDcrRouter } from "./oauth/dcr.js";
|
|
|
34
34
|
import { oauthTokenRouter } from "./oauth/token.js";
|
|
35
35
|
import { OAuthStore } from "./oauth/store.js";
|
|
36
36
|
import { createRateLimiter } from "./oauth/rate-limit.js";
|
|
37
|
+
import { isJwtExpired, encodeSessionId, replayTargetMachine, } from "./oauth/session-utils.js";
|
|
37
38
|
// ---------------------------------------------------------------------------
|
|
38
39
|
// Helpers
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
@@ -73,9 +74,14 @@ const version = resolveServerVersion();
|
|
|
73
74
|
*/
|
|
74
75
|
async function validateApiKey(apiKey) {
|
|
75
76
|
try {
|
|
76
|
-
|
|
77
|
+
// Use a genuinely free, auth-gated endpoint. /catalogs/bodies is metered at
|
|
78
|
+
// 0 credits (usage_meter.go EstimateUsageUnits) yet still requires auth (NOT
|
|
79
|
+
// in middleware.go IsPublicPath), so an invalid key returns 401/403 while a
|
|
80
|
+
// valid key is validated without charging a credit on every session init.
|
|
81
|
+
// (The previous /ephemeris/moon/phase probe fell through to the default
|
|
82
|
+
// 1-credit charge — connecting cost the user a credit.)
|
|
83
|
+
const resp = await axios.get(`${BACKEND_URL}/catalogs/bodies`, {
|
|
77
84
|
headers: { "X-API-Key": apiKey },
|
|
78
|
-
params: { datetime: new Date().toISOString() }, // required param per OpenAPI spec
|
|
79
85
|
timeout: 5_000,
|
|
80
86
|
validateStatus: () => true, // don't throw on any status
|
|
81
87
|
});
|
|
@@ -279,25 +285,22 @@ function createMcpServer() {
|
|
|
279
285
|
try {
|
|
280
286
|
const startTime = Date.now();
|
|
281
287
|
console.error(`[MCP] Executing tool: ${toolName}`);
|
|
282
|
-
|
|
288
|
+
// Bound execution so a hung backend request can't pin the session forever.
|
|
289
|
+
// 120s comfortably covers the slowest known endpoint (cold heatmap
|
|
290
|
+
// composite ~9.3s) with a wide margin.
|
|
291
|
+
const result = await withTimeout(tool.handler(request.params.arguments ?? {}), TOOL_CALL_TIMEOUT_MS, toolName);
|
|
283
292
|
const durationMs = Date.now() - startTime;
|
|
284
293
|
return formatToolResponse(toolName, result, durationMs);
|
|
285
294
|
}
|
|
286
295
|
catch (error) {
|
|
287
|
-
const startTime = Date.now(); // Not strictly accurate for errors but acceptable for logging structure if needed
|
|
288
|
-
const durationMs = Date.now() - startTime;
|
|
289
296
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
},
|
|
298
|
-
],
|
|
299
|
-
isError: !isRetryable,
|
|
300
|
-
};
|
|
297
|
+
console.error(`[MCP] ❌ Failed: ${toolName} - ${errorMessage}`);
|
|
298
|
+
// All failures surface as isError: true so the host can recover
|
|
299
|
+
// (retry / OAuth refresh). The remote transport has no device-auth flow,
|
|
300
|
+
// so formatToolError's device-auth exception never fires here — a 401
|
|
301
|
+
// (including the "disconnect and reconnect" message) renders as a real
|
|
302
|
+
// error and prompts the host's OAuth refresh.
|
|
303
|
+
return formatToolError(error);
|
|
301
304
|
}
|
|
302
305
|
});
|
|
303
306
|
// --- Resource handlers ---
|
|
@@ -370,6 +373,51 @@ function createMcpServer() {
|
|
|
370
373
|
return server;
|
|
371
374
|
}
|
|
372
375
|
const httpSessions = new Map();
|
|
376
|
+
// Sessions are held in memory and only freed on explicit DELETE / transport
|
|
377
|
+
// close. Hosts that vanish without teardown (tab closed, network drop) would
|
|
378
|
+
// otherwise leak sessions until process restart, so reap anything idle past
|
|
379
|
+
// the TTL. A reaped session 404s on its next request and the client
|
|
380
|
+
// re-initializes per the MCP spec.
|
|
381
|
+
const SESSION_IDLE_TTL_MS = 2 * 60 * 60 * 1000; // 2h
|
|
382
|
+
const TOOL_CALL_TIMEOUT_MS = 120_000;
|
|
383
|
+
function withTimeout(promise, ms, label) {
|
|
384
|
+
return new Promise((resolve, reject) => {
|
|
385
|
+
const timer = setTimeout(() => reject(new Error(`Tool '${label}' timed out after ${ms / 1000}s`)), ms);
|
|
386
|
+
promise.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
function reapIdleSessions() {
|
|
390
|
+
const cutoff = Date.now() - SESSION_IDLE_TTL_MS;
|
|
391
|
+
for (const [id, session] of httpSessions) {
|
|
392
|
+
if (session.lastSeen < cutoff) {
|
|
393
|
+
httpSessions.delete(id);
|
|
394
|
+
session.server.close().catch(() => { });
|
|
395
|
+
console.error(`[HTTP] Session reaped (idle > ${SESSION_IDLE_TTL_MS / 60000}m): ${id}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Keep a long-lived SSE response alive through Fly's proxy and any
|
|
401
|
+
* intermediaries: disable the socket idle timeout and emit an SSE comment
|
|
402
|
+
* frame every 25s (comment frames are ignored by EventSource parsers).
|
|
403
|
+
* Call only after the transport has sent the SSE headers.
|
|
404
|
+
*/
|
|
405
|
+
function startSseKeepalive(res) {
|
|
406
|
+
res.socket?.setTimeout(0);
|
|
407
|
+
const ping = setInterval(() => {
|
|
408
|
+
if (res.writableEnded || res.destroyed) {
|
|
409
|
+
clearInterval(ping);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
res.write(":ping\n\n");
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
clearInterval(ping);
|
|
417
|
+
}
|
|
418
|
+
}, 25_000);
|
|
419
|
+
res.on("close", () => clearInterval(ping));
|
|
420
|
+
}
|
|
373
421
|
async function main() {
|
|
374
422
|
await initTools();
|
|
375
423
|
const app = express();
|
|
@@ -391,6 +439,36 @@ async function main() {
|
|
|
391
439
|
function isOriginAllowed(origin) {
|
|
392
440
|
return ORIGIN_ALLOWLIST.some((m) => typeof m === "string" ? m === origin : m.test(origin));
|
|
393
441
|
}
|
|
442
|
+
// DNS-rebinding defense: Origin validation alone doesn't cover the Host
|
|
443
|
+
// header, which is the vector rebinding attacks use. Restrict the MCP
|
|
444
|
+
// endpoints to the hosts we actually serve. Override/extend with
|
|
445
|
+
// MCP_ALLOWED_HOSTS (comma-separated) if the deployment hostname changes.
|
|
446
|
+
const HOST_ALLOWLIST = [
|
|
447
|
+
/^mcp\.openephemeris\.com$/i,
|
|
448
|
+
/^oe-mcp-live\.fly\.dev$/i,
|
|
449
|
+
/^localhost(:\d+)?$/i,
|
|
450
|
+
/^127\.0\.0\.1(:\d+)?$/i,
|
|
451
|
+
/^\[::1\](:\d+)?$/i,
|
|
452
|
+
...(process.env.MCP_ALLOWED_HOSTS ?? "")
|
|
453
|
+
.split(",")
|
|
454
|
+
.map((h) => h.trim())
|
|
455
|
+
.filter(Boolean)
|
|
456
|
+
.map((h) => new RegExp(`^${h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(:\\d+)?$`, "i")),
|
|
457
|
+
];
|
|
458
|
+
const MCP_ROUTES = new Set(["/mcp", "/sse", "/message"]);
|
|
459
|
+
app.use((req, res, next) => {
|
|
460
|
+
if (MCP_ROUTES.has(req.path)) {
|
|
461
|
+
const host = req.headers.host;
|
|
462
|
+
if (host && !HOST_ALLOWLIST.some((m) => m.test(host))) {
|
|
463
|
+
res.status(403).json({
|
|
464
|
+
error: "host_not_allowed",
|
|
465
|
+
error_description: `Host '${host}' is not permitted.`,
|
|
466
|
+
});
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
next();
|
|
471
|
+
});
|
|
394
472
|
app.use((req, res, next) => {
|
|
395
473
|
const origin = req.headers.origin;
|
|
396
474
|
// Browser request → must match allowlist. Native client (no Origin) → allow.
|
|
@@ -515,12 +593,53 @@ async function main() {
|
|
|
515
593
|
if (sessionId) {
|
|
516
594
|
const session = httpSessions.get(sessionId);
|
|
517
595
|
if (!session) {
|
|
596
|
+
// Cross-machine session recovery (Fly): min_machines_running >= 2 but
|
|
597
|
+
// httpSessions is per-process. If the session id encodes a DIFFERENT
|
|
598
|
+
// machine, ask Fly's proxy to replay this request to the owning machine
|
|
599
|
+
// — unless it was already replayed once (loop guard), in which case we
|
|
600
|
+
// fall through to 404 so the client re-initializes per the MCP spec.
|
|
601
|
+
const target = replayTargetMachine(sessionId, {
|
|
602
|
+
alreadyReplayed: typeof req.headers["fly-replay-src"] === "string",
|
|
603
|
+
});
|
|
604
|
+
if (target) {
|
|
605
|
+
res.set("fly-replay", `instance=${target}`);
|
|
606
|
+
res.status(307).end();
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
518
609
|
res.status(404).json({
|
|
519
610
|
error: "session_not_found",
|
|
520
611
|
message: "Unknown or expired MCP session. Re-initialize to start a new one.",
|
|
521
612
|
});
|
|
522
613
|
return;
|
|
523
614
|
}
|
|
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
|
+
// Refresh the session's Bearer JWT if the host rotated it. claude.ai
|
|
636
|
+
// refreshes tokens via /oauth/token roughly hourly (self-signed Supabase
|
|
637
|
+
// JWTs expire in 1h); the frozen JWT captured at init would otherwise 401
|
|
638
|
+
// forever. Re-read the header and push the fresh token into the client.
|
|
639
|
+
if (resumedAuth.jwt) {
|
|
640
|
+
session.client.setJwt(resumedAuth.jwt);
|
|
641
|
+
}
|
|
642
|
+
session.lastSeen = Date.now();
|
|
524
643
|
await runWithClient(session.client, () => session.transport.handleRequest(req, res, req.body));
|
|
525
644
|
return;
|
|
526
645
|
}
|
|
@@ -538,6 +657,19 @@ async function main() {
|
|
|
538
657
|
});
|
|
539
658
|
return;
|
|
540
659
|
}
|
|
660
|
+
// Reject already-expired JWTs at the gate with an OAuth-refresh signal.
|
|
661
|
+
// Signature is validated downstream by the Go API — here we only decode the
|
|
662
|
+
// `exp` claim (no verification) so an expired token triggers the host's
|
|
663
|
+
// token refresh (401 invalid_token) instead of dying as a confusing tool
|
|
664
|
+
// error mid-session.
|
|
665
|
+
if (jwt && isJwtExpired(jwt)) {
|
|
666
|
+
res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", error_description="The access token expired", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
|
|
667
|
+
res.status(401).json({
|
|
668
|
+
error: "invalid_token",
|
|
669
|
+
message: "Your session token has expired. Re-authorize to continue.",
|
|
670
|
+
});
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
541
673
|
// Validate API keys against the Go backend; JWTs are validated downstream
|
|
542
674
|
// by the Go sidecar's ValidateSupabaseJWT when tool calls are proxied.
|
|
543
675
|
if (apiKey) {
|
|
@@ -570,9 +702,12 @@ async function main() {
|
|
|
570
702
|
}
|
|
571
703
|
// Create per-session transport, server, and client
|
|
572
704
|
const transport = new StreamableHTTPServerTransport({
|
|
573
|
-
|
|
705
|
+
// Embed the Fly machine id so a resume request landing on another machine
|
|
706
|
+
// can be replayed to the owner (see the resume branch above). No-op off
|
|
707
|
+
// Fly (FLY_MACHINE_ID unset) — the raw UUID is used unchanged.
|
|
708
|
+
sessionIdGenerator: () => encodeSessionId(randomUUID()),
|
|
574
709
|
onsessioninitialized: (id) => {
|
|
575
|
-
httpSessions.set(id, { server, transport, client });
|
|
710
|
+
httpSessions.set(id, { server, transport, client, lastSeen: Date.now() });
|
|
576
711
|
console.error(`[HTTP] Session initialized: ${id}`);
|
|
577
712
|
},
|
|
578
713
|
});
|
|
@@ -599,12 +734,24 @@ async function main() {
|
|
|
599
734
|
}
|
|
600
735
|
const session = httpSessions.get(sessionId);
|
|
601
736
|
if (!session) {
|
|
737
|
+
// Cross-machine replay for the SSE leg (same scheme as POST /mcp resume).
|
|
738
|
+
const target = replayTargetMachine(sessionId, {
|
|
739
|
+
alreadyReplayed: typeof req.headers["fly-replay-src"] === "string",
|
|
740
|
+
});
|
|
741
|
+
if (target) {
|
|
742
|
+
res.set("fly-replay", `instance=${target}`);
|
|
743
|
+
res.status(307).end();
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
602
746
|
res.status(404).json({
|
|
603
747
|
error: "session_not_found",
|
|
604
748
|
message: "Unknown or expired MCP session.",
|
|
605
749
|
});
|
|
606
750
|
return;
|
|
607
751
|
}
|
|
752
|
+
session.lastSeen = Date.now();
|
|
753
|
+
// The SSE leg stays open indefinitely — keep it alive through proxies.
|
|
754
|
+
startSseKeepalive(res);
|
|
608
755
|
await session.transport.handleRequest(req, res);
|
|
609
756
|
});
|
|
610
757
|
// DELETE /mcp — client-initiated session teardown
|
|
@@ -715,6 +862,8 @@ async function main() {
|
|
|
715
862
|
try {
|
|
716
863
|
// server.connect() calls transport.start() internally in SDK v1.27+
|
|
717
864
|
await server.connect(transport);
|
|
865
|
+
// Stream is established — keep it alive through Fly's proxy.
|
|
866
|
+
startSseKeepalive(res);
|
|
718
867
|
}
|
|
719
868
|
catch (err) {
|
|
720
869
|
console.error(`SSE session ${sessionId} failed to start:`, err);
|
|
@@ -742,9 +891,17 @@ async function main() {
|
|
|
742
891
|
await transport.handlePostMessage(req, res);
|
|
743
892
|
}
|
|
744
893
|
});
|
|
745
|
-
|
|
894
|
+
// Reap abandoned sessions (no DELETE, no transport close) every 10 minutes.
|
|
895
|
+
setInterval(reapIdleSessions, 10 * 60 * 1000).unref();
|
|
896
|
+
const httpServer = app.listen(PORT, "0.0.0.0", () => {
|
|
746
897
|
console.log(`OpenEphemeris MCP SSE server v${version} listening on port ${PORT}`);
|
|
747
898
|
});
|
|
899
|
+
// SSE streams are long-lived by design; Node 18+ closes idle connections
|
|
900
|
+
// after 5s keep-alive by default at the server level — the per-response
|
|
901
|
+
// socket timeout is disabled in startSseKeepalive, but also raise the
|
|
902
|
+
// server-wide headers/request timeouts so slow proxies don't get cut.
|
|
903
|
+
httpServer.keepAliveTimeout = 65_000;
|
|
904
|
+
httpServer.headersTimeout = 70_000;
|
|
748
905
|
}
|
|
749
906
|
// Prevent SDK recursive close bugs from crashing the process
|
|
750
907
|
process.on("uncaughtException", (err) => {
|
|
@@ -755,6 +912,13 @@ process.on("uncaughtException", (err) => {
|
|
|
755
912
|
console.error("Uncaught exception:", err);
|
|
756
913
|
process.exit(1);
|
|
757
914
|
});
|
|
915
|
+
// Log-and-continue on unhandled promise rejections. A rejected promise from a
|
|
916
|
+
// single request (e.g. a client that disconnected mid-stream) must never take
|
|
917
|
+
// down the whole server and kill every other live session. Log for triage and
|
|
918
|
+
// keep serving.
|
|
919
|
+
process.on("unhandledRejection", (reason) => {
|
|
920
|
+
console.error("Unhandled promise rejection:", reason);
|
|
921
|
+
});
|
|
758
922
|
main().catch((error) => {
|
|
759
923
|
console.error("Fatal error:", error);
|
|
760
924
|
process.exit(1);
|
package/dist/tools/dev.js
CHANGED
|
@@ -73,7 +73,7 @@ const DEV_API_REFERENCE = "Target API: Open Ephemeris REST API (https://api.open
|
|
|
73
73
|
" POST /acg/power-lines — Astrocartography power lines (lat/lon GeoJSON)\n" +
|
|
74
74
|
" POST /acg/hits — ACG power at a specific location\n" +
|
|
75
75
|
" GET /calendar/astrology/moon-phases — Moon phase calendar for a date range\n" +
|
|
76
|
-
" GET /location/autocomplete — Geocode a place name (query:
|
|
76
|
+
" GET /location/autocomplete — Geocode a place name (query: query=City Name)\n" +
|
|
77
77
|
" POST /timezone/lookup — Resolve timezone + UTC offset for a location\n" +
|
|
78
78
|
" POST /chinese/bazi — Chinese Ba Zi (Four Pillars) chart (body: {year, month, day, hour})\n" +
|
|
79
79
|
" GET /chinese/zodiac — Chinese zodiac year element/animal\n" +
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -73,3 +73,28 @@ export declare function validateCoordinates(args: any, latKey: string, lonKey: s
|
|
|
73
73
|
* Applies a safety limit to JSON payloads to prevent crashing LLM contexts.
|
|
74
74
|
*/
|
|
75
75
|
export declare function formatToolResponse(toolName: string, result: any, durationMs: number): any;
|
|
76
|
+
/**
|
|
77
|
+
* Decide whether a thrown tool error is the stdio device-auth-pending case,
|
|
78
|
+
* which must NOT be flagged isError so the model relays the verification link.
|
|
79
|
+
*
|
|
80
|
+
* Everything else — 429, 5xx, network, plain auth failures — is a real error
|
|
81
|
+
* and MUST report isError: true so the host recovers (retry / OAuth refresh)
|
|
82
|
+
* instead of treating a failure as a silent success.
|
|
83
|
+
*
|
|
84
|
+
* Device-auth-pending is uniquely identified by a BackendError with
|
|
85
|
+
* code === "auth_required" AND retryable === true (set only when the background
|
|
86
|
+
* device-auth flow is live and its message carries a verification link). The
|
|
87
|
+
* plain "disconnect and reconnect" 401 is retryable === false → a real error.
|
|
88
|
+
*/
|
|
89
|
+
export declare function isDeviceAuthPendingError(error: unknown): boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Build the MCP error content block for a failed tool call. Shared by the stdio
|
|
92
|
+
* and SSE/HTTP CallTool handlers so the isError policy stays consistent.
|
|
93
|
+
*/
|
|
94
|
+
export declare function formatToolError(error: unknown): {
|
|
95
|
+
content: {
|
|
96
|
+
type: "text";
|
|
97
|
+
text: string;
|
|
98
|
+
}[];
|
|
99
|
+
isError: boolean;
|
|
100
|
+
};
|
package/dist/tools/index.js
CHANGED
|
@@ -216,3 +216,36 @@ export function formatToolResponse(toolName, result, durationMs) {
|
|
|
216
216
|
],
|
|
217
217
|
};
|
|
218
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Decide whether a thrown tool error is the stdio device-auth-pending case,
|
|
221
|
+
* which must NOT be flagged isError so the model relays the verification link.
|
|
222
|
+
*
|
|
223
|
+
* Everything else — 429, 5xx, network, plain auth failures — is a real error
|
|
224
|
+
* and MUST report isError: true so the host recovers (retry / OAuth refresh)
|
|
225
|
+
* instead of treating a failure as a silent success.
|
|
226
|
+
*
|
|
227
|
+
* Device-auth-pending is uniquely identified by a BackendError with
|
|
228
|
+
* code === "auth_required" AND retryable === true (set only when the background
|
|
229
|
+
* device-auth flow is live and its message carries a verification link). The
|
|
230
|
+
* plain "disconnect and reconnect" 401 is retryable === false → a real error.
|
|
231
|
+
*/
|
|
232
|
+
export function isDeviceAuthPendingError(error) {
|
|
233
|
+
const e = error;
|
|
234
|
+
return !!e && e.code === "auth_required" && e.retryable === true;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Build the MCP error content block for a failed tool call. Shared by the stdio
|
|
238
|
+
* and SSE/HTTP CallTool handlers so the isError policy stays consistent.
|
|
239
|
+
*/
|
|
240
|
+
export function formatToolError(error) {
|
|
241
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
242
|
+
return {
|
|
243
|
+
content: [
|
|
244
|
+
{
|
|
245
|
+
type: "text",
|
|
246
|
+
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).`,
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
isError: !isDeviceAuthPendingError(error),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* output-schemas.ts — Shared JSON Schema output schema definitions for MCP tools.
|
|
3
3
|
*
|
|
4
4
|
* MCP spec 2025-11-25 supports `outputSchema` on tool definitions. Smithery
|
|
5
|
-
* uses this field to score the "Output schemas" quality criterion (
|
|
5
|
+
* uses this field to score the "Output schemas" quality criterion (scored per registered tool; count guarded by test/allowlist-and-tools.test.ts).
|
|
6
6
|
*
|
|
7
7
|
* All tools return an MCP content block response. Most return a single JSON text
|
|
8
8
|
* block; visual tools return an image block or an image + text block.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* output-schemas.ts — Shared JSON Schema output schema definitions for MCP tools.
|
|
3
3
|
*
|
|
4
4
|
* MCP spec 2025-11-25 supports `outputSchema` on tool definitions. Smithery
|
|
5
|
-
* uses this field to score the "Output schemas" quality criterion (
|
|
5
|
+
* uses this field to score the "Output schemas" quality criterion (scored per registered tool; count guarded by test/allowlist-and-tools.test.ts).
|
|
6
6
|
*
|
|
7
7
|
* All tools return an MCP content block response. Most return a single JSON text
|
|
8
8
|
* block; visual tools return an image block or an image + text block.
|
|
@@ -24,7 +24,9 @@ registerTool({
|
|
|
24
24
|
properties: {
|
|
25
25
|
birth_datetime: {
|
|
26
26
|
type: "string",
|
|
27
|
-
description: "ISO 8601 natal birth date/time."
|
|
27
|
+
description: "ISO 8601 natal birth date/time. MUST include a UTC offset (or 'Z'), " +
|
|
28
|
+
"e.g. '1990-05-15T14:30:00-05:00'. A naive datetime (no offset) is " +
|
|
29
|
+
"interpreted as UTC and will place the ACG lines in the wrong location.",
|
|
28
30
|
},
|
|
29
31
|
birth_latitude: {
|
|
30
32
|
type: "number",
|
|
@@ -114,7 +116,9 @@ registerTool({
|
|
|
114
116
|
properties: {
|
|
115
117
|
birth_datetime: {
|
|
116
118
|
type: "string",
|
|
117
|
-
description: "ISO 8601 natal birth date/time."
|
|
119
|
+
description: "ISO 8601 natal birth date/time. MUST include a UTC offset (or 'Z'), " +
|
|
120
|
+
"e.g. '1990-05-15T14:30:00-05:00'. A naive datetime (no offset) is " +
|
|
121
|
+
"interpreted as UTC and will place the ACG lines in the wrong location.",
|
|
118
122
|
},
|
|
119
123
|
birth_latitude: {
|
|
120
124
|
type: "number",
|
|
@@ -15,7 +15,9 @@ registerTool({
|
|
|
15
15
|
properties: {
|
|
16
16
|
birth_datetime: {
|
|
17
17
|
type: "string",
|
|
18
|
-
description: "ISO 8601 natal birth date/time."
|
|
18
|
+
description: "ISO 8601 natal birth date/time. MUST include a UTC offset (or 'Z'), " +
|
|
19
|
+
"e.g. '1990-05-15T14:30:00-05:00'. A naive datetime (no offset) is " +
|
|
20
|
+
"interpreted as UTC and will shift the progressed positions.",
|
|
19
21
|
},
|
|
20
22
|
birth_latitude: {
|
|
21
23
|
type: "number",
|
package/dist/ui/chart-wheel.html
CHANGED
|
@@ -7535,32 +7535,29 @@ function Zv(e4) {
|
|
|
7535
7535
|
n2(e5);
|
|
7536
7536
|
});
|
|
7537
7537
|
}
|
|
7538
|
-
var Qv = document.getElementById("loading-state"), $v = document.getElementById("chart-container"), ey = document.getElementById("chart-info"), ty = document.getElementById("chart-status"), ny = document.getElementById("controls-panel"), ry = document.getElementById("patterns-strip"), iy = document.getElementById("aspect-filter"), ay = document.getElementById("overview-table"), oy = document.getElementById("param-controls-container");
|
|
7539
|
-
console.log("[ChartWheel] \u{1F680} main.ts loaded, creating App instance...");
|
|
7540
|
-
var sy = new r_({ name: "Chart Wheel Explorer", version: "1.0.0" }), $ = null, cy = "placidus", ly = null, uy = false, dy = false;
|
|
7538
|
+
var Qv = document.getElementById("loading-state"), $v = document.getElementById("chart-container"), ey = document.getElementById("chart-info"), ty = document.getElementById("chart-status"), ny = document.getElementById("controls-panel"), ry = document.getElementById("patterns-strip"), iy = document.getElementById("aspect-filter"), ay = document.getElementById("overview-table"), oy = document.getElementById("param-controls-container"), sy = new r_({ name: "Chart Wheel Explorer", version: "1.0.0" }), $ = null, cy = "placidus", ly = null, uy = false, dy = false;
|
|
7541
7539
|
function fy(e4) {
|
|
7542
7540
|
ty && (e4 ? (ty.textContent = "\xB7 ".concat(e4), ty.setAttribute("data-active", "")) : (ty.textContent = "", ty.removeAttribute("data-active")));
|
|
7543
7541
|
}
|
|
7544
7542
|
sy.ontoolresult = (e4) => {
|
|
7545
|
-
var t2
|
|
7546
|
-
|
|
7547
|
-
|
|
7548
|
-
if (Fy(), !o2 || !o2.planets) {
|
|
7543
|
+
var t2;
|
|
7544
|
+
let n2 = Gv(e4);
|
|
7545
|
+
if (Fy(), !n2 || !n2.planets) {
|
|
7549
7546
|
console.error("[ChartWheel] \u274C No chart data found in tool result"), Iy("Chart data could not be parsed from the server response.", Ly);
|
|
7550
7547
|
return;
|
|
7551
7548
|
}
|
|
7552
|
-
if ($ =
|
|
7553
|
-
var
|
|
7554
|
-
let e5 = (
|
|
7549
|
+
if ($ = n2, cy = (t2 = n2.house_system) == null ? "placidus" : t2, !ly) {
|
|
7550
|
+
var r2, i2, a2, o2, s2, c2, l2, u2, d2, f2;
|
|
7551
|
+
let e5 = (r2 = n2._birth_params) == null ? {} : r2, t3 = (i2 = (a2 = (o2 = e5.datetime) == null ? n2.datetime : o2) == null ? n2.birth_datetime : a2) == null ? "" : i2, p2 = (s2 = (c2 = e5.location) == null ? n2.location : c2) == null ? "" : s2, m2 = (l2 = (u2 = e5.timezone) == null ? n2.timezone : u2) == null ? "" : l2, h2 = (d2 = e5.latitude) == null ? n2.latitude : d2, g2 = (f2 = e5.longitude) == null ? n2.longitude : f2, _2 = "", v2 = "12:00";
|
|
7555
7552
|
if (t3 && t3.includes("T")) {
|
|
7556
7553
|
let e6 = t3.split("T");
|
|
7557
7554
|
_2 = e6[0], v2 = e6[1].substring(0, 5);
|
|
7558
7555
|
}
|
|
7559
|
-
ly = new Iv({ app: sy, container: oy, initialParams: { date: _2, time: v2, locationName:
|
|
7556
|
+
ly = new Iv({ app: sy, container: oy, initialParams: { date: _2, time: v2, locationName: p2, timezone: m2, latitude: typeof h2 == "number" ? h2 : null, longitude: typeof g2 == "number" ? g2 : null }, showStepControls: false, onRefresh: async (e6) => {
|
|
7560
7557
|
ly == null || ly.setBusy(true), Qv.removeAttribute("hidden"), $v.setAttribute("hidden", "");
|
|
7561
7558
|
try {
|
|
7562
|
-
var t4,
|
|
7563
|
-
let
|
|
7559
|
+
var t4, n3, r3, i3;
|
|
7560
|
+
let a3 = "".concat(e6.date, "T").concat(e6.time, ":00"), o3 = new Promise((e7, t5) => setTimeout(() => t5(Error("Recalculate timed out after 30s")), 3e4)), s3 = Gv(await Promise.race([sy.callServerTool({ name: "chart_wheel_recalculate", arguments: { datetime: "".concat(a3).concat(e6.timezone ? "" : "Z"), latitude: (t4 = e6.latitude) == null ? void 0 : t4, longitude: (n3 = e6.longitude) == null ? void 0 : n3, timezone: (r3 = e6.timezone) == null ? void 0 : r3, location: (i3 = e6.locationName) == null ? void 0 : i3, house_system: cy || "placidus", chart_type: "natal" } }), o3]));
|
|
7564
7561
|
s3 ? ($ = s3, s3.house_system && (cy = s3.house_system), py(s3)) : $ && (Qv.setAttribute("hidden", ""), $v.removeAttribute("hidden"));
|
|
7565
7562
|
} catch (e7) {
|
|
7566
7563
|
console.error("[ChartWheel] Recalculate failed or timed out:", e7), $ && (Qv.setAttribute("hidden", ""), $v.removeAttribute("hidden"));
|
|
@@ -7569,7 +7566,7 @@ sy.ontoolresult = (e4) => {
|
|
|
7569
7566
|
}
|
|
7570
7567
|
} });
|
|
7571
7568
|
}
|
|
7572
|
-
|
|
7569
|
+
py(n2);
|
|
7573
7570
|
};
|
|
7574
7571
|
function py(e4) {
|
|
7575
7572
|
var t2, n2, r2, i2, a2, o2, s2;
|
|
@@ -7749,7 +7746,7 @@ function Cy() {
|
|
|
7749
7746
|
async function wy(e4, t2) {
|
|
7750
7747
|
if (uy) try {
|
|
7751
7748
|
let n2 = "[Chart Wheel] Selected ".concat(e4, " \u2014 ").concat(Object.entries(t2).filter(([, e5]) => e5 != null && e5 !== false && e5 !== "").map(([e5, t3]) => "".concat(e5, ": ").concat(t3)).join(", "));
|
|
7752
|
-
await sy.updateModelContext({ content: [{ type: "text", text: n2 }] })
|
|
7749
|
+
await sy.updateModelContext({ content: [{ type: "text", text: n2 }] });
|
|
7753
7750
|
} catch (e5) {
|
|
7754
7751
|
console.warn("[ChartWheel] \u26A0\uFE0F updateModelContext failed:", e5);
|
|
7755
7752
|
}
|
|
@@ -7842,10 +7839,8 @@ async function My(e4) {
|
|
|
7842
7839
|
t2 = "Interpret the ".concat(n3.angle, " at ").concat(n3.position, " (").concat(n3.sign, " \xB7 ").concat(n3.element, " \xB7 ").concat(n3.modality, ") in this natal chart. Give a focused interpretation of what this angle represents for the native \u2014 identity, life direction, relationships, or roots depending on which angle \u2014 and how the sign on the angle shapes that expression.");
|
|
7843
7840
|
}
|
|
7844
7841
|
if (t2) try {
|
|
7845
|
-
console.log("[ChartWheel] \u{1F4AC} Sending interpretation request to Claude...");
|
|
7846
7842
|
let e5 = await sy.sendMessage({ role: "user", content: [{ type: "text", text: t2 }] });
|
|
7847
7843
|
if (e5.isError) throw console.error("[ChartWheel] \u274C Host rejected sendMessage:", e5), Error("Host rejected message");
|
|
7848
|
-
console.log("[ChartWheel] \u2705 Message sent to chat thread");
|
|
7849
7844
|
} catch (e5) {
|
|
7850
7845
|
throw console.error("[ChartWheel] \u274C sendMessage failed:", e5), e5;
|
|
7851
7846
|
}
|
|
@@ -7878,11 +7873,11 @@ function Ly() {
|
|
|
7878
7873
|
}
|
|
7879
7874
|
Yv(sy, { loadingEl: Qv, contentEl: $v, statusFn: fy, paramControls: ly }), Py = setTimeout(() => {
|
|
7880
7875
|
Iy("Chart data is taking longer than expected.", Ly);
|
|
7881
|
-
}, 3e4),
|
|
7876
|
+
}, 3e4), (async () => {
|
|
7882
7877
|
try {
|
|
7883
|
-
await sy.connect()
|
|
7878
|
+
await sy.connect();
|
|
7884
7879
|
let e4 = sy.getHostCapabilities();
|
|
7885
|
-
uy = !!(e4 != null && e4.modelContext), dy = !!(e4 != null && e4.sampling),
|
|
7880
|
+
uy = !!(e4 != null && e4.modelContext), dy = !!(e4 != null && e4.sampling), Jv(sy, document.getElementById("pan-zoom-controls"), ["aspect-filter", "controls-panel", "param-controls-container", "chart-info", "patterns-strip", "info-panel", "cw-action-bar"]), Zv(sy), $ && py($);
|
|
7886
7881
|
} catch (e4) {
|
|
7887
7882
|
console.error("[ChartWheel] \u274C app.connect() FAILED:", e4), Iy("Connection failed: ".concat(e4 instanceof Error ? e4.message : String(e4)));
|
|
7888
7883
|
}
|
package/dist/ui/moon-phase.html
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<meta name="color-scheme" content="dark light" />
|
|
7
|
-
<meta name="mcp-app-preferred-height" content="560" />
|
|
8
|
-
<title>Moon Phase Explorer</title>
|
|
9
|
-
<!-- External Google Fonts removed: Claude.ai sandbox enforces hardcoded
|
|
10
|
-
frame-src 'self' blob: data: and blocks external CDN fetches at
|
|
11
|
-
iframe render time. Styles fall back to the system font stack. -->
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="color-scheme" content="dark light" />
|
|
7
|
+
<meta name="mcp-app-preferred-height" content="560" />
|
|
8
|
+
<title>Moon Phase Explorer</title>
|
|
9
|
+
<!-- External Google Fonts removed: Claude.ai sandbox enforces hardcoded
|
|
10
|
+
frame-src 'self' blob: data: and blocks external CDN fetches at
|
|
11
|
+
iframe render time. Styles fall back to the system font stack. -->
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
<style>*,:before,:after{box-sizing:border-box;margin:0;padding:0}:root{--bg-primary:#090b0f;--bg-secondary:#12161d;--bg-tertiary:#1c222b;--bg-hover:#272e3b;--border:#282e38;--border-subtle:#1d2229;--text-primary:#e1e5eb;--text-secondary:#9da5b1;--text-muted:#6c727b;--accent:#62b3ff;--accent-warm:#e6ac3d;--voc-warn:#f6922e;--cue-warm:#f4a34b;--cue-cool:#6ab3fd;--radius:8px;--radius-sm:4px;--radius-lg:12px;--font-display:"Space Grotesk", system-ui, sans-serif;--font-body:"Inter", system-ui, sans-serif;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}@supports (color:color(display-p3 0 0 0)){:root{--bg-primary:color(display-p3 .036047 .0438664 .059078);--bg-secondary:color(display-p3 .0739679 .0866504 .111186);--bg-tertiary:color(display-p3 .114361 .13221 .166818);--bg-hover:color(display-p3 .15679 .180114 .225406);--border:color(display-p3 .161376 .180145 .216325);--border-subtle:color(display-p3 .118728 .13221 .158123);--text-primary:color(display-p3 .883958 .896627 .920543);--text-secondary:color(display-p3 .622745 .646433 .6914);--text-muted:color(display-p3 .429297 .445909 .477439);--accent:color(display-p3 .446063 .694439 1.01077);--accent-warm:color(display-p3 .866978 .684443 .324483);--voc-warn:color(display-p3 .910067 .591092 .274641);--cue-warm:color(display-p3 .911412 .653072 .360243);--cue-cool:color(display-p3 .48245 .695507 .967802)}}@supports (color:lab(0% 0 0)){:root{--bg-primary:lab(3.02674% -.179462 -2.15476);--bg-secondary:lab(7.16596% -.441141 -5.41423);--bg-tertiary:lab(12.9118% -.60384 -7.27841);--bg-hover:lab(18.6882% -.727877 -9.08878);--border:lab(18.7165% -.689313 -7.30941);--border-subtle:lab(12.9391% -.548743 -5.49436);--text-primary:lab(90.6878% -.517368 -3.73452);--text-secondary:lab(67.45% -.932217 -7.41489);--text-muted:lab(47.7476% -.701487 -5.56231);--accent:lab(70.4043% -7.21234 -50.7672);--accent-warm:lab(74.5248% 14.3487 62.5064);--voc-warn:lab(70.3313% 33.71 65.3029);--cue-warm:lab(74.0849% 25.4197 56.7058);--cue-cool:lab(70.5454% -7.4636 -44.2057)}}@media (prefers-color-scheme:light){:root{--bg-primary:#f3f5f9;--bg-secondary:#e7ebf2;--bg-tertiary:#d8dee8;--bg-hover:#d0d8e5;--border:#bdc5d1;--border-subtle:#d2d8e2;--text-primary:#0f1216;--text-secondary:#414853;--text-muted:#585e67;--accent:#0065b2;--accent-warm:#966800;--voc-warn:#a95a00;--cue-warm:#954200;--cue-cool:#1e5ba1;--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light}@supports (color:color(display-p3 0 0 0)){:root{--bg-primary:color(display-p3 .95471 .961142 .973263);--bg-secondary:color(display-p3 .909797 .922535 .946581);--bg-tertiary:color(display-p3 .852455 .871315 .906983);--bg-hover:color(display-p3 .821139 .84609 .893374);--border:color(display-p3 .745559 .770059 .816515);--border-subtle:color(display-p3 .826933 .845684 .881152);--text-primary:color(display-p3 .0608605 .0691527 .0850621);--text-secondary:color(display-p3 .261513 .281801 .32064);--text-muted:color(display-p3 .350602 .367691 .400197);--accent:color(display-p3 .143802 .388951 .683672);--accent-warm:color(display-p3 .596632 .396992 -.158844);--voc-warn:color(display-p3 .673365 .329149 -.157701);--cue-warm:color(display-p3 .567457 .255699 -.0353967);--cue-cool:color(display-p3 .186321 .349931 .613332)}}@supports (color:lab(0% 0 0)){:root{--bg-primary:lab(96.5044% -.267148 -1.87216);--bg-secondary:lab(93.0079% -.518024 -3.73493);--bg-tertiary:lab(88.3503% -.749856 -5.58736);--bg-hover:lab(86.0117% -.963002 -7.43048);--border:lab(79.0511% -.952899 -7.42528);--border-subtle:lab(86.0302% -.748128 -5.58645);--text-primary:lab(5.23796% -.285208 -3.0582);--text-secondary:lab(30.3224% -.794604 -7.35167);--text-muted:lab(39.6225% -.718519 -5.91861);--accent:lab(41.1205% -1.70796 -50.1423);--accent-warm:lab(47.6194% 20.1868 104.51);--voc-warn:lab(46.8395% 41.0975 106.287);--cue-warm:lab(38.6598% 39.325 67.0649);--cue-cool:lab(37.6228% 1.24846 -44.7496)}}}html,body{width:100%;height:100%;min-height:480px;font-family:var(--font-body);background:var(--bg-primary);color:var(--text-primary);font-size:13px;line-height:1.5;overflow-y:auto}body{flex-direction:column;display:flex}#app{flex-direction:column;flex:1;display:flex;overflow:hidden}#loading-state{color:var(--text-secondary);flex-direction:column;flex:1;justify-content:center;align-items:center;gap:12px;display:flex}#loading-state[hidden]{display:none}.oe-loader{width:48px;height:48px;animation:1.2s linear infinite spin}.oe-loader circle{fill:none;stroke:var(--accent);stroke-width:3px;stroke-dasharray:200;stroke-dashoffset:80px;stroke-linecap:round}@keyframes spin{to{transform:rotate(360deg)}}.chart-status{background:var(--bg-secondary);border-top:1px solid var(--border-subtle);color:var(--text-muted);opacity:0;z-index:50;padding:6px 14px;font-size:11px;transition:opacity .2s,transform .2s;position:fixed;bottom:0;left:0;right:0;transform:translateY(100%)}.chart-status.visible{opacity:1;transform:translateY(0)}.pan-zoom-controls{z-index:40;gap:4px;display:flex;position:fixed;top:8px;right:8px}.pan-zoom-controls[hidden]{display:none}.pan-btn{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);width:28px;height:28px;color:var(--text-secondary);cursor:pointer;justify-content:center;align-items:center;font-size:14px;transition:background .15s,color .15s;display:flex}.pan-btn:hover{background:var(--bg-hover);color:var(--text-primary)}.pan-btn.reset{width:auto;padding:0 10px;font-size:12px}#moon-container{flex-direction:column;flex:1;min-height:440px;padding:16px;display:flex;overflow-y:auto}#moon-container[hidden]{display:none}.moon-layout{flex:1;align-items:flex-start;gap:24px;display:flex}@media (max-width:500px){.moon-layout{flex-direction:column;align-items:center;gap:16px}}.moon-dial-col{flex-direction:column;flex-shrink:0;align-items:center;gap:12px;display:flex}.moon-dial{justify-content:center;align-items:center;width:200px;height:200px;display:flex;position:relative}.moon-svg{filter:drop-shadow(0 0 20px rgba(131,163,187,.2));filter:drop-shadow(0 0 20px color(display-p3 .537913 .6362 .723376/.2));filter:drop-shadow(0 0 20px lab(65.2381% -7.35197 -16.1178/.2));width:100%;height:100%;animation:.8s ease-out moon-fade-in}@keyframes moon-fade-in{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}.moon-illum-ring{background:var(--bg-secondary);border:1px solid var(--border-subtle);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border-radius:20px;flex-direction:column;align-items:center;gap:1px;padding:3px 10px;display:flex;position:absolute;bottom:-2px}.moon-illum-value{font-size:22px;font-weight:700;font-family:var(--font-display);font-variant-numeric:tabular-nums;color:var(--accent-warm);letter-spacing:-.02em}.moon-illum-label{text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);font-size:9px}.moon-phase-badge{background:var(--bg-secondary);border:1px solid var(--border);border-radius:20px;align-items:center;gap:6px;padding:6px 14px;display:flex}.moon-phase-icon{font-size:18px}.moon-phase-name{font-size:15px;font-weight:700;font-family:var(--font-display);letter-spacing:-.01em;text-transform:capitalize}.moon-direction{color:var(--text-muted);align-items:center;gap:4px;font-size:12px;display:flex}.dir-arrow{font-size:14px;font-weight:700}.dir-waxing{color:#57bc8a;color:color(display-p3 .44464 .726476 .557142);color:lab(69.1118% -39.6203 15.6094)}.dir-waning{color:#e6857e;color:color(display-p3 .850722 .541293 .507352);color:lab(66.3643% 37.397 20.9063)}.moon-info-col{flex-direction:column;flex:1;gap:6px;min-width:0;display:flex}.moon-card{background:var(--bg-secondary);border:1px solid var(--border-subtle);border-radius:var(--radius);cursor:pointer;padding:10px 14px;transition:background .15s,border-color .15s,box-shadow .15s}.moon-card:hover{background:var(--bg-hover);border-color:var(--border);box-shadow:0 2px 12px rgba(0,0,0,.1);box-shadow:0 2px 12px color(display-p3 0 0 0/.1);box-shadow:0 2px 12px lab(0% 0 0/.1)}.moon-card--voc.moon-card--active{border-color:var(--voc-warn);background:#2c1e13;background:color(display-p3 .165435 .120647 .0798046);background:lab(12.9057% 5.79391 10.6146)}@media (prefers-color-scheme:light){.moon-card--voc.moon-card--active{background:#ffead8;background:color(display-p3 1.00032 .917094 .842425);background:lab(94.1113% 7.0433 13.5336)}}.moon-card-header{align-items:center;gap:6px;margin-bottom:4px;display:flex}.moon-card-icon{font-size:14px}.moon-card-title{font-size:11px;font-weight:600;font-family:var(--font-body);text-transform:uppercase;letter-spacing:.07em;color:var(--text-muted)}.moon-card-chevron{color:var(--text-muted);flex-shrink:0;margin-left:auto;font-size:13px;transition:transform .2s}.moon-card--open .moon-card-chevron{transform:rotate(90deg)}.moon-card-value{font-size:16px;font-weight:700;font-family:var(--font-display);color:var(--text-primary);text-transform:capitalize;line-height:1.2}.moon-card-detail{color:var(--text-muted);margin-top:2px;font-size:11px}.moon-card--primary{border-color:var(--border);padding:8px 13px}.moon-card--primary .moon-card-value{letter-spacing:-.02em;font-size:16px}.moon-card--primary .moon-card-header{margin-bottom:2px}.moon-card--primary .moon-card-title{font-size:10px}.moon-card--compact{padding:6px 12px}.moon-card--compact .moon-card-header{margin-bottom:2px}.moon-card--compact .moon-card-value{font-size:13px;font-weight:600}.moon-card--compact .moon-card-title{letter-spacing:.06em;font-size:10px}.moon-card--inline{padding:6px 12px}.moon-card--inline .moon-card-header{gap:8px;margin-bottom:0}.moon-card--inline .moon-card-title{letter-spacing:.06em;font-size:10px}.moon-card-inline-value{font-family:var(--font-display);color:var(--text-primary);font-variant-numeric:tabular-nums;letter-spacing:-.01em;margin-left:auto;font-size:13px;font-weight:600}.moon-card-inline-value.tempo-fast,.moon-card-inline-value.prox-super{color:var(--cue-warm)}.moon-card-inline-value.tempo-slow{color:var(--cue-cool)}.moon-card-inline-value.prox-micro{color:var(--text-secondary)}.moon-card--inline .moon-card-chevron{margin-left:0}.moon-card-expand{grid-template-rows:0fr;margin-top:0;transition:grid-template-rows .22s,margin-top .22s;display:grid}.moon-card--open .moon-card-expand{grid-template-rows:1fr;margin-top:8px}.moon-card-expand-inner{overflow:hidden}.moon-card-expand-text{font-size:11px;font-family:var(--font-body);color:var(--text-secondary);border-top:1px solid var(--border-subtle);padding-top:7px;font-style:italic;line-height:1.55;display:block}.moon-card-row{color:var(--text-secondary);align-items:center;gap:4px;padding:1px 0;font-size:12px;display:flex}.moon-card-row-icon{font-size:12px}.moon-actions{flex-wrap:wrap;gap:8px;margin-top:6px;display:flex}.action-btn{border-radius:var(--radius);border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-secondary);cursor:pointer;align-items:center;gap:4px;padding:8px 14px;font-size:12px;font-weight:600;transition:background .15s,color .15s,box-shadow .15s;display:inline-flex}.action-btn:hover{background:var(--bg-hover);color:var(--text-primary)}.action-btn--primary{color:#cbdfff;color:color(display-p3 .811259 .873257 .992208);color:lab(88.2011% -1.89927 -18.3428);background:#11397b;background:color(display-p3 .112797 .221529 .46413);background:lab(24.6079% 7.13965 -41.9262);border-color:#305289;border-color:color(display-p3 .218625 .316452 .521702);border-color:lab(34.3143% 2.02942 -35.2838)}.action-btn--primary:hover{background:#1b478f;background:color(display-p3 .151166 .273205 .543079);background:lab(30.361% 6.93905 -45.4659);box-shadow:0 0 0 1px #3862a7;box-shadow:0 0 0 1px color(display-p3 .2584 .379139 .634372);box-shadow:0 0 0 1px lab(41.0971% 2.98774 -42.2721)}.error-state,.empty-state{text-align:center;flex-direction:column;flex:1;justify-content:center;align-items:center;gap:8px;padding:32px;display:flex}.error-icon,.empty-icon{font-size:32px}.error-retry-btn{border:1px solid var(--border,rgba(128,128,160,.3));color:var(--accent-warm,var(--text-primary,inherit));font:inherit;letter-spacing:.02em;cursor:pointer;background:0 0;border-radius:999px;margin-top:4px;padding:7px 20px;font-size:11px;font-weight:600;transition:background .18s,border-color .18s}.error-retry-btn:hover{background:rgba(128,128,160,.12)}.tooltip{z-index:100;border-radius:var(--radius-sm);background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-primary);pointer-events:none;opacity:0;max-width:200px;padding:6px 10px;font-size:11px;transition:opacity .15s;position:fixed}.tooltip.visible{opacity:1}.chart-layer{opacity:1;transform-origin:310px 310px;animation:.5s cubic-bezier(.16,1,.3,1) both svgLayerAssemble;animation-delay:calc(var(--layer-index,0) * 80ms);transform:scale(1)}@keyframes svgLayerAssemble{0%{opacity:0;transform:scale(.97)}to{opacity:1;transform:scale(1)}}.floating-action-btn:after{content:"";background:linear-gradient(90deg,transparent,rgba(255,255,255,.2),transparent);width:50%;height:100%;animation:3s 2s infinite shimmer;position:absolute;top:0;left:-100%;transform:skew(-20deg)}@keyframes shimmer{0%{left:-100%}20%{left:200%}to{left:200%}}
|
|
15
15
|
/*$vite$:1*/</style>
|
|
16
|
-
</head>
|
|
17
|
-
<body>
|
|
18
|
-
<div id="app">
|
|
19
|
-
<div id="loading-state">
|
|
20
|
-
<svg class="oe-loader" viewBox="0 0 100 100"><circle cx="50" cy="50" r="45"/></svg>
|
|
21
|
-
<p>Computing lunar phase…</p>
|
|
22
|
-
</div>
|
|
23
|
-
<div id="chart-status" class="chart-status" aria-live="polite"></div>
|
|
24
|
-
<!-- Moon phase doesn't need zoom — image is fixed. Container is kept so
|
|
25
|
-
setupFullscreenToggle has a host to inject the fullscreen button into. -->
|
|
26
|
-
<div id="pan-zoom-controls" class="pan-zoom-controls" hidden></div>
|
|
27
|
-
<div id="moon-container" hidden></div>
|
|
28
|
-
<div id="tooltip" class="tooltip" aria-live="polite"></div>
|
|
29
|
-
</div>
|
|
30
|
-
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<div id="app">
|
|
19
|
+
<div id="loading-state">
|
|
20
|
+
<svg class="oe-loader" viewBox="0 0 100 100"><circle cx="50" cy="50" r="45"/></svg>
|
|
21
|
+
<p>Computing lunar phase…</p>
|
|
22
|
+
</div>
|
|
23
|
+
<div id="chart-status" class="chart-status" aria-live="polite"></div>
|
|
24
|
+
<!-- Moon phase doesn't need zoom — image is fixed. Container is kept so
|
|
25
|
+
setupFullscreenToggle has a host to inject the fullscreen button into. -->
|
|
26
|
+
<div id="pan-zoom-controls" class="pan-zoom-controls" hidden></div>
|
|
27
|
+
<div id="moon-container" hidden></div>
|
|
28
|
+
<div id="tooltip" class="tooltip" aria-live="polite"></div>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
31
|
<script type="module">var e = Object.defineProperty, t = (t2, n2) => {
|
|
32
32
|
let r2 = {};
|
|
33
33
|
for (var i2 in t2) e(r2, i2, { get: t2[i2], enumerable: true });
|
|
@@ -6783,5 +6783,5 @@ y_.ontoolresult = function(e4) {
|
|
|
6783
6783
|
}
|
|
6784
6784
|
})();
|
|
6785
6785
|
</script>
|
|
6786
|
-
</body>
|
|
6787
|
-
</html>
|
|
6786
|
+
</body>
|
|
6787
|
+
</html>
|