@openephemeris/mcp-server 4.18.0 → 4.20.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 (35) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/index.js +8 -110
  3. package/dist/server-sse.js +7 -114
  4. package/dist/tools/apps/bazi-app.d.ts +1 -1
  5. package/dist/tools/apps/bazi-app.js +3 -2
  6. package/dist/tools/apps/bi-wheel-app.d.ts +1 -1
  7. package/dist/tools/apps/bi-wheel-app.js +3 -2
  8. package/dist/tools/apps/bodygraph-app.d.ts +7 -4
  9. package/dist/tools/apps/bodygraph-app.js +13 -5
  10. package/dist/tools/apps/chart-wheel-app.d.ts +1 -1
  11. package/dist/tools/apps/chart-wheel-app.js +3 -2
  12. package/dist/tools/apps/moon-phase-app.d.ts +1 -1
  13. package/dist/tools/apps/moon-phase-app.js +3 -2
  14. package/dist/tools/apps/transit-timeline-app.d.ts +1 -1
  15. package/dist/tools/apps/transit-timeline-app.js +3 -2
  16. package/dist/tools/apps/ui-resource.d.ts +43 -0
  17. package/dist/tools/apps/ui-resource.js +66 -0
  18. package/dist/tools/apps/ui-resources.d.ts +22 -0
  19. package/dist/tools/apps/ui-resources.js +105 -0
  20. package/dist/tools/apps/vedic-chart-app.d.ts +1 -1
  21. package/dist/tools/apps/vedic-chart-app.js +3 -2
  22. package/dist/tools/specialized/comparative.js +25 -23
  23. package/dist/tools/specialized/hd_cycles.js +22 -23
  24. package/dist/tools/specialized/progressed.js +5 -2
  25. package/dist/tools/specialized/relocation.js +5 -2
  26. package/dist/tools/visual.d.ts +15 -0
  27. package/dist/tools/visual.js +22 -0
  28. package/dist/ui/bazi.html +893 -856
  29. package/dist/ui/bi-wheel.html +555 -518
  30. package/dist/ui/bodygraph.html +433 -337
  31. package/dist/ui/chart-wheel.html +632 -595
  32. package/dist/ui/moon-phase.html +190 -155
  33. package/dist/ui/transit-timeline.html +865 -830
  34. package/dist/ui/vedic-chart.html +252 -215
  35. package/package.json +2 -1
@@ -0,0 +1,66 @@
1
+ /**
2
+ * ui-resource.ts — identity and `_meta` of the seven MCP App `ui://` resources.
3
+ *
4
+ * Imported by every `*-app.ts` (for its resource URI) and by ui-resources.ts
5
+ * (the list/read registry both transports serve). Kept free of app imports so
6
+ * the app modules can depend on it without a cycle.
7
+ */
8
+ import { SERVER_VERSION } from "../index.js";
9
+ export const UI_RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
10
+ /**
11
+ * Versioned resource URI: `ui://openephemeris/<slug>?v=<server version>`.
12
+ *
13
+ * ChatGPT caches a `ui://` resource per connector and never re-reads it for a
14
+ * URI it has seen — a deploy alone does not refresh the widget. Stamping the
15
+ * package version into the URI makes every release a new URI, so hosts fetch
16
+ * the new bundle on their own. Caveat: a widget change must ship with a
17
+ * version bump to bust the cache (same version ⇒ same URI).
18
+ *
19
+ * The SAME string is used in the tool `_meta` (ui.resourceUri and its aliases),
20
+ * in resources/list and in resources/read, on both transports.
21
+ */
22
+ export function uiResourceUri(slug) {
23
+ return `ui://openephemeris/${slug}?v=${SERVER_VERSION}`;
24
+ }
25
+ /** The URI without its `?v=` query — what resources/read matches on. */
26
+ export function uiResourceBase(uri) {
27
+ const q = uri.indexOf("?");
28
+ return q === -1 ? uri : uri.slice(0, q);
29
+ }
30
+ /**
31
+ * CSP the bundles actually need: none. Every bundle is a single self-contained
32
+ * HTML file (JS/CSS inlined, images and icons as data: URIs), and the apps talk
33
+ * to the host over postMessage only — no fetch, no remote script, style, image
34
+ * or font. Empty lists are the spec's secure default; declaring them explicitly
35
+ * documents that this is deliberate, not forgotten.
36
+ */
37
+ export const UI_RESOURCE_CSP = {
38
+ connectDomains: [],
39
+ resourceDomains: [],
40
+ };
41
+ /**
42
+ * `_meta` for a `ui://` resource (resources/list entry and resources/read
43
+ * content item). Two wire forms, same values:
44
+ *
45
+ * ui.* — MCP Apps (SEP-1865) McpUiResourceMeta.
46
+ * openai/* — ChatGPT's aliases for the same fields.
47
+ *
48
+ * prefersBorder: true — the host draws the card border and background; the apps
49
+ * therefore paint no card, border or glow of their own (shared/tokens.css).
50
+ *
51
+ * ui.domain / openai/widgetDomain are deliberately NOT set: their format is
52
+ * host-specific (a hash subdomain on Claude, a derived *.oaiusercontent.com on
53
+ * ChatGPT) and a wrong value can stop the widget rendering. Omitted = the
54
+ * host's default sandbox origin, which is what every live render uses today.
55
+ */
56
+ export function uiResourceMeta(widgetDescription) {
57
+ return {
58
+ ui: {
59
+ prefersBorder: true,
60
+ csp: UI_RESOURCE_CSP,
61
+ },
62
+ "openai/widgetPrefersBorder": true,
63
+ "openai/widgetCSP": { connect_domains: [], resource_domains: [] },
64
+ "openai/widgetDescription": widgetDescription,
65
+ };
66
+ }
@@ -0,0 +1,22 @@
1
+ export interface UiResourceEntry {
2
+ uri: string;
3
+ /** Short name used in the "bundle not found" error. */
4
+ label: string;
5
+ name: string;
6
+ description: string;
7
+ /** Tells the model what the widget already shows (ChatGPT openai/widgetDescription). */
8
+ widgetDescription: string;
9
+ getBundle: () => string | null;
10
+ }
11
+ export declare const UI_RESOURCES: UiResourceEntry[];
12
+ /** resources/list — only bundles that are actually built. */
13
+ export declare function listUiResources(): Array<Record<string, unknown>>;
14
+ /**
15
+ * resources/read. Matches on the URI WITHOUT its `?v=` so a host holding an
16
+ * older (or unversioned) URI from a cached tools/list still gets the current
17
+ * bundle; the content item echoes the URI that was asked for. Returns null for
18
+ * a URI that is not one of ours.
19
+ */
20
+ export declare function readUiResource(uri: string): {
21
+ contents: Array<Record<string, unknown>>;
22
+ } | null;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * ui-resources.ts — the resources/list + resources/read registry for the seven
3
+ * MCP App bundles, shared by BOTH transports (src/index.ts stdio and
4
+ * src/server-sse.ts HTTP). They used to carry copy-paste twins of the same
5
+ * seven if-blocks; one table keeps URI, name, description and `_meta` in step.
6
+ */
7
+ import { UI_RESOURCE_MIME_TYPE, uiResourceBase, uiResourceMeta } from "./ui-resource.js";
8
+ import { CHART_WHEEL_RESOURCE_URI, getChartWheelBundle } from "./chart-wheel-app.js";
9
+ import { BODYGRAPH_RESOURCE_URI, getBodygraphBundle } from "./bodygraph-app.js";
10
+ import { BI_WHEEL_RESOURCE_URI, getBiWheelBundle } from "./bi-wheel-app.js";
11
+ import { MOON_PHASE_RESOURCE_URI, getMoonPhaseBundle } from "./moon-phase-app.js";
12
+ import { TRANSIT_TIMELINE_RESOURCE_URI, getTransitTimelineBundle } from "./transit-timeline-app.js";
13
+ import { VEDIC_CHART_RESOURCE_URI, getVedicChartBundle } from "./vedic-chart-app.js";
14
+ import { BAZI_RESOURCE_URI, getBaziBundle } from "./bazi-app.js";
15
+ export const UI_RESOURCES = [
16
+ {
17
+ uri: CHART_WHEEL_RESOURCE_URI,
18
+ label: "Chart Wheel",
19
+ name: "Chart Wheel Explorer",
20
+ description: "Interactive natal chart wheel with clickable planets, houses, and aspects.",
21
+ widgetDescription: "Shows the full natal chart wheel — every planet, house cusp and aspect line — and lets the user click any of them for a reading. Don't repeat the placements as a list; highlight what stands out and invite them to explore the wheel.",
22
+ getBundle: getChartWheelBundle,
23
+ },
24
+ {
25
+ uri: BODYGRAPH_RESOURCE_URI,
26
+ label: "Bodygraph",
27
+ name: "Human Design Bodygraph Explorer",
28
+ description: "Interactive Human Design Bodygraph with clickable centers, gates, and channels.",
29
+ widgetDescription: "Shows the Human Design bodygraph with type, profile and authority, and lets the user click centers, channels and gates for their meaning. Don't redraw or list the whole chart; interpret the key points and point them to the graph.",
30
+ getBundle: getBodygraphBundle,
31
+ },
32
+ {
33
+ uri: BI_WHEEL_RESOURCE_URI,
34
+ label: "Bi-Wheel",
35
+ name: "Bi-Wheel Explorer",
36
+ description: "Interactive synastry or transit bi-wheel with cross-aspect lines and clickable planets.",
37
+ widgetDescription: "Shows two charts as a bi-wheel (natal inside, transits or a second person outside) with the cross-aspects drawn, each clickable. Don't list every cross-aspect; summarize the strongest contacts.",
38
+ getBundle: getBiWheelBundle,
39
+ },
40
+ {
41
+ uri: MOON_PHASE_RESOURCE_URI,
42
+ label: "Moon Phase",
43
+ name: "Moon Phase Explorer",
44
+ description: "Interactive lunar phase dial showing illumination, sign, and void-of-course status.",
45
+ widgetDescription: "Shows the current moon: phase, illumination, sign, void-of-course status, speed and the next new and full moon. Don't restate those numbers; add meaning or answer the user's question.",
46
+ getBundle: getMoonPhaseBundle,
47
+ },
48
+ {
49
+ uri: TRANSIT_TIMELINE_RESOURCE_URI,
50
+ label: "Transit Timeline",
51
+ name: "Transit Timeline Explorer",
52
+ description: "Interactive vertical timeline of upcoming transit hits grouped by month, with clickable events.",
53
+ widgetDescription: "Shows a dated timeline of upcoming transits to the natal chart, grouped by month, each clickable. Don't re-list the dates; call out the few that matter most.",
54
+ getBundle: getTransitTimelineBundle,
55
+ },
56
+ {
57
+ uri: VEDIC_CHART_RESOURCE_URI,
58
+ label: "Vedic Chart",
59
+ name: "Vedic Chart Explorer",
60
+ description: "Interactive Vedic (Jyotish) South Indian Rashi grid with clickable sign placements.",
61
+ widgetDescription: "Shows the sidereal South Indian Rashi chart with every graha in its sign, each clickable. Don't re-list placements; interpret the notable ones.",
62
+ getBundle: getVedicChartBundle,
63
+ },
64
+ {
65
+ uri: BAZI_RESOURCE_URI,
66
+ label: "BaZi",
67
+ name: "BaZi Four Pillars Explorer",
68
+ description: "Interactive BaZi (Four Pillars of Destiny) chart with clickable Year/Month/Day/Hour pillars.",
69
+ widgetDescription: "Shows the Four Pillars (year, month, day, hour stems and branches) with the Day Master, each pillar clickable. Don't re-list the pillars; interpret the balance.",
70
+ getBundle: getBaziBundle,
71
+ },
72
+ ];
73
+ /** resources/list — only bundles that are actually built. */
74
+ export function listUiResources() {
75
+ return UI_RESOURCES.filter((r) => r.getBundle()).map((r) => ({
76
+ uri: r.uri,
77
+ name: r.name,
78
+ description: r.description,
79
+ mimeType: UI_RESOURCE_MIME_TYPE,
80
+ _meta: uiResourceMeta(r.widgetDescription),
81
+ }));
82
+ }
83
+ /**
84
+ * resources/read. Matches on the URI WITHOUT its `?v=` so a host holding an
85
+ * older (or unversioned) URI from a cached tools/list still gets the current
86
+ * bundle; the content item echoes the URI that was asked for. Returns null for
87
+ * a URI that is not one of ours.
88
+ */
89
+ export function readUiResource(uri) {
90
+ const base = uiResourceBase(uri);
91
+ const entry = UI_RESOURCES.find((r) => uiResourceBase(r.uri) === base);
92
+ if (!entry)
93
+ return null;
94
+ const bundle = entry.getBundle();
95
+ if (!bundle)
96
+ throw new Error(`${entry.label} UI bundle not found. Run \`npm run build:ui\` to build it.`);
97
+ return {
98
+ contents: [{
99
+ uri,
100
+ mimeType: UI_RESOURCE_MIME_TYPE,
101
+ text: bundle,
102
+ _meta: uiResourceMeta(entry.widgetDescription),
103
+ }],
104
+ };
105
+ }
@@ -17,7 +17,7 @@
17
17
  * Also exports resource helpers (getVedicChartBundle, etc.) for use in
18
18
  * index.ts and server-sse.ts.
19
19
  */
20
- export declare const VEDIC_CHART_RESOURCE_URI = "ui://openephemeris/vedic-chart";
20
+ export declare const VEDIC_CHART_RESOURCE_URI: string;
21
21
  export declare const VEDIC_CHART_MIME_TYPE = "text/html;profile=mcp-app";
22
22
  /** Read the pre-built HTML bundle. Returns null if not yet built. */
23
23
  export declare function getVedicChartBundle(): string | null;
@@ -21,14 +21,15 @@ import fs from "node:fs";
21
21
  import path from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { registerTool, validateRequired, validateCoordinates, SERVER_VERSION } from "../index.js";
24
+ import { UI_RESOURCE_MIME_TYPE, uiResourceUri } from "./ui-resource.js";
24
25
  import { getActiveClient } from "../../backend/client.js";
25
26
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
26
27
  import { localToUtcIsoHistorical } from "../datetime-historical.js";
27
28
  import { coordsFromArgsOrLocation } from "./_location-resolver.js";
28
29
  import { RENDER_TOKEN_PROPERTY, renderTokenFromVisual, renderTokenHeaders } from "./_render-token.js";
29
30
  // ── Constants ─────────────────────────────────────────────────────────────────
30
- export const VEDIC_CHART_RESOURCE_URI = "ui://openephemeris/vedic-chart";
31
- export const VEDIC_CHART_MIME_TYPE = "text/html;profile=mcp-app";
31
+ export const VEDIC_CHART_RESOURCE_URI = uiResourceUri("vedic-chart");
32
+ export const VEDIC_CHART_MIME_TYPE = UI_RESOURCE_MIME_TYPE;
32
33
  const here = path.dirname(fileURLToPath(import.meta.url));
33
34
  const BUNDLE_PATHS = [
34
35
  path.resolve(here, "..", "..", "..", "dist", "ui", "vedic-chart.html"),
@@ -1,6 +1,7 @@
1
1
  import { registerTool, validateRequired } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
- import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
3
+ import { OUTPUT_SCHEMA_IMAGE_AND_JSON, OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
+ import { applyVisual, visualProperties } from "../visual.js";
4
5
  import { DATETIME_DESC, assertZonedDatetime, toDateTimeInputBody, timezoneProperty } from "../datetime.js";
5
6
  /** Reject a zone-less person datetime here, before a credit is spent on it. */
6
7
  function assertPersonZoned(prefix, args) {
@@ -23,8 +24,9 @@ function buildSubject(name, datetime, lat, lon, timezone) {
23
24
  // POST /comparative/composite
24
25
  registerTool({
25
26
  name: "ephemeris_composite",
26
- description: "Calculate a composite chart from two or more natal charts. The composite " +
27
- "uses midpoints of each planet pair to derive a single relationship chart.\n\n" +
27
+ description: "Calculate a Davison relationship chart for two people — a real chart cast for the " +
28
+ "midpoint in time and place between the two births, with its own houses and angles. " +
29
+ "For the planet-by-planet midpoint composite use ephemeris_composite_midpoint.\n\n" +
28
30
  "CREDIT COST: 3 credits per call.\n\n" +
29
31
  "EXAMPLE: Composite for two people:\n" +
30
32
  " person_a_datetime='1990-04-15T14:30:00', person_a_timezone='America/Chicago',\n" +
@@ -43,6 +45,7 @@ registerTool({
43
45
  person_b_latitude: { type: "number", description: "Person B birth latitude." },
44
46
  person_b_longitude: { type: "number", description: "Person B birth longitude." },
45
47
  format: { type: "string", enum: ["json", "llm"], description: "Use 'llm' for token-efficient LLM projection." },
48
+ ...visualProperties("the relationship chart wheel"),
46
49
  },
47
50
  required: [
48
51
  "person_a_datetime", "person_a_latitude", "person_a_longitude",
@@ -50,7 +53,7 @@ registerTool({
50
53
  ],
51
54
  additionalProperties: false,
52
55
  },
53
- outputSchema: OUTPUT_SCHEMA_JSON,
56
+ outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
54
57
  annotations: { title: "Davison Relationship Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
55
58
  handler: async (args) => {
56
59
  validateRequired(args, [
@@ -62,15 +65,14 @@ registerTool({
62
65
  const query = {};
63
66
  if (args.format)
64
67
  query.format = args.format;
65
- return await getActiveClient().request("POST", "/comparative/composite", {
66
- params: query,
67
- data: {
68
- subjects: [
69
- buildSubject("Person A", args.person_a_datetime, args.person_a_latitude, args.person_a_longitude, args.person_a_timezone),
70
- buildSubject("Person B", args.person_b_datetime, args.person_b_latitude, args.person_b_longitude, args.person_b_timezone),
71
- ],
72
- }
73
- });
68
+ const body = {
69
+ subjects: [
70
+ buildSubject("Person A", args.person_a_datetime, args.person_a_latitude, args.person_a_longitude, args.person_a_timezone),
71
+ buildSubject("Person B", args.person_b_datetime, args.person_b_latitude, args.person_b_longitude, args.person_b_timezone),
72
+ ],
73
+ };
74
+ applyVisual(body, args);
75
+ return await getActiveClient().request("POST", "/comparative/composite", { params: query, data: body });
74
76
  },
75
77
  });
76
78
  // POST /comparative/composite/midpoint
@@ -139,6 +141,7 @@ registerTool({
139
141
  person_b_latitude: { type: "number", description: "Person B birth latitude." },
140
142
  person_b_longitude: { type: "number", description: "Person B birth longitude." },
141
143
  format: { type: "string", enum: ["json", "llm"], description: "Use 'llm' for token-efficient LLM projection." },
144
+ ...visualProperties("a bi-wheel of the two charts"),
142
145
  },
143
146
  required: [
144
147
  "person_a_datetime", "person_a_latitude", "person_a_longitude",
@@ -146,7 +149,7 @@ registerTool({
146
149
  ],
147
150
  additionalProperties: false,
148
151
  },
149
- outputSchema: OUTPUT_SCHEMA_JSON,
152
+ outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
150
153
  annotations: { title: "Synastry House Overlay", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
151
154
  handler: async (args) => {
152
155
  validateRequired(args, [
@@ -158,15 +161,14 @@ registerTool({
158
161
  const query = {};
159
162
  if (args.format)
160
163
  query.format = args.format;
161
- return await getActiveClient().request("POST", "/comparative/overlay", {
162
- params: query,
163
- data: {
164
- subjects: [
165
- buildSubject("Person A", args.person_a_datetime, args.person_a_latitude, args.person_a_longitude, args.person_a_timezone),
166
- buildSubject("Person B", args.person_b_datetime, args.person_b_latitude, args.person_b_longitude, args.person_b_timezone),
167
- ],
168
- }
169
- });
164
+ const body = {
165
+ subjects: [
166
+ buildSubject("Person A", args.person_a_datetime, args.person_a_latitude, args.person_a_longitude, args.person_a_timezone),
167
+ buildSubject("Person B", args.person_b_datetime, args.person_b_latitude, args.person_b_longitude, args.person_b_timezone),
168
+ ],
169
+ };
170
+ applyVisual(body, args);
171
+ return await getActiveClient().request("POST", "/comparative/overlay", { params: query, data: body });
170
172
  },
171
173
  });
172
174
  // POST /comparative/natal-transits
@@ -1,6 +1,7 @@
1
1
  import { registerTool, validateRequired } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
- import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
3
+ import { OUTPUT_SCHEMA_IMAGE_AND_JSON } from "../output-schemas.js";
4
+ import { applyVisual, visualProperties } from "../visual.js";
4
5
  import { DATETIME_DESC, TIMEZONE_PROPERTY } from "../datetime.js";
5
6
  import { localToUtcIsoHistorical } from "../datetime-historical.js";
6
7
  /** Optional birth coordinates, present only to feed the historical timezone
@@ -53,24 +54,23 @@ registerTool({
53
54
  enum: ["json", "llm"],
54
55
  description: "Output format. 'llm' returns compact projection for token efficiency.",
55
56
  },
57
+ ...visualProperties("the bodygraph for that moment"),
56
58
  },
57
59
  required: ["planet", "datetime", "return_year"],
58
60
  additionalProperties: false,
59
61
  },
60
- outputSchema: OUTPUT_SCHEMA_JSON,
62
+ outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
61
63
  annotations: { title: "HD Planetary Return", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
62
64
  handler: async (args) => {
63
65
  validateRequired(args, ["planet", "datetime", "return_year"]);
64
- return await getActiveClient().request("POST", "/human-design/cycles/return", {
65
- data: {
66
- planet: args.planet,
67
- birth_datetime_utc: await localToUtcIsoHistorical("datetime", args.datetime, args.timezone, { latitude: args.latitude, longitude: args.longitude }),
68
- return_year: args.return_year,
69
- format: args.format,
70
- include_chiron: args.include_chiron,
71
- include_lilith: args.include_lilith,
72
- },
73
- });
66
+ const body = {
67
+ planet: args.planet,
68
+ birth_datetime_utc: await localToUtcIsoHistorical("datetime", args.datetime, args.timezone, { latitude: args.latitude, longitude: args.longitude }),
69
+ return_year: args.return_year,
70
+ format: args.format,
71
+ };
72
+ applyVisual(body, args);
73
+ return await getActiveClient().request("POST", "/human-design/cycles/return", { data: body });
74
74
  },
75
75
  });
76
76
  registerTool({
@@ -107,23 +107,22 @@ registerTool({
107
107
  enum: ["json", "llm"],
108
108
  description: "Output format. 'llm' returns compact projection for token efficiency.",
109
109
  },
110
+ ...visualProperties("the bodygraph for that moment"),
110
111
  },
111
112
  required: ["planet", "datetime", "target_year"],
112
113
  additionalProperties: false,
113
114
  },
114
- outputSchema: OUTPUT_SCHEMA_JSON,
115
+ outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
115
116
  annotations: { title: "HD Opposition Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
116
117
  handler: async (args) => {
117
118
  validateRequired(args, ["planet", "datetime", "target_year"]);
118
- return await getActiveClient().request("POST", "/human-design/cycles/opposition", {
119
- data: {
120
- planet: args.planet,
121
- birth_datetime_utc: await localToUtcIsoHistorical("datetime", args.datetime, args.timezone, { latitude: args.latitude, longitude: args.longitude }),
122
- target_year: args.target_year,
123
- format: args.format,
124
- include_chiron: args.include_chiron,
125
- include_lilith: args.include_lilith,
126
- },
127
- });
119
+ const body = {
120
+ planet: args.planet,
121
+ birth_datetime_utc: await localToUtcIsoHistorical("datetime", args.datetime, args.timezone, { latitude: args.latitude, longitude: args.longitude }),
122
+ target_year: args.target_year,
123
+ format: args.format,
124
+ };
125
+ applyVisual(body, args);
126
+ return await getActiveClient().request("POST", "/human-design/cycles/opposition", { data: body });
128
127
  },
129
128
  });
@@ -1,6 +1,7 @@
1
1
  import { registerTool, validateRequired } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
- import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
3
+ import { OUTPUT_SCHEMA_IMAGE_AND_JSON } from "../output-schemas.js";
4
+ import { applyVisual, visualProperties } from "../visual.js";
4
5
  import { DATETIME_DESC, TIMEZONE_PROPERTY, WINDOW_DATE_DESC, assertZonedDatetime } from "../datetime.js";
5
6
  registerTool({
6
7
  name: "ephemeris_progressed_chart",
@@ -44,11 +45,12 @@ registerTool({
44
45
  type: "boolean",
45
46
  description: "Whether to include aspect grid in the response. Default false.",
46
47
  },
48
+ ...visualProperties("a natal-inside, progressed-outside bi-wheel"),
47
49
  },
48
50
  required: ["birth_datetime", "birth_latitude", "birth_longitude", "target_datetime"],
49
51
  additionalProperties: false,
50
52
  },
51
- outputSchema: OUTPUT_SCHEMA_JSON,
53
+ outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
52
54
  annotations: { title: "Progressed Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
53
55
  handler: async (args) => {
54
56
  validateRequired(args, ["birth_datetime", "birth_latitude", "birth_longitude", "target_datetime"]);
@@ -76,6 +78,7 @@ registerTool({
76
78
  if (args.include_aspects != null) {
77
79
  body.options = { include_aspects: args.include_aspects };
78
80
  }
81
+ applyVisual(body, args);
79
82
  return await getActiveClient().post("/ephemeris/progressed", body);
80
83
  },
81
84
  });
@@ -1,6 +1,7 @@
1
1
  import { registerTool, validateRequired } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
- import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
3
+ import { OUTPUT_SCHEMA_IMAGE_AND_JSON } from "../output-schemas.js";
4
+ import { applyVisual, visualProperties } from "../visual.js";
4
5
  import { DATETIME_DESC, TIMEZONE_PROPERTY, assertZonedDatetime } from "../datetime.js";
5
6
  registerTool({
6
7
  name: "ephemeris_relocation",
@@ -45,11 +46,12 @@ registerTool({
45
46
  enum: ["json", "llm"],
46
47
  description: "Output format. 'llm' = compact token-efficient output (available on all tiers).",
47
48
  },
49
+ ...visualProperties("the relocated chart wheel"),
48
50
  },
49
51
  required: ["natal_datetime", "natal_latitude", "natal_longitude", "relocation_latitude", "relocation_longitude"],
50
52
  additionalProperties: false,
51
53
  },
52
- outputSchema: OUTPUT_SCHEMA_JSON,
54
+ outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
53
55
  annotations: { title: "Relocation Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
54
56
  handler: async (args) => {
55
57
  validateRequired(args, ["natal_datetime", "natal_latitude", "natal_longitude", "relocation_latitude", "relocation_longitude"]);
@@ -72,6 +74,7 @@ registerTool({
72
74
  };
73
75
  if (args.house_system)
74
76
  body.house_system = args.house_system;
77
+ applyVisual(body, args);
75
78
  const query = {};
76
79
  if (args.format)
77
80
  query.format = args.format;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * include_visual for data tools whose endpoint can embed a rendered picture
3
+ * (+2 credits). formatToolResponse turns the returned `visual` into an image
4
+ * block next to the JSON. Kept to one terse flag: tool text is re-sent to the
5
+ * model on every pass (see tool-surface-budget.test.ts).
6
+ */
7
+ /** Input-schema property; `what` names the picture, e.g. "the relocated wheel". */
8
+ export declare function visualProperties(what: string): {
9
+ readonly include_visual: {
10
+ readonly type: "boolean";
11
+ readonly description: `Also return ${string} as an image (+2 credits).`;
12
+ };
13
+ };
14
+ /** Copies the visual request onto the API body. Always SVG: the MCP host rasterizes nothing. */
15
+ export declare function applyVisual(body: Record<string, unknown>, args: any): void;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * include_visual for data tools whose endpoint can embed a rendered picture
3
+ * (+2 credits). formatToolResponse turns the returned `visual` into an image
4
+ * block next to the JSON. Kept to one terse flag: tool text is re-sent to the
5
+ * model on every pass (see tool-surface-budget.test.ts).
6
+ */
7
+ /** Input-schema property; `what` names the picture, e.g. "the relocated wheel". */
8
+ export function visualProperties(what) {
9
+ return {
10
+ include_visual: {
11
+ type: "boolean",
12
+ description: `Also return ${what} as an image (+2 credits).`,
13
+ },
14
+ };
15
+ }
16
+ /** Copies the visual request onto the API body. Always SVG: the MCP host rasterizes nothing. */
17
+ export function applyVisual(body, args) {
18
+ if (!args.include_visual)
19
+ return;
20
+ body.include_visual = true;
21
+ body.visual_config = { format: "svg", theme: "light", size: 800 };
22
+ }