@openephemeris/mcp-server 3.16.0 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/LICENSE +21 -21
- package/README.md +6 -4
- package/dist/backend/client.d.ts +9 -0
- package/dist/backend/client.js +35 -6
- package/dist/index.js +12 -11
- package/dist/oauth/session-utils.d.ts +59 -0
- package/dist/oauth/session-utils.js +110 -0
- package/dist/oauth/token.js +54 -7
- package/dist/server-sse.js +184 -20
- package/dist/tools/apps/bodygraph-app.d.ts +2 -1
- package/dist/tools/apps/bodygraph-app.js +144 -1
- package/dist/tools/dev.js +1 -1
- package/dist/tools/index.d.ts +25 -0
- package/dist/tools/index.js +33 -0
- package/dist/tools/output-schemas.d.ts +1 -1
- package/dist/tools/output-schemas.js +1 -1
- package/dist/tools/specialized/acg.js +6 -2
- package/dist/tools/specialized/progressed.js +3 -1
- package/dist/ui/chart-wheel.html +15 -20
- package/dist/ui/moon-phase.html +28 -28
- package/package.json +4 -2
- package/dist/tools/apps/bazi-app.d.ts +0 -23
- package/dist/tools/apps/bazi-app.js +0 -226
- package/dist/tools/apps/transit-timeline-app.d.ts +0 -20
- package/dist/tools/apps/transit-timeline-app.js +0 -295
- package/dist/tools/apps/vedic-chart-app.d.ts +0 -17
- package/dist/tools/apps/vedic-chart-app.js +0 -228
- package/dist/ui/bazi.html +0 -213
- package/dist/ui/transit-timeline.html +0 -6874
- package/dist/ui/vedic-chart.html +0 -210
package/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);
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* • explore_human_design_transit — natal + transit overlay (premium)
|
|
7
7
|
* • explore_human_design_connection — two-person connection/synastry overlay (premium)
|
|
8
8
|
* Click handlers [app-only]: hd_on_center_click, hd_on_gate_click,
|
|
9
|
-
* hd_on_channel_click, hd_on_planet_click, hd_on_variable_click
|
|
9
|
+
* hd_on_channel_click, hd_on_planet_click, hd_on_variable_click,
|
|
10
|
+
* hd_on_connection_channel_click, hd_on_transit_channel_click (overlay, Phase 5).
|
|
10
11
|
* The transit + connection tools reuse this bodygraph iframe resource; their
|
|
11
12
|
* overlay SVG is returned inline by /human-design/transit-chart and
|
|
12
13
|
* /human-design/composite (include_visual) rather than a separate render call.
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* • explore_human_design_transit — natal + transit overlay (premium)
|
|
7
7
|
* • explore_human_design_connection — two-person connection/synastry overlay (premium)
|
|
8
8
|
* Click handlers [app-only]: hd_on_center_click, hd_on_gate_click,
|
|
9
|
-
* hd_on_channel_click, hd_on_planet_click, hd_on_variable_click
|
|
9
|
+
* hd_on_channel_click, hd_on_planet_click, hd_on_variable_click,
|
|
10
|
+
* hd_on_connection_channel_click, hd_on_transit_channel_click (overlay, Phase 5).
|
|
10
11
|
* The transit + connection tools reuse this bodygraph iframe resource; their
|
|
11
12
|
* overlay SVG is returned inline by /human-design/transit-chart and
|
|
12
13
|
* /human-design/composite (include_visual) rather than a separate render call.
|
|
@@ -704,6 +705,148 @@ registerTool({
|
|
|
704
705
|
};
|
|
705
706
|
},
|
|
706
707
|
});
|
|
708
|
+
const CONNECTION_TYPE_DATA = {
|
|
709
|
+
electromagnetic: {
|
|
710
|
+
label: "Electromagnetic",
|
|
711
|
+
tagline: "attraction — opposites complete the channel",
|
|
712
|
+
body: "Each of you holds one different gate of this channel; together you complete a current neither runs alone. This is the classic draw — and, on the same wiring, the friction. What the channel does comes alive only in the meeting; apart, each of you is back to half. The gift is chemistry and mutual completion. The edge is that the completeness depends on the other, and the same difference that pulls you together can push you apart. Each of you stays with your own strategy and inner authority — the connection is information about the interplay, not an instruction.",
|
|
713
|
+
},
|
|
714
|
+
companionship: {
|
|
715
|
+
label: "Companionship",
|
|
716
|
+
tagline: "sameness — you both already run the whole channel",
|
|
717
|
+
body: "You each already define this whole channel on your own, so meeting here is recognition rather than completion. Nothing has to be negotiated; you simply operate the same way. Friendship and ease live here. The edge is that there is no charge to cross — sameness can go flat, or become the place you take each other for granted. Ease is not the same as aliveness.",
|
|
718
|
+
},
|
|
719
|
+
dominance: {
|
|
720
|
+
label: "Dominance",
|
|
721
|
+
tagline: "one defines the whole channel, the other is fully open here",
|
|
722
|
+
body: "One of you defines this entire channel; the other holds neither gate and is completely open here. The defined partner conditions the open one steadily and consistently, and the open partner takes the current in fully, unfiltered by any definition of their own — seeing the defined one clearly, for exactly who they are. It is meant to be accepted and surrendered to, not fixed or matched. The gift is consistency and clear sight. The edge is the not-self reading of \"I always have to do it your way\": the open partner can lose themselves to the steady conditioning if they drift from their own authority. Surrender here is a choice made from authority, not a disappearance.",
|
|
723
|
+
},
|
|
724
|
+
compromise: {
|
|
725
|
+
label: "Compromise",
|
|
726
|
+
tagline: "one defines the whole channel, the other holds a single gate",
|
|
727
|
+
body: "One of you defines this whole channel; the other holds exactly one of its two gates. Push and pull land on that shared gate — the half-definition keeps getting overridden by the full one, so the single-gate partner is consistently compromised here. This is the spot where \"you do it your way, I do it mine\" surfaces most. Met consciously, the recurring friction on that one gate names exactly where the work is and can sharpen both of you. Unwatched, it calcifies into a standing grievance instead of a workable edge.",
|
|
728
|
+
},
|
|
729
|
+
};
|
|
730
|
+
const TRANSIT_CLASS_DATA = {
|
|
731
|
+
transit_completed: {
|
|
732
|
+
label: "Transit completes this channel",
|
|
733
|
+
body: "For a window, a passing transit completes this channel: one of its gates is defined in your own design, the other is switched on by the sky. While that holds, the current runs in you the way it does for someone built this way full-time — except you are not, and that is the whole point. It is a gift on loan: a real taste of standing definition here, without your having become someone else for it. The pull is to grab it and decide this is now who you are; when the transit separates and the channel opens back up, that reading only leaves you grieving. Nothing about your type, strategy, or authority has changed. Use the borrowed current while it lasts, keep deciding from your own authority, and let it go cleanly when the sky moves on.",
|
|
734
|
+
},
|
|
735
|
+
transit_only: {
|
|
736
|
+
label: "Transit defines this channel on its own",
|
|
737
|
+
body: "For a window, a passing transit lights both gates of this channel — neither is yours natally. The channel runs entirely on borrowed weather; it is not part of your design at all, and it switches off completely when the transit moves on. Notice the temporary current for what it is, and keep deciding from your own authority rather than mistaking it for a new capacity.",
|
|
738
|
+
},
|
|
739
|
+
reinforced: {
|
|
740
|
+
label: "Transit reinforces your definition here",
|
|
741
|
+
body: "You already define this channel, and a passing transit also lands on one of its gates today — your standing definition, amplified for a window. Nothing new is added; the current you already run simply gets louder. When the transit moves on, it settles back to its usual level.",
|
|
742
|
+
},
|
|
743
|
+
natal: {
|
|
744
|
+
label: "Your standing definition",
|
|
745
|
+
body: "Both gates of this channel are defined in your own design. This is part of your fixed wiring, not transit weather — the passing sky does not change it. It reads here for context alongside the temporary activations.",
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
// ── Tool: hd_on_connection_channel_click [app-only] ────────────────────────
|
|
749
|
+
// Fired when the user clicks a connection (synastry) channel in the overlay
|
|
750
|
+
// bodygraph. Serves the connection-type dynamic for that specific channel.
|
|
751
|
+
registerTool({
|
|
752
|
+
name: "hd_on_connection_channel_click",
|
|
753
|
+
description: "Event handler called when the user clicks a connected channel in a Human Design connection (synastry) overlay bodygraph. " +
|
|
754
|
+
"Returns the channel identity and the relationship dynamic for its connection type " +
|
|
755
|
+
"(electromagnetic / companionship / dominance / compromise). " +
|
|
756
|
+
"This tool is called automatically by the bodygraph UI; you do not need to call it directly.",
|
|
757
|
+
inputSchema: {
|
|
758
|
+
type: "object",
|
|
759
|
+
properties: {
|
|
760
|
+
channel: { type: "string", description: "Channel identifier string (e.g. '1-8', '20-34')." },
|
|
761
|
+
connection_type: {
|
|
762
|
+
type: "string",
|
|
763
|
+
description: "Connection type: electromagnetic | companionship | dominance | compromise.",
|
|
764
|
+
},
|
|
765
|
+
gate1: { type: "number", description: "First gate number." },
|
|
766
|
+
gate2: { type: "number", description: "Second gate number." },
|
|
767
|
+
},
|
|
768
|
+
required: ["channel"],
|
|
769
|
+
},
|
|
770
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
771
|
+
annotations: { title: "Connection Channel Interpretation", readOnlyHint: true, openWorldHint: false },
|
|
772
|
+
_meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
|
|
773
|
+
handler: async (args) => {
|
|
774
|
+
const channelStr = String(args.channel ?? "");
|
|
775
|
+
const g1 = args.gate1 != null ? Number(args.gate1) : null;
|
|
776
|
+
const g2 = args.gate2 != null ? Number(args.gate2) : null;
|
|
777
|
+
const typeKey = String(args.connection_type ?? "").toLowerCase();
|
|
778
|
+
const key = channelStr.includes("-") ? channelStr : g1 != null && g2 != null ? `${g1}-${g2}` : channelStr;
|
|
779
|
+
const chan = HD_CHANNEL_DATA[key] ?? HD_CHANNEL_DATA[key.split("-").reverse().join("-")];
|
|
780
|
+
const conn = CONNECTION_TYPE_DATA[typeKey];
|
|
781
|
+
const chanName = chan?.name ?? "this channel";
|
|
782
|
+
const gatesLine = `**Gates:** ${g1 ?? channelStr.split("-")[0] ?? "?"} ↔ ${g2 ?? channelStr.split("-")[1] ?? "?"}`;
|
|
783
|
+
if (!conn) {
|
|
784
|
+
return {
|
|
785
|
+
content: [{
|
|
786
|
+
type: "text",
|
|
787
|
+
text: `**Channel ${channelStr} — ${chanName}**\n\n${gatesLine}\n\n` +
|
|
788
|
+
`This channel is defined across both people. ${chan?.description ?? ""}`.trim(),
|
|
789
|
+
}],
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
content: [{
|
|
794
|
+
type: "text",
|
|
795
|
+
text: `**${conn.label} Connection — Channel of ${chanName} (${channelStr})**\n\n` +
|
|
796
|
+
`${gatesLine}\n\n` +
|
|
797
|
+
`*${conn.tagline}*\n\n` +
|
|
798
|
+
`${conn.body}` +
|
|
799
|
+
(chan?.description ? `\n\n**This channel:** ${chan.description}` : ""),
|
|
800
|
+
}],
|
|
801
|
+
};
|
|
802
|
+
},
|
|
803
|
+
});
|
|
804
|
+
// ── Tool: hd_on_transit_channel_click [app-only] ──────────────────────────
|
|
805
|
+
// Fired when the user clicks a channel in a transit overlay bodygraph. Serves
|
|
806
|
+
// the meaning of the channel's transit class (esp. transit_completed).
|
|
807
|
+
registerTool({
|
|
808
|
+
name: "hd_on_transit_channel_click",
|
|
809
|
+
description: "Event handler called when the user clicks a channel in a Human Design transit overlay bodygraph. " +
|
|
810
|
+
"Returns the channel identity and what its transit class means — especially transit_completed, where a transit " +
|
|
811
|
+
"temporarily completes a channel (one natal gate + one transit gate). " +
|
|
812
|
+
"This tool is called automatically by the bodygraph UI; you do not need to call it directly.",
|
|
813
|
+
inputSchema: {
|
|
814
|
+
type: "object",
|
|
815
|
+
properties: {
|
|
816
|
+
channel: { type: "string", description: "Channel identifier string (e.g. '64-47')." },
|
|
817
|
+
transit_class: {
|
|
818
|
+
type: "string",
|
|
819
|
+
description: "Transit class: natal | reinforced | transit_completed | transit_only.",
|
|
820
|
+
},
|
|
821
|
+
gate1: { type: "number", description: "First gate number." },
|
|
822
|
+
gate2: { type: "number", description: "Second gate number." },
|
|
823
|
+
},
|
|
824
|
+
required: ["channel"],
|
|
825
|
+
},
|
|
826
|
+
outputSchema: OUTPUT_SCHEMA_JSON,
|
|
827
|
+
annotations: { title: "Transit Channel Interpretation", readOnlyHint: true, openWorldHint: false },
|
|
828
|
+
_meta: { ui: { resourceUri: BODYGRAPH_RESOURCE_URI, visibility: ["app"] } },
|
|
829
|
+
handler: async (args) => {
|
|
830
|
+
const channelStr = String(args.channel ?? "");
|
|
831
|
+
const g1 = args.gate1 != null ? Number(args.gate1) : null;
|
|
832
|
+
const g2 = args.gate2 != null ? Number(args.gate2) : null;
|
|
833
|
+
const cls = String(args.transit_class ?? "transit_completed").toLowerCase();
|
|
834
|
+
const key = channelStr.includes("-") ? channelStr : g1 != null && g2 != null ? `${g1}-${g2}` : channelStr;
|
|
835
|
+
const chan = HD_CHANNEL_DATA[key] ?? HD_CHANNEL_DATA[key.split("-").reverse().join("-")];
|
|
836
|
+
const info = TRANSIT_CLASS_DATA[cls] ?? TRANSIT_CLASS_DATA.transit_completed;
|
|
837
|
+
const chanName = chan?.name ?? "this channel";
|
|
838
|
+
const gatesLine = `**Gates:** ${g1 ?? channelStr.split("-")[0] ?? "?"} ↔ ${g2 ?? channelStr.split("-")[1] ?? "?"}`;
|
|
839
|
+
return {
|
|
840
|
+
content: [{
|
|
841
|
+
type: "text",
|
|
842
|
+
text: `**Channel of ${chanName} (${channelStr}) — ${info.label}**\n\n` +
|
|
843
|
+
`${gatesLine}\n\n` +
|
|
844
|
+
`${info.body}` +
|
|
845
|
+
(chan?.description ? `\n\n**This channel:** ${chan.description}` : ""),
|
|
846
|
+
}],
|
|
847
|
+
};
|
|
848
|
+
},
|
|
849
|
+
});
|
|
707
850
|
const HD_CENTER_DATA = {
|
|
708
851
|
Head: {
|
|
709
852
|
keynote: "Inspiration, mental pressure, questioning",
|
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",
|