@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/LICENSE +21 -21
  3. package/README.md +52 -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/server-sse.js +114 -14
  11. package/dist/tools/apps/bazi-app.js +12 -26
  12. package/dist/tools/apps/bi-wheel-app.js +2 -2
  13. package/dist/tools/apps/bodygraph-app.d.ts +5 -5
  14. package/dist/tools/apps/bodygraph-app.js +187 -225
  15. package/dist/tools/apps/chart-wheel-app.js +5 -3
  16. package/dist/tools/apps/location-tools.js +167 -18
  17. package/dist/tools/apps/moon-phase-app.js +10 -3
  18. package/dist/tools/apps/transit-timeline-app.js +6 -4
  19. package/dist/tools/apps/vedic-chart-app.js +15 -49
  20. package/dist/tools/datetime.d.ts +65 -0
  21. package/dist/tools/datetime.js +153 -0
  22. package/dist/tools/index.d.ts +45 -2
  23. package/dist/tools/index.js +78 -2
  24. package/dist/tools/specialized/account.d.ts +1 -0
  25. package/dist/tools/specialized/account.js +100 -0
  26. package/dist/tools/specialized/acg.js +16 -14
  27. package/dist/tools/specialized/bazi.d.ts +7 -1
  28. package/dist/tools/specialized/bazi.js +86 -19
  29. package/dist/tools/specialized/bi_wheel.js +5 -4
  30. package/dist/tools/specialized/chart_wheel.js +5 -8
  31. package/dist/tools/specialized/comparative.js +13 -5
  32. package/dist/tools/specialized/electional.js +7 -7
  33. package/dist/tools/specialized/ephemeris_core.js +13 -8
  34. package/dist/tools/specialized/ephemeris_extended.js +27 -17
  35. package/dist/tools/specialized/hd_bodygraph.js +8 -10
  36. package/dist/tools/specialized/hd_cycles.js +7 -14
  37. package/dist/tools/specialized/hd_group.js +20 -13
  38. package/dist/tools/specialized/human_design.js +11 -17
  39. package/dist/tools/specialized/moon.js +8 -2
  40. package/dist/tools/specialized/natal.js +7 -9
  41. package/dist/tools/specialized/progressed.js +12 -8
  42. package/dist/tools/specialized/relocation.js +9 -3
  43. package/dist/tools/specialized/returns.js +23 -11
  44. package/dist/tools/specialized/synastry.js +17 -6
  45. package/dist/tools/specialized/transits.js +9 -5
  46. package/dist/tools/specialized/vedic.js +5 -3
  47. package/dist/tools/specialized/venus_star_points.js +14 -9
  48. package/dist/ui/bazi.html +1063 -1049
  49. package/dist/ui/bi-wheel.html +4197 -4120
  50. package/dist/ui/bodygraph.html +3855 -3720
  51. package/dist/ui/chart-wheel.html +3779 -3706
  52. package/dist/ui/moon-phase.html +3228 -3145
  53. package/dist/ui/transit-timeline.html +199 -170
  54. package/dist/ui/vedic-chart.html +1116 -1098
  55. package/package.json +6 -3
  56. 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
+ }
@@ -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
@@ -150,8 +130,14 @@ registerTool({
150
130
  },
151
131
  datetime: {
152
132
  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.",
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 — MCP App tool registration for the Human Design Bodygraph Explorer.
2
+ * bodygraph-app.ts — MCP App tool registration for the Human Design Bodygraph Explorer.
3
3
  *
4
4
  * Entry tools [model + app]:
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)
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 — no SVG endpoint
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
  *