@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/LICENSE +21 -21
  3. package/README.md +75 -3
  4. package/dist/analytics.js +37 -5
  5. package/dist/backend/client.d.ts +7 -0
  6. package/dist/backend/client.js +39 -38
  7. package/dist/index.js +64 -2
  8. package/dist/oauth/session-utils.d.ts +42 -18
  9. package/dist/oauth/session-utils.js +79 -0
  10. package/dist/prompts.js +55 -42
  11. package/dist/server-sse.js +114 -14
  12. package/dist/tools/apps/bazi-app.js +15 -28
  13. package/dist/tools/apps/bi-wheel-app.js +14 -11
  14. package/dist/tools/apps/bodygraph-app.d.ts +5 -5
  15. package/dist/tools/apps/bodygraph-app.js +159 -212
  16. package/dist/tools/apps/chart-wheel-app.js +21 -20
  17. package/dist/tools/apps/location-tools.js +167 -18
  18. package/dist/tools/apps/moon-phase-app.js +10 -3
  19. package/dist/tools/apps/transit-timeline-app.js +6 -4
  20. package/dist/tools/apps/vedic-chart-app.js +15 -49
  21. package/dist/tools/datetime.d.ts +65 -0
  22. package/dist/tools/datetime.js +153 -0
  23. package/dist/tools/dev.js +4 -3
  24. package/dist/tools/index.d.ts +45 -2
  25. package/dist/tools/index.js +81 -2
  26. package/dist/tools/specialized/account.d.ts +1 -0
  27. package/dist/tools/specialized/account.js +100 -0
  28. package/dist/tools/specialized/acg.js +16 -14
  29. package/dist/tools/specialized/bazi.d.ts +7 -1
  30. package/dist/tools/specialized/bazi.js +89 -23
  31. package/dist/tools/specialized/bi_wheel.js +5 -4
  32. package/dist/tools/specialized/chart_wheel.js +5 -8
  33. package/dist/tools/specialized/comparative.js +40 -21
  34. package/dist/tools/specialized/electional.js +13 -10
  35. package/dist/tools/specialized/ephemeris_core.js +13 -8
  36. package/dist/tools/specialized/ephemeris_extended.js +60 -53
  37. package/dist/tools/specialized/hd_bodygraph.js +8 -10
  38. package/dist/tools/specialized/hd_cycles.js +7 -14
  39. package/dist/tools/specialized/hd_group.js +20 -13
  40. package/dist/tools/specialized/human_design.js +11 -17
  41. package/dist/tools/specialized/moon.js +14 -6
  42. package/dist/tools/specialized/natal.js +7 -9
  43. package/dist/tools/specialized/progressed.js +12 -8
  44. package/dist/tools/specialized/relocation.js +9 -3
  45. package/dist/tools/specialized/returns.js +23 -11
  46. package/dist/tools/specialized/synastry.js +17 -6
  47. package/dist/tools/specialized/transits.js +9 -5
  48. package/dist/tools/specialized/vedic.js +5 -3
  49. package/dist/tools/specialized/venus_star_points.js +14 -9
  50. package/dist/ui/bazi.html +1063 -1049
  51. package/dist/ui/bi-wheel.html +4188 -4128
  52. package/dist/ui/bodygraph.html +3673 -3616
  53. package/dist/ui/chart-wheel.html +3769 -3713
  54. package/dist/ui/moon-phase.html +3219 -3153
  55. package/dist/ui/transit-timeline.html +199 -170
  56. package/dist/ui/vedic-chart.html +1116 -1098
  57. package/package.json +3 -2
  58. 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
- "**A) Geocode** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
72
- "`query: { q: 'City Name, Country' }`. This returns verified decimal lat/lon.\n\n" +
73
- "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
74
- "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`. " +
75
- "This returns the correct IANA timezone and UTC offset for the birth year, including historical DST.\n\n" +
76
- "- Format: pass `datetime` as local ISO 8601 (e.g. `1990-04-15T14:30:00`) " +
77
- "and `timezone` as the IANA name returned by the lookup. Do NOT append Z to a local birth time.\n\n" +
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
- "**A) Geocode** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
150
- "`query: { q: 'Birth City, Country' }`. Use the returned decimal lat/lon.\n\n" +
151
- "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
152
- "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n\n" +
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
- "**A) Geocode** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
215
- "`query: { q: 'Birth City, Country' }`.\n\n" +
216
- "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
217
- "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n\n" +
218
- "- Format: local ISO 8601 datetime (no Z) + IANA timezone name separately for each person\n" +
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
- "**A) Geocode birth city** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
287
- "`query: { q: 'Birth City, Country' }`.\n\n" +
288
- "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
289
- "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n\n" +
290
- "**C) Geocode each target city** — repeat the autocomplete call for each destination city.\n\n" +
291
- "- Format birth datetime as local ISO 8601 (no Z), pass IANA timezone separately\n\n" +
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
- "**A) Geocode** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
376
- "`query: { q: 'Birth City, Country' }`.\n\n" +
377
- "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
378
- "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n" +
379
- "This returns the UTC offset for the birth year, including historical DST.\n\n" +
380
- "- Use the returned offset to convert local birth time → UTC\n" +
381
- "- Format as ISO 8601 with Z: e.g. `1990-04-15T19:30:00Z`\n\n" +
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
- "**A) Geocode** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
549
- "`query: { q: 'Birth City, Country' }`.\n\n" +
550
- "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
551
- "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n" +
552
- "This returns the correct IANA timezone and UTC offset for the birth year, including historical DST.\n\n" +
553
- "- Use the returned offset to convert local birth time → UTC\n" +
554
- "- Format: `1990-01-15T03:00:00Z`\n\n" +
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
- "**A) Geocode birth city** — call `dev_read_api` with `path: '/location/autocomplete'`, " +
776
- "`query: { q: 'Birth City, Country' }`.\n\n" +
777
- "**B) Timezone** — call `dev_write_api` with `path: '/timezone/lookup'`, " +
778
- "`body: { latitude: ..., longitude: ..., datetime: 'YYYY-MM-DDTHH:MM:SS' }`.\n\n" +
779
- "- For `birth_datetime`, include the UTC offset from the timezone lookup: `1985-06-21T14:00:00-05:00`\n" +
780
- " (or convert to UTC with Z: `1985-06-21T19:00:00Z`)\n\n" +
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" +
@@ -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
- function createMcpServer(analyticsId = "anonymous") {
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
- "52+ typed tools covering natal charts, transit forecasting, Human Design " +
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, { tool: toolName, duration_ms: durationMs });
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
- const status = error?.response?.status;
316
- captureEvent("mcp_tool_error", analyticsId, { tool: toolName, status: status ?? "unknown" });
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
- const tools = modelVisibleTools("http").map((tool) => ({
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. 52+ typed tools covering " +
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
- captureEvent("mcp_session_init", analyticsId, { auth: apiKey ? "api_key" : "oauth" });
784
- const server = createMcpServer(analyticsId);
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 the dedicated " +
139
- "chinese_bazi / bazi_ten_gods / bazi_element_balance / bazi_luck_pillars tools instead.",
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 (e.g. '1987-07-15T14:00:00'). " +
154
- "year/month/day/hour are extracted automatically. Use this OR the individual fields.",
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,