@openephemeris/mcp-server 4.2.0 → 4.3.1
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 +101 -0
- package/dist/index.js +10 -0
- package/dist/server-sse.js +3 -1
- package/dist/tools/apps/bodygraph-app.js +20 -2
- package/dist/tools/datetime.d.ts +53 -4
- package/dist/tools/datetime.js +72 -19
- package/dist/tools/specialized/account.js +12 -2
- package/dist/tools/specialized/comparative.js +5 -7
- package/dist/tools/specialized/ephemeris_core.js +3 -3
- package/dist/tools/specialized/ephemeris_extended.js +8 -8
- package/dist/tools/specialized/returns.js +5 -5
- package/dist/ui/bodygraph.html +1057 -1051
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,107 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [4.3.1] — 2026-07-29
|
|
11
|
+
|
|
12
|
+
Two rendering fixes surfaced by the Phase-0 v4.3 audit, plus a companion
|
|
13
|
+
server-side fix that unblocks the tool this release advertised.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **`account_usage` no longer tells unlimited-plan customers they have "0 remaining".**
|
|
18
|
+
A service-tier row reports `included_units = -1` (the sentinel for unlimited);
|
|
19
|
+
the tool rendered it literally as *"Credits: 993 used of -1 included — 0 remaining
|
|
20
|
+
(0% used)"* — three mutually contradictory statements about the same quota, and
|
|
21
|
+
the "0 remaining" reading a paid customer as cut off. Unlimited plans now render
|
|
22
|
+
as *"Credits: 993 used · unlimited plan"* with no synthesized percentage.
|
|
23
|
+
NEW-11 from the audit.
|
|
24
|
+
- **Companion:** `ephemeris_house_cusps` accepts the `timezone` companion the
|
|
25
|
+
4.3.0 schema advertised. The Go server rejected the field at the JSON decode
|
|
26
|
+
layer with *"unknown field provided in DateTimeInput"*, so an agent following
|
|
27
|
+
OE's own naive-datetime error message hit a second error. Fixed server-side
|
|
28
|
+
in [`openephemeris/openephemeris#466`](https://github.com/openephemeris/openephemeris/pull/466)
|
|
29
|
+
(no MCP change) — the schema, generated struct, and handler were all correct;
|
|
30
|
+
only the bespoke JSON decoder's allowlist was out of sync. NEW-10 from the
|
|
31
|
+
audit.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **`account_usage` now prints a `Server version` line.** External audits and
|
|
36
|
+
eval runs could not previously attribute a score to a specific published
|
|
37
|
+
build — the version is set in `serverInfo.version` on `initialize` but was
|
|
38
|
+
not surfaced anywhere a human-readable tool response could echo. NEW-9 from
|
|
39
|
+
the audit.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## [4.3.0] — 2026-07-29
|
|
44
|
+
|
|
45
|
+
The bodygraph iframes get the same view toggle across every mode (natal, transit,
|
|
46
|
+
connection), the datetime contract stops being repeated on every datetime tool,
|
|
47
|
+
and eleven tools stop lying about which zone the caller named.
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **Bodygraph mandala layout works on transit and connection overlay iframes.**
|
|
52
|
+
The natal bodygraph iframe has had a graph ↔ mandala toggle since 3.19.0;
|
|
53
|
+
the transit and connection overlays did not, because the overlay render path
|
|
54
|
+
dropped `layout` on the floor. The mandala scene builder already delegates
|
|
55
|
+
channel and center rendering to the same overlay-aware helpers the graph
|
|
56
|
+
layout uses, so this was purely a matter of wiring: openapi
|
|
57
|
+
`VisualRenderConfig` gains an optional `layout` (`graph` | `mandala`),
|
|
58
|
+
`visualConfigFromRender` copies it through, `RenderBodygraphOverlayEmbed`
|
|
59
|
+
applies the same mandala scaling the natal path already had, and both
|
|
60
|
+
`explore_human_design_transit` and `explore_human_design_connection` accept
|
|
61
|
+
a `layout` argument that forwards to `visual_config.layout`. The iframe's
|
|
62
|
+
existing view-toggle button renders on overlay payloads too now, and
|
|
63
|
+
reroutes via the `_refetch` metadata so the model never sees the switch.
|
|
64
|
+
Overlay attributes (`data-connection-type`, `data-transit-new`, the overlay
|
|
65
|
+
legend) survive the mandala switch — the mandala inherits the overlay-aware
|
|
66
|
+
channel builder unchanged.
|
|
67
|
+
|
|
68
|
+
### Changed
|
|
69
|
+
|
|
70
|
+
- **The datetime contract paragraph moved off every tool description and into the
|
|
71
|
+
server `instructions` field.** The 130-token contract text used to be sent on
|
|
72
|
+
every datetime-accepting tool and re-sent on every model pass — ~40 sites'
|
|
73
|
+
worth of pure repetition. It's now stated once, up front, in `instructions`
|
|
74
|
+
(both stdio and HTTP transports), with each parameter description carrying a
|
|
75
|
+
one-sentence rule + a pointer back. The 400 rejection still rewrites the
|
|
76
|
+
caller's own value into each remedy, so a host that fails to propagate
|
|
77
|
+
`instructions` learns the rule from the first violation. Core surface dropped
|
|
78
|
+
from ~18.7k to ~17.2k tokens (−1.5k, 8%); full surface dropped from ~35.1k to
|
|
79
|
+
~29.9k (−5.2k, 15%). NEW-4 from the Phase-0 v4.1 audit.
|
|
80
|
+
|
|
81
|
+
### Fixed
|
|
82
|
+
|
|
83
|
+
- **`ephemeris_house_cusps` (and ten other DateTimeInput tools) lied about how the
|
|
84
|
+
caller supplied the zone.** Calling with `datetime="1987-07-15T09:01:00"` +
|
|
85
|
+
`timezone="America/Chicago"` came back with `datetime_zone: "UTC"` and
|
|
86
|
+
`datetime_zone_source: "offset"` — because the tool converted the value to a
|
|
87
|
+
Z-suffixed UTC string on the client before sending it, and the server (correctly)
|
|
88
|
+
described what it received. `ephemeris_natal_chart` reported the same input as
|
|
89
|
+
`America/Chicago` / `timezone`, so the two tools disagreed about the same fact,
|
|
90
|
+
and the mislabelled `"UTC"` was the exact wrong value the naive-datetime contract
|
|
91
|
+
was written to catch — a false alarm on a correct call.
|
|
92
|
+
|
|
93
|
+
The 11 tools that take a `DateTimeInput` body field (`ephemeris_house_cusps`,
|
|
94
|
+
`ephemeris_planet_position`, `ephemeris_dignities`, `ephemeris_retrograde_status`,
|
|
95
|
+
`ephemeris_midpoints`, `ephemeris_fixed_stars`, `ephemeris_hermetic_lots`,
|
|
96
|
+
`ephemeris_angles_points`, `ephemeris_solar_return`, `ephemeris_lunar_return`,
|
|
97
|
+
`ephemeris_natal_transits`) now pass a naive datetime + IANA zone through as
|
|
98
|
+
`{ iso, timezone: { iana_name } }` — the shape the Go handler already resolves
|
|
99
|
+
correctly and stamps as `datetime_zone_source: "timezone"`. Endpoints whose body
|
|
100
|
+
field is named `*_utc` (`vedic_chart`, Human Design, the ACG `epoch`) still
|
|
101
|
+
pre-convert client-side, because their Go types are strict RFC 3339 `time.Time`
|
|
102
|
+
and cannot accept a companion zone.
|
|
103
|
+
|
|
104
|
+
Contract test asserts, for every fixed tool, that the wire body carries the naive
|
|
105
|
+
datetime and the IANA name — not a synthesized Z-suffixed value — and the Go
|
|
106
|
+
handler test proves all three input forms (`Z`, `±HH:MM`, naive + IANA) resolve
|
|
107
|
+
to the same instant and each self-describes its provenance correctly.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
10
111
|
## [4.2.0] — 2026-07-29
|
|
11
112
|
|
|
12
113
|
`ephemeris_next_lunar_phase` could not answer the question it exists to answer. The
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
6
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
7
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
8
8
|
import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools, parseToolSurface, SERVER_VERSION } from "./tools/index.js";
|
|
9
|
+
import { DATETIME_CONTRACT_INSTRUCTIONS } from "./tools/datetime.js";
|
|
9
10
|
import { captureEvent, distinctIdFor } from "./analytics.js";
|
|
10
11
|
import { listPrompts, getPromptContent } from "./prompts.js";
|
|
11
12
|
// ── MCP App resource imports ────────────────────────────────────────────────
|
|
@@ -62,6 +63,15 @@ const server = new Server({
|
|
|
62
63
|
},
|
|
63
64
|
},
|
|
64
65
|
},
|
|
66
|
+
instructions: "Open Ephemeris computes real astronomy (JPL DE440, sub-arcsecond) — never guess " +
|
|
67
|
+
"or approximate positions yourself; always call a tool. " +
|
|
68
|
+
"For anything the user should SEE (natal chart, bi-wheel, bodygraph, moon phase), " +
|
|
69
|
+
"prefer the explore_* tools — they render interactive visuals inline. " +
|
|
70
|
+
"ephemeris_* tools return data; use format='llm' on them for compact output. " +
|
|
71
|
+
"If the user has no birth data handy, start with the sky right now — " +
|
|
72
|
+
"explore_moon_phase and ephemeris_retrograde_status need none. " +
|
|
73
|
+
"See the 'welcome_to_open_ephemeris' prompt for orientation.\n\n" +
|
|
74
|
+
DATETIME_CONTRACT_INSTRUCTIONS,
|
|
65
75
|
});
|
|
66
76
|
// Which slice of the registry this process advertises. Tools outside the core
|
|
67
77
|
// surface stay callable by name — this only controls what tools/list returns.
|
package/dist/server-sse.js
CHANGED
|
@@ -24,6 +24,7 @@ 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
26
|
import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools, parseToolSurface } from "./tools/index.js";
|
|
27
|
+
import { DATETIME_CONTRACT_INSTRUCTIONS } from "./tools/datetime.js";
|
|
27
28
|
import { BackendClient, runWithClient } from "./backend/client.js";
|
|
28
29
|
import { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
|
|
29
30
|
import { BODYGRAPH_RESOURCE_URI, BODYGRAPH_MIME_TYPE, getBodygraphBundle, } from "./tools/apps/bodygraph-app.js";
|
|
@@ -269,7 +270,8 @@ function createMcpServer(analyticsId = "anonymous", surface = "core") {
|
|
|
269
270
|
"ephemeris_* tools return data; use format='llm' on them for compact output. " +
|
|
270
271
|
"If the user has no birth data handy, start with the sky right now — " +
|
|
271
272
|
"explore_moon_phase and ephemeris_retrograde_status need none. " +
|
|
272
|
-
"See the 'welcome_to_open_ephemeris' prompt for orientation
|
|
273
|
+
"See the 'welcome_to_open_ephemeris' prompt for orientation.\n\n" +
|
|
274
|
+
DATETIME_CONTRACT_INSTRUCTIONS,
|
|
273
275
|
});
|
|
274
276
|
// --- Tool handlers ---
|
|
275
277
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -1321,6 +1321,14 @@ registerTool({
|
|
|
1321
1321
|
description: "Visual theme for the overlay bodygraph. Set automatically by the embedded app to match " +
|
|
1322
1322
|
"the host; defaults to dark. You normally never need to pass this.",
|
|
1323
1323
|
},
|
|
1324
|
+
layout: {
|
|
1325
|
+
type: "string",
|
|
1326
|
+
enum: ["graph", "mandala"],
|
|
1327
|
+
description: "Overlay composition. 'graph' (default) is the classic bodygraph rectangle with the " +
|
|
1328
|
+
"transit's activated channels highlighted; 'mandala' nests the same overlay inside the " +
|
|
1329
|
+
"concentric ring set the natal chart uses. Set by the in-iframe layout toggle, never by " +
|
|
1330
|
+
"the model.",
|
|
1331
|
+
},
|
|
1324
1332
|
},
|
|
1325
1333
|
required: ["datetime"],
|
|
1326
1334
|
},
|
|
@@ -1363,9 +1371,10 @@ registerTool({
|
|
|
1363
1371
|
// dark MCP hosts — mirror the natal path's explicit dark default. The
|
|
1364
1372
|
// iframe re-calls this tool with theme once it detects the host theme.
|
|
1365
1373
|
const theme = args.theme === "light" ? "light" : "dark";
|
|
1374
|
+
const layout = args.layout === "mandala" ? "mandala" : undefined;
|
|
1366
1375
|
if (bundleAvailable) {
|
|
1367
1376
|
body.include_visual = true;
|
|
1368
|
-
body.visual_config = { theme };
|
|
1377
|
+
body.visual_config = { theme, ...(layout ? { layout } : {}) };
|
|
1369
1378
|
}
|
|
1370
1379
|
const resp = await client.request("POST", "/human-design/transit-chart", { data: body });
|
|
1371
1380
|
const transit = (resp?.transit ?? {});
|
|
@@ -1443,6 +1452,14 @@ registerTool({
|
|
|
1443
1452
|
description: "Visual theme for the overlay bodygraph. Set automatically by the embedded app to match " +
|
|
1444
1453
|
"the host; defaults to dark. You normally never need to pass this.",
|
|
1445
1454
|
},
|
|
1455
|
+
layout: {
|
|
1456
|
+
type: "string",
|
|
1457
|
+
enum: ["graph", "mandala"],
|
|
1458
|
+
description: "Overlay composition. 'graph' (default) is the classic bodygraph rectangle with connected " +
|
|
1459
|
+
"channels classified by connection type; 'mandala' nests the same overlay inside the " +
|
|
1460
|
+
"concentric ring set the natal chart uses. Set by the in-iframe layout toggle, never by " +
|
|
1461
|
+
"the model.",
|
|
1462
|
+
},
|
|
1446
1463
|
},
|
|
1447
1464
|
required: ["person_a", "person_b"],
|
|
1448
1465
|
},
|
|
@@ -1472,9 +1489,10 @@ registerTool({
|
|
|
1472
1489
|
const bundleAvailable = Boolean(getBodygraphBundle());
|
|
1473
1490
|
// Mirror the transit tool: explicit dark default, host-theme refetch.
|
|
1474
1491
|
const theme = args.theme === "light" ? "light" : "dark";
|
|
1492
|
+
const layout = args.layout === "mandala" ? "mandala" : undefined;
|
|
1475
1493
|
if (bundleAvailable) {
|
|
1476
1494
|
body.include_visual = true;
|
|
1477
|
-
body.visual_config = { theme };
|
|
1495
|
+
body.visual_config = { theme, ...(layout ? { layout } : {}) };
|
|
1478
1496
|
}
|
|
1479
1497
|
const resp = await client.request("POST", "/human-design/composite", { data: body });
|
|
1480
1498
|
const connections = Array.isArray(resp?.connections) ? resp.connections : [];
|
package/dist/tools/datetime.d.ts
CHANGED
|
@@ -27,19 +27,30 @@ export declare function hasZoneSuffix(value: string): boolean;
|
|
|
27
27
|
/** The canonical description for a birth / chart-moment datetime parameter. */
|
|
28
28
|
export declare const DATETIME_DESC: string;
|
|
29
29
|
/** The canonical description for the companion `timezone` parameter. */
|
|
30
|
-
export declare const TIMEZONE_DESC
|
|
30
|
+
export declare const TIMEZONE_DESC = "IANA timezone (e.g. `America/Chicago`); required when the datetime is naive, ignored otherwise.";
|
|
31
31
|
/** The canonical description for a search-window date parameter (date or datetime). */
|
|
32
|
-
export declare const WINDOW_DATE_DESC:
|
|
32
|
+
export declare const WINDOW_DATE_DESC = "ISO 8601 date or zoned datetime; a naive clock time is rejected. Date-only resolves to 12:00 UTC.";
|
|
33
33
|
/** The `timezone` property to spread into a tool's inputSchema. */
|
|
34
34
|
export declare const TIMEZONE_PROPERTY: {
|
|
35
35
|
readonly type: "string";
|
|
36
|
-
readonly description:
|
|
36
|
+
readonly description: "IANA timezone (e.g. `America/Chicago`); required when the datetime is naive, ignored otherwise.";
|
|
37
37
|
};
|
|
38
38
|
/** Build a `<prefix>_timezone` property with a tailored example. */
|
|
39
39
|
export declare function timezoneProperty(label: string, example?: string): {
|
|
40
40
|
readonly type: "string";
|
|
41
|
-
readonly description: string;
|
|
41
|
+
readonly description: `IANA timezone for ${string} (e.g. \`${string}\`); required when the datetime is naive.`;
|
|
42
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* The full statement of the datetime contract, for the server `instructions`
|
|
45
|
+
* field. Every datetime-accepting tool's parameter description points here.
|
|
46
|
+
*
|
|
47
|
+
* Setting this on the server (both stdio and HTTP transports) surfaces the
|
|
48
|
+
* contract to the model once, up front, instead of re-sending it on every
|
|
49
|
+
* tool description. Clients that fail to propagate `instructions` still get
|
|
50
|
+
* the rule enforced by the 400 rejection, whose detail rewrites the caller's
|
|
51
|
+
* own value into each remedy — the model learns from the first violation.
|
|
52
|
+
*/
|
|
53
|
+
export declare const DATETIME_CONTRACT_INSTRUCTIONS: string;
|
|
43
54
|
/**
|
|
44
55
|
* The rejection message. Names both remedies, rewriting the caller's own value
|
|
45
56
|
* into each — the commonest failure is not realising the value was ambiguous.
|
|
@@ -53,6 +64,39 @@ export declare function ambiguousDatetimeMessage(field: string, value: string, t
|
|
|
53
64
|
* resolved server-side (or by `localToUtcIso` for the `*_utc` endpoints).
|
|
54
65
|
*/
|
|
55
66
|
export declare function assertZonedDatetime(field: string, value: unknown, timezone?: unknown, timezoneField?: string): void;
|
|
67
|
+
/**
|
|
68
|
+
* Body shape for a `DateTimeInput`-typed request field (`date_time`,
|
|
69
|
+
* `birth_datetime`, `target_datetime`, `transit_datetime`, …). The Go type
|
|
70
|
+
* carries an optional `timezone` companion, so a naive local wall-clock time
|
|
71
|
+
* plus its IANA name is a legitimate value the server resolves itself.
|
|
72
|
+
*/
|
|
73
|
+
export type DateTimeInputBody = {
|
|
74
|
+
iso: string;
|
|
75
|
+
timezone?: {
|
|
76
|
+
iana_name: string;
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Build a `DateTimeInput` body that PRESERVES zone provenance.
|
|
81
|
+
*
|
|
82
|
+
* Use this in place of `localToUtcIso` for any endpoint whose body field is a
|
|
83
|
+
* `DateTimeInput` (i.e. `{ iso, timezone?, components?, julian_day? }`). Those
|
|
84
|
+
* endpoints already resolve a naive datetime plus an inner `timezone.iana_name`
|
|
85
|
+
* server-side and stamp `datetime_zone_source: "timezone"` on the response.
|
|
86
|
+
* Pre-converting to UTC on the client makes the server believe the caller
|
|
87
|
+
* supplied a Z-suffixed offset — the metadata then reports
|
|
88
|
+
* `datetime_zone: "UTC", datetime_zone_source: "offset"` for a call that
|
|
89
|
+
* actually named "America/Chicago", and `house_cusps` and `natal_chart`
|
|
90
|
+
* disagree about the same fact (NEW-8).
|
|
91
|
+
*
|
|
92
|
+
* - Zone-suffixed input passes through (with `±HHMM` normalised to `±HH:MM`);
|
|
93
|
+
* server records `source: "offset"`.
|
|
94
|
+
* - Naive input + `tz` returns `{ iso, timezone: { iana_name: tz } }`; server
|
|
95
|
+
* records `source: "timezone", zone: tz`.
|
|
96
|
+
* - Naive input with no `tz` throws (same behaviour as `localToUtcIso`).
|
|
97
|
+
* - Date-only or unrecognised strings pass through untouched.
|
|
98
|
+
*/
|
|
99
|
+
export declare function toDateTimeInputBody(field: string, dt: string, tz?: string, timezoneField?: string): DateTimeInputBody;
|
|
56
100
|
/**
|
|
57
101
|
* Convert a datetime to a UTC ISO 8601 string for the endpoints whose field is
|
|
58
102
|
* named `*_utc` and whose Go type is a strict RFC 3339 `time.Time`.
|
|
@@ -61,5 +105,10 @@ export declare function assertZonedDatetime(field: string, value: unknown, timez
|
|
|
61
105
|
* appends a bare "Z" to a zone-less value — it throws instead. Appending Z
|
|
62
106
|
* asserts the input was UTC, which is exactly the silent assumption that made
|
|
63
107
|
* the original defect invisible.
|
|
108
|
+
*
|
|
109
|
+
* For endpoints that take a `DateTimeInput` body field (not `*_utc`), prefer
|
|
110
|
+
* `toDateTimeInputBody` — pre-converting to UTC erases the zone name the
|
|
111
|
+
* caller supplied, and the response metadata then lies about how the moment
|
|
112
|
+
* was named.
|
|
64
113
|
*/
|
|
65
114
|
export declare function localToUtcIso(field: string, dt: string, tz?: string, timezoneField?: string): string;
|
package/dist/tools/datetime.js
CHANGED
|
@@ -34,26 +34,26 @@ export function hasZoneSuffix(value) {
|
|
|
34
34
|
return ZONE_SUFFIX.test((value ?? "").trim());
|
|
35
35
|
}
|
|
36
36
|
// ─── Canonical parameter documentation ───────────────────────────────────────
|
|
37
|
-
// Every tool
|
|
38
|
-
// reads identically everywhere
|
|
39
|
-
//
|
|
37
|
+
// Every datetime-accepting tool uses these constants verbatim, so the contract
|
|
38
|
+
// reads identically everywhere and `test/datetime-contract.test.ts` can gate
|
|
39
|
+
// on the exact string. They deliberately point at the server `instructions`
|
|
40
|
+
// field (spelled out in DATETIME_CONTRACT_INSTRUCTIONS below) rather than
|
|
41
|
+
// restating the contract inline: the previous ~130-token paragraph was repeated
|
|
42
|
+
// on every datetime tool, so each character cost ~40x across the surface and
|
|
43
|
+
// was re-sent on every model pass. NEW-4 in the Phase-0 v4.1 audit asked for
|
|
44
|
+
// it back.
|
|
45
|
+
//
|
|
46
|
+
// The full rule still lives somewhere the model can see it: `instructions`
|
|
47
|
+
// carries the expanded text, and the 400 rejection rewrites the caller's own
|
|
48
|
+
// value into each remedy — the model learns from the first violation even
|
|
49
|
+
// when a host fails to propagate `instructions`.
|
|
40
50
|
/** The canonical description for a birth / chart-moment datetime parameter. */
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// model pass. Every RULE stays; the rationale for the rule lives in the module
|
|
44
|
-
// docstring above, which the model never sees.
|
|
45
|
-
export const DATETIME_DESC = "ISO 8601 datetime that states its zone. Either put the zone on the value " +
|
|
46
|
-
"('1987-07-15T09:01:00-05:00', or '...T14:01:00Z' for UTC), or pass local " +
|
|
47
|
-
"wall-clock time plus the `timezone` argument. A zone-less time is REJECTED. " +
|
|
48
|
-
"A date with no time ('1987-07-15') resolves to 12:00 UTC.";
|
|
51
|
+
export const DATETIME_DESC = "ISO 8601 datetime; zone required — a `Z`/±HH:MM suffix or the `timezone` argument. " +
|
|
52
|
+
"Date-only resolves to 12:00 UTC. Full rule: server `instructions`.";
|
|
49
53
|
/** The canonical description for the companion `timezone` parameter. */
|
|
50
|
-
export const TIMEZONE_DESC = "IANA timezone
|
|
51
|
-
"Required when the datetime has no 'Z' or ±HH:MM offset; ignored when it does. " +
|
|
52
|
-
"Historical DST is resolved correctly.";
|
|
54
|
+
export const TIMEZONE_DESC = "IANA timezone (e.g. `America/Chicago`); required when the datetime is naive, ignored otherwise.";
|
|
53
55
|
/** The canonical description for a search-window date parameter (date or datetime). */
|
|
54
|
-
export const WINDOW_DATE_DESC = "ISO 8601 date
|
|
55
|
-
"A date with no time resolves to 12:00 UTC. A time without a 'Z' or ±HH:MM offset " +
|
|
56
|
-
"is rejected as ambiguous.";
|
|
56
|
+
export const WINDOW_DATE_DESC = "ISO 8601 date or zoned datetime; a naive clock time is rejected. Date-only resolves to 12:00 UTC.";
|
|
57
57
|
/** The `timezone` property to spread into a tool's inputSchema. */
|
|
58
58
|
export const TIMEZONE_PROPERTY = {
|
|
59
59
|
type: "string",
|
|
@@ -63,10 +63,25 @@ export const TIMEZONE_PROPERTY = {
|
|
|
63
63
|
export function timezoneProperty(label, example = "America/Chicago") {
|
|
64
64
|
return {
|
|
65
65
|
type: "string",
|
|
66
|
-
description: `IANA timezone
|
|
67
|
-
"Required when that datetime has no 'Z' or ±HH:MM offset; ignored when it does.",
|
|
66
|
+
description: `IANA timezone for ${label} (e.g. \`${example}\`); required when the datetime is naive.`,
|
|
68
67
|
};
|
|
69
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* The full statement of the datetime contract, for the server `instructions`
|
|
71
|
+
* field. Every datetime-accepting tool's parameter description points here.
|
|
72
|
+
*
|
|
73
|
+
* Setting this on the server (both stdio and HTTP transports) surfaces the
|
|
74
|
+
* contract to the model once, up front, instead of re-sending it on every
|
|
75
|
+
* tool description. Clients that fail to propagate `instructions` still get
|
|
76
|
+
* the rule enforced by the 400 rejection, whose detail rewrites the caller's
|
|
77
|
+
* own value into each remedy — the model learns from the first violation.
|
|
78
|
+
*/
|
|
79
|
+
export const DATETIME_CONTRACT_INSTRUCTIONS = "Datetime contract: every clock time needs a zone. Either put the zone on the value " +
|
|
80
|
+
"(`1987-07-15T09:01:00-05:00` for a local time, or `...T14:01:00Z` for UTC), or pass " +
|
|
81
|
+
"the naive local time and name the zone in the sibling `timezone` argument (IANA name, " +
|
|
82
|
+
"e.g. `America/Chicago`). A zone-less clock time is a hard 400 — the engine never " +
|
|
83
|
+
"guesses UTC, because an unstated zone shifts the Ascendant by ~15° per hour and moves " +
|
|
84
|
+
"every house placement. A bare date (no clock time) resolves to 12:00 UTC.";
|
|
70
85
|
// ─── Enforcement ─────────────────────────────────────────────────────────────
|
|
71
86
|
/**
|
|
72
87
|
* The rejection message. Names both remedies, rewriting the caller's own value
|
|
@@ -98,6 +113,39 @@ export function assertZonedDatetime(field, value, timezone, timezoneField = "tim
|
|
|
98
113
|
return;
|
|
99
114
|
throw new Error(ambiguousDatetimeMessage(field, value, timezoneField));
|
|
100
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Build a `DateTimeInput` body that PRESERVES zone provenance.
|
|
118
|
+
*
|
|
119
|
+
* Use this in place of `localToUtcIso` for any endpoint whose body field is a
|
|
120
|
+
* `DateTimeInput` (i.e. `{ iso, timezone?, components?, julian_day? }`). Those
|
|
121
|
+
* endpoints already resolve a naive datetime plus an inner `timezone.iana_name`
|
|
122
|
+
* server-side and stamp `datetime_zone_source: "timezone"` on the response.
|
|
123
|
+
* Pre-converting to UTC on the client makes the server believe the caller
|
|
124
|
+
* supplied a Z-suffixed offset — the metadata then reports
|
|
125
|
+
* `datetime_zone: "UTC", datetime_zone_source: "offset"` for a call that
|
|
126
|
+
* actually named "America/Chicago", and `house_cusps` and `natal_chart`
|
|
127
|
+
* disagree about the same fact (NEW-8).
|
|
128
|
+
*
|
|
129
|
+
* - Zone-suffixed input passes through (with `±HHMM` normalised to `±HH:MM`);
|
|
130
|
+
* server records `source: "offset"`.
|
|
131
|
+
* - Naive input + `tz` returns `{ iso, timezone: { iana_name: tz } }`; server
|
|
132
|
+
* records `source: "timezone", zone: tz`.
|
|
133
|
+
* - Naive input with no `tz` throws (same behaviour as `localToUtcIso`).
|
|
134
|
+
* - Date-only or unrecognised strings pass through untouched.
|
|
135
|
+
*/
|
|
136
|
+
export function toDateTimeInputBody(field, dt, tz, timezoneField = "timezone") {
|
|
137
|
+
const value = (dt ?? "").trim();
|
|
138
|
+
if (hasZoneSuffix(value)) {
|
|
139
|
+
return { iso: value.replace(/([+-]\d{2})(\d{2})$/, "$1:$2") };
|
|
140
|
+
}
|
|
141
|
+
if (!isNaiveClockTime(value)) {
|
|
142
|
+
return { iso: value };
|
|
143
|
+
}
|
|
144
|
+
if (!tz || tz.trim() === "") {
|
|
145
|
+
throw new Error(ambiguousDatetimeMessage(field, value, timezoneField));
|
|
146
|
+
}
|
|
147
|
+
return { iso: value, timezone: { iana_name: tz } };
|
|
148
|
+
}
|
|
101
149
|
/**
|
|
102
150
|
* Convert a datetime to a UTC ISO 8601 string for the endpoints whose field is
|
|
103
151
|
* named `*_utc` and whose Go type is a strict RFC 3339 `time.Time`.
|
|
@@ -106,6 +154,11 @@ export function assertZonedDatetime(field, value, timezone, timezoneField = "tim
|
|
|
106
154
|
* appends a bare "Z" to a zone-less value — it throws instead. Appending Z
|
|
107
155
|
* asserts the input was UTC, which is exactly the silent assumption that made
|
|
108
156
|
* the original defect invisible.
|
|
157
|
+
*
|
|
158
|
+
* For endpoints that take a `DateTimeInput` body field (not `*_utc`), prefer
|
|
159
|
+
* `toDateTimeInputBody` — pre-converting to UTC erases the zone name the
|
|
160
|
+
* caller supplied, and the response metadata then lies about how the moment
|
|
161
|
+
* was named.
|
|
109
162
|
*/
|
|
110
163
|
export function localToUtcIso(field, dt, tz, timezoneField = "timezone") {
|
|
111
164
|
const value = (dt ?? "").trim();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { registerTool } from "../index.js";
|
|
1
|
+
import { registerTool, SERVER_VERSION } from "../index.js";
|
|
2
2
|
import { getActiveClient, BackendError, DASHBOARD_ACCOUNT_URL, LOGIN_SIGNUP_URL, UPGRADE_URL, WALLET_TOPUP_URL, } from "../../backend/client.js";
|
|
3
3
|
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
4
4
|
// Tiers where the primary next step is a wallet top-up rather than a plan change.
|
|
@@ -63,6 +63,10 @@ registerTool({
|
|
|
63
63
|
const period = row.month || args?.month || new Date().toISOString().substring(0, 7);
|
|
64
64
|
const used = row.total_units ?? 0;
|
|
65
65
|
const included = row.included_units ?? 0;
|
|
66
|
+
// `included_units < 0` is a sentinel for unlimited (service/enterprise plans).
|
|
67
|
+
// Rendering it literally as "0 remaining (0% used)" tells a paid customer they
|
|
68
|
+
// are cut off — the exact opposite of the truth. NEW-11 from the audit.
|
|
69
|
+
const unlimited = included < 0;
|
|
66
70
|
const remaining = Math.max(included - used, 0);
|
|
67
71
|
const percent = row.percent_units_used ?? (included > 0 ? Math.round((used / included) * 1000) / 10 : 0);
|
|
68
72
|
const totalCalls = row.total_calls ?? 0;
|
|
@@ -71,7 +75,9 @@ registerTool({
|
|
|
71
75
|
``,
|
|
72
76
|
`- **Plan:** ${tier}`,
|
|
73
77
|
`- **Period:** ${period}`,
|
|
74
|
-
|
|
78
|
+
unlimited
|
|
79
|
+
? `- **Credits:** ${used.toLocaleString()} used · unlimited plan`
|
|
80
|
+
: `- **Credits:** ${used.toLocaleString()} used of ${included.toLocaleString()} included — **${remaining.toLocaleString()} remaining** (${percent}% used)`,
|
|
75
81
|
`- **Total API calls:** ${totalCalls.toLocaleString()}`,
|
|
76
82
|
];
|
|
77
83
|
if ((row.overage_units ?? 0) > 0) {
|
|
@@ -86,6 +92,10 @@ registerTool({
|
|
|
86
92
|
lines.push(`- **Renews:** ${renewal.toISOString().substring(0, 10)}`);
|
|
87
93
|
}
|
|
88
94
|
}
|
|
95
|
+
// Verifiable build identifier so external evals can attribute every score to
|
|
96
|
+
// a specific published version rather than trusting the tester's word.
|
|
97
|
+
// NEW-9 from the audit.
|
|
98
|
+
lines.push(`- **Server version:** ${SERVER_VERSION}`);
|
|
89
99
|
lines.push(``);
|
|
90
100
|
if (WALLET_TIERS.has(tier)) {
|
|
91
101
|
lines.push(`**Need more credits?** Top up your wallet at ${WALLET_TOPUP_URL} (from $5), ` +
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { registerTool, validateRequired } from "../index.js";
|
|
2
2
|
import { getActiveClient } from "../../backend/client.js";
|
|
3
3
|
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
4
|
-
import { DATETIME_DESC, assertZonedDatetime,
|
|
4
|
+
import { DATETIME_DESC, assertZonedDatetime, toDateTimeInputBody, timezoneProperty } from "../datetime.js";
|
|
5
5
|
/** Reject a zone-less person datetime here, before a credit is spent on it. */
|
|
6
6
|
function assertPersonZoned(prefix, args) {
|
|
7
7
|
assertZonedDatetime(`${prefix}_datetime`, args[`${prefix}_datetime`], args[`${prefix}_timezone`], `${prefix}_timezone`);
|
|
@@ -203,12 +203,10 @@ registerTool({
|
|
|
203
203
|
subject: buildSubject("Natal", args.natal_datetime, args.natal_latitude, args.natal_longitude, args.natal_timezone),
|
|
204
204
|
};
|
|
205
205
|
if (args.transit_datetime) {
|
|
206
|
-
// transit_datetime is a DateTimeInput
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
body.transit_datetime =
|
|
210
|
-
iso: localToUtcIso("transit_datetime", args.transit_datetime, args.transit_timezone, "transit_timezone"),
|
|
211
|
-
};
|
|
206
|
+
// transit_datetime is a DateTimeInput — pass the caller's zone name
|
|
207
|
+
// through so the response's datetime_zone_source reads "timezone",
|
|
208
|
+
// not "offset" (NEW-8).
|
|
209
|
+
body.transit_datetime = toDateTimeInputBody("transit_datetime", args.transit_datetime, args.transit_timezone, "transit_timezone");
|
|
212
210
|
}
|
|
213
211
|
const query = {};
|
|
214
212
|
if (args.format)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { registerTool, validateRequired } from "../index.js";
|
|
2
2
|
import { getActiveClient } from "../../backend/client.js";
|
|
3
3
|
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
4
|
-
import { DATETIME_DESC, TIMEZONE_PROPERTY,
|
|
4
|
+
import { DATETIME_DESC, TIMEZONE_PROPERTY, toDateTimeInputBody } from "../datetime.js";
|
|
5
5
|
// POST /ephemeris/planet-position — OE-016
|
|
6
6
|
registerTool({
|
|
7
7
|
name: "ephemeris_planet_position",
|
|
@@ -45,7 +45,7 @@ registerTool({
|
|
|
45
45
|
validateRequired(args, ["planet_id", "datetime"]);
|
|
46
46
|
const body = {
|
|
47
47
|
planet_id: args.planet_id,
|
|
48
|
-
date_time:
|
|
48
|
+
date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
|
|
49
49
|
};
|
|
50
50
|
if (args.latitude != null)
|
|
51
51
|
body.latitude = args.latitude;
|
|
@@ -93,7 +93,7 @@ registerTool({
|
|
|
93
93
|
handler: async (args) => {
|
|
94
94
|
validateRequired(args, ["datetime", "latitude", "longitude"]);
|
|
95
95
|
const body = {
|
|
96
|
-
date_time:
|
|
96
|
+
date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
|
|
97
97
|
latitude: args.latitude,
|
|
98
98
|
longitude: args.longitude,
|
|
99
99
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { registerTool, validateRequired, validateCoordinates } from "../index.js";
|
|
2
2
|
import { getActiveClient } from "../../backend/client.js";
|
|
3
3
|
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
4
|
-
import { DATETIME_DESC, TIMEZONE_PROPERTY, assertZonedDatetime,
|
|
4
|
+
import { DATETIME_DESC, TIMEZONE_PROPERTY, assertZonedDatetime, toDateTimeInputBody } from "../datetime.js";
|
|
5
5
|
function buildSubject(name, datetime, lat, lon) {
|
|
6
6
|
return {
|
|
7
7
|
name,
|
|
@@ -78,7 +78,7 @@ registerTool({
|
|
|
78
78
|
handler: async (args) => {
|
|
79
79
|
validateRequired(args, ["datetime"]);
|
|
80
80
|
return await getActiveClient().post("/ephemeris/dignities", {
|
|
81
|
-
date_time:
|
|
81
|
+
date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
|
|
82
82
|
});
|
|
83
83
|
},
|
|
84
84
|
});
|
|
@@ -121,14 +121,14 @@ registerTool({
|
|
|
121
121
|
// If a specific planet is requested, delegate directly.
|
|
122
122
|
if (args.planet_id != null) {
|
|
123
123
|
return await client.post("/ephemeris/retrograde-status", {
|
|
124
|
-
date_time:
|
|
124
|
+
date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
|
|
125
125
|
planet_id: args.planet_id,
|
|
126
126
|
});
|
|
127
127
|
}
|
|
128
128
|
// Fan-out: the backend only handles one planet per call; query all 10 in parallel.
|
|
129
129
|
const PLANET_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
130
130
|
const results = await Promise.all(PLANET_IDS.map((pid) => client.post("/ephemeris/retrograde-status", {
|
|
131
|
-
date_time:
|
|
131
|
+
date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
|
|
132
132
|
planet_id: pid,
|
|
133
133
|
}).catch(() => null)));
|
|
134
134
|
// Merge into a keyed object: { planet_name: {...status} }
|
|
@@ -169,7 +169,7 @@ registerTool({
|
|
|
169
169
|
handler: async (args) => {
|
|
170
170
|
validateRequired(args, ["datetime", "latitude", "longitude"]);
|
|
171
171
|
return await getActiveClient().post("/ephemeris/midpoints", {
|
|
172
|
-
date_time:
|
|
172
|
+
date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
|
|
173
173
|
latitude: args.latitude,
|
|
174
174
|
longitude: args.longitude,
|
|
175
175
|
});
|
|
@@ -218,7 +218,7 @@ registerTool({
|
|
|
218
218
|
// the exact failure this tool exists to avoid — reject it instead.
|
|
219
219
|
validateCoordinates(args, "latitude", "longitude");
|
|
220
220
|
const body = {
|
|
221
|
-
date_time:
|
|
221
|
+
date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
|
|
222
222
|
};
|
|
223
223
|
if (args.latitude != null)
|
|
224
224
|
body.latitude = args.latitude;
|
|
@@ -258,7 +258,7 @@ registerTool({
|
|
|
258
258
|
handler: async (args) => {
|
|
259
259
|
validateRequired(args, ["datetime", "latitude", "longitude"]);
|
|
260
260
|
return await getActiveClient().post("/ephemeris/hermetic-lots", {
|
|
261
|
-
date_time:
|
|
261
|
+
date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
|
|
262
262
|
latitude: args.latitude,
|
|
263
263
|
longitude: args.longitude,
|
|
264
264
|
});
|
|
@@ -286,7 +286,7 @@ registerTool({
|
|
|
286
286
|
handler: async (args) => {
|
|
287
287
|
validateRequired(args, ["datetime", "latitude", "longitude"]);
|
|
288
288
|
return await getActiveClient().post("/ephemeris/angles-points", {
|
|
289
|
-
date_time:
|
|
289
|
+
date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
|
|
290
290
|
latitude: args.latitude,
|
|
291
291
|
longitude: args.longitude,
|
|
292
292
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { registerTool, validateRequired } from "../index.js";
|
|
2
2
|
import { getActiveClient } from "../../backend/client.js";
|
|
3
3
|
import { OUTPUT_SCHEMA_IMAGE_AND_JSON, OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
4
|
-
import { DATETIME_DESC, TIMEZONE_PROPERTY, assertZonedDatetime,
|
|
4
|
+
import { DATETIME_DESC, TIMEZONE_PROPERTY, assertZonedDatetime, toDateTimeInputBody } from "../datetime.js";
|
|
5
5
|
// POST /predictive/returns/solar
|
|
6
6
|
registerTool({
|
|
7
7
|
name: "ephemeris_solar_return",
|
|
@@ -71,8 +71,8 @@ registerTool({
|
|
|
71
71
|
// Default target to current year if not provided
|
|
72
72
|
const targetDt = args.target_datetime ?? new Date().toISOString();
|
|
73
73
|
const body = {
|
|
74
|
-
birth_datetime:
|
|
75
|
-
target_datetime:
|
|
74
|
+
birth_datetime: toDateTimeInputBody("birth_datetime", args.birth_datetime, args.timezone),
|
|
75
|
+
target_datetime: toDateTimeInputBody("target_datetime", targetDt, args.timezone),
|
|
76
76
|
};
|
|
77
77
|
if (args.birth_latitude != null && args.birth_longitude != null) {
|
|
78
78
|
body.location = {
|
|
@@ -143,8 +143,8 @@ registerTool({
|
|
|
143
143
|
assertZonedDatetime("target_datetime", args.target_datetime, args.timezone);
|
|
144
144
|
const targetDt = args.target_datetime ?? new Date().toISOString();
|
|
145
145
|
const body = {
|
|
146
|
-
birth_datetime:
|
|
147
|
-
target_datetime:
|
|
146
|
+
birth_datetime: toDateTimeInputBody("birth_datetime", args.birth_datetime, args.timezone),
|
|
147
|
+
target_datetime: toDateTimeInputBody("target_datetime", targetDt, args.timezone),
|
|
148
148
|
};
|
|
149
149
|
if (args.birth_latitude != null && args.birth_longitude != null) {
|
|
150
150
|
body.location = {
|