@honua/mcp-server 0.1.9-beta.0 → 0.1.11-beta.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 (39) hide show
  1. package/certification/setup-parity.md +79 -0
  2. package/dist/src/certification/admin-parity.d.ts +7 -1
  3. package/dist/src/certification/admin-parity.d.ts.map +1 -1
  4. package/dist/src/certification/admin-parity.js +9 -2
  5. package/dist/src/certification/setup-parity.d.ts +86 -0
  6. package/dist/src/certification/setup-parity.d.ts.map +1 -0
  7. package/dist/src/certification/setup-parity.js +188 -0
  8. package/dist/src/eval/cli.js +2 -2
  9. package/dist/src/eval/corpus.d.ts.map +1 -1
  10. package/dist/src/eval/corpus.js +6 -5
  11. package/dist/src/eval/grade.d.ts.map +1 -1
  12. package/dist/src/eval/grade.js +25 -0
  13. package/dist/src/eval/report.d.ts +9 -0
  14. package/dist/src/eval/report.d.ts.map +1 -1
  15. package/dist/src/eval/report.js +15 -5
  16. package/dist/src/eval/runner.d.ts.map +1 -1
  17. package/dist/src/eval/runner.js +16 -1
  18. package/dist/src/eval/types.d.ts +37 -2
  19. package/dist/src/eval/types.d.ts.map +1 -1
  20. package/dist/src/eval/workflow-corpus.d.ts +9 -0
  21. package/dist/src/eval/workflow-corpus.d.ts.map +1 -0
  22. package/dist/src/eval/workflow-corpus.js +306 -0
  23. package/dist/src/proxy.d.ts.map +1 -1
  24. package/dist/src/proxy.js +7 -1
  25. package/dist/src/release/zero-to-map-checkpoint.d.ts.map +1 -1
  26. package/dist/src/release/zero-to-map-checkpoint.js +9 -1
  27. package/dist/src/release/zero-to-map-cli.d.ts +11 -1
  28. package/dist/src/release/zero-to-map-cli.d.ts.map +1 -1
  29. package/dist/src/release/zero-to-map-cli.js +52 -12
  30. package/dist/src/release/zero-to-map.d.ts +132 -59
  31. package/dist/src/release/zero-to-map.d.ts.map +1 -1
  32. package/dist/src/release/zero-to-map.js +459 -167
  33. package/package.json +7 -4
  34. package/release/zero-to-map/README.md +76 -79
  35. package/release/zero-to-map/configs/claude-code.mcp.json +1 -1
  36. package/release/zero-to-map/configs/claude-desktop.json +1 -1
  37. package/release/zero-to-map/configs/cursor.json +1 -1
  38. package/release/zero-to-map/journey.v1.json +965 -502
  39. package/release/zero-to-map/contracts/esri-gp-mcp.v1.json +0 -73
@@ -8,46 +8,40 @@ import { publicHttps } from "./zero-to-map-provision.js";
8
8
  * not contain a second installer, admin client, or MCP proxy.
9
9
  */
10
10
  import { createHash } from "node:crypto";
11
- import { ADMIN_MCP_EXCLUDED_OPERATIONS, ADMIN_MCP_EXCLUDED_OPERATION_COUNT, ADMIN_MCP_EXCLUSION_ROSTER_SHA256, ADMIN_MCP_PUBLISHED_TOOL_NAMES, MCP_DEFAULT_STATIC_TOOL_COUNT, MCP_DEFAULT_TOTAL_TOOL_COUNT, } from "@honua/sdk-js/control-plane";
11
+ import { inflateSync } from "node:zlib";
12
12
  export const ZERO_TO_MAP_PLAN_SCHEMA = "honua.zero-to-map.plan/v1";
13
13
  export const ZERO_TO_MAP_RECEIPT_SCHEMA = "honua.zero-to-map.receipt/v1";
14
14
  export const ZERO_TO_MAP_CONSOLE_RECEIPT_SCHEMA = "honua.zero-to-map.console-receipt/v1";
15
15
  export const ZERO_TO_MAP_CONSOLE_RECEIPT_REQUEST_SCHEMA = "honua.zero-to-map.console-receipt-request/v1";
16
16
  export const ZERO_TO_MAP_CATALOG_RECEIPT_SCHEMA = "honua.zero-to-map.catalog/v1";
17
- /** Identifier of the always-required default roster. */
18
- export const ZERO_TO_MAP_BASE_PROFILE_ID = "base";
19
17
  /**
20
- * Additive profiles the zero-to-map journey requires, in receipt order.
18
+ * Stages in the release journey: install, admin, style, geoprocessing, studio,
19
+ * proposal, console, artifact. `style` proves a canonical style applied to a
20
+ * *published* layer reaches rendered pixels. `console` fetches the share URLs
21
+ * the same admin publish returned; it does not import a second-principal receipt.
22
+ */
23
+ export const ZERO_TO_MAP_STAGE_COUNT = 8;
24
+ /**
25
+ * Receipt id for the closed operator roster: the MCP tools this journey calls,
26
+ * not a 432/441 catalog total and not the `analysis` or `esri-gp` profiles.
27
+ */
28
+ export const ZERO_TO_MAP_CLOSED_ROSTER_ID = "operator";
29
+ /**
30
+ * The reserved `tools/list` view name that opts a request back out of any
31
+ * server-configured or session-negotiated workflow view and returns the
32
+ * complete paginated catalog (honua-server#3428).
21
33
  *
22
- * `analysis` contributes six members; only `honua_buffer_features` - the
23
- * MCP-native dataset Buffer verb the journey actually executes - is confirmed
24
- * by an in-repo artifact. `esri-gp` contributes exactly the three tools named
25
- * by `release/zero-to-map/contracts/esri-gp-mcp.v1.json`.
34
+ * The candidate may publish a bounded workflow view and may make one the
35
+ * default through {@link ZERO_TO_MAP_WORKFLOW_VIEW_CONFIG_KEY}. Selecting a
36
+ * view can only *narrow* discovery, so a host that configures a default view
37
+ * would hide tools the journey calls. The preflight always selects this escape
38
+ * hatch explicitly rather than relying on the candidate having no default.
26
39
  */
27
- export const ZERO_TO_MAP_ADDITIVE_PROFILES = [
28
- {
29
- id: "analysis",
30
- memberCount: 6,
31
- confirmedMembers: ["honua_buffer_features"],
32
- namePrefixes: [],
33
- },
34
- {
35
- id: "esri-gp",
36
- memberCount: 3,
37
- confirmedMembers: ["honua_esri_gp_describe_task", "honua_esri_gp_execute_task", "honua_esri_gp_list_tasks"],
38
- namePrefixes: ["honua_esri_gp_"],
39
- },
40
- ];
41
- /** Profiles the candidate must advertise before the journey is executable. */
42
- export const ZERO_TO_MAP_REQUIRED_PROFILES = [
43
- ZERO_TO_MAP_BASE_PROFILE_ID,
44
- ...ZERO_TO_MAP_ADDITIVE_PROFILES.map((profile) => profile.id),
45
- ];
46
- /** Tools the base roster contributes: 47 static + 385 published Admin projections. */
47
- export const ZERO_TO_MAP_BASE_PROFILE_TOOL_COUNT = MCP_DEFAULT_STATIC_TOOL_COUNT + ADMIN_MCP_PUBLISHED_TOOL_NAMES.length;
48
- /** Derived candidate total. Never hardcode this; it follows from the enabled profiles. */
49
- export const ZERO_TO_MAP_EXPECTED_TOTAL_TOOL_COUNT = ZERO_TO_MAP_BASE_PROFILE_TOOL_COUNT +
50
- ZERO_TO_MAP_ADDITIVE_PROFILES.reduce((total, profile) => total + profile.memberCount, 0);
40
+ export const ZERO_TO_MAP_FULL_CATALOG_VIEW = "full";
41
+ /** Configuration key whose value would narrow the candidate's default `tools/list`. */
42
+ export const ZERO_TO_MAP_WORKFLOW_VIEW_CONFIG_KEY = "Mcp:WorkflowViews:DefaultView";
43
+ /** Environment-variable spelling of {@link ZERO_TO_MAP_WORKFLOW_VIEW_CONFIG_KEY}. */
44
+ export const ZERO_TO_MAP_WORKFLOW_VIEW_ENV_KEY = "Mcp__WorkflowViews__DefaultView";
51
45
  const STUDIO_GENERATION_MUTATION_TOOLS = new Set([
52
46
  "honua_studio_add_control",
53
47
  "honua_studio_add_layer",
@@ -63,6 +57,286 @@ const STUDIO_PUBLICATION_GENERATION_CAPTURE_POINTERS = [
63
57
  "/structuredContent/draft/generation",
64
58
  "/structuredContent/generation",
65
59
  ];
60
+ const PNG_SIGNATURE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
61
+ /** Channels per pixel for each PNG colour type; `undefined` marks a reserved value. */
62
+ const PNG_CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
63
+ const CRC_TABLE = (() => {
64
+ const table = new Uint32Array(256);
65
+ for (let index = 0; index < 256; index += 1) {
66
+ let value = index;
67
+ for (let bit = 0; bit < 8; bit += 1)
68
+ value = value & 1 ? 0xed_b8_83_20 ^ (value >>> 1) : value >>> 1;
69
+ table[index] = value >>> 0;
70
+ }
71
+ return table;
72
+ })();
73
+ /** PNG chunk CRC-32 over the type bytes followed by the chunk data. */
74
+ function crc32(bytes, start, end) {
75
+ let crc = 0xff_ff_ff_ff;
76
+ for (let index = start; index < end; index += 1)
77
+ crc = CRC_TABLE[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8);
78
+ return (crc ^ 0xff_ff_ff_ff) >>> 0;
79
+ }
80
+ /**
81
+ * Reverse the PNG per-scanline filters so the raster can be inspected as pixels
82
+ * rather than as filtered bytes. Filter types are PNG spec 9.2; an unknown type
83
+ * is a corrupt raster, not something to skip past.
84
+ */
85
+ function unfilterRaster(raster, height, bytesPerPixel, bytesPerRow, where) {
86
+ const out = new Uint8Array(height * bytesPerRow);
87
+ const step = Math.max(1, bytesPerPixel);
88
+ for (let row = 0; row < height; row += 1) {
89
+ const filter = raster[row * (bytesPerRow + 1)];
90
+ const from = row * (bytesPerRow + 1) + 1;
91
+ const to = row * bytesPerRow;
92
+ const above = (row - 1) * bytesPerRow;
93
+ for (let index = 0; index < bytesPerRow; index += 1) {
94
+ const raw = raster[from + index];
95
+ const left = index >= step ? out[to + index - step] : 0;
96
+ const up = row > 0 ? out[above + index] : 0;
97
+ const upLeft = row > 0 && index >= step ? out[above + index - step] : 0;
98
+ let value;
99
+ switch (filter) {
100
+ case 0:
101
+ value = raw;
102
+ break;
103
+ case 1:
104
+ value = raw + left;
105
+ break;
106
+ case 2:
107
+ value = raw + up;
108
+ break;
109
+ case 3:
110
+ value = raw + ((left + up) >> 1);
111
+ break;
112
+ case 4: {
113
+ const estimate = left + up - upLeft;
114
+ const dLeft = Math.abs(estimate - left);
115
+ const dUp = Math.abs(estimate - up);
116
+ const dUpLeft = Math.abs(estimate - upLeft);
117
+ value = raw + (dLeft <= dUp && dLeft <= dUpLeft ? left : dUp <= dUpLeft ? up : upLeft);
118
+ break;
119
+ }
120
+ default:
121
+ throw new Error(`${where} uses unknown PNG filter type ${filter} on row ${row}; the raster is corrupt`);
122
+ }
123
+ out[to + index] = value & 0xff;
124
+ }
125
+ }
126
+ return out;
127
+ }
128
+ /**
129
+ * Validate that fetched bytes are a PNG of the declared size that actually
130
+ * decodes to a drawn map.
131
+ *
132
+ * A style/render no-op is not a transport failure: the tool answers, the
133
+ * resource resolves, and the bytes may even be a syntactically plausible PNG.
134
+ * Structural checks alone are not enough to tell those apart - a blob whose
135
+ * chunks are merely *labelled* IDAT passes any counter that only sums chunk
136
+ * lengths, and a correctly-sized PNG of uniform background is a valid image
137
+ * that proves nothing about the style under test. So this decodes: every chunk
138
+ * CRC is verified, the stream must terminate in IEND, the concatenated IDAT
139
+ * payload must inflate, the inflated raster must be exactly the size the IHDR
140
+ * geometry implies, and the unfiltered pixels must not be a single flat colour.
141
+ * Each failure names the artifact and the property that was not met.
142
+ */
143
+ export function assertRenderedPng(bytes, mediaType, expected) {
144
+ const where = `rendered artifact ${expected.uri}`;
145
+ if (mediaType !== expected.mediaType) {
146
+ throw new Error(`${where} declared media type ${mediaType}; expected ${expected.mediaType}`);
147
+ }
148
+ if (bytes.length < PNG_SIGNATURE.length || !PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)) {
149
+ throw new Error(`${where} is not a PNG: the 8-byte PNG signature is absent`);
150
+ }
151
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
152
+ let offset = PNG_SIGNATURE.length;
153
+ let header;
154
+ const idatParts = [];
155
+ let sawEnd = false;
156
+ let palette;
157
+ let transparency;
158
+ while (offset + 8 <= bytes.length) {
159
+ const length = view.getUint32(offset);
160
+ const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]);
161
+ const dataStart = offset + 8;
162
+ if (dataStart + length + 4 > bytes.length) {
163
+ throw new Error(`${where} is truncated: chunk ${type} declares ${length} bytes past the end of the artifact`);
164
+ }
165
+ const declaredCrc = view.getUint32(dataStart + length);
166
+ const actualCrc = crc32(bytes, offset + 4, dataStart + length);
167
+ if (declaredCrc !== actualCrc) {
168
+ throw new Error(`${where} fails its ${type} chunk CRC (declared ${declaredCrc.toString(16)}, computed ${actualCrc.toString(16)}); the artifact is corrupt and no decoder would accept it`);
169
+ }
170
+ if (type === "IHDR") {
171
+ if (length !== 13)
172
+ throw new Error(`${where} has a malformed IHDR chunk: ${length} bytes, expected 13`);
173
+ header = {
174
+ width: view.getUint32(dataStart),
175
+ height: view.getUint32(dataStart + 4),
176
+ bitDepth: bytes[dataStart + 8],
177
+ colorType: bytes[dataStart + 9],
178
+ interlace: bytes[dataStart + 12],
179
+ };
180
+ }
181
+ else if (type === "PLTE") {
182
+ palette = bytes.subarray(dataStart, dataStart + length);
183
+ }
184
+ else if (type === "tRNS") {
185
+ transparency = bytes.subarray(dataStart, dataStart + length);
186
+ }
187
+ else if (type === "IDAT") {
188
+ idatParts.push(bytes.subarray(dataStart, dataStart + length));
189
+ }
190
+ else if (type === "IEND") {
191
+ sawEnd = true;
192
+ break;
193
+ }
194
+ offset = dataStart + length + 4;
195
+ }
196
+ if (!header)
197
+ throw new Error(`${where} carries no IHDR chunk, so it declares no image dimensions`);
198
+ if (!sawEnd)
199
+ throw new Error(`${where} never terminates in an IEND chunk; the artifact is incomplete`);
200
+ if (header.width !== expected.width || header.height !== expected.height) {
201
+ throw new Error(`${where} is ${header.width}x${header.height}; the renderer reported ${expected.width}x${expected.height}`);
202
+ }
203
+ const channels = PNG_CHANNELS[header.colorType];
204
+ if (channels === undefined)
205
+ throw new Error(`${where} declares reserved PNG colour type ${header.colorType}`);
206
+ const idatBytes = idatParts.reduce((total, part) => total + part.length, 0);
207
+ if (idatBytes === 0) {
208
+ throw new Error(`${where} carries no IDAT pixel data: the artifact declares a canvas but nothing was drawn on it`);
209
+ }
210
+ const legalDepths = {
211
+ 0: [1, 2, 4, 8, 16],
212
+ 2: [8, 16],
213
+ 3: [1, 2, 4, 8],
214
+ 4: [8, 16],
215
+ 6: [8, 16],
216
+ };
217
+ if (!legalDepths[header.colorType]?.includes(header.bitDepth) || ![0, 1].includes(header.interlace)) {
218
+ throw new Error(`${where} declares an invalid PNG bit depth or interlace method`);
219
+ }
220
+ if (header.width < 1 || header.height < 1)
221
+ throw new Error(`${where} has empty PNG dimensions`);
222
+ if (header.colorType === 3 && (!palette || palette.length === 0 || palette.length % 3 !== 0)) {
223
+ throw new Error(`${where} has no valid PNG palette`);
224
+ }
225
+ // Each Adam7 pass is its own filtered image. Decode all passes; interlacing
226
+ // must never exempt a render from the raster-shape or visible-content proof.
227
+ const layout = header.interlace === 1
228
+ ? [
229
+ [0, 0, 8, 8],
230
+ [4, 0, 8, 8],
231
+ [0, 4, 4, 8],
232
+ [2, 0, 4, 4],
233
+ [0, 2, 2, 4],
234
+ [1, 0, 2, 2],
235
+ [0, 1, 1, 2],
236
+ ]
237
+ : [[0, 0, 1, 1]];
238
+ const passes = layout.map(([x, y, dx, dy]) => {
239
+ const width = Math.max(0, Math.ceil((header.width - x) / dx));
240
+ const height = Math.max(0, Math.ceil((header.height - y) / dy));
241
+ const rowBytes = Math.ceil((width * channels * header.bitDepth) / 8);
242
+ return {
243
+ x: x,
244
+ y: y,
245
+ dx: dx,
246
+ dy: dy,
247
+ width,
248
+ height,
249
+ rowBytes,
250
+ length: width && height ? height * (rowBytes + 1) : 0,
251
+ };
252
+ });
253
+ const expectedRaster = passes.reduce((total, pass) => total + pass.length, 0);
254
+ const compressed = Buffer.concat(idatParts.map((part) => Buffer.from(part.buffer, part.byteOffset, part.length)));
255
+ let raster;
256
+ try {
257
+ raster = inflateSync(compressed, { maxOutputLength: expectedRaster + 1 });
258
+ }
259
+ catch (error) {
260
+ throw new Error(`${where} has an IDAT stream that does not inflate within its declared raster size (${error instanceof Error ? error.message : String(error)}); no PNG decoder could render this artifact`);
261
+ }
262
+ if (raster.length !== expectedRaster) {
263
+ throw new Error(`${where} inflates to ${raster.length} raster bytes; the declared PNG needs exactly ${expectedRaster}`);
264
+ }
265
+ const maxSample = (1 << header.bitDepth) - 1;
266
+ const seen = new Set();
267
+ const decoded = Buffer.alloc(header.width * header.height * 8);
268
+ let visiblePixelCount = 0;
269
+ let passOffset = 0;
270
+ for (const pass of passes) {
271
+ if (!pass.length)
272
+ continue;
273
+ const pixels = unfilterRaster(raster.subarray(passOffset, passOffset + pass.length), pass.height, Math.ceil((channels * header.bitDepth) / 8), pass.rowBytes, where);
274
+ passOffset += pass.length;
275
+ for (let row = 0; row < pass.height; row += 1) {
276
+ for (let column = 0; column < pass.width; column += 1) {
277
+ const samples = Array.from({ length: channels }, (_, channel) => {
278
+ const bit = (column * channels + channel) * header.bitDepth;
279
+ const at = row * pass.rowBytes + Math.floor(bit / 8);
280
+ return header.bitDepth === 16
281
+ ? pixels[at] * 256 + pixels[at + 1]
282
+ : (pixels[at] >> (8 - header.bitDepth - (bit % 8))) & maxSample;
283
+ });
284
+ let alpha = maxSample;
285
+ let color = samples;
286
+ if (header.colorType === 3) {
287
+ const index = samples[0];
288
+ if (!palette || index * 3 + 2 >= palette.length)
289
+ throw new Error(`${where} references a missing PNG palette entry`);
290
+ color = Array.from(palette.subarray(index * 3, index * 3 + 3));
291
+ alpha = transparency?.[index] ?? 255;
292
+ }
293
+ else if (header.colorType === 4 || header.colorType === 6) {
294
+ alpha = samples[samples.length - 1];
295
+ color = samples.slice(0, -1);
296
+ }
297
+ else if (transparency &&
298
+ samples.every((sample, index) => transparency[index * 2] !== undefined &&
299
+ sample === transparency[index * 2] * 256 + transparency[index * 2 + 1])) {
300
+ alpha = 0;
301
+ }
302
+ const maximum = header.colorType === 3 ? 255 : maxSample;
303
+ const rgb = color.length === 1 ? [color[0], color[0], color[0]] : color;
304
+ const at = ((pass.y + row * pass.dy) * header.width + pass.x + column * pass.dx) * 8;
305
+ for (const [index, sample] of [...rgb, alpha].entries()) {
306
+ decoded.writeUInt16BE(Math.round((sample * 65535) / maximum), at + index * 2);
307
+ }
308
+ if (alpha > 0)
309
+ visiblePixelCount += 1;
310
+ // RGB hidden behind zero alpha has no visible content, however many
311
+ // different byte values the encoder leaves in those channels.
312
+ if (seen.size < 2)
313
+ seen.add(alpha === 0 ? "transparent" : `${color.join(",")}/${alpha}`);
314
+ }
315
+ }
316
+ }
317
+ if (visiblePixelCount === 0)
318
+ throw new Error(`${where} decodes to no visible pixels; the render is fully transparent`);
319
+ if (seen.size < 2) {
320
+ throw new Error(`${where} decodes to a single flat colour across all ${header.width}x${header.height} pixels: the canvas was painted but no feature was drawn on it, which is what a style/render no-op produces`);
321
+ }
322
+ // Checked last, so a structurally explicable artifact is diagnosed by its
323
+ // structure and only a well-formed but implausibly small render falls through
324
+ // to the size floor.
325
+ if (bytes.length < expected.minByteLength) {
326
+ throw new Error(`${where} is ${bytes.length} bytes; a drawn ${expected.width}x${expected.height} map is at least ` +
327
+ `${expected.minByteLength}. An empty raster of the right dimensions is what a style/render no-op returns.`);
328
+ }
329
+ return {
330
+ uri: expected.uri,
331
+ mediaType: expected.mediaType,
332
+ width: header.width,
333
+ height: header.height,
334
+ byteLength: bytes.length,
335
+ imageSha256: createHash("sha256").update(bytes).digest("hex"),
336
+ visiblePixelCount,
337
+ decodedPixelSha256: createHash("sha256").update(decoded).digest("hex"),
338
+ };
339
+ }
66
340
  export class JourneyBlockedError extends Error {
67
341
  code;
68
342
  constructor(message, code = "dependency-unavailable") {
@@ -81,8 +355,8 @@ export function parseZeroToMapPlan(value) {
81
355
  const releaseContract = nonEmptyString(plan.releaseContract, "plan.releaseContract");
82
356
  const fixtures = stringArray(plan.fixtures, "plan.fixtures");
83
357
  const dependencyRefs = stringArray(plan.dependencyRefs, "plan.dependencyRefs");
84
- if (!Array.isArray(plan.stages) || plan.stages.length !== 7) {
85
- throw new Error("plan.stages must contain the seven D9.3 stages");
358
+ if (!Array.isArray(plan.stages) || plan.stages.length !== ZERO_TO_MAP_STAGE_COUNT) {
359
+ throw new Error(`plan.stages must contain the ${ZERO_TO_MAP_STAGE_COUNT} D9.3 stages`);
86
360
  }
87
361
  const actionIds = new Set();
88
362
  const stages = plan.stages.map((candidate, index) => {
@@ -186,7 +460,20 @@ export async function runZeroToMapJourney(plan, adapter, options) {
186
460
  throw new Error(`${action.id} disclosed forbidden pre-approval evidence at ${pointer}`);
187
461
  }
188
462
  }
189
- const captures = captureValues(action.captures, result.value, variables);
463
+ // Capture contracts are where a silent no-op surfaces: the tool
464
+ // answers without error but the value it was supposed to change did
465
+ // not change. Name the stage, the action and the tool so the operator
466
+ // is told which step of the journey did nothing, not merely that some
467
+ // pointer disagreed.
468
+ let captures;
469
+ try {
470
+ captures = captureValues(action.captures, result.value, variables);
471
+ }
472
+ catch (captureError) {
473
+ const message = captureError instanceof Error ? captureError.message : String(captureError);
474
+ throw new Error(`stage ${stage.number} ${stage.id}, action ${action.id}` +
475
+ `${action.kind === "mcp" ? ` (${action.tool})` : ""}: ${message}`);
476
+ }
190
477
  Object.assign(variables, captures);
191
478
  Object.assign(capturedVariables, captures);
192
479
  actions.push({
@@ -490,6 +777,23 @@ function requiredMcpToolUses(plan) {
490
777
  function describeToolUse(use) {
491
778
  return `${use.tool} (stage ${use.stageNumber} ${use.stageId}, action ${use.actionId})`;
492
779
  }
780
+ function dimensionTemplate(value, path) {
781
+ if (typeof value === "string") {
782
+ if (value.trim().length === 0)
783
+ throw new Error(`${path} must not be empty`);
784
+ return value;
785
+ }
786
+ if (Number.isInteger(value) && value > 0)
787
+ return value;
788
+ throw new Error(`${path} must be a positive integer or a template resolving to one`);
789
+ }
790
+ function resolveDimension(value, variables, path) {
791
+ const resolved = typeof value === "string" ? resolveTemplateValue(value, variables) : value;
792
+ if (!Number.isInteger(resolved) || resolved < 1) {
793
+ throw new Error(`${path} resolved to ${JSON.stringify(resolved)}, which is not a positive integer`);
794
+ }
795
+ return resolved;
796
+ }
493
797
  function rosterDigest(names) {
494
798
  return createHash("sha256")
495
799
  .update(`${[...names].sort().join("\n")}\n`)
@@ -503,144 +807,92 @@ function summarizeNames(names, limit = 10) {
503
807
  ? `${sorted.slice(0, limit).join(", ")}, ... (+${sorted.length - limit})`
504
808
  : sorted.join(", ");
505
809
  }
810
+ /**
811
+ * Distinct MCP tools the plan calls, sorted. This is the closed operator roster.
812
+ * A server may advertise more; the preflight does not require a catalog total.
813
+ */
814
+ export function zeroToMapClosedRoster(plan) {
815
+ return [...new Set(requiredMcpToolUses(plan).map((use) => use.tool))].sort();
816
+ }
817
+ /**
818
+ * Turn a share URL into an absolute request URL.
819
+ *
820
+ * Admin publication returns `StudioPublishedRoutes.BuildActiveUrl`, a
821
+ * root-relative `/api/v1/studio/published/...` path. Live mode resolves that
822
+ * path against the MCP endpoint origin. Absolute HTTPS is accepted as-is.
823
+ * HTTP is accepted only for a loopback origin, which is the local-install proof.
824
+ */
825
+ export function resolvePublishedShareUrl(url, mcpUrl) {
826
+ if (url.includes("${"))
827
+ throw new Error("published share URL was not resolved");
828
+ const absolute = url.startsWith("/") ? joinSharePath(url, mcpUrl) : url;
829
+ let requested;
830
+ try {
831
+ requested = new URL(absolute);
832
+ }
833
+ catch {
834
+ throw new Error("published share URL must be HTTPS, or HTTP on a loopback origin");
835
+ }
836
+ if (requested.username || requested.password || requested.search || requested.hash) {
837
+ throw new Error("published share URL must not embed credentials, query, or fragment");
838
+ }
839
+ const loopback = requested.hostname === "localhost" || requested.hostname === "127.0.0.1" || requested.hostname === "::1";
840
+ if (requested.protocol === "https:" || (requested.protocol === "http:" && loopback))
841
+ return requested.toString();
842
+ throw new Error("published share URL must be HTTPS, or HTTP on a loopback origin");
843
+ }
844
+ function joinSharePath(path, mcpUrl) {
845
+ if (!mcpUrl)
846
+ throw new Error("a root-relative share URL requires the MCP endpoint origin");
847
+ let endpoint;
848
+ try {
849
+ endpoint = new URL(mcpUrl);
850
+ }
851
+ catch {
852
+ throw new Error("a root-relative share URL requires the MCP endpoint origin");
853
+ }
854
+ return `${endpoint.origin}${path}`;
855
+ }
506
856
  /**
507
857
  * Preflight the advertised MCP catalog before the first live mutation.
508
858
  *
509
- * The base roster, the audited exclusions and each enabled server profile are
510
- * verified independently, and the expected total is *derived* from those
511
- * rosters rather than written down. A previous revision required the catalog
512
- * to equal the 432-tool default roster while the journey simultaneously
513
- * required analysis and Esri GP tools, so no candidate configuration could
514
- * pass. Findings are reported per cause (missing / unexpected / duplicate /
515
- * excluded / truncated / drifted) and name the journey stage and action that
516
- * needs the tool.
859
+ * The closed roster is the set of tools this journey calls. The preflight does
860
+ * not require a 432- or 441-tool catalog, and it does not require the
861
+ * `analysis` or `esri-gp` profiles. A missing member names the stage and
862
+ * action that needs it. Extra advertised tools are not a failure: the server
863
+ * may publish more than this journey uses. Input-schema drift on a planned
864
+ * call still blocks before that call is sent.
517
865
  */
518
866
  async function assertMcpCatalog(plan, adapter) {
519
867
  const requiredUses = requiredMcpToolUses(plan);
520
- const catalog = await adapter.listTools();
868
+ const closedRoster = zeroToMapClosedRoster(plan);
869
+ // Opt out of any server-configured or session-negotiated workflow view. A
870
+ // candidate with `Mcp:WorkflowViews:DefaultView` set would otherwise hide
871
+ // tools this journey calls, and the finding would look like a missing tool
872
+ // rather than a discovery setting.
873
+ const catalog = await adapter.listTools({ view: ZERO_TO_MAP_FULL_CATALOG_VIEW });
521
874
  const names = catalog.map((tool) => tool.name);
522
875
  const findings = [];
523
876
  const duplicateNames = duplicateValues(names);
524
877
  if (duplicateNames.length > 0) {
525
878
  findings.push(`duplicate: the catalog advertises ${summarizeNames(duplicateNames)} more than once`);
526
879
  }
527
- // --- base profile: the 385 published Admin projections ---------------------
528
- const advertisedAdmin = names.filter((name) => name.startsWith("honua_admin_"));
529
- const advertisedAdminSet = new Set(advertisedAdmin);
530
- const expectedAdmin = new Set(ADMIN_MCP_PUBLISHED_TOOL_NAMES);
531
- const missingAdmin = [...expectedAdmin].filter((name) => !advertisedAdminSet.has(name)).sort();
532
- const unexpectedAdmin = [...advertisedAdminSet].filter((name) => !expectedAdmin.has(name)).sort();
533
- if (missingAdmin.length > 0) {
534
- findings.push(`missing: ${missingAdmin.length} of the ${expectedAdmin.size} published Admin projections are absent ` +
535
- `(${summarizeNames(missingAdmin)})`);
536
- }
537
- if (unexpectedAdmin.length > 0) {
538
- findings.push(`unexpected: the catalog advertises unpublished Admin tools ${summarizeNames(unexpectedAdmin)}`);
539
- }
540
- // --- base profile: the 11 audited secret/session exclusions ----------------
541
- const excludedToolNames = ADMIN_MCP_EXCLUDED_OPERATIONS.map((operation) => operation.toolName);
542
- if (excludedToolNames.length !== ADMIN_MCP_EXCLUDED_OPERATION_COUNT) {
543
- findings.push(`excluded: the generated exclusion roster carries ${excludedToolNames.length} operations; the audited ` +
544
- `contract requires ${ADMIN_MCP_EXCLUDED_OPERATION_COUNT}`);
545
- }
546
- const advertisedExclusions = excludedToolNames.filter((name) => advertisedAdminSet.has(name)).sort();
547
- if (advertisedExclusions.length > 0) {
548
- findings.push(`excluded: the catalog advertises audited secret/session operations ${summarizeNames(advertisedExclusions)}`);
549
- }
550
- // --- additive profiles, verified independently -----------------------------
551
- const nonAdminNames = names.filter((name) => !name.startsWith("honua_admin_"));
552
- const namedProfiles = ZERO_TO_MAP_ADDITIVE_PROFILES.filter((profile) => profile.namePrefixes.length > 0);
553
- const fallbackProfile = ZERO_TO_MAP_ADDITIVE_PROFILES.find((profile) => profile.namePrefixes.length === 0);
554
- const claimed = new Set();
555
- const profileReceipts = [];
556
- for (const profile of namedProfiles) {
557
- const members = nonAdminNames.filter((name) => profile.namePrefixes.some((prefix) => name.startsWith(prefix)));
558
- for (const member of members)
559
- claimed.add(member);
560
- const memberSet = new Set(members);
561
- const missingMembers = profile.confirmedMembers.filter((name) => !memberSet.has(name)).sort();
562
- const unexpectedMembers = [...memberSet].filter((name) => !profile.confirmedMembers.includes(name)).sort();
563
- if (missingMembers.length > 0) {
564
- findings.push(`missing: the ${profile.id} profile is not advertised - ${missingMembers
565
- .map((tool) => {
566
- const use = requiredUses.find((candidate) => candidate.tool === tool);
567
- return use ? describeToolUse(use) : tool;
568
- })
569
- .join(", ")}. Enable the ${profile.id} server profile on the candidate and rerun.`);
570
- }
571
- if (unexpectedMembers.length > 0) {
572
- findings.push(`unexpected: the ${profile.id} profile advertises ${summarizeNames(unexpectedMembers)}, which the checked-in profile contract does not declare`);
573
- }
574
- if (members.length !== profile.memberCount) {
575
- findings.push(`${members.length < profile.memberCount ? "missing" : "unexpected"}: the ${profile.id} profile advertises ` +
576
- `${members.length} members; the profile contract declares ${profile.memberCount}`);
577
- }
578
- profileReceipts.push({
579
- id: profile.id,
580
- expectedMembers: profile.memberCount,
581
- advertisedMembers: members.length,
582
- confirmedMembers: [...profile.confirmedMembers].sort(),
583
- nameResolvedMembers: [...members].sort(),
584
- rosterSha256: rosterDigest(members),
585
- });
586
- }
587
- // The fallback profile's members are not separable from the base static
588
- // surface by name (the server has not published an `analysis` roster yet:
589
- // honua-server#3363). It is therefore verified by its confirmed members plus
590
- // its declared size, and the base static count is derived from the residue.
591
- const residue = nonAdminNames.filter((name) => !claimed.has(name));
592
- const fallbackMembers = fallbackProfile ? residue.length - MCP_DEFAULT_STATIC_TOOL_COUNT : 0;
593
- const baseStaticTools = residue.length - fallbackMembers;
594
- if (fallbackProfile) {
595
- const residueSet = new Set(residue);
596
- const missingMembers = fallbackProfile.confirmedMembers.filter((name) => !residueSet.has(name)).sort();
597
- if (missingMembers.length > 0) {
598
- findings.push(`missing: the ${fallbackProfile.id} profile is not advertised - ${missingMembers
599
- .map((tool) => {
600
- const use = requiredUses.find((candidate) => candidate.tool === tool);
601
- return use ? describeToolUse(use) : tool;
602
- })
603
- .join(", ")}. Enable the ${fallbackProfile.id} server profile on the candidate and rerun.`);
604
- }
605
- if (fallbackMembers !== fallbackProfile.memberCount) {
606
- findings.push(`${fallbackMembers < fallbackProfile.memberCount ? "missing" : "unexpected"}: the non-Admin surface ` +
607
- `advertises ${residue.length} tools; the base static roster (${MCP_DEFAULT_STATIC_TOOL_COUNT}) plus the ` +
608
- `${fallbackProfile.id} profile (${fallbackProfile.memberCount}) derive ` +
609
- `${MCP_DEFAULT_STATIC_TOOL_COUNT + fallbackProfile.memberCount}`);
610
- }
611
- profileReceipts.push({
612
- id: fallbackProfile.id,
613
- expectedMembers: fallbackProfile.memberCount,
614
- advertisedMembers: Math.max(fallbackMembers, 0),
615
- confirmedMembers: [...fallbackProfile.confirmedMembers].sort(),
616
- nameResolvedMembers: [...fallbackProfile.confirmedMembers].filter((name) => residueSet.has(name)).sort(),
617
- rosterSha256: rosterDigest(residue),
618
- });
619
- }
620
- // --- derived totals --------------------------------------------------------
621
- if (ZERO_TO_MAP_BASE_PROFILE_TOOL_COUNT !== MCP_DEFAULT_TOTAL_TOOL_COUNT) {
622
- findings.push(`unexpected: the generated base roster derives ${MCP_DEFAULT_STATIC_TOOL_COUNT} static + ${expectedAdmin.size} ` +
623
- `Admin = ${ZERO_TO_MAP_BASE_PROFILE_TOOL_COUNT} tools, but the default roster constant is ` +
624
- `${MCP_DEFAULT_TOTAL_TOOL_COUNT}`);
625
- }
626
- const expectedTotal = ZERO_TO_MAP_EXPECTED_TOTAL_TOOL_COUNT;
627
- if (names.length !== expectedTotal) {
628
- const shortfall = expectedTotal - names.length;
629
- findings.push(shortfall > 0 && duplicateNames.length === 0 && unexpectedAdmin.length === 0
630
- ? `truncated: the catalog returned ${names.length} of ${expectedTotal} tools (${shortfall} short). Every tools/list page must be drained before the roster is judged; a short read is a pagination fault, not a roster decision.`
631
- : `unexpected: the catalog advertises ${names.length} tools; the enabled profiles ` +
632
- `(${ZERO_TO_MAP_REQUIRED_PROFILES.join(" + ")}) derive ${expectedTotal}`);
880
+ const advertised = new Map(catalog.map((tool) => [tool.name, tool]));
881
+ const missing = closedRoster.filter((tool) => !advertised.has(tool));
882
+ if (missing.length > 0) {
883
+ const described = missing
884
+ .map((tool) => requiredUses
885
+ .filter((use) => use.tool === tool)
886
+ .map(describeToolUse)
887
+ .join(", "))
888
+ .join("; ");
889
+ findings.push(`missing: the ${ZERO_TO_MAP_CLOSED_ROSTER_ID} roster is not advertised - ${described}. ` +
890
+ `This preflight already selects view="${ZERO_TO_MAP_FULL_CATALOG_VIEW}", so a candidate workflow view ` +
891
+ `configured through ${ZERO_TO_MAP_WORKFLOW_VIEW_CONFIG_KEY} (${ZERO_TO_MAP_WORKFLOW_VIEW_ENV_KEY}) is not the cause.`);
633
892
  }
634
893
  if (findings.length > 0) {
635
- throw new JourneyBlockedError(`MCP catalog preflight failed for profiles ${ZERO_TO_MAP_REQUIRED_PROFILES.join(" + ")}: ${findings.join("; ")}`, "mcp-catalog-incomplete");
636
- }
637
- // --- every tool the plan actually calls ------------------------------------
638
- const advertised = new Map(catalog.map((tool) => [tool.name, tool]));
639
- const missingRequired = [...new Set(requiredUses.map((use) => use.tool))].filter((tool) => !advertised.has(tool));
640
- if (missingRequired.length > 0) {
641
- throw new JourneyBlockedError(`MCP catalog is missing required tools: ${missingRequired.join(", ")}`, "mcp-catalog-incomplete");
894
+ throw new JourneyBlockedError(`MCP catalog preflight failed for the ${ZERO_TO_MAP_CLOSED_ROSTER_ID} roster: ${findings.join("; ")}`, "mcp-catalog-incomplete");
642
895
  }
643
- // --- input-schema drift on every planned call ------------------------------
644
896
  for (const stage of plan.stages) {
645
897
  for (const action of stage.actions) {
646
898
  if (action.kind !== "mcp")
@@ -662,19 +914,33 @@ async function assertMcpCatalog(plan, adapter) {
662
914
  }
663
915
  }
664
916
  }
917
+ const adminNames = names.filter((name) => name.startsWith("honua_admin_"));
918
+ const staticNames = names.filter((name) => !name.startsWith("honua_admin_"));
919
+ const closedAdmin = closedRoster.filter((name) => name.startsWith("honua_admin_"));
920
+ const closedStatic = closedRoster.filter((name) => !name.startsWith("honua_admin_"));
665
921
  return {
666
922
  schemaVersion: ZERO_TO_MAP_CATALOG_RECEIPT_SCHEMA,
667
- activeProfiles: [...ZERO_TO_MAP_REQUIRED_PROFILES],
668
- expectedTotalTools: expectedTotal,
923
+ activeProfiles: [ZERO_TO_MAP_CLOSED_ROSTER_ID],
924
+ requestedView: ZERO_TO_MAP_FULL_CATALOG_VIEW,
925
+ expectedTotalTools: closedRoster.length,
669
926
  advertisedTotalTools: names.length,
670
- baseStaticTools,
671
- baseAdminTools: advertisedAdmin.length,
672
- auditedExclusions: excludedToolNames.length,
673
- profiles: profileReceipts,
927
+ baseStaticTools: closedStatic.length,
928
+ baseAdminTools: closedAdmin.length,
929
+ auditedExclusions: 0,
930
+ profiles: [
931
+ {
932
+ id: ZERO_TO_MAP_CLOSED_ROSTER_ID,
933
+ expectedMembers: closedRoster.length,
934
+ advertisedMembers: closedRoster.length,
935
+ confirmedMembers: closedRoster,
936
+ nameResolvedMembers: closedRoster,
937
+ rosterSha256: rosterDigest(closedRoster),
938
+ },
939
+ ],
674
940
  catalogSha256: rosterDigest(names),
675
- adminRosterSha256: rosterDigest(advertisedAdmin),
676
- staticRosterSha256: rosterDigest(residue),
677
- exclusionRosterSha256: ADMIN_MCP_EXCLUSION_ROSTER_SHA256,
941
+ adminRosterSha256: rosterDigest(adminNames),
942
+ staticRosterSha256: rosterDigest(staticNames),
943
+ exclusionRosterSha256: rosterDigest([]),
678
944
  };
679
945
  }
680
946
  function duplicateValues(values) {
@@ -761,6 +1027,17 @@ async function executeAction(action, adapter, variables) {
761
1027
  ...action,
762
1028
  uri: resolveTemplateString(action.uri, variables),
763
1029
  });
1030
+ case "mcp-image": {
1031
+ const uri = resolveTemplateString(action.uri, variables);
1032
+ const resolved = { ...action, uri };
1033
+ return adapter.readImageResource(resolved, {
1034
+ uri,
1035
+ mediaType: action.expectedMediaType,
1036
+ width: resolveDimension(action.expectedWidth, variables, `${action.id}.expectedWidth`),
1037
+ height: resolveDimension(action.expectedHeight, variables, `${action.id}.expectedHeight`),
1038
+ minByteLength: action.minByteLength,
1039
+ });
1040
+ }
764
1041
  case "gpserver":
765
1042
  return adapter.runGpServer({
766
1043
  ...action,
@@ -814,6 +1091,7 @@ function parseAction(value, path, ids) {
814
1091
  if (kind !== "cli" &&
815
1092
  kind !== "mcp" &&
816
1093
  kind !== "mcp-resource" &&
1094
+ kind !== "mcp-image" &&
817
1095
  kind !== "gpserver" &&
818
1096
  kind !== "receipt" &&
819
1097
  kind !== "http") {
@@ -847,6 +1125,20 @@ function parseAction(value, path, ids) {
847
1125
  uri: nonEmptyString(action.uri, `${path}.uri`),
848
1126
  ...(action.waitFor === undefined ? {} : { waitFor: parseResourceWait(action.waitFor, `${path}.waitFor`) }),
849
1127
  };
1128
+ case "mcp-image": {
1129
+ if (!Number.isInteger(action.minByteLength) || action.minByteLength < 1) {
1130
+ throw new Error(`${path}.minByteLength must be a positive integer`);
1131
+ }
1132
+ return {
1133
+ ...common,
1134
+ kind,
1135
+ uri: nonEmptyString(action.uri, `${path}.uri`),
1136
+ expectedMediaType: nonEmptyString(action.expectedMediaType, `${path}.expectedMediaType`),
1137
+ expectedWidth: dimensionTemplate(action.expectedWidth, `${path}.expectedWidth`),
1138
+ expectedHeight: dimensionTemplate(action.expectedHeight, `${path}.expectedHeight`),
1139
+ minByteLength: action.minByteLength,
1140
+ };
1141
+ }
850
1142
  case "gpserver":
851
1143
  return {
852
1144
  ...common,