@showmeonmap/mcp 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -101,6 +101,8 @@ Ask Claude Desktop things like:
101
101
  **Feedback channel (1)**: `report_blocker`.
102
102
  **Saved workflows (3)**: `save_workflow`, `list_workflows`, `run_workflow`.
103
103
 
104
+ **0.7.1**: `build_map`'s `next_actions` come from the map itself (real data filters and the plan's own follow-ups, not generic suggestions); a Cloudflare error page from the backend reaches the model as a typed message with its code and Ray ID instead of raw HTML; shared maps keep the fields they paint and say when they hold a sample of the features.
105
+
104
106
  **0.7.0** (everything merged since 0.6.0): the three workflow tools (with `batch` runs over a value list); `run_analysis` adds spatial join, merge/append, near and dissolve; `rationale` is optional on every tool; no tuple schemas (Claude dropped `set_camera` / `add_annotation`); fact-only tool descriptions; `export_layer_data` works on a saved map; share links store the features the map shows after `filter_layer` and its counts follow the filter; the server reports its real version; and an npm install no longer writes rationale logs into your `node_modules`.
105
107
 
106
108
  `focus_area` (0.4.0) refines an existing map to a **named** area — no coordinates required. Pass a `layerId` plus an `areaRef` (`{kind:'corridor', street, from?, to?}`, `{kind:'place', name}`, or `{kind:'aroundIntersection', street, cross}`) and the server resolves the real geometry from OpenStreetMap, then applies one of three `mode`s: `filter` (hide non-matching), `select` (extract to a new layer), or `clip` (geometrically trim — lines are split at the boundary). The reply carries a grounded `contextAnswer` such as `"kept 37 of 150 features"`. A resolver miss returns a `clarification` with real nearby candidates rather than a guessed location.
@@ -118,14 +120,15 @@ Every tool returns a **three-channel envelope**:
118
120
  "shareable_url": "http://localhost:3000/m/…",
119
121
  "metadata": { "title": "…", "layer_count": 2, "last_updated": "…" },
120
122
  "next_actions": [
121
- { "label": "Filter to features where name is not empty", "argsTemplate": { "instruction": "…" } }
123
+ { "label": "Show only Earthquakes with magnitude ≥ 5.3 (5 of 10 features)", "tool": "filter_layer",
124
+ "argsTemplate": { "layerId": "smo_usgs_earthquakes", "predicate": { "op": "gte", "field": "magnitude", "value": 5.3 } } }
122
125
  ],
123
126
  "outOfScope": { "reason": "…" },
124
127
  "clarification": { "question": "…", "candidates": [ … ] }
125
128
  }
126
129
  ```
127
130
 
128
- The calling agent reasons over `structured`, surfaces `shareable_url` to its end user for interactive drill-down, and uses `next_actions` as typed hints for follow-up tool calls.
131
+ The calling agent reasons over `structured`, surfaces `shareable_url` to its end user for interactive drill-down, and uses `next_actions` as typed hints for follow-up tool calls: each hint's `argsTemplate` is exactly the args (minus `map_id`) of the named `tool`. Hints come from the map's own data, followups and current state; an empty list means nothing useful to suggest.
129
132
 
130
133
  For the full Op catalog and predicate grammar that the V2 tools accept, fetch the MCP resource at `showmeonmap://docs/workspace-ops-spec` — agents should not guess these shapes.
131
134
 
@@ -0,0 +1,340 @@
1
+ /**
2
+ * build_map `next_actions` — state-aware, subject-aware follow-up hints.
3
+ *
4
+ * Until 2026-10-12 every build_map returned the same three constants
5
+ * ("Filter the layer to features where name is not empty", "Change the
6
+ * basemap to satellite", "Zoom the camera closer") whatever the map was. A
7
+ * live MCP test on three maps (M5+ earthquakes near Japan, California
8
+ * earthquakes, wildfires near Vancouver) showed all three were junk: every
9
+ * earthquake and fire detection HAS a name, the wildfire map was ALREADY on
10
+ * satellite, and the plan's own better follow-ups ("Smoke plume", "Air
11
+ * quality") never reached the caller. Agents learned to ignore the field.
12
+ *
13
+ * The rules this module enforces (pinned both directions in
14
+ * __tests__/tools/nextActions.test.ts):
15
+ *
16
+ * - A filter hint names a field that exists on the layer AND varies across
17
+ * its features, and its threshold/value comes from the features
18
+ * themselves (a numeric threshold near the observed median, re-checked so
19
+ * it keeps some features and drops some). Nothing is invented.
20
+ * - Never suggest a state the map is already in (basemap already that
21
+ * style, already zoomed close, already zoomed out).
22
+ * - Surface the plan's own `followups` (handler + companion chips) — with
23
+ * "this area" / "here" rewritten to the place in the caller's query,
24
+ * because a fresh build_map has no "this area". When the place cannot be
25
+ * read back out of the query, a region-neutral follow-up is dropped
26
+ * rather than sent somewhere wrong.
27
+ * - Nothing useful → `[]`. An empty list is a correct answer.
28
+ *
29
+ * Each hint is `{ label, argsTemplate, tool }`: `argsTemplate` is exactly
30
+ * the args (minus `map_id`) of `tool`, so it passes that tool's strict
31
+ * input validation as-is — `filter_layer` gets `{layerId, predicate}`,
32
+ * `mutate_map` gets `{instruction}`, `build_map` gets `{query}`.
33
+ */
34
+ export const MAX_NEXT_ACTIONS = 3;
35
+ const MAX_FILTER_HINTS = 2;
36
+ const MAX_FOLLOWUP_HINTS = 2;
37
+ // Measures worth thresholding when the layer itself does not name one
38
+ // (valueField / filterConfig always qualify). Keys are normalized
39
+ // (lower-case, alphanumerics only) so "Size (ha)" matches "sizeha".
40
+ const KNOWN_MEASURES = [
41
+ 'magnitude', 'mag', 'frp', 'brightness', 'sizeha', 'hectares', 'acres', 'areaha',
42
+ 'containment', 'depth', 'significance', 'sig', 'population', 'pop',
43
+ ];
44
+ // Measures where SMALLER is the notable end (shallow earthquakes do the damage).
45
+ const LOWER_IS_NOTABLE = new Set(['depth']);
46
+ // Categorical fields worth filtering, and the values that are the story when present.
47
+ const KNOWN_CATEGORICAL = new Set([
48
+ 'status', 'confidence', 'severity', 'alert', 'alertlevel', 'urgency', 'certainty', 'firestatus', 'stage',
49
+ ]);
50
+ const SALIENT_VALUES = [
51
+ 'out of control', 'active', 'high', 'h', 'extreme', 'severe', 'major', 'warning', 'emergency', 'red',
52
+ ];
53
+ // Never a measure / filter dimension: coordinates (a coordinate column is
54
+ // never a measure — CLAUDE.md), clocks, ids, links, free text.
55
+ const EXCLUDED_FIELD = new RegExp('^(lat|latitude|lng|lon|long|longitude|x|y|easting|northing|' +
56
+ 'time|timestamp|date|datetime|updated|hoursago|epoch|' +
57
+ 'id|fid|objectid|gid|eventid|code|url|link|details|' +
58
+ 'name|title|place|label|description|incident)$');
59
+ function normKey(k) {
60
+ return k.toLowerCase().replace(/[^a-z0-9]/g, '');
61
+ }
62
+ function isInternalField(k) {
63
+ return k.startsWith('_');
64
+ }
65
+ function featuresOf(layer) {
66
+ const data = layer.source?.data;
67
+ if (!data || data.type !== 'FeatureCollection' || !Array.isArray(data.features))
68
+ return [];
69
+ return data.features.map((f) => (f && typeof f.properties === 'object' && f.properties ? f.properties : {}));
70
+ }
71
+ function layerName(layer) {
72
+ return (layer.title ?? layer.id ?? 'the layer').trim();
73
+ }
74
+ /** A threshold that reads cleanly AND still splits the observed values. */
75
+ function niceThreshold(values, lowerIsNotable) {
76
+ const sorted = [...values].sort((a, b) => a - b);
77
+ const n = sorted.length;
78
+ const median = n % 2 ? sorted[(n - 1) / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2;
79
+ const allInt = sorted.every((v) => Number.isInteger(v));
80
+ const range = sorted[n - 1] - sorted[0];
81
+ const candidates = [];
82
+ if (allInt)
83
+ candidates.push(Math.round(median));
84
+ else if (range <= 20)
85
+ candidates.push(Math.round(median * 10) / 10);
86
+ else {
87
+ const mag = Math.pow(10, Math.floor(Math.log10(Math.abs(median) || 1)) - 1);
88
+ candidates.push(Math.round(median / mag) * mag);
89
+ }
90
+ candidates.push(median);
91
+ for (const raw of candidates) {
92
+ const t = Number(raw.toPrecision(6));
93
+ const kept = sorted.filter((v) => (lowerIsNotable ? v <= t : v >= t)).length;
94
+ if (kept > 0 && kept < n)
95
+ return { t, kept };
96
+ }
97
+ return null;
98
+ }
99
+ function collectFieldStats(props) {
100
+ const stats = new Map();
101
+ for (const p of props) {
102
+ for (const [k, v] of Object.entries(p)) {
103
+ if (v === null || v === undefined || v === '')
104
+ continue;
105
+ let s = stats.get(k);
106
+ if (!s) {
107
+ s = { key: k, numeric: [], strings: new Map() };
108
+ stats.set(k, s);
109
+ }
110
+ if (typeof v === 'number' && Number.isFinite(v))
111
+ s.numeric.push(v);
112
+ else if (typeof v === 'string')
113
+ s.strings.set(v, (s.strings.get(v) ?? 0) + 1);
114
+ }
115
+ }
116
+ return stats;
117
+ }
118
+ /** Data-derived filter_layer hints for one layer, best first. */
119
+ function filterHintsForLayer(layer) {
120
+ const layerId = layer.id;
121
+ if (!layerId)
122
+ return [];
123
+ const props = featuresOf(layer);
124
+ const n = props.length;
125
+ if (n < 3)
126
+ return [];
127
+ const stats = collectFieldStats(props);
128
+ const name = layerName(layer);
129
+ const preferred = new Set();
130
+ if (layer.viz?.valueField)
131
+ preferred.add(layer.viz.valueField);
132
+ for (const fc of layer.filterConfig ?? [])
133
+ if (fc.field)
134
+ preferred.add(fc.field);
135
+ const eligible = (k) => !isInternalField(k) && !EXCLUDED_FIELD.test(normKey(k));
136
+ // ── numeric thresholds ────────────────────────────────────────────────
137
+ const numericFields = [...stats.values()]
138
+ .filter((s) => eligible(s.key))
139
+ .filter((s) => s.numeric.length >= Math.ceil(n * 0.8))
140
+ .filter((s) => new Set(s.numeric).size >= 3)
141
+ .filter((s) => preferred.has(s.key) || KNOWN_MEASURES.includes(normKey(s.key)))
142
+ .sort((a, b) => rank(a.key) - rank(b.key));
143
+ function rank(k) {
144
+ if (layer.viz?.valueField === k)
145
+ return -2;
146
+ if (preferred.has(k))
147
+ return -1;
148
+ return KNOWN_MEASURES.indexOf(normKey(k));
149
+ }
150
+ const preferredNumeric = [];
151
+ const otherNumeric = [];
152
+ const seenSignatures = new Set();
153
+ for (const s of numericFields) {
154
+ // A synthetic duplicate (e.g. `value` = magnitude for sizing) says nothing new.
155
+ const signature = s.numeric.join(',');
156
+ if (seenSignatures.has(signature))
157
+ continue;
158
+ seenSignatures.add(signature);
159
+ const lower = LOWER_IS_NOTABLE.has(normKey(s.key));
160
+ const th = niceThreshold(s.numeric, lower);
161
+ if (!th)
162
+ continue;
163
+ const op = lower ? 'lte' : 'gte';
164
+ const sym = lower ? '≤' : '≥';
165
+ (preferred.has(s.key) ? preferredNumeric : otherNumeric).push({
166
+ label: `Show only ${name} with ${s.key} ${sym} ${th.t} (${th.kept} of ${n} features)`,
167
+ argsTemplate: { layerId, predicate: { op, field: s.key, value: th.t } },
168
+ tool: 'filter_layer',
169
+ });
170
+ }
171
+ const categorical = [];
172
+ // ── categorical: a salient value that some, not all, features carry ──
173
+ for (const s of stats.values()) {
174
+ if (!eligible(s.key))
175
+ continue;
176
+ const isKnown = KNOWN_CATEGORICAL.has(normKey(s.key)) ||
177
+ (layer.filterConfig ?? []).some((fc) => fc.field === s.key && fc.kind === 'category');
178
+ if (!isKnown)
179
+ continue;
180
+ const totalStrings = [...s.strings.values()].reduce((a, b) => a + b, 0);
181
+ if (totalStrings < Math.ceil(n * 0.8) || s.strings.size < 2 || s.strings.size > 12)
182
+ continue;
183
+ const salient = SALIENT_VALUES
184
+ .map((sv) => [...s.strings.keys()].find((v) => v.trim().toLowerCase() === sv))
185
+ .find((v) => typeof v === 'string');
186
+ if (!salient)
187
+ continue;
188
+ const count = s.strings.get(salient) ?? 0;
189
+ if (count === 0 || count === totalStrings)
190
+ continue;
191
+ categorical.push({
192
+ label: `Show only ${name} where ${s.key} is "${salient}" (${count} of ${n} features)`,
193
+ argsTemplate: { layerId, predicate: { op: 'eq', field: s.key, value: salient } },
194
+ tool: 'filter_layer',
195
+ });
196
+ break;
197
+ }
198
+ // The layer's own encoded measure, then the story value of a status-like
199
+ // field, then other known measures.
200
+ return [...preferredNumeric, ...categorical, ...otherNumeric];
201
+ }
202
+ // ── plan follow-ups ────────────────────────────────────────────────────────
203
+ const TIME_WINDOW_TAIL_RE = /(?:^|\s+)(?:(?:in|over|during|for|within|from|since)\s+)?(?:the\s+)?(?:(?:this|last|past|next|recent|previous|current|coming)\s+|\d+\s+)(?:\d+\s+)?(?:hour|hours|day|days|week|weeks|month|months|year|years|decade|decades)(?:\s+ago)?$/i;
204
+ const SIMPLE_TIME_TAIL_RE = /\s+(?:(?:in|during|since|for)\s+)?(?:today|tonight|now|right now|recently|yesterday|(?:19|20)\d{2})$/i;
205
+ const PRESENTATION_TAIL_RE = /,\s*(?:sized|colou?red|shown|styled|drawn|rendered|displayed|with|using|as|on)\b.*$/i;
206
+ const LOCATION_PREP_RE = /\b(?:in|near|around|across|over|at|within)\s+/gi;
207
+ const BAD_PLACE_START = /^(?:the\s+)?(?:past|last|this|next|recent|previous|current|coming|today|tonight|least|most)\b/i;
208
+ /**
209
+ * The place a build_map query names, or null. Conservative on purpose: a
210
+ * wrong place is worse than no follow-up, so anything doubtful returns null.
211
+ * "earthquakes above magnitude 5 near Japan this month" → "Japan"
212
+ * "earthquakes in California in the past year" → "California"
213
+ * "active wildfires near Vancouver, British Columbia" → "Vancouver, British Columbia"
214
+ */
215
+ export function placeFromQuery(query) {
216
+ const q = (query ?? '').trim().replace(/[.?!]+$/, '');
217
+ for (const m of q.matchAll(LOCATION_PREP_RE)) {
218
+ let place = q.slice((m.index ?? 0) + m[0].length);
219
+ place = place.replace(PRESENTATION_TAIL_RE, '');
220
+ for (let i = 0; i < 3; i++) {
221
+ const before = place;
222
+ place = place.replace(TIME_WINDOW_TAIL_RE, '').replace(SIMPLE_TIME_TAIL_RE, '').trim();
223
+ if (place === before)
224
+ break;
225
+ }
226
+ if (!place || place.length > 60 || /\d/.test(place) || BAD_PLACE_START.test(place))
227
+ continue;
228
+ return place;
229
+ }
230
+ return null;
231
+ }
232
+ // Phrases that only mean something next to the map the user is looking at.
233
+ const CONTEXT_PHRASE_RE = /\b(?:this area|this region|the area|here|nearby|on screen|current view|neighbou?ring)\b/i;
234
+ function localizeMessage(message, place) {
235
+ if (!CONTEXT_PHRASE_RE.test(message))
236
+ return message;
237
+ if (!place)
238
+ return null;
239
+ const out = message
240
+ .replace(/\b(?:this area|this region|the area)\b/gi, place)
241
+ .replace(/\bhere\b/gi, `near ${place}`);
242
+ // "nearby" / "on screen" / "neighbouring" have no faithful rewrite — drop.
243
+ return CONTEXT_PHRASE_RE.test(out) ? null : out;
244
+ }
245
+ const MUTATION_RE = /^(?:filter|only show|show only|zoom|switch|change|set|remove|hide|colou?r|restyle|clear)\b/i;
246
+ const BASEMAP_STYLES = ['satellite', 'dark', 'light', 'streets', 'engineering'];
247
+ /** True when the hint would put the map into a state it is already in. */
248
+ export function isRedundantWithState(text, plan) {
249
+ const t = text.toLowerCase();
250
+ const current = plan.basemap?.style?.toLowerCase();
251
+ const aboutBasemap = /\b(?:basemap|base map|map style|satellite (?:view|imagery|basemap|map))\b/.test(t) ||
252
+ /\b(?:switch|change|set)\s+(?:the\s+)?(?:map\s+)?to\s+(?:satellite|dark|light|streets|engineering)\b/.test(t);
253
+ if (current && aboutBasemap) {
254
+ const target = BASEMAP_STYLES.find((s) => new RegExp(`\\b${s}\\b`).test(t));
255
+ if (target && target === current)
256
+ return true;
257
+ }
258
+ const cam = plan.camera;
259
+ if (cam && /\bzoom\b/.test(t)) {
260
+ const zoom = typeof cam.zoom === 'number' ? cam.zoom : undefined;
261
+ const span = cam.mode === 'fitBounds' && Array.isArray(cam.bounds)
262
+ ? Math.max(Math.abs(cam.bounds[2] - cam.bounds[0]), Math.abs(cam.bounds[3] - cam.bounds[1]))
263
+ : undefined;
264
+ const close = (zoom !== undefined && zoom >= 12) || (span !== undefined && span <= 0.1);
265
+ const far = cam.mode === 'globalView' || (zoom !== undefined && zoom <= 2) || (span !== undefined && span >= 300);
266
+ if (/\bzoom\b(?:\s+\w+){0,3}?\s+(?:in|closer)\b/.test(t) && close)
267
+ return true;
268
+ if (/\bzoom\b(?:\s+\w+){0,3}?\s+out\b/.test(t) && far)
269
+ return true;
270
+ }
271
+ return false;
272
+ }
273
+ function followupHints(plan, query) {
274
+ const followups = Array.isArray(plan.followups) ? plan.followups : [];
275
+ if (!followups.length)
276
+ return [];
277
+ const place = placeFromQuery(query);
278
+ const hints = [];
279
+ const seen = new Set();
280
+ for (const f of followups) {
281
+ const message = typeof f?.message === 'string' ? f.message.trim() : '';
282
+ const label = typeof f?.label === 'string' && f.label.trim() ? f.label.trim() : message;
283
+ if (!message)
284
+ continue;
285
+ if (isRedundantWithState(`${label} ${message}`, plan))
286
+ continue;
287
+ if (MUTATION_RE.test(message)) {
288
+ if (seen.has(message.toLowerCase()))
289
+ continue;
290
+ seen.add(message.toLowerCase());
291
+ hints.push({ label, argsTemplate: { instruction: message }, tool: 'mutate_map' });
292
+ continue;
293
+ }
294
+ const localized = localizeMessage(message, place);
295
+ if (!localized || seen.has(localized.toLowerCase()))
296
+ continue;
297
+ seen.add(localized.toLowerCase());
298
+ hints.push({ label: `${label} — new map: "${localized}"`, argsTemplate: { query: localized }, tool: 'build_map' });
299
+ }
300
+ return hints;
301
+ }
302
+ /**
303
+ * next_actions for a freshly built map. `plan` must be the full-resolution
304
+ * MapPlan the API returned (features inline), `query` the build_map query.
305
+ */
306
+ export function buildMapNextActions(plan, query) {
307
+ if (!plan)
308
+ return [];
309
+ const layers = (Array.isArray(plan.layers) ? plan.layers : []);
310
+ const subjectLayers = layers
311
+ .filter((l) => l && l.role !== 'context' && !l.hideFromLegend && !l.hiddenByDefault)
312
+ .map((l) => ({ layer: l, count: featuresOf(l).length }))
313
+ .filter((x) => x.count >= 3)
314
+ .sort((a, b) => b.count - a.count);
315
+ // Best hint from each of the busiest layers first (diversity), then a
316
+ // second hint from the busiest layer if room remains.
317
+ const perLayer = subjectLayers.map((x) => filterHintsForLayer(x.layer));
318
+ const filters = [];
319
+ for (const hs of perLayer)
320
+ if (hs[0] && filters.length < MAX_FILTER_HINTS)
321
+ filters.push(hs[0]);
322
+ for (const hs of perLayer) {
323
+ for (const h of hs.slice(1))
324
+ if (filters.length < MAX_FILTER_HINTS)
325
+ filters.push(h);
326
+ }
327
+ const follow = followupHints(plan, query).slice(0, MAX_FOLLOWUP_HINTS);
328
+ // Best data filter, then the plan's own follow-ups, then the rest — so a
329
+ // map that has both surfaces both.
330
+ const out = [...filters.slice(0, 1), ...follow, ...filters.slice(1)];
331
+ return out.filter((h) => !isRedundantWithState(h.label, plan)).slice(0, MAX_NEXT_ACTIONS);
332
+ }
333
+ /** mutate_map's server-suggested strings → mutate_map hints. */
334
+ export function instructionHints(strings) {
335
+ return (strings ?? []).slice(0, MAX_NEXT_ACTIONS).map((label) => ({
336
+ label,
337
+ argsTemplate: { instruction: label },
338
+ tool: 'mutate_map',
339
+ }));
340
+ }
package/dist/tools.js CHANGED
@@ -23,6 +23,7 @@ import { fileURLToPath } from 'node:url';
23
23
  import { dirname, resolve } from 'node:path';
24
24
  import { MCP_TOOL_META, mcpRequiresConfirmation } from "./toolMeta.js";
25
25
  import { buildAnalysisStory } from "./storyDraft.js";
26
+ import { buildMapNextActions, instructionHints, MAX_NEXT_ACTIONS } from "./nextActions.js";
26
27
  import { fetchNoRedirect } from "./fetchNoRedirect.js";
27
28
  import { BUILD_MAP_DESCRIPTION, SERVER_INSTRUCTIONS } from "./serverInstructions.js";
28
29
  import { describeTolerance, generalizeLayers, storedBytes, WORKSPACE_CEILING } from "./generalizeGeometry.js";
@@ -252,7 +253,7 @@ function generalizedNote(generalized) {
252
253
  const parts = generalized.map((g) => `${g.title ?? g.layerId} (${describeTolerance(g.toleranceDeg)})`);
253
254
  return `Boundaries simplified to fit the conversation view: ${parts.join(', ')}. Data values are unchanged; the shareable link shows full detail until the map is edited.`;
254
255
  }
255
- function envelope(baseUrl, mapId, workspace, mapPlan, nextActionStrings, extras = {}) {
256
+ function envelope(baseUrl, mapId, workspace, mapPlan, nextActions, extras = {}) {
256
257
  const layerCount = Array.isArray(mapPlan?.layers) ? mapPlan.layers.length : 0;
257
258
  const opsCount = Array.isArray(workspace?.opsLog) ? workspace.opsLog.length : 0;
258
259
  return {
@@ -268,10 +269,9 @@ function envelope(baseUrl, mapId, workspace, mapPlan, nextActionStrings, extras
268
269
  layer_count: layerCount,
269
270
  last_updated: new Date().toISOString(),
270
271
  },
271
- next_actions: (nextActionStrings ?? []).slice(0, 3).map((label) => ({
272
- label,
273
- argsTemplate: { instruction: label },
274
- })),
272
+ next_actions: (nextActions ?? []).every((a) => typeof a === 'string')
273
+ ? instructionHints(nextActions)
274
+ : nextActions.slice(0, MAX_NEXT_ACTIONS),
275
275
  ...(extras.outOfScope ? { outOfScope: extras.outOfScope } : {}),
276
276
  ...(extras.clarification ? { clarification: extras.clarification } : {}),
277
277
  ...(extras.opsApplied ? { opsApplied: extras.opsApplied } : {}),
@@ -422,6 +422,30 @@ function withConfirmation(toolName, handler) {
422
422
  return handler(rest, extra);
423
423
  });
424
424
  }
425
+ /**
426
+ * The error text a failed backend call hands the calling model.
427
+ *
428
+ * Live failure (2026-10-12): a build_map came back as Cloudflare's edge error
429
+ * page (the Worker died before our own handler could answer), and the first
430
+ * 200 characters of that HTML — `<!DOCTYPE html><!--[if lt IE 7]>…` — were all
431
+ * the model saw: no error code, no Ray ID to find the request in Workers Logs,
432
+ * no hint that one retry is the right move. An HTML body is never ours, so say
433
+ * what it is and keep the two identifiers worth having.
434
+ */
435
+ export function describeErrorBody(text) {
436
+ if (!/^\s*(<!DOCTYPE html|<html)/i.test(text))
437
+ return text.slice(0, 200);
438
+ const code = text.match(/cf-error-code[^>]*>\s*(\d+)\s*</i)?.[1];
439
+ const ray = text.match(/Ray ID:?\s*(?:<[^>]+>\s*)*([0-9a-f]{12,})/i)?.[1];
440
+ const title = text.match(/<title>\s*([^<|]+?)\s*(?:\||<\/title>)/i)?.[1];
441
+ const parts = [
442
+ 'upstream error page, not a ShowMeOnMap response',
443
+ code ? `Cloudflare error ${code}` : null,
444
+ title ?? null,
445
+ ray ? `Ray ID ${ray}` : null,
446
+ ].filter(Boolean);
447
+ return `${parts.join(' · ')}. Transient — retry once.`;
448
+ }
425
449
  async function apiFetch(baseUrl, apiKey, path, body) {
426
450
  const resp = await fetchNoRedirect(`${baseUrl}${path}`, {
427
451
  method: 'POST',
@@ -438,7 +462,7 @@ async function apiFetch(baseUrl, apiKey, path, body) {
438
462
  // success (one of four parallel build_map calls lost its map this way,
439
463
  // 2026-08-01).
440
464
  if (!resp.ok) {
441
- throw new Error(`${path} ${resp.status}: ${text.slice(0, 200)}`);
465
+ throw new Error(`${path} ${resp.status}: ${describeErrorBody(text)}`);
442
466
  }
443
467
  try {
444
468
  return JSON.parse(text);
@@ -470,7 +494,11 @@ async function apiFetch(baseUrl, apiKey, path, body) {
470
494
  // own viz reads.
471
495
  const SHARE_MAX_VERTICES_PER_RING = 60;
472
496
  const SHARE_MAX_POLYS_PER_MULTI = 3;
473
- const SHARE_MAX_FEATURES_PER_LAYER = 2000;
497
+ // Mirrors saveMapSnapshot: a byte budget for the whole geojsonLayers
498
+ // sidecar replaces the old first-2000-features cut, which shared a
499
+ // row-ordered analysis grid as a strip of the city (audited 2026-10-13).
500
+ const SHARE_MAX_FEATURES_PER_LAYER = 20_000;
501
+ export const SHARE_GEOJSON_BUDGET_BYTES = 4_000_000;
474
502
  const SHARE_BASE_KEEP_PROPS = new Set(['value', 'name', 'iso3', 'label', '_dataVintage']);
475
503
  function roundCoord(n) {
476
504
  return Math.round(n * 10_000) / 10_000;
@@ -514,23 +542,87 @@ function compactGeometry(geom) {
514
542
  }
515
543
  return geom;
516
544
  }
517
- /** Base whitelist + every field name the layer's viz reads + `_smo_*`
518
- * internals (tooltip/popup dispatch). Exported for tests. */
519
- export function compactFeatureCollection(fc, viz,
545
+ /** A key that names a feature property: `field`, `valueField`, `fieldX`, … */
546
+ const SHARE_FIELD_KEY = /^field$|Field$|^field[A-Z]/;
547
+ /** Every feature property `node` reads: string values under field-named
548
+ * keys at ANY depth, plus `["get", X]` expression refs. Mirrors
549
+ * `keepSetForLayer` in src/lib/share/saveMapSnapshot.ts — a top-level-only
550
+ * scan stripped the nested `viz.colorScale.field`, so shared opportunity
551
+ * grids painted every cell the lowest class (2026-10-13). */
552
+ export function collectReadFields(node, out = new Set()) {
553
+ if (Array.isArray(node)) {
554
+ if (node[0] === 'get' && typeof node[1] === 'string')
555
+ out.add(node[1]);
556
+ for (const item of node)
557
+ collectReadFields(item, out);
558
+ return out;
559
+ }
560
+ if (!node || typeof node !== 'object')
561
+ return out;
562
+ for (const [k, v] of Object.entries(node)) {
563
+ if (typeof v === 'string') {
564
+ if (SHARE_FIELD_KEY.test(k))
565
+ out.add(v);
566
+ }
567
+ else {
568
+ collectReadFields(v, out);
569
+ }
570
+ }
571
+ return out;
572
+ }
573
+ /** `n` of `items` by a seeded shuffle, returned in original order —
574
+ * deterministic, spread over the whole extent, no stride moiré. Mirrors
575
+ * `sampleSpread` in src/lib/share/saveMapSnapshot.ts. */
576
+ export function sampleSpread(items, n) {
577
+ if (items.length <= n)
578
+ return [...items];
579
+ if (n <= 0)
580
+ return [];
581
+ const idx = Array.from(items, (_, i) => i);
582
+ let seed = (items.length * 2654435761) >>> 0;
583
+ const rand = () => {
584
+ // mulberry32
585
+ seed = (seed + 0x6d2b79f5) >>> 0;
586
+ let t = seed;
587
+ t = Math.imul(t ^ (t >>> 15), t | 1);
588
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
589
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
590
+ };
591
+ for (let i = 0; i < n; i++) {
592
+ const j = i + Math.floor(rand() * (idx.length - i));
593
+ [idx[i], idx[j]] = [idx[j], idx[i]];
594
+ }
595
+ return idx.slice(0, n).sort((x, y) => x - y).map((i) => items[i]);
596
+ }
597
+ /** Base whitelist + every field the layer reads + `_smo_*` internals
598
+ * (tooltip/popup dispatch). `reads` is the layer minus its source (or just
599
+ * its viz) — walked at any depth. Exported for tests. */
600
+ export function compactFeatureCollection(fc, reads,
520
601
  /** Fields the layer's filter reads — /api/maps applies it after this strip. */
521
602
  filterFields = []) {
522
- const keep = new Set([...SHARE_BASE_KEEP_PROPS, ...filterFields]);
523
- for (const [k, v] of Object.entries(viz ?? {})) {
524
- if (typeof v === 'string' && /(Field|^field[A-Z])/.test(k))
525
- keep.add(v);
526
- }
527
- const features = (fc.features ?? []).slice(0, SHARE_MAX_FEATURES_PER_LAYER).map((f) => ({
603
+ const keep = collectReadFields(reads, new Set([...SHARE_BASE_KEEP_PROPS, ...filterFields]));
604
+ const features = sampleSpread(fc.features ?? [], SHARE_MAX_FEATURES_PER_LAYER).map((f) => ({
528
605
  ...f,
529
606
  geometry: compactGeometry(f.geometry),
530
607
  properties: Object.fromEntries(Object.entries(f.properties ?? {}).filter(([k]) => keep.has(k) || k.startsWith('_smo_'))),
531
608
  }));
532
609
  return { type: fc.type ?? 'FeatureCollection', features };
533
610
  }
611
+ /** Shrink every compacted layer by one factor when the sidecar is over
612
+ * budget, sampled spread; returns `{kept,total}` for each layer the share
613
+ * carries only partly. Exported for tests. */
614
+ export function budgetShareLayers(compacted, totals, budgetBytes = SHARE_GEOJSON_BUDGET_BYTES) {
615
+ const bytes = JSON.stringify(compacted).length;
616
+ const scale = bytes > budgetBytes ? budgetBytes / bytes : 1;
617
+ const sampled = {};
618
+ for (const [id, fc] of Object.entries(compacted)) {
619
+ if (scale < 1)
620
+ fc.features = sampleSpread(fc.features, Math.floor(fc.features.length * scale));
621
+ if (fc.features.length < (totals[id] ?? 0))
622
+ sampled[id] = { kept: fc.features.length, total: totals[id] };
623
+ }
624
+ return sampled;
625
+ }
534
626
  /** Every `field` a predicate tree reads (attribute/temporal leaves). */
535
627
  export function predicateFields(predicate, out = []) {
536
628
  if (Array.isArray(predicate)) {
@@ -557,14 +649,23 @@ layerFilters) {
557
649
  // Callers pass the build query, or an edit's optional rationale label.
558
650
  const query = queryOrLabel || mapPlan.title || 'Map';
559
651
  const geojsonLayers = {};
652
+ const totals = {};
653
+ const temporalField = mapPlan.temporalHint?.field;
560
654
  const strippedLayers = mapPlan.layers.map((raw) => {
561
655
  const layer = raw;
562
656
  if (layer?.source?.kind === 'geojson' && layer.source.data && layer.id) {
563
- geojsonLayers[layer.id] = compactFeatureCollection(layer.source.data, layer.viz, predicateFields(layerFilters?.[layer.id]));
657
+ const data = layer.source.data;
658
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- source carries the data, not field references
659
+ const { source: _source, ...reads } = layer;
660
+ totals[layer.id] = data.features?.length ?? 0;
661
+ geojsonLayers[layer.id] = compactFeatureCollection(data, reads, [...predicateFields(layerFilters?.[layer.id]), ...(typeof temporalField === 'string' ? [temporalField] : [])]);
564
662
  return { ...layer, source: { ...layer.source, data: undefined } };
565
663
  }
566
664
  return layer;
567
665
  });
666
+ // Budget the sidecar; /m/ reads `sampledLayers` and tells the viewer the
667
+ // copy is a sample — a partial map must never pass as the whole one.
668
+ const sampled = budgetShareLayers(geojsonLayers, totals);
568
669
  const title = mapPlan.title ?? mapPlan.layers[0]?.title ?? 'Untitled Map';
569
670
  const body = {
570
671
  id: mapId,
@@ -573,6 +674,7 @@ layerFilters) {
573
674
  blob: {
574
675
  mapPlan: { ...mapPlan, layers: strippedLayers },
575
676
  geojsonLayers,
677
+ ...(Object.keys(sampled).length ? { sampledLayers: sampled } : {}),
576
678
  query,
577
679
  version: 1,
578
680
  ...(story ? { story } : {}),
@@ -638,7 +740,7 @@ async function fetchPublicSnapshot(baseUrl, mapId) {
638
740
  return { status: 'deleted', plan: null };
639
741
  const text = await resp.text();
640
742
  if (!resp.ok)
641
- throw new Error(`${path} ${resp.status}: ${text.slice(0, 200)}`);
743
+ throw new Error(`${path} ${resp.status}: ${describeErrorBody(text)}`);
642
744
  const body = JSON.parse(text);
643
745
  const plan = body.blob?.mapPlan;
644
746
  if (!plan)
@@ -989,11 +1091,10 @@ export function createServer(opts) {
989
1091
  lastAccessed: Date.now(),
990
1092
  });
991
1093
  await store.put(mapId, prepared.entry);
992
- const nextActions = [
993
- 'Filter the layer to features where name is not empty',
994
- 'Change the basemap to satellite',
995
- 'Zoom the camera closer',
996
- ];
1094
+ // Derived from THIS map: its fields, its basemap/camera, its own
1095
+ // followups — computed on the full-resolution API plan (features
1096
+ // inline), never the generalized stored copy. May be [] (nextActions.ts).
1097
+ const nextActions = buildMapNextActions(resp.mapPlan, query);
997
1098
  const note = generalizedNote(prepared.generalized);
998
1099
  return toContent(envelope(baseUrl, mapId, prepared.entry.workspace, prepared.entry.mapPlan, nextActions, { ...(shared ? {} : { shareUnpersisted: true }), ...(note ? { note } : {}) }));
999
1100
  }));
@@ -2217,7 +2318,7 @@ export function createServer(opts) {
2217
2318
  });
2218
2319
  const text = await resp.text();
2219
2320
  if (!resp.ok)
2220
- throw new Error(`/api/workflows ${resp.status}: ${text.slice(0, 200)}`);
2321
+ throw new Error(`/api/workflows ${resp.status}: ${describeErrorBody(text)}`);
2221
2322
  const data = JSON.parse(text);
2222
2323
  return toContent({
2223
2324
  ok: true,
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  /** The npm package version, reported to MCP clients in `initialize`. Pinned
2
2
  * equal to package.json by __tests__/tools/registration.test.ts. */
3
- export const PACKAGE_VERSION = '0.7.0';
3
+ export const PACKAGE_VERSION = '0.7.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@showmeonmap/mcp",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "mcpName": "com.showmeonmap/maps",
5
5
  "description": "MCP server for ShowMeOnMap \u2014 geospatial visualization tools for any MCP-speaking agent (Claude Desktop, Cursor, Zed, ChatGPT Apps SDK)",
6
6
  "type": "module",