@honua/mcp-server 0.1.9-beta.0 → 0.1.10-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.
@@ -8,12 +8,20 @@ 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 { inflateSync } from "node:zlib";
11
12
  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";
12
13
  export const ZERO_TO_MAP_PLAN_SCHEMA = "honua.zero-to-map.plan/v1";
13
14
  export const ZERO_TO_MAP_RECEIPT_SCHEMA = "honua.zero-to-map.receipt/v1";
14
15
  export const ZERO_TO_MAP_CONSOLE_RECEIPT_SCHEMA = "honua.zero-to-map.console-receipt/v1";
15
16
  export const ZERO_TO_MAP_CONSOLE_RECEIPT_REQUEST_SCHEMA = "honua.zero-to-map.console-receipt-request/v1";
16
17
  export const ZERO_TO_MAP_CATALOG_RECEIPT_SCHEMA = "honua.zero-to-map.catalog/v1";
18
+ /**
19
+ * Stages in the release journey: install, admin, style, geoprocessing, studio,
20
+ * proposal, console, artifact. `style` proves a canonical style applied to a
21
+ * *published* layer reaches rendered pixels - the Studio-draft styling in the
22
+ * `studio` stage never leaves a draft and so never proved that.
23
+ */
24
+ export const ZERO_TO_MAP_STAGE_COUNT = 8;
17
25
  /** Identifier of the always-required default roster. */
18
26
  export const ZERO_TO_MAP_BASE_PROFILE_ID = "base";
19
27
  /**
@@ -38,6 +46,25 @@ export const ZERO_TO_MAP_ADDITIVE_PROFILES = [
38
46
  namePrefixes: ["honua_esri_gp_"],
39
47
  },
40
48
  ];
49
+ /**
50
+ * The reserved `tools/list` view name that opts a request back out of any
51
+ * server-configured or session-negotiated workflow view and returns the
52
+ * complete paginated catalog (honua-server#3428).
53
+ *
54
+ * The candidate server may publish a bounded workflow view - today `setup` - and
55
+ * may make one the profile default through the configuration key named by
56
+ * {@link ZERO_TO_MAP_WORKFLOW_VIEW_CONFIG_KEY}. Selecting a view can only
57
+ * *narrow* discovery, so a host that configures a default view would serve the
58
+ * roster preflight a short catalog and the preflight would blame a missing
59
+ * server profile for what is actually a discovery setting. The preflight
60
+ * therefore always selects this escape hatch explicitly rather than relying on
61
+ * the candidate having no default configured.
62
+ */
63
+ export const ZERO_TO_MAP_FULL_CATALOG_VIEW = "full";
64
+ /** Configuration key whose value would narrow the candidate's default `tools/list`. */
65
+ export const ZERO_TO_MAP_WORKFLOW_VIEW_CONFIG_KEY = "Mcp:WorkflowViews:DefaultView";
66
+ /** Environment-variable spelling of {@link ZERO_TO_MAP_WORKFLOW_VIEW_CONFIG_KEY}. */
67
+ export const ZERO_TO_MAP_WORKFLOW_VIEW_ENV_KEY = "Mcp__WorkflowViews__DefaultView";
41
68
  /** Profiles the candidate must advertise before the journey is executable. */
42
69
  export const ZERO_TO_MAP_REQUIRED_PROFILES = [
43
70
  ZERO_TO_MAP_BASE_PROFILE_ID,
@@ -63,6 +90,286 @@ const STUDIO_PUBLICATION_GENERATION_CAPTURE_POINTERS = [
63
90
  "/structuredContent/draft/generation",
64
91
  "/structuredContent/generation",
65
92
  ];
93
+ const PNG_SIGNATURE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
94
+ /** Channels per pixel for each PNG colour type; `undefined` marks a reserved value. */
95
+ const PNG_CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
96
+ const CRC_TABLE = (() => {
97
+ const table = new Uint32Array(256);
98
+ for (let index = 0; index < 256; index += 1) {
99
+ let value = index;
100
+ for (let bit = 0; bit < 8; bit += 1)
101
+ value = value & 1 ? 0xed_b8_83_20 ^ (value >>> 1) : value >>> 1;
102
+ table[index] = value >>> 0;
103
+ }
104
+ return table;
105
+ })();
106
+ /** PNG chunk CRC-32 over the type bytes followed by the chunk data. */
107
+ function crc32(bytes, start, end) {
108
+ let crc = 0xff_ff_ff_ff;
109
+ for (let index = start; index < end; index += 1)
110
+ crc = CRC_TABLE[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8);
111
+ return (crc ^ 0xff_ff_ff_ff) >>> 0;
112
+ }
113
+ /**
114
+ * Reverse the PNG per-scanline filters so the raster can be inspected as pixels
115
+ * rather than as filtered bytes. Filter types are PNG spec 9.2; an unknown type
116
+ * is a corrupt raster, not something to skip past.
117
+ */
118
+ function unfilterRaster(raster, height, bytesPerPixel, bytesPerRow, where) {
119
+ const out = new Uint8Array(height * bytesPerRow);
120
+ const step = Math.max(1, bytesPerPixel);
121
+ for (let row = 0; row < height; row += 1) {
122
+ const filter = raster[row * (bytesPerRow + 1)];
123
+ const from = row * (bytesPerRow + 1) + 1;
124
+ const to = row * bytesPerRow;
125
+ const above = (row - 1) * bytesPerRow;
126
+ for (let index = 0; index < bytesPerRow; index += 1) {
127
+ const raw = raster[from + index];
128
+ const left = index >= step ? out[to + index - step] : 0;
129
+ const up = row > 0 ? out[above + index] : 0;
130
+ const upLeft = row > 0 && index >= step ? out[above + index - step] : 0;
131
+ let value;
132
+ switch (filter) {
133
+ case 0:
134
+ value = raw;
135
+ break;
136
+ case 1:
137
+ value = raw + left;
138
+ break;
139
+ case 2:
140
+ value = raw + up;
141
+ break;
142
+ case 3:
143
+ value = raw + ((left + up) >> 1);
144
+ break;
145
+ case 4: {
146
+ const estimate = left + up - upLeft;
147
+ const dLeft = Math.abs(estimate - left);
148
+ const dUp = Math.abs(estimate - up);
149
+ const dUpLeft = Math.abs(estimate - upLeft);
150
+ value = raw + (dLeft <= dUp && dLeft <= dUpLeft ? left : dUp <= dUpLeft ? up : upLeft);
151
+ break;
152
+ }
153
+ default:
154
+ throw new Error(`${where} uses unknown PNG filter type ${filter} on row ${row}; the raster is corrupt`);
155
+ }
156
+ out[to + index] = value & 0xff;
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+ /**
162
+ * Validate that fetched bytes are a PNG of the declared size that actually
163
+ * decodes to a drawn map.
164
+ *
165
+ * A style/render no-op is not a transport failure: the tool answers, the
166
+ * resource resolves, and the bytes may even be a syntactically plausible PNG.
167
+ * Structural checks alone are not enough to tell those apart - a blob whose
168
+ * chunks are merely *labelled* IDAT passes any counter that only sums chunk
169
+ * lengths, and a correctly-sized PNG of uniform background is a valid image
170
+ * that proves nothing about the style under test. So this decodes: every chunk
171
+ * CRC is verified, the stream must terminate in IEND, the concatenated IDAT
172
+ * payload must inflate, the inflated raster must be exactly the size the IHDR
173
+ * geometry implies, and the unfiltered pixels must not be a single flat colour.
174
+ * Each failure names the artifact and the property that was not met.
175
+ */
176
+ export function assertRenderedPng(bytes, mediaType, expected) {
177
+ const where = `rendered artifact ${expected.uri}`;
178
+ if (mediaType !== expected.mediaType) {
179
+ throw new Error(`${where} declared media type ${mediaType}; expected ${expected.mediaType}`);
180
+ }
181
+ if (bytes.length < PNG_SIGNATURE.length || !PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)) {
182
+ throw new Error(`${where} is not a PNG: the 8-byte PNG signature is absent`);
183
+ }
184
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
185
+ let offset = PNG_SIGNATURE.length;
186
+ let header;
187
+ const idatParts = [];
188
+ let sawEnd = false;
189
+ let palette;
190
+ let transparency;
191
+ while (offset + 8 <= bytes.length) {
192
+ const length = view.getUint32(offset);
193
+ const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]);
194
+ const dataStart = offset + 8;
195
+ if (dataStart + length + 4 > bytes.length) {
196
+ throw new Error(`${where} is truncated: chunk ${type} declares ${length} bytes past the end of the artifact`);
197
+ }
198
+ const declaredCrc = view.getUint32(dataStart + length);
199
+ const actualCrc = crc32(bytes, offset + 4, dataStart + length);
200
+ if (declaredCrc !== actualCrc) {
201
+ 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`);
202
+ }
203
+ if (type === "IHDR") {
204
+ if (length !== 13)
205
+ throw new Error(`${where} has a malformed IHDR chunk: ${length} bytes, expected 13`);
206
+ header = {
207
+ width: view.getUint32(dataStart),
208
+ height: view.getUint32(dataStart + 4),
209
+ bitDepth: bytes[dataStart + 8],
210
+ colorType: bytes[dataStart + 9],
211
+ interlace: bytes[dataStart + 12],
212
+ };
213
+ }
214
+ else if (type === "PLTE") {
215
+ palette = bytes.subarray(dataStart, dataStart + length);
216
+ }
217
+ else if (type === "tRNS") {
218
+ transparency = bytes.subarray(dataStart, dataStart + length);
219
+ }
220
+ else if (type === "IDAT") {
221
+ idatParts.push(bytes.subarray(dataStart, dataStart + length));
222
+ }
223
+ else if (type === "IEND") {
224
+ sawEnd = true;
225
+ break;
226
+ }
227
+ offset = dataStart + length + 4;
228
+ }
229
+ if (!header)
230
+ throw new Error(`${where} carries no IHDR chunk, so it declares no image dimensions`);
231
+ if (!sawEnd)
232
+ throw new Error(`${where} never terminates in an IEND chunk; the artifact is incomplete`);
233
+ if (header.width !== expected.width || header.height !== expected.height) {
234
+ throw new Error(`${where} is ${header.width}x${header.height}; the renderer reported ${expected.width}x${expected.height}`);
235
+ }
236
+ const channels = PNG_CHANNELS[header.colorType];
237
+ if (channels === undefined)
238
+ throw new Error(`${where} declares reserved PNG colour type ${header.colorType}`);
239
+ const idatBytes = idatParts.reduce((total, part) => total + part.length, 0);
240
+ if (idatBytes === 0) {
241
+ throw new Error(`${where} carries no IDAT pixel data: the artifact declares a canvas but nothing was drawn on it`);
242
+ }
243
+ const legalDepths = {
244
+ 0: [1, 2, 4, 8, 16],
245
+ 2: [8, 16],
246
+ 3: [1, 2, 4, 8],
247
+ 4: [8, 16],
248
+ 6: [8, 16],
249
+ };
250
+ if (!legalDepths[header.colorType]?.includes(header.bitDepth) || ![0, 1].includes(header.interlace)) {
251
+ throw new Error(`${where} declares an invalid PNG bit depth or interlace method`);
252
+ }
253
+ if (header.width < 1 || header.height < 1)
254
+ throw new Error(`${where} has empty PNG dimensions`);
255
+ if (header.colorType === 3 && (!palette || palette.length === 0 || palette.length % 3 !== 0)) {
256
+ throw new Error(`${where} has no valid PNG palette`);
257
+ }
258
+ // Each Adam7 pass is its own filtered image. Decode all passes; interlacing
259
+ // must never exempt a render from the raster-shape or visible-content proof.
260
+ const layout = header.interlace === 1
261
+ ? [
262
+ [0, 0, 8, 8],
263
+ [4, 0, 8, 8],
264
+ [0, 4, 4, 8],
265
+ [2, 0, 4, 4],
266
+ [0, 2, 2, 4],
267
+ [1, 0, 2, 2],
268
+ [0, 1, 1, 2],
269
+ ]
270
+ : [[0, 0, 1, 1]];
271
+ const passes = layout.map(([x, y, dx, dy]) => {
272
+ const width = Math.max(0, Math.ceil((header.width - x) / dx));
273
+ const height = Math.max(0, Math.ceil((header.height - y) / dy));
274
+ const rowBytes = Math.ceil((width * channels * header.bitDepth) / 8);
275
+ return {
276
+ x: x,
277
+ y: y,
278
+ dx: dx,
279
+ dy: dy,
280
+ width,
281
+ height,
282
+ rowBytes,
283
+ length: width && height ? height * (rowBytes + 1) : 0,
284
+ };
285
+ });
286
+ const expectedRaster = passes.reduce((total, pass) => total + pass.length, 0);
287
+ const compressed = Buffer.concat(idatParts.map((part) => Buffer.from(part.buffer, part.byteOffset, part.length)));
288
+ let raster;
289
+ try {
290
+ raster = inflateSync(compressed, { maxOutputLength: expectedRaster + 1 });
291
+ }
292
+ catch (error) {
293
+ 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`);
294
+ }
295
+ if (raster.length !== expectedRaster) {
296
+ throw new Error(`${where} inflates to ${raster.length} raster bytes; the declared PNG needs exactly ${expectedRaster}`);
297
+ }
298
+ const maxSample = (1 << header.bitDepth) - 1;
299
+ const seen = new Set();
300
+ const decoded = Buffer.alloc(header.width * header.height * 8);
301
+ let visiblePixelCount = 0;
302
+ let passOffset = 0;
303
+ for (const pass of passes) {
304
+ if (!pass.length)
305
+ continue;
306
+ const pixels = unfilterRaster(raster.subarray(passOffset, passOffset + pass.length), pass.height, Math.ceil((channels * header.bitDepth) / 8), pass.rowBytes, where);
307
+ passOffset += pass.length;
308
+ for (let row = 0; row < pass.height; row += 1) {
309
+ for (let column = 0; column < pass.width; column += 1) {
310
+ const samples = Array.from({ length: channels }, (_, channel) => {
311
+ const bit = (column * channels + channel) * header.bitDepth;
312
+ const at = row * pass.rowBytes + Math.floor(bit / 8);
313
+ return header.bitDepth === 16
314
+ ? pixels[at] * 256 + pixels[at + 1]
315
+ : (pixels[at] >> (8 - header.bitDepth - (bit % 8))) & maxSample;
316
+ });
317
+ let alpha = maxSample;
318
+ let color = samples;
319
+ if (header.colorType === 3) {
320
+ const index = samples[0];
321
+ if (!palette || index * 3 + 2 >= palette.length)
322
+ throw new Error(`${where} references a missing PNG palette entry`);
323
+ color = Array.from(palette.subarray(index * 3, index * 3 + 3));
324
+ alpha = transparency?.[index] ?? 255;
325
+ }
326
+ else if (header.colorType === 4 || header.colorType === 6) {
327
+ alpha = samples[samples.length - 1];
328
+ color = samples.slice(0, -1);
329
+ }
330
+ else if (transparency &&
331
+ samples.every((sample, index) => transparency[index * 2] !== undefined &&
332
+ sample === transparency[index * 2] * 256 + transparency[index * 2 + 1])) {
333
+ alpha = 0;
334
+ }
335
+ const maximum = header.colorType === 3 ? 255 : maxSample;
336
+ const rgb = color.length === 1 ? [color[0], color[0], color[0]] : color;
337
+ const at = ((pass.y + row * pass.dy) * header.width + pass.x + column * pass.dx) * 8;
338
+ for (const [index, sample] of [...rgb, alpha].entries()) {
339
+ decoded.writeUInt16BE(Math.round((sample * 65535) / maximum), at + index * 2);
340
+ }
341
+ if (alpha > 0)
342
+ visiblePixelCount += 1;
343
+ // RGB hidden behind zero alpha has no visible content, however many
344
+ // different byte values the encoder leaves in those channels.
345
+ if (seen.size < 2)
346
+ seen.add(alpha === 0 ? "transparent" : `${color.join(",")}/${alpha}`);
347
+ }
348
+ }
349
+ }
350
+ if (visiblePixelCount === 0)
351
+ throw new Error(`${where} decodes to no visible pixels; the render is fully transparent`);
352
+ if (seen.size < 2) {
353
+ 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`);
354
+ }
355
+ // Checked last, so a structurally explicable artifact is diagnosed by its
356
+ // structure and only a well-formed but implausibly small render falls through
357
+ // to the size floor.
358
+ if (bytes.length < expected.minByteLength) {
359
+ throw new Error(`${where} is ${bytes.length} bytes; a drawn ${expected.width}x${expected.height} map is at least ` +
360
+ `${expected.minByteLength}. An empty raster of the right dimensions is what a style/render no-op returns.`);
361
+ }
362
+ return {
363
+ uri: expected.uri,
364
+ mediaType: expected.mediaType,
365
+ width: header.width,
366
+ height: header.height,
367
+ byteLength: bytes.length,
368
+ imageSha256: createHash("sha256").update(bytes).digest("hex"),
369
+ visiblePixelCount,
370
+ decodedPixelSha256: createHash("sha256").update(decoded).digest("hex"),
371
+ };
372
+ }
66
373
  export class JourneyBlockedError extends Error {
67
374
  code;
68
375
  constructor(message, code = "dependency-unavailable") {
@@ -81,8 +388,8 @@ export function parseZeroToMapPlan(value) {
81
388
  const releaseContract = nonEmptyString(plan.releaseContract, "plan.releaseContract");
82
389
  const fixtures = stringArray(plan.fixtures, "plan.fixtures");
83
390
  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");
391
+ if (!Array.isArray(plan.stages) || plan.stages.length !== ZERO_TO_MAP_STAGE_COUNT) {
392
+ throw new Error(`plan.stages must contain the ${ZERO_TO_MAP_STAGE_COUNT} D9.3 stages`);
86
393
  }
87
394
  const actionIds = new Set();
88
395
  const stages = plan.stages.map((candidate, index) => {
@@ -186,7 +493,20 @@ export async function runZeroToMapJourney(plan, adapter, options) {
186
493
  throw new Error(`${action.id} disclosed forbidden pre-approval evidence at ${pointer}`);
187
494
  }
188
495
  }
189
- const captures = captureValues(action.captures, result.value, variables);
496
+ // Capture contracts are where a silent no-op surfaces: the tool
497
+ // answers without error but the value it was supposed to change did
498
+ // not change. Name the stage, the action and the tool so the operator
499
+ // is told which step of the journey did nothing, not merely that some
500
+ // pointer disagreed.
501
+ let captures;
502
+ try {
503
+ captures = captureValues(action.captures, result.value, variables);
504
+ }
505
+ catch (captureError) {
506
+ const message = captureError instanceof Error ? captureError.message : String(captureError);
507
+ throw new Error(`stage ${stage.number} ${stage.id}, action ${action.id}` +
508
+ `${action.kind === "mcp" ? ` (${action.tool})` : ""}: ${message}`);
509
+ }
190
510
  Object.assign(variables, captures);
191
511
  Object.assign(capturedVariables, captures);
192
512
  actions.push({
@@ -490,6 +810,23 @@ function requiredMcpToolUses(plan) {
490
810
  function describeToolUse(use) {
491
811
  return `${use.tool} (stage ${use.stageNumber} ${use.stageId}, action ${use.actionId})`;
492
812
  }
813
+ function dimensionTemplate(value, path) {
814
+ if (typeof value === "string") {
815
+ if (value.trim().length === 0)
816
+ throw new Error(`${path} must not be empty`);
817
+ return value;
818
+ }
819
+ if (Number.isInteger(value) && value > 0)
820
+ return value;
821
+ throw new Error(`${path} must be a positive integer or a template resolving to one`);
822
+ }
823
+ function resolveDimension(value, variables, path) {
824
+ const resolved = typeof value === "string" ? resolveTemplateValue(value, variables) : value;
825
+ if (!Number.isInteger(resolved) || resolved < 1) {
826
+ throw new Error(`${path} resolved to ${JSON.stringify(resolved)}, which is not a positive integer`);
827
+ }
828
+ return resolved;
829
+ }
493
830
  function rosterDigest(names) {
494
831
  return createHash("sha256")
495
832
  .update(`${[...names].sort().join("\n")}\n`)
@@ -517,7 +854,11 @@ function summarizeNames(names, limit = 10) {
517
854
  */
518
855
  async function assertMcpCatalog(plan, adapter) {
519
856
  const requiredUses = requiredMcpToolUses(plan);
520
- const catalog = await adapter.listTools();
857
+ // Explicitly opt out of any server-configured or session-negotiated workflow
858
+ // view. A candidate with `Mcp:WorkflowViews:DefaultView` set would otherwise
859
+ // serve a deliberately narrowed catalog and every roster finding below would
860
+ // name a missing server profile for what is really a discovery setting.
861
+ const catalog = await adapter.listTools({ view: ZERO_TO_MAP_FULL_CATALOG_VIEW });
521
862
  const names = catalog.map((tool) => tool.name);
522
863
  const findings = [];
523
864
  const duplicateNames = duplicateValues(names);
@@ -627,7 +968,7 @@ async function assertMcpCatalog(plan, adapter) {
627
968
  if (names.length !== expectedTotal) {
628
969
  const shortfall = expectedTotal - names.length;
629
970
  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.`
971
+ ? `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. This preflight already selects view="${ZERO_TO_MAP_FULL_CATALOG_VIEW}", so a candidate workflow view configured through ${ZERO_TO_MAP_WORKFLOW_VIEW_CONFIG_KEY} (${ZERO_TO_MAP_WORKFLOW_VIEW_ENV_KEY}) is not the cause.`
631
972
  : `unexpected: the catalog advertises ${names.length} tools; the enabled profiles ` +
632
973
  `(${ZERO_TO_MAP_REQUIRED_PROFILES.join(" + ")}) derive ${expectedTotal}`);
633
974
  }
@@ -665,6 +1006,7 @@ async function assertMcpCatalog(plan, adapter) {
665
1006
  return {
666
1007
  schemaVersion: ZERO_TO_MAP_CATALOG_RECEIPT_SCHEMA,
667
1008
  activeProfiles: [...ZERO_TO_MAP_REQUIRED_PROFILES],
1009
+ requestedView: ZERO_TO_MAP_FULL_CATALOG_VIEW,
668
1010
  expectedTotalTools: expectedTotal,
669
1011
  advertisedTotalTools: names.length,
670
1012
  baseStaticTools,
@@ -761,6 +1103,17 @@ async function executeAction(action, adapter, variables) {
761
1103
  ...action,
762
1104
  uri: resolveTemplateString(action.uri, variables),
763
1105
  });
1106
+ case "mcp-image": {
1107
+ const uri = resolveTemplateString(action.uri, variables);
1108
+ const resolved = { ...action, uri };
1109
+ return adapter.readImageResource(resolved, {
1110
+ uri,
1111
+ mediaType: action.expectedMediaType,
1112
+ width: resolveDimension(action.expectedWidth, variables, `${action.id}.expectedWidth`),
1113
+ height: resolveDimension(action.expectedHeight, variables, `${action.id}.expectedHeight`),
1114
+ minByteLength: action.minByteLength,
1115
+ });
1116
+ }
764
1117
  case "gpserver":
765
1118
  return adapter.runGpServer({
766
1119
  ...action,
@@ -814,6 +1167,7 @@ function parseAction(value, path, ids) {
814
1167
  if (kind !== "cli" &&
815
1168
  kind !== "mcp" &&
816
1169
  kind !== "mcp-resource" &&
1170
+ kind !== "mcp-image" &&
817
1171
  kind !== "gpserver" &&
818
1172
  kind !== "receipt" &&
819
1173
  kind !== "http") {
@@ -847,6 +1201,20 @@ function parseAction(value, path, ids) {
847
1201
  uri: nonEmptyString(action.uri, `${path}.uri`),
848
1202
  ...(action.waitFor === undefined ? {} : { waitFor: parseResourceWait(action.waitFor, `${path}.waitFor`) }),
849
1203
  };
1204
+ case "mcp-image": {
1205
+ if (!Number.isInteger(action.minByteLength) || action.minByteLength < 1) {
1206
+ throw new Error(`${path}.minByteLength must be a positive integer`);
1207
+ }
1208
+ return {
1209
+ ...common,
1210
+ kind,
1211
+ uri: nonEmptyString(action.uri, `${path}.uri`),
1212
+ expectedMediaType: nonEmptyString(action.expectedMediaType, `${path}.expectedMediaType`),
1213
+ expectedWidth: dimensionTemplate(action.expectedWidth, `${path}.expectedWidth`),
1214
+ expectedHeight: dimensionTemplate(action.expectedHeight, `${path}.expectedHeight`),
1215
+ minByteLength: action.minByteLength,
1216
+ };
1217
+ }
850
1218
  case "gpserver":
851
1219
  return {
852
1220
  ...common,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@honua/mcp-server",
3
- "version": "0.1.9-beta.0",
3
+ "version": "0.1.10-beta.0",
4
4
  "description": "Platform-free MCP server for geospatial services: expose discovery, query, and analysis over any public ArcGIS/Esri FeatureServer or OGC API endpoint to any MCP client, with no platform lock-in",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Honua",
@@ -82,12 +82,13 @@
82
82
  "zod": "^3.24.0"
83
83
  },
84
84
  "overrides": {
85
- "hono": "^4.12.27",
85
+ "hono": "^4.13.7",
86
86
  "@hono/node-server": "^2.0.5",
87
- "fast-uri": "^3.1.3"
87
+ "fast-uri": "^3.1.6",
88
+ "qs": "^6.16.0"
88
89
  },
89
90
  "peerDependencies": {
90
- "@honua/sdk-js": "^0.1.9-beta.0"
91
+ "@honua/sdk-js": "^0.1.10-beta.0"
91
92
  },
92
93
  "devDependencies": {
93
94
  "@biomejs/biome": "1.9.4",
@@ -78,6 +78,25 @@ npm run release:zero-to-map -- \
78
78
  --output ./zero-to-map-live-receipt.json
79
79
  ```
80
80
 
81
+ `dbSecretReference` is a request-supplied secret reference: the journey sends
82
+ it to the server, and the server resolves it from its own environment. The
83
+ server the journey runs against must therefore both define that variable (the
84
+ fixture database connection string) and permit it in its allowlist for
85
+ request-supplied secret references (honua-server #5055), which is
86
+ deny-by-default. Permit exactly this one name rather than a prefix, in the
87
+ server's environment:
88
+
89
+ ```sh
90
+ Security__RequestSecretReferences__AllowedEnvironmentVariables__0=HONUA_ZERO_TO_MAP_DB_CONNECTION
91
+ ```
92
+
93
+ Without that entry the `create-connection` action in stage 2 is refused before
94
+ any connection is stored. Server images that predate the setting ignore the
95
+ variable. `honua admin install local` does not write either variable into the
96
+ compose file it generates, so add both to the `honua` service's environment (or
97
+ start an equivalent server yourself) before running with `--execute`. If you
98
+ pass a different `--var dbSecretReference=env:<NAME>`, permit `<NAME>` instead.
99
+
81
100
  The first pass exits `2` with `external-receipt-missing`. It has already run
82
101
  stages 1-5 exactly once. Give the checkpoint's resolved
83
102
  `consoleReceiptRequest` to the Console producer, carry
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "honua": {
4
4
  "command": "npx",
5
- "args": ["-y", "--package", "@honua/mcp-server@0.1.7-beta.0", "honua-mcp-proxy"],
5
+ "args": ["-y", "--package", "@honua/mcp-server@0.1.9-beta.0", "honua-mcp-proxy"],
6
6
  "env": {
7
7
  "HONUA_MCP_REMOTE_URL": "http://localhost:8080/mcp"
8
8
  }
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "honua": {
4
4
  "command": "npx",
5
- "args": ["-y", "--package", "@honua/mcp-server@0.1.7-beta.0", "honua-mcp-proxy"],
5
+ "args": ["-y", "--package", "@honua/mcp-server@0.1.9-beta.0", "honua-mcp-proxy"],
6
6
  "env": {
7
7
  "HONUA_MCP_REMOTE_URL": "http://localhost:8080/mcp"
8
8
  }
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "honua": {
4
4
  "command": "npx",
5
- "args": ["-y", "--package", "@honua/mcp-server@0.1.7-beta.0", "honua-mcp-proxy"],
5
+ "args": ["-y", "--package", "@honua/mcp-server@0.1.9-beta.0", "honua-mcp-proxy"],
6
6
  "env": {
7
7
  "HONUA_MCP_REMOTE_URL": "http://localhost:8080/mcp"
8
8
  }