@openephemeris/mcp-server 3.23.1 → 4.0.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 +105 -0
- package/LICENSE +21 -21
- package/README.md +52 -3
- package/dist/analytics.js +37 -5
- package/dist/backend/client.d.ts +7 -0
- package/dist/backend/client.js +39 -38
- package/dist/index.js +64 -2
- package/dist/oauth/session-utils.d.ts +42 -18
- package/dist/oauth/session-utils.js +79 -0
- package/dist/server-sse.js +114 -14
- package/dist/tools/apps/bazi-app.js +12 -26
- package/dist/tools/apps/bi-wheel-app.js +2 -2
- package/dist/tools/apps/bodygraph-app.d.ts +5 -5
- package/dist/tools/apps/bodygraph-app.js +187 -225
- package/dist/tools/apps/chart-wheel-app.js +5 -3
- package/dist/tools/apps/location-tools.js +167 -18
- package/dist/tools/apps/moon-phase-app.js +10 -3
- package/dist/tools/apps/transit-timeline-app.js +6 -4
- package/dist/tools/apps/vedic-chart-app.js +15 -49
- package/dist/tools/datetime.d.ts +65 -0
- package/dist/tools/datetime.js +153 -0
- package/dist/tools/index.d.ts +45 -2
- package/dist/tools/index.js +78 -2
- package/dist/tools/specialized/account.d.ts +1 -0
- package/dist/tools/specialized/account.js +100 -0
- package/dist/tools/specialized/acg.js +16 -14
- package/dist/tools/specialized/bazi.d.ts +7 -1
- package/dist/tools/specialized/bazi.js +86 -19
- package/dist/tools/specialized/bi_wheel.js +5 -4
- package/dist/tools/specialized/chart_wheel.js +5 -8
- package/dist/tools/specialized/comparative.js +13 -5
- package/dist/tools/specialized/electional.js +7 -7
- package/dist/tools/specialized/ephemeris_core.js +13 -8
- package/dist/tools/specialized/ephemeris_extended.js +27 -17
- package/dist/tools/specialized/hd_bodygraph.js +8 -10
- package/dist/tools/specialized/hd_cycles.js +7 -14
- package/dist/tools/specialized/hd_group.js +20 -13
- package/dist/tools/specialized/human_design.js +11 -17
- package/dist/tools/specialized/moon.js +8 -2
- package/dist/tools/specialized/natal.js +7 -9
- package/dist/tools/specialized/progressed.js +12 -8
- package/dist/tools/specialized/relocation.js +9 -3
- package/dist/tools/specialized/returns.js +23 -11
- package/dist/tools/specialized/synastry.js +17 -6
- package/dist/tools/specialized/transits.js +9 -5
- package/dist/tools/specialized/vedic.js +5 -3
- package/dist/tools/specialized/venus_star_points.js +14 -9
- package/dist/ui/bazi.html +1063 -1049
- package/dist/ui/bi-wheel.html +4197 -4120
- package/dist/ui/bodygraph.html +3855 -3720
- package/dist/ui/chart-wheel.html +3779 -3706
- package/dist/ui/moon-phase.html +3228 -3145
- package/dist/ui/transit-timeline.html +199 -170
- package/dist/ui/vedic-chart.html +1116 -1098
- package/package.json +6 -3
- package/smithery.yaml +1 -1
|
@@ -15,7 +15,14 @@
|
|
|
15
15
|
* a different machine and 404. We embed the FLY_MACHINE_ID into generated
|
|
16
16
|
* session IDs so an incoming request can be re-routed to the owning machine
|
|
17
17
|
* via the `fly-replay` header. Local/dev (no FLY_MACHINE_ID) is a no-op.
|
|
18
|
+
*
|
|
19
|
+
* 3. Session-credential binding — a session id is a bearer-ish routing token,
|
|
20
|
+
* not proof of identity. We bind each session to the credential that
|
|
21
|
+
* initialized it (see credentialBinding* below) so a leaked session id
|
|
22
|
+
* alone can't be used with a *different* valid credential to hijack another
|
|
23
|
+
* user's session or SSE stream.
|
|
18
24
|
*/
|
|
25
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
19
26
|
/**
|
|
20
27
|
* Decode a JWT's payload without verifying the signature and return its `exp`
|
|
21
28
|
* claim (seconds since epoch), or null if the token is malformed / has no exp.
|
|
@@ -108,3 +115,75 @@ export function replayTargetMachine(sessionId, opts) {
|
|
|
108
115
|
return null; // we own it
|
|
109
116
|
return target;
|
|
110
117
|
}
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Session-credential binding
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
/**
|
|
122
|
+
* Decode a JWT's `sub` (subject) claim without verifying the signature.
|
|
123
|
+
* The subject is the stable Supabase user id — it survives token refresh,
|
|
124
|
+
* whereas the raw JWT string rotates roughly hourly. Returns null when the
|
|
125
|
+
* token is malformed or carries no usable string `sub`.
|
|
126
|
+
*/
|
|
127
|
+
export function decodeJwtSub(token) {
|
|
128
|
+
if (typeof token !== "string" || !token)
|
|
129
|
+
return null;
|
|
130
|
+
const parts = token.split(".");
|
|
131
|
+
if (parts.length !== 3)
|
|
132
|
+
return null;
|
|
133
|
+
try {
|
|
134
|
+
const payloadJson = Buffer.from(parts[1], "base64url").toString("utf8");
|
|
135
|
+
const payload = JSON.parse(payloadJson);
|
|
136
|
+
if (typeof payload.sub === "string" && payload.sub)
|
|
137
|
+
return payload.sub;
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Compute the STABLE binding token for a presented credential — the value a
|
|
146
|
+
* session is bound to at init and re-checked on every resume:
|
|
147
|
+
*
|
|
148
|
+
* - API key → the raw key. A given key is re-presented verbatim on every
|
|
149
|
+
* request, so binding to it is exact.
|
|
150
|
+
* - JWT → the `sub` claim (Supabase user id), NOT the raw token. claude.ai
|
|
151
|
+
* rotates the Bearer JWT ~hourly via /oauth/token; binding to the raw token
|
|
152
|
+
* would 403 every legitimate refresh. The subject is constant across
|
|
153
|
+
* refreshes for the same user, so binding survives rotation while still
|
|
154
|
+
* rejecting a different user's token.
|
|
155
|
+
* - JWT with no decodable `sub` → fall back to the raw token (best effort;
|
|
156
|
+
* Supabase access tokens always carry `sub`, so this is a degenerate case).
|
|
157
|
+
*
|
|
158
|
+
* Returns null when neither credential is present.
|
|
159
|
+
*/
|
|
160
|
+
export function credentialBindingToken(auth) {
|
|
161
|
+
if (auth.apiKey)
|
|
162
|
+
return `key:${auth.apiKey}`;
|
|
163
|
+
if (auth.jwt) {
|
|
164
|
+
const sub = decodeJwtSub(auth.jwt);
|
|
165
|
+
return sub ? `sub:${sub}` : `jwt:${auth.jwt}`;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
/** SHA-256 hex digest of a binding token — what we store on the session record. */
|
|
170
|
+
export function hashBindingToken(token) {
|
|
171
|
+
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Compute the stored/compared binding hash for a request's auth, or null when
|
|
175
|
+
* the request carries no credential.
|
|
176
|
+
*/
|
|
177
|
+
export function credentialBindingHash(auth) {
|
|
178
|
+
const token = credentialBindingToken(auth);
|
|
179
|
+
return token ? hashBindingToken(token) : null;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Constant-time comparison of two binding hashes. Both are fixed-length hex
|
|
183
|
+
* digests; a length mismatch (or a null presented hash) is a non-match.
|
|
184
|
+
*/
|
|
185
|
+
export function bindingHashMatches(stored, presented) {
|
|
186
|
+
if (!presented || stored.length !== presented.length)
|
|
187
|
+
return false;
|
|
188
|
+
return timingSafeEqual(Buffer.from(stored, "utf8"), Buffer.from(presented, "utf8"));
|
|
189
|
+
}
|
package/dist/server-sse.js
CHANGED
|
@@ -23,7 +23,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
23
23
|
import { InMemoryEventStore } from "./event-store.js";
|
|
24
24
|
import { captureEvent, distinctIdFor } from "./analytics.js";
|
|
25
25
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
|
|
26
|
-
import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools } from "./tools/index.js";
|
|
26
|
+
import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools, parseToolSurface } from "./tools/index.js";
|
|
27
27
|
import { BackendClient, runWithClient } from "./backend/client.js";
|
|
28
28
|
import { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
|
|
29
29
|
import { BODYGRAPH_RESOURCE_URI, BODYGRAPH_MIME_TYPE, getBodygraphBundle, } from "./tools/apps/bodygraph-app.js";
|
|
@@ -40,7 +40,7 @@ import { oauthDcrRouter } from "./oauth/dcr.js";
|
|
|
40
40
|
import { oauthTokenRouter } from "./oauth/token.js";
|
|
41
41
|
import { OAuthStore } from "./oauth/store.js";
|
|
42
42
|
import { createRateLimiter } from "./oauth/rate-limit.js";
|
|
43
|
-
import { isJwtExpired, encodeSessionId, replayTargetMachine, } from "./oauth/session-utils.js";
|
|
43
|
+
import { isJwtExpired, encodeSessionId, replayTargetMachine, credentialBindingHash, bindingHashMatches, } from "./oauth/session-utils.js";
|
|
44
44
|
// ---------------------------------------------------------------------------
|
|
45
45
|
// Helpers
|
|
46
46
|
// ---------------------------------------------------------------------------
|
|
@@ -204,7 +204,23 @@ const WELCOME_PROMPT_CONTENT = [
|
|
|
204
204
|
// ---------------------------------------------------------------------------
|
|
205
205
|
// MCP Server factory — one per SSE/HTTP session
|
|
206
206
|
// ---------------------------------------------------------------------------
|
|
207
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Properties attached to every remote-transport event, mirroring the stdio
|
|
209
|
+
* shape in src/index.ts so a single PostHog query can compare the two.
|
|
210
|
+
* `client_name` comes from the MCP initialize handshake and identifies the
|
|
211
|
+
* connecting host (claude.ai, Claude Desktop, Cursor, …).
|
|
212
|
+
*/
|
|
213
|
+
function transportProps(server, surface) {
|
|
214
|
+
const info = server.getClientVersion();
|
|
215
|
+
return {
|
|
216
|
+
transport: "http",
|
|
217
|
+
surface,
|
|
218
|
+
client_name: info?.name ?? "unknown",
|
|
219
|
+
client_version: info?.version ?? "unknown",
|
|
220
|
+
server_version: version,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function createMcpServer(analyticsId = "anonymous", surface = "core") {
|
|
208
224
|
const server = new Server({
|
|
209
225
|
name: "openephemeris-mcp",
|
|
210
226
|
title: "Open Ephemeris",
|
|
@@ -220,7 +236,7 @@ function createMcpServer(analyticsId = "anonymous") {
|
|
|
220
236
|
// but the SDK's Zod schema doesn't include it yet — pass via spread
|
|
221
237
|
...{
|
|
222
238
|
description: "NASA JPL DE440-backed astronomical computation engine for AI agents. " +
|
|
223
|
-
"
|
|
239
|
+
"90+ typed tools covering natal charts, transit forecasting, Human Design " +
|
|
224
240
|
"bodygraphs, eclipses, astrocartography power lines, Venus Star Points, " +
|
|
225
241
|
"electional timing, synastry, composite charts, Vedic/Jyotish, Chinese BaZi, " +
|
|
226
242
|
"and more — powered by JPL DE440 ephemerides for sub-arcsecond accuracy.",
|
|
@@ -257,7 +273,7 @@ function createMcpServer(analyticsId = "anonymous") {
|
|
|
257
273
|
});
|
|
258
274
|
// --- Tool handlers ---
|
|
259
275
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
260
|
-
tools: modelVisibleTools("http").map((tool) => ({
|
|
276
|
+
tools: modelVisibleTools("http", surface).map((tool) => ({
|
|
261
277
|
name: tool.name,
|
|
262
278
|
description: tool.description,
|
|
263
279
|
inputSchema: tool.inputSchema,
|
|
@@ -305,15 +321,30 @@ function createMcpServer(analyticsId = "anonymous") {
|
|
|
305
321
|
// composite ~9.3s) with a wide margin.
|
|
306
322
|
const result = await withTimeout(tool.handler(request.params.arguments ?? {}), TOOL_CALL_TIMEOUT_MS, toolName);
|
|
307
323
|
const durationMs = Date.now() - startTime;
|
|
308
|
-
captureEvent("mcp_tool_call", analyticsId, {
|
|
324
|
+
captureEvent("mcp_tool_call", analyticsId, {
|
|
325
|
+
tool: toolName,
|
|
326
|
+
duration_ms: durationMs,
|
|
327
|
+
...transportProps(server, surface),
|
|
328
|
+
});
|
|
309
329
|
return formatToolResponse(toolName, result, durationMs);
|
|
310
330
|
}
|
|
311
331
|
catch (error) {
|
|
312
332
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
313
333
|
console.error(`[MCP] ❌ Failed: ${toolName} - ${errorMessage}`);
|
|
314
334
|
// status makes 402 (paywall) and 429 (rate limit) countable in PostHog.
|
|
315
|
-
|
|
316
|
-
|
|
335
|
+
// Errors thrown locally (validateRequired, withTimeout) are plain Errors
|
|
336
|
+
// with no status — previously they all landed as status:"unknown", which
|
|
337
|
+
// made a burst of client-side argument errors indistinguishable from a
|
|
338
|
+
// backend outage. error_kind splits the two.
|
|
339
|
+
const err = error;
|
|
340
|
+
const status = err?.status ?? err?.response?.status;
|
|
341
|
+
captureEvent("mcp_tool_error", analyticsId, {
|
|
342
|
+
tool: toolName,
|
|
343
|
+
status: status ?? "unknown",
|
|
344
|
+
error_kind: status != null ? "backend" : "local",
|
|
345
|
+
code: err?.code ?? "none",
|
|
346
|
+
...transportProps(server, surface),
|
|
347
|
+
});
|
|
317
348
|
// All failures surface as isError: true so the host can recover
|
|
318
349
|
// (retry / OAuth refresh). The remote transport has no device-auth flow,
|
|
319
350
|
// so formatToolError's device-auth exception never fires here — a 401
|
|
@@ -435,6 +466,20 @@ function createMcpServer(analyticsId = "anonymous") {
|
|
|
435
466
|
});
|
|
436
467
|
return server;
|
|
437
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* Shared 403 for a resume/attach whose presented credential does not match the
|
|
471
|
+
* one the session was initialized with. RFC 7807 problem+json.
|
|
472
|
+
*/
|
|
473
|
+
function rejectCredentialMismatch(res) {
|
|
474
|
+
res.status(403).type("application/problem+json").json({
|
|
475
|
+
type: "https://openephemeris.com/problems/session-credential-mismatch",
|
|
476
|
+
title: "Session credential mismatch",
|
|
477
|
+
status: 403,
|
|
478
|
+
error: "session_credential_mismatch",
|
|
479
|
+
detail: "This MCP session belongs to a different credential. Re-initialize a new " +
|
|
480
|
+
"session with your own API key or Bearer token instead of resuming this one.",
|
|
481
|
+
});
|
|
482
|
+
}
|
|
438
483
|
const httpSessions = new Map();
|
|
439
484
|
// Sessions are held in memory and only freed on explicit DELETE / transport
|
|
440
485
|
// close. Hosts that vanish without teardown (tab closed, network drop) would
|
|
@@ -552,7 +597,7 @@ export async function createSseApp() {
|
|
|
552
597
|
res.setHeader("Vary", "Origin");
|
|
553
598
|
}
|
|
554
599
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
555
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-OpenEphemeris-API-Key, mcp-session-id");
|
|
600
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-OpenEphemeris-API-Key, X-OE-Tool-Surface, mcp-session-id");
|
|
556
601
|
if (req.method === "OPTIONS") {
|
|
557
602
|
res.status(204).end();
|
|
558
603
|
return;
|
|
@@ -610,7 +655,10 @@ export async function createSseApp() {
|
|
|
610
655
|
// Spec: https://smithery.ai/docs/build/publish#server-scanning
|
|
611
656
|
// ---------------------------------------------------------------------------
|
|
612
657
|
app.get("/.well-known/mcp/server-card.json", (_req, res) => {
|
|
613
|
-
|
|
658
|
+
// Deliberately "full": the card is a registry catalogue, not model context.
|
|
659
|
+
// Smithery and friends should see every callable tool even though a live
|
|
660
|
+
// session advertises the leaner core surface by default.
|
|
661
|
+
const tools = modelVisibleTools("http", "full").map((tool) => ({
|
|
614
662
|
name: tool.name,
|
|
615
663
|
description: tool.description,
|
|
616
664
|
inputSchema: tool.inputSchema,
|
|
@@ -621,7 +669,7 @@ export async function createSseApp() {
|
|
|
621
669
|
serverInfo: {
|
|
622
670
|
name: "Open Ephemeris",
|
|
623
671
|
version,
|
|
624
|
-
description: "NASA JPL DE440-backed astronomical computation engine for AI agents.
|
|
672
|
+
description: "NASA JPL DE440-backed astronomical computation engine for AI agents. 90+ typed tools covering " +
|
|
625
673
|
"natal charts, transit forecasting, Human Design bodygraphs, eclipses, astrocartography " +
|
|
626
674
|
"power lines, Venus Star Points, electional timing, synastry, composite charts, Vedic/Jyotish, " +
|
|
627
675
|
"Chinese BaZi, and more — powered by JPL DE440 ephemerides for sub-arcsecond " +
|
|
@@ -695,6 +743,15 @@ export async function createSseApp() {
|
|
|
695
743
|
});
|
|
696
744
|
return;
|
|
697
745
|
}
|
|
746
|
+
// Session-credential binding: the presented credential must hash to the
|
|
747
|
+
// same stable binding token the session was initialized with. This is the
|
|
748
|
+
// check that turns the session id from a bearer token into a routing token
|
|
749
|
+
// — a leaked id plus a *different* valid key/JWT cannot hijack the session.
|
|
750
|
+
// (JWT binds to `sub`, so claude.ai's ~hourly token refresh still matches.)
|
|
751
|
+
if (!bindingHashMatches(session.credentialHash, credentialBindingHash(resumedAuth))) {
|
|
752
|
+
rejectCredentialMismatch(res);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
698
755
|
// Refresh the session's Bearer JWT if the host rotated it. claude.ai
|
|
699
756
|
// refreshes tokens via /oauth/token roughly hourly (self-signed Supabase
|
|
700
757
|
// JWTs expire in 1h); the frozen JWT captured at init would otherwise 401
|
|
@@ -763,6 +820,11 @@ export async function createSseApp() {
|
|
|
763
820
|
});
|
|
764
821
|
return;
|
|
765
822
|
}
|
|
823
|
+
// Bind the session to the initializing credential. Computed BEFORE the
|
|
824
|
+
// transport is created so onsessioninitialized (which fires synchronously
|
|
825
|
+
// inside handleRequest, before any resume can arrive) can store it.
|
|
826
|
+
// Non-null past the auth gate above (apiKey || jwt is guaranteed here).
|
|
827
|
+
const credentialHash = credentialBindingHash({ apiKey, jwt }) ?? "";
|
|
766
828
|
// Create per-session transport, server, and client
|
|
767
829
|
const transport = new StreamableHTTPServerTransport({
|
|
768
830
|
// Embed the Fly machine id so a resume request landing on another machine
|
|
@@ -774,14 +836,27 @@ export async function createSseApp() {
|
|
|
774
836
|
// — the session is machine-pinned via fly-replay.
|
|
775
837
|
eventStore: new InMemoryEventStore(),
|
|
776
838
|
onsessioninitialized: (id) => {
|
|
777
|
-
httpSessions.set(id, { server, transport, client, lastSeen: Date.now() });
|
|
839
|
+
httpSessions.set(id, { server, transport, client, credentialHash, lastSeen: Date.now() });
|
|
778
840
|
console.error(`[HTTP] Session initialized: ${id}`);
|
|
779
841
|
},
|
|
780
842
|
});
|
|
781
843
|
const client = new BackendClient({ baseURL: BACKEND_URL, apiKey, jwt });
|
|
782
844
|
const analyticsId = distinctIdFor(apiKey ?? jwt);
|
|
783
|
-
|
|
784
|
-
|
|
845
|
+
// Tool surface is fixed for the life of the session: we do not declare
|
|
846
|
+
// `tools.listChanged`, so a host has no obligation to re-fetch the list.
|
|
847
|
+
// Opt into the full surface with `?profile=full` on the connector URL, or
|
|
848
|
+
// an `X-OE-Tool-Surface: full` header where the host allows custom headers.
|
|
849
|
+
const surface = parseToolSurface(req.query.profile ?? req.headers["x-oe-tool-surface"]);
|
|
850
|
+
const server = createMcpServer(analyticsId, surface);
|
|
851
|
+
// Fire session_init after the handshake so getClientVersion() is populated
|
|
852
|
+
// — without this the connecting host is unknown and we cannot tell which
|
|
853
|
+
// clients the remote server is actually serving.
|
|
854
|
+
server.oninitialized = () => {
|
|
855
|
+
captureEvent("mcp_session_init", analyticsId, {
|
|
856
|
+
auth: apiKey ? "api_key" : "oauth",
|
|
857
|
+
...transportProps(server, surface),
|
|
858
|
+
});
|
|
859
|
+
};
|
|
785
860
|
transport.onclose = () => {
|
|
786
861
|
if (transport.sessionId) {
|
|
787
862
|
httpSessions.delete(transport.sessionId);
|
|
@@ -801,6 +876,27 @@ export async function createSseApp() {
|
|
|
801
876
|
});
|
|
802
877
|
return;
|
|
803
878
|
}
|
|
879
|
+
// The SSE leg is an authenticated attach, not an open pipe keyed only by a
|
|
880
|
+
// session id: a leaked id previously let anyone attach to another user's
|
|
881
|
+
// event stream. Require a credential (same gate as POST resume) and, once
|
|
882
|
+
// the session is found, the same credential-binding match.
|
|
883
|
+
const attachAuth = extractAuth(req);
|
|
884
|
+
if (!attachAuth.apiKey && !attachAuth.jwt) {
|
|
885
|
+
res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
|
|
886
|
+
res.status(401).json({
|
|
887
|
+
error: "auth_required",
|
|
888
|
+
message: "Authentication required. Pass an API key via X-API-Key header, or a Bearer token via Authorization header.",
|
|
889
|
+
});
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (attachAuth.jwt && isJwtExpired(attachAuth.jwt)) {
|
|
893
|
+
res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", error_description="The access token expired", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
|
|
894
|
+
res.status(401).json({
|
|
895
|
+
error: "invalid_token",
|
|
896
|
+
message: "Your session token has expired. Re-authorize to continue.",
|
|
897
|
+
});
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
804
900
|
const session = httpSessions.get(sessionId);
|
|
805
901
|
if (!session) {
|
|
806
902
|
// Cross-machine replay for the SSE leg (same scheme as POST /mcp resume).
|
|
@@ -818,6 +914,10 @@ export async function createSseApp() {
|
|
|
818
914
|
});
|
|
819
915
|
return;
|
|
820
916
|
}
|
|
917
|
+
if (!bindingHashMatches(session.credentialHash, credentialBindingHash(attachAuth))) {
|
|
918
|
+
rejectCredentialMismatch(res);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
821
921
|
session.lastSeen = Date.now();
|
|
822
922
|
// The SSE leg stays open indefinitely — keep it alive through proxies.
|
|
823
923
|
startSseKeepalive(res);
|
|
@@ -22,6 +22,10 @@ import { fileURLToPath } from "node:url";
|
|
|
22
22
|
import { registerTool, SERVER_VERSION } from "../index.js";
|
|
23
23
|
import { getActiveClient } from "../../backend/client.js";
|
|
24
24
|
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
25
|
+
// One BaZi component parser, shared with the specialized tools. The local copy
|
|
26
|
+
// this replaces used `new Date(naive)`, which resolves in the host process's
|
|
27
|
+
// timezone and silently shifted the hour pillar on any non-UTC server.
|
|
28
|
+
import { parseBaziArgs } from "../specialized/bazi.js";
|
|
25
29
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
26
30
|
export const BAZI_RESOURCE_URI = "ui://openephemeris/bazi";
|
|
27
31
|
export const BAZI_MIME_TYPE = "text/html;profile=mcp-app";
|
|
@@ -56,30 +60,6 @@ export function getBaziBundle() {
|
|
|
56
60
|
export function clearBaziBundleCache() {
|
|
57
61
|
cachedBundle = null;
|
|
58
62
|
}
|
|
59
|
-
function parseBaziArgs(args) {
|
|
60
|
-
let year = args.year;
|
|
61
|
-
let month = args.month;
|
|
62
|
-
let day = args.day;
|
|
63
|
-
let hour = args.hour;
|
|
64
|
-
if (args.datetime && (!year || !month || !day)) {
|
|
65
|
-
const dt = new Date(String(args.datetime));
|
|
66
|
-
if (!isNaN(dt.getTime())) {
|
|
67
|
-
year = dt.getUTCFullYear();
|
|
68
|
-
month = dt.getUTCMonth() + 1;
|
|
69
|
-
day = dt.getUTCDate();
|
|
70
|
-
if (hour == null)
|
|
71
|
-
hour = dt.getUTCHours();
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
if (!year || !month || !day) {
|
|
75
|
-
throw new Error("Provide year/month/day fields, or a datetime ISO string. " +
|
|
76
|
-
"Example: year=1987, month=7, day=15 OR datetime='1987-07-15T14:00:00'");
|
|
77
|
-
}
|
|
78
|
-
const out = { year, month, day };
|
|
79
|
-
if (hour != null)
|
|
80
|
-
out.hour = hour;
|
|
81
|
-
return out;
|
|
82
|
-
}
|
|
83
63
|
/**
|
|
84
64
|
* Fetch the BaZi chart via the include_visual intercept — one call returns
|
|
85
65
|
* both the structured pillar data (year/month/day/hour/day_master) and the
|
|
@@ -150,8 +130,14 @@ registerTool({
|
|
|
150
130
|
},
|
|
151
131
|
datetime: {
|
|
152
132
|
type: "string",
|
|
153
|
-
description: "Alternative to year/month/day: ISO 8601 datetime
|
|
154
|
-
"
|
|
133
|
+
description: "Alternative to year/month/day: ISO 8601 datetime read as LOCAL wall-clock time at the " +
|
|
134
|
+
"birth place — BaZi pillars are local by definition, so a zone-less value is correct " +
|
|
135
|
+
"here and is NOT converted to UTC. If it carries a 'Z' or offset, also pass timezone.",
|
|
136
|
+
},
|
|
137
|
+
timezone: {
|
|
138
|
+
type: "string",
|
|
139
|
+
description: "IANA timezone name for the birth place, e.g. 'Asia/Shanghai'. Only needed when " +
|
|
140
|
+
"datetime carries a 'Z' or ±HH:MM offset.",
|
|
155
141
|
},
|
|
156
142
|
},
|
|
157
143
|
additionalProperties: false,
|
|
@@ -427,7 +427,7 @@ registerTool({
|
|
|
427
427
|
type: "string",
|
|
428
428
|
description: "ISO 8601 datetime for Person 1 / Natal chart (e.g. '1990-04-15T14:30:00-05:00').",
|
|
429
429
|
},
|
|
430
|
-
person1_latitude: { type: "number", description: "Birth latitude for Person 1 (decimal degrees, positive = North)." },
|
|
430
|
+
person1_latitude: { type: "number", description: "Birth latitude for Person 1 (decimal degrees, positive = North). Resolve from a place name with location_search; never recall coordinates from memory." },
|
|
431
431
|
person1_longitude: { type: "number", description: "Birth longitude for Person 1 (decimal degrees, positive = East)." },
|
|
432
432
|
person1_timezone: { type: "string", description: "IANA timezone for Person 1 (e.g. 'America/New_York')." },
|
|
433
433
|
person1_name: {
|
|
@@ -449,7 +449,7 @@ registerTool({
|
|
|
449
449
|
},
|
|
450
450
|
location: {
|
|
451
451
|
type: "string",
|
|
452
|
-
description: "Label for Person 1 / Natal location (display only, e.g. 'New York, NY').",
|
|
452
|
+
description: "Label for Person 1 / Natal location (display only, e.g. 'New York, NY'). This is a caption, NOT a geocoder — it does not set the chart location. Supply latitude/longitude too (resolve them with location_search), or the chart is computed at 0N 0E.",
|
|
453
453
|
},
|
|
454
454
|
mode: {
|
|
455
455
|
type: "string",
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* bodygraph-app.ts
|
|
2
|
+
* bodygraph-app.ts — MCP App tool registration for the Human Design Bodygraph Explorer.
|
|
3
3
|
*
|
|
4
4
|
* Entry tools [model + app]:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* • explore_human_design — natal bodygraph, data + UI resource
|
|
6
|
+
* • explore_human_design_transit — natal + transit overlay (premium)
|
|
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
9
|
* hd_on_channel_click, hd_on_planet_click, hd_on_variable_click,
|
|
10
10
|
* hd_on_connection_channel_click, hd_on_transit_channel_click (overlay, Phase 5).
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* overlay SVG is returned inline by /human-design/transit-chart and
|
|
13
13
|
* /human-design/composite (include_visual) rather than a separate render call.
|
|
14
14
|
*
|
|
15
|
-
* The bodygraph is rendered client-side from structured JSON
|
|
15
|
+
* The bodygraph is rendered client-side from structured JSON — no SVG endpoint
|
|
16
16
|
* is called, keeping latency at zero and the architecture consistent with
|
|
17
17
|
* the chart-wheel-app approach.
|
|
18
18
|
*
|