@openephemeris/mcp-server 3.24.0 → 4.1.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 +173 -0
- package/LICENSE +21 -21
- package/README.md +75 -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/prompts.js +55 -42
- package/dist/server-sse.js +114 -14
- package/dist/tools/apps/bazi-app.js +15 -28
- package/dist/tools/apps/bi-wheel-app.js +14 -11
- package/dist/tools/apps/bodygraph-app.d.ts +5 -5
- package/dist/tools/apps/bodygraph-app.js +159 -212
- package/dist/tools/apps/chart-wheel-app.js +21 -20
- 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/dev.js +4 -3
- package/dist/tools/index.d.ts +45 -2
- package/dist/tools/index.js +81 -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 +89 -23
- package/dist/tools/specialized/bi_wheel.js +5 -4
- package/dist/tools/specialized/chart_wheel.js +5 -8
- package/dist/tools/specialized/comparative.js +40 -21
- package/dist/tools/specialized/electional.js +13 -10
- package/dist/tools/specialized/ephemeris_core.js +13 -8
- package/dist/tools/specialized/ephemeris_extended.js +60 -53
- 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 +14 -6
- 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 +4188 -4128
- package/dist/ui/bodygraph.html +3673 -3616
- package/dist/ui/chart-wheel.html +3769 -3713
- package/dist/ui/moon-phase.html +3219 -3153
- package/dist/ui/transit-timeline.html +199 -170
- package/dist/ui/vedic-chart.html +1116 -1098
- package/package.json +3 -2
- package/smithery.yaml +1 -1
|
@@ -1,21 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* src/oauth/session-utils.ts — Pure helpers for session reliability.
|
|
3
|
-
*
|
|
4
|
-
* Two concerns live here so they can be unit-tested without spinning up the
|
|
5
|
-
* full SSE server:
|
|
6
|
-
*
|
|
7
|
-
* 1. JWT expiry decoding — read the `exp` claim WITHOUT signature
|
|
8
|
-
* verification. The signature is validated downstream by the Go API's
|
|
9
|
-
* ValidateSupabaseJWT; here we only need to know whether a token is
|
|
10
|
-
* already expired so the /mcp gate can trigger the host's OAuth refresh
|
|
11
|
-
* (401 invalid_token) instead of letting it die as a confusing tool error.
|
|
12
|
-
*
|
|
13
|
-
* 2. Machine-scoped session IDs — Fly runs min_machines_running >= 2, but the
|
|
14
|
-
* HTTP/SSE session store is a per-process Map. A resume request can land on
|
|
15
|
-
* a different machine and 404. We embed the FLY_MACHINE_ID into generated
|
|
16
|
-
* session IDs so an incoming request can be re-routed to the owning machine
|
|
17
|
-
* via the `fly-replay` header. Local/dev (no FLY_MACHINE_ID) is a no-op.
|
|
18
|
-
*/
|
|
19
1
|
/**
|
|
20
2
|
* Decode a JWT's payload without verifying the signature and return its `exp`
|
|
21
3
|
* claim (seconds since epoch), or null if the token is malformed / has no exp.
|
|
@@ -57,3 +39,45 @@ export declare function replayTargetMachine(sessionId: string, opts: {
|
|
|
57
39
|
selfMachineId?: string;
|
|
58
40
|
alreadyReplayed: boolean;
|
|
59
41
|
}): string | null;
|
|
42
|
+
/**
|
|
43
|
+
* Decode a JWT's `sub` (subject) claim without verifying the signature.
|
|
44
|
+
* The subject is the stable Supabase user id — it survives token refresh,
|
|
45
|
+
* whereas the raw JWT string rotates roughly hourly. Returns null when the
|
|
46
|
+
* token is malformed or carries no usable string `sub`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function decodeJwtSub(token: string): string | null;
|
|
49
|
+
/**
|
|
50
|
+
* Compute the STABLE binding token for a presented credential — the value a
|
|
51
|
+
* session is bound to at init and re-checked on every resume:
|
|
52
|
+
*
|
|
53
|
+
* - API key → the raw key. A given key is re-presented verbatim on every
|
|
54
|
+
* request, so binding to it is exact.
|
|
55
|
+
* - JWT → the `sub` claim (Supabase user id), NOT the raw token. claude.ai
|
|
56
|
+
* rotates the Bearer JWT ~hourly via /oauth/token; binding to the raw token
|
|
57
|
+
* would 403 every legitimate refresh. The subject is constant across
|
|
58
|
+
* refreshes for the same user, so binding survives rotation while still
|
|
59
|
+
* rejecting a different user's token.
|
|
60
|
+
* - JWT with no decodable `sub` → fall back to the raw token (best effort;
|
|
61
|
+
* Supabase access tokens always carry `sub`, so this is a degenerate case).
|
|
62
|
+
*
|
|
63
|
+
* Returns null when neither credential is present.
|
|
64
|
+
*/
|
|
65
|
+
export declare function credentialBindingToken(auth: {
|
|
66
|
+
apiKey?: string;
|
|
67
|
+
jwt?: string;
|
|
68
|
+
}): string | null;
|
|
69
|
+
/** SHA-256 hex digest of a binding token — what we store on the session record. */
|
|
70
|
+
export declare function hashBindingToken(token: string): string;
|
|
71
|
+
/**
|
|
72
|
+
* Compute the stored/compared binding hash for a request's auth, or null when
|
|
73
|
+
* the request carries no credential.
|
|
74
|
+
*/
|
|
75
|
+
export declare function credentialBindingHash(auth: {
|
|
76
|
+
apiKey?: string;
|
|
77
|
+
jwt?: string;
|
|
78
|
+
}): string | null;
|
|
79
|
+
/**
|
|
80
|
+
* Constant-time comparison of two binding hashes. Both are fixed-length hex
|
|
81
|
+
* digests; a length mismatch (or a null presented hash) is a non-match.
|
|
82
|
+
*/
|
|
83
|
+
export declare function bindingHashMatches(stored: string, presented: string | null): boolean;
|
|
@@ -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/prompts.js
CHANGED
|
@@ -68,13 +68,15 @@ export const PROMPTS = [
|
|
|
68
68
|
" Default to Placidus if they're unsure.\n\n" +
|
|
69
69
|
"## Step 2 — Resolve Coordinates and Timezone (MANDATORY)\n" +
|
|
70
70
|
"**Do NOT guess coordinates or timezones from memory.** Use the API tools to resolve them:\n\n" +
|
|
71
|
-
"**
|
|
72
|
-
"`
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"-
|
|
77
|
-
"
|
|
71
|
+
"**Resolve the birth place with `location_search`** — pass `query: 'Birth City, Country'` " +
|
|
72
|
+
"and `date` set to the birth date (`YYYY-MM-DD`). One call returns decimal lat/lon, the " +
|
|
73
|
+
"IANA timezone, and `utcOffsetAtDate` — the offset that actually applied at that place on " +
|
|
74
|
+
"that date (1987 US DST rules differ from today's). If the result is `ambiguous`, ASK which " +
|
|
75
|
+
"place the user means instead of taking the first.\n\n" +
|
|
76
|
+
"- Pass the local wall-clock time as `datetime` (e.g. `1990-04-15T14:30:00`) together with " +
|
|
77
|
+
"the `timezone` from the search. Do NOT convert to UTC yourself and do NOT append a Z to a " +
|
|
78
|
+
"local birth time — a zone-less datetime is rejected, and a hand-converted one is how charts " +
|
|
79
|
+
"end up hours off.\n\n" +
|
|
78
80
|
"## Step 3 — Tool Call\n" +
|
|
79
81
|
"Make a single call to `explore_natal_chart` (the interactive chart wheel) with:\n" +
|
|
80
82
|
"- `datetime`: local ISO 8601 (no Z)\n" +
|
|
@@ -146,10 +148,11 @@ export const PROMPTS = [
|
|
|
146
148
|
"4. **Any major events coming up** they already know about — reframes the transits around real context\n\n" +
|
|
147
149
|
"## Step 2 — Resolve Coordinates and Timezone (MANDATORY)\n" +
|
|
148
150
|
"**Do NOT guess coordinates or timezones from memory.** Use the API tools:\n\n" +
|
|
149
|
-
"**
|
|
150
|
-
"`
|
|
151
|
-
"
|
|
152
|
-
"
|
|
151
|
+
"**Resolve the birth place with `location_search`** — pass `query: 'Birth City, Country'` " +
|
|
152
|
+
"and `date` set to the birth date (`YYYY-MM-DD`). One call returns decimal lat/lon, the " +
|
|
153
|
+
"IANA timezone, and `utcOffsetAtDate` — the offset that actually applied at that place on " +
|
|
154
|
+
"that date (1987 US DST rules differ from today's). If the result is `ambiguous`, ASK which " +
|
|
155
|
+
"place the user means instead of taking the first.\n\n" +
|
|
153
156
|
"- Set `start_date` to today and `end_date` to the end of the forecast window\n\n" +
|
|
154
157
|
"## Step 3 — Tool Call Sequence\n" +
|
|
155
158
|
"Two calls minimum, run sequentially:\n\n" +
|
|
@@ -211,11 +214,13 @@ export const PROMPTS = [
|
|
|
211
214
|
"7. **House system**: Placidus (default), Whole Sign, Koch, Equal?\n\n" +
|
|
212
215
|
"## Step 2 — Resolve Both Sets of Coordinates (MANDATORY)\n" +
|
|
213
216
|
"**Do NOT guess coordinates or timezones.** For EACH person:\n\n" +
|
|
214
|
-
"**
|
|
215
|
-
"`
|
|
216
|
-
"
|
|
217
|
-
"
|
|
218
|
-
"
|
|
217
|
+
"**Resolve the birth place with `location_search`** — pass `query: 'Birth City, Country'` " +
|
|
218
|
+
"and `date` set to the birth date (`YYYY-MM-DD`). One call returns decimal lat/lon, the " +
|
|
219
|
+
"IANA timezone, and `utcOffsetAtDate` — the offset that actually applied at that place on " +
|
|
220
|
+
"that date (1987 US DST rules differ from today's). If the result is `ambiguous`, ASK which " +
|
|
221
|
+
"place the user means instead of taking the first.\n\n" +
|
|
222
|
+
"- Do this for BOTH people. Pass each local wall-clock datetime with its own `timezone`; " +
|
|
223
|
+
"never convert to UTC by hand.\n" +
|
|
219
224
|
"- Note if either birth time is unknown — flag this upfront before running tools\n\n" +
|
|
220
225
|
"## Step 3 — Tool Call\n" +
|
|
221
226
|
"Make a single call to `ephemeris_synastry` with:\n" +
|
|
@@ -283,12 +288,14 @@ export const PROMPTS = [
|
|
|
283
288
|
"sometimes it explains why a place felt 'off'\n\n" +
|
|
284
289
|
"## Step 2 — Resolve Coordinates (MANDATORY)\n" +
|
|
285
290
|
"**Do NOT guess coordinates or timezones.** Use the API tools:\n\n" +
|
|
286
|
-
"**
|
|
287
|
-
"`
|
|
288
|
-
"
|
|
289
|
-
"
|
|
290
|
-
"
|
|
291
|
-
"
|
|
291
|
+
"**Resolve the birth place with `location_search`** — pass `query: 'Birth City, Country'` " +
|
|
292
|
+
"and `date` set to the birth date (`YYYY-MM-DD`). One call returns decimal lat/lon, the " +
|
|
293
|
+
"IANA timezone, and `utcOffsetAtDate` — the offset that actually applied at that place on " +
|
|
294
|
+
"that date (1987 US DST rules differ from today's). If the result is `ambiguous`, ASK which " +
|
|
295
|
+
"place the user means instead of taking the first.\n\n" +
|
|
296
|
+
"**Then geocode each target city** — one `location_search` per destination.\n\n" +
|
|
297
|
+
"- Pass the birth datetime as local wall-clock time with its `timezone`; never append a Z " +
|
|
298
|
+
"to a local time.\n\n" +
|
|
292
299
|
"## Step 3 — Tool Call Sequence\n" +
|
|
293
300
|
"Two calls minimum. Start lean, offer more after delivery.\n\n" +
|
|
294
301
|
"**A) City-Level Hits** — for EACH target city, call `acg_hits` with:\n" +
|
|
@@ -372,13 +379,15 @@ export const PROMPTS = [
|
|
|
372
379
|
"4. **Birth city and country** — needed for timezone conversion\n\n" +
|
|
373
380
|
"## Step 2 — CRITICAL: Convert to UTC\n" +
|
|
374
381
|
"Human Design requires **UTC datetime**. The tool rejects local times without a UTC offset.\n\n" +
|
|
375
|
-
"**
|
|
376
|
-
"`
|
|
377
|
-
"
|
|
378
|
-
"
|
|
379
|
-
"
|
|
380
|
-
"-
|
|
381
|
-
"
|
|
382
|
+
"**Resolve the birth place with `location_search`** — pass `query: 'Birth City, Country'` " +
|
|
383
|
+
"and `date` set to the birth date (`YYYY-MM-DD`). One call returns decimal lat/lon, the " +
|
|
384
|
+
"IANA timezone, and `utcOffsetAtDate` — the offset that actually applied at that place on " +
|
|
385
|
+
"that date (1987 US DST rules differ from today's). If the result is `ambiguous`, ASK which " +
|
|
386
|
+
"place the user means instead of taking the first.\n\n" +
|
|
387
|
+
"- Pass the local wall-clock time as `datetime` (e.g. `1990-04-15T14:30:00`) together with " +
|
|
388
|
+
"the `timezone` from the search. Do NOT convert to UTC yourself and do NOT append a Z to a " +
|
|
389
|
+
"local birth time — a zone-less datetime is rejected, and a hand-converted one is how charts " +
|
|
390
|
+
"end up hours off.\n\n" +
|
|
382
391
|
"**Why HD needs UTC**: The system calculates two activation moments:\n" +
|
|
383
392
|
"- **Personality (Conscious / Black)**: the birth datetime\n" +
|
|
384
393
|
"- **Design (Unconscious / Red)**: 88° of the Sun's arc before birth (approximately 88–89 days, varying by season)\n" +
|
|
@@ -545,13 +554,15 @@ export const PROMPTS = [
|
|
|
545
554
|
" Default to Lahiri if unsure.\n\n" +
|
|
546
555
|
"## Step 2 — Convert to UTC (MANDATORY)\n" +
|
|
547
556
|
"The `vedic_chart` tool requires a UTC datetime (with Z suffix):\n\n" +
|
|
548
|
-
"**
|
|
549
|
-
"`
|
|
550
|
-
"
|
|
551
|
-
"
|
|
552
|
-
"
|
|
553
|
-
"-
|
|
554
|
-
"
|
|
557
|
+
"**Resolve the birth place with `location_search`** — pass `query: 'Birth City, Country'` " +
|
|
558
|
+
"and `date` set to the birth date (`YYYY-MM-DD`). One call returns decimal lat/lon, the " +
|
|
559
|
+
"IANA timezone, and `utcOffsetAtDate` — the offset that actually applied at that place on " +
|
|
560
|
+
"that date (1987 US DST rules differ from today's). If the result is `ambiguous`, ASK which " +
|
|
561
|
+
"place the user means instead of taking the first.\n\n" +
|
|
562
|
+
"- Pass the local wall-clock time as `datetime` (e.g. `1990-04-15T14:30:00`) together with " +
|
|
563
|
+
"the `timezone` from the search. Do NOT convert to UTC yourself and do NOT append a Z to a " +
|
|
564
|
+
"local birth time — a zone-less datetime is rejected, and a hand-converted one is how charts " +
|
|
565
|
+
"end up hours off.\n\n" +
|
|
555
566
|
"## Step 3 — Call the Tool\n" +
|
|
556
567
|
"Call `vedic_chart` with:\n" +
|
|
557
568
|
"- `datetime`: UTC ISO 8601 with Z\n" +
|
|
@@ -772,12 +783,14 @@ export const PROMPTS = [
|
|
|
772
783
|
"4. **House system**: Placidus (default), Whole Sign, Koch, Equal?\n\n" +
|
|
773
784
|
"## Step 2 — Resolve Data (MANDATORY)\n" +
|
|
774
785
|
"**Do NOT guess coordinates or timezones.** Use the API tools:\n\n" +
|
|
775
|
-
"**
|
|
776
|
-
"`
|
|
777
|
-
"
|
|
778
|
-
"
|
|
779
|
-
"
|
|
780
|
-
"
|
|
786
|
+
"**Resolve the birth place with `location_search`** — pass `query: 'Birth City, Country'` " +
|
|
787
|
+
"and `date` set to the birth date (`YYYY-MM-DD`). One call returns decimal lat/lon, the " +
|
|
788
|
+
"IANA timezone, and `utcOffsetAtDate` — the offset that actually applied at that place on " +
|
|
789
|
+
"that date (1987 US DST rules differ from today's). If the result is `ambiguous`, ASK which " +
|
|
790
|
+
"place the user means instead of taking the first.\n\n" +
|
|
791
|
+
"- For `birth_datetime`, either append `utcOffsetAtDate` from the search " +
|
|
792
|
+
"(`1985-06-21T14:00:00-05:00`) or pass the local time with its `timezone`. Both are accepted; " +
|
|
793
|
+
"a zone-less value is not.\n\n" +
|
|
781
794
|
"**C) Geocode birthday location** — repeat the autocomplete call for the city where they'll be " +
|
|
782
795
|
"on their birthday. This becomes `return_latitude`/`return_longitude`.\n\n" +
|
|
783
796
|
"## Step 3 — Tool Call\n" +
|
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
|
|
@@ -135,8 +115,9 @@ registerTool({
|
|
|
135
115
|
"(Year = ancestry/early life, Month = parents/career, Day = self/spouse, Hour = children/later life).\n\n" +
|
|
136
116
|
"CREDIT COST: 3 credits per call (1 base + 2 visual render).\n\n" +
|
|
137
117
|
"Use this for a rich, interactive BaZi experience in MCP Apps-capable hosts (Claude Desktop). " +
|
|
138
|
-
"Falls back to a text summary in other hosts. For non-visual data lookups, use
|
|
139
|
-
"
|
|
118
|
+
"Falls back to a text summary in other hosts. For non-visual data lookups, use chinese_bazi " +
|
|
119
|
+
"instead; deeper derivations (Ten Gods, element balance, luck pillars) have dedicated tools " +
|
|
120
|
+
"on the full tool surface (`?profile=full`).",
|
|
140
121
|
inputSchema: {
|
|
141
122
|
type: "object",
|
|
142
123
|
properties: {
|
|
@@ -150,8 +131,14 @@ registerTool({
|
|
|
150
131
|
},
|
|
151
132
|
datetime: {
|
|
152
133
|
type: "string",
|
|
153
|
-
description: "Alternative to year/month/day: ISO 8601 datetime
|
|
154
|
-
"
|
|
134
|
+
description: "Alternative to year/month/day: ISO 8601 datetime read as LOCAL wall-clock time at the " +
|
|
135
|
+
"birth place — BaZi pillars are local by definition, so a zone-less value is correct " +
|
|
136
|
+
"here and is NOT converted to UTC. If it carries a 'Z' or offset, also pass timezone.",
|
|
137
|
+
},
|
|
138
|
+
timezone: {
|
|
139
|
+
type: "string",
|
|
140
|
+
description: "IANA timezone name for the birth place, e.g. 'Asia/Shanghai'. Only needed when " +
|
|
141
|
+
"datetime carries a 'Z' or ±HH:MM offset.",
|
|
155
142
|
},
|
|
156
143
|
},
|
|
157
144
|
additionalProperties: false,
|