@showmeonmap/mcp 0.6.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 +12 -4
- package/dist/fetchNoRedirect.js +16 -0
- package/dist/generalizeGeometry.js +232 -0
- package/dist/http.js +2 -0
- package/dist/index.js +2 -1
- package/dist/nextActions.js +340 -0
- package/dist/serverInstructions.js +34 -0
- package/dist/sessionStoreD1.js +28 -8
- package/dist/tools.js +774 -107
- package/dist/version.js +3 -0
- package/dist/viewer.js +117 -0
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -23,6 +23,11 @@ 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";
|
|
27
|
+
import { fetchNoRedirect } from "./fetchNoRedirect.js";
|
|
28
|
+
import { BUILD_MAP_DESCRIPTION, SERVER_INSTRUCTIONS } from "./serverInstructions.js";
|
|
29
|
+
import { describeTolerance, generalizeLayers, storedBytes, WORKSPACE_CEILING } from "./generalizeGeometry.js";
|
|
30
|
+
import { VIEWER_URI, VIEWER_TOOLS, VIEW_META_KEY, MCP_APP_MIME, viewerShellHtml, viewerResourceMeta, splitLayersByBudget, utf8Bytes, } from "./viewer.js";
|
|
26
31
|
// -----------------------------------------------------------------------------
|
|
27
32
|
// Agent-facing spec resource (Teddy Riker, "Designing for Agents" §2,
|
|
28
33
|
// 2026-04-23). Loaded once at module init from a sibling .md file. Tool
|
|
@@ -58,6 +63,11 @@ const WORKSPACE_OPS_SPEC_TEXT = (() => {
|
|
|
58
63
|
const MCP_RATIONALE_DIR = (() => {
|
|
59
64
|
try {
|
|
60
65
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
66
|
+
// Repo checkout only. Installed from npm, three levels up from dist/ is
|
|
67
|
+
// the user's own node_modules — the package must not write their tool
|
|
68
|
+
// rationales to disk there (found preparing 0.7.0; 0.6.0 did).
|
|
69
|
+
if (!/[\\/]packages[\\/]mcp-server[\\/](src|dist)$/.test(here) || /[\\/]node_modules[\\/]/.test(here))
|
|
70
|
+
return null;
|
|
61
71
|
// tools.ts sits at packages/mcp-server/src/. Climb three levels
|
|
62
72
|
// (src → mcp-server → packages → <repo>) to land at <repo>/reports/
|
|
63
73
|
// mcp-rationale, alongside the agent-blockers sink and the
|
|
@@ -97,6 +107,56 @@ export function asSessionId(value) {
|
|
|
97
107
|
}
|
|
98
108
|
return value;
|
|
99
109
|
}
|
|
110
|
+
/** The map's geometry stays over WORKSPACE_CEILING even after generalization. */
|
|
111
|
+
export class MapTooLargeError extends Error {
|
|
112
|
+
mapId;
|
|
113
|
+
bytes;
|
|
114
|
+
constructor(mapId, bytes, shareUrl) {
|
|
115
|
+
super(`This map is too large to keep in the conversation (${(bytes / 1e6).toFixed(1)} MB after simplifying its geometry). ` +
|
|
116
|
+
(shareUrl ? `Open it on ShowMeOnMap: ${shareUrl}` : 'Try a smaller area or fewer layers.'));
|
|
117
|
+
this.mapId = mapId;
|
|
118
|
+
this.bytes = bytes;
|
|
119
|
+
this.name = 'MapTooLargeError';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Generalize an entry's oversize inline layers (workspace ops + mapPlan) —
|
|
124
|
+
* see generalizeGeometry.ts. Idempotent: a generalized layer is marked and
|
|
125
|
+
* skipped next time.
|
|
126
|
+
*/
|
|
127
|
+
export function prepareEntry(entry) {
|
|
128
|
+
const ws = generalizeLayers(entry.workspace);
|
|
129
|
+
const mp = generalizeLayers(entry.mapPlan);
|
|
130
|
+
const seen = new Set();
|
|
131
|
+
const generalized = [...ws.generalized, ...mp.generalized].filter((g) => {
|
|
132
|
+
if (seen.has(g.layerId))
|
|
133
|
+
return false;
|
|
134
|
+
seen.add(g.layerId);
|
|
135
|
+
return true;
|
|
136
|
+
});
|
|
137
|
+
return { entry: { ...entry, workspace: ws.value, mapPlan: mp.value }, generalized };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Every write to the session store goes through here, so no call site can
|
|
141
|
+
* forget it: oversize line/polygon layers are generalized, and a workspace
|
|
142
|
+
* still over the ceiling is refused with a clear error instead of the raw
|
|
143
|
+
* D1 `SQLITE_TOOBIG` (one TEXT cell, 2 MB cap). The share link is named in
|
|
144
|
+
* the error only when it is known to exist.
|
|
145
|
+
*/
|
|
146
|
+
export function withGeneralization(inner, shareUrlFor = () => null) {
|
|
147
|
+
return {
|
|
148
|
+
get: (mapId) => inner.get(mapId),
|
|
149
|
+
delete: (mapId) => inner.delete(mapId),
|
|
150
|
+
size: () => inner.size(),
|
|
151
|
+
async put(mapId, entry) {
|
|
152
|
+
const prepared = prepareEntry(entry).entry;
|
|
153
|
+
const bytes = storedBytes(prepared.workspace);
|
|
154
|
+
if (bytes > WORKSPACE_CEILING)
|
|
155
|
+
throw new MapTooLargeError(mapId, bytes, shareUrlFor(mapId));
|
|
156
|
+
await inner.put(mapId, prepared);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
100
160
|
export function createMemorySessionStore(maxSize = 256) {
|
|
101
161
|
const cache = new Map();
|
|
102
162
|
return {
|
|
@@ -186,7 +246,14 @@ function compactStructured(workspace, mapPlan) {
|
|
|
186
246
|
}
|
|
187
247
|
return compacted;
|
|
188
248
|
}
|
|
189
|
-
|
|
249
|
+
/** Tool-text honesty line for generalized layers, so Claude can say so. */
|
|
250
|
+
function generalizedNote(generalized) {
|
|
251
|
+
if (!generalized.length)
|
|
252
|
+
return undefined;
|
|
253
|
+
const parts = generalized.map((g) => `${g.title ?? g.layerId} (${describeTolerance(g.toleranceDeg)})`);
|
|
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.`;
|
|
255
|
+
}
|
|
256
|
+
function envelope(baseUrl, mapId, workspace, mapPlan, nextActions, extras = {}) {
|
|
190
257
|
const layerCount = Array.isArray(mapPlan?.layers) ? mapPlan.layers.length : 0;
|
|
191
258
|
const opsCount = Array.isArray(workspace?.opsLog) ? workspace.opsLog.length : 0;
|
|
192
259
|
return {
|
|
@@ -202,10 +269,9 @@ function envelope(baseUrl, mapId, workspace, mapPlan, nextActionStrings, extras
|
|
|
202
269
|
layer_count: layerCount,
|
|
203
270
|
last_updated: new Date().toISOString(),
|
|
204
271
|
},
|
|
205
|
-
next_actions: (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
})),
|
|
272
|
+
next_actions: (nextActions ?? []).every((a) => typeof a === 'string')
|
|
273
|
+
? instructionHints(nextActions)
|
|
274
|
+
: nextActions.slice(0, MAX_NEXT_ACTIONS),
|
|
209
275
|
...(extras.outOfScope ? { outOfScope: extras.outOfScope } : {}),
|
|
210
276
|
...(extras.clarification ? { clarification: extras.clarification } : {}),
|
|
211
277
|
...(extras.opsApplied ? { opsApplied: extras.opsApplied } : {}),
|
|
@@ -214,6 +280,7 @@ function envelope(baseUrl, mapId, workspace, mapPlan, nextActionStrings, extras
|
|
|
214
280
|
...(extras.memo ? { memo: extras.memo } : {}),
|
|
215
281
|
...(extras.stats ? { stats: extras.stats } : {}),
|
|
216
282
|
...(extras.note ? { note: extras.note } : {}),
|
|
283
|
+
...(extras.batch ? { batch: extras.batch } : {}),
|
|
217
284
|
};
|
|
218
285
|
}
|
|
219
286
|
function toContent(obj) {
|
|
@@ -240,32 +307,37 @@ function toContent(obj) {
|
|
|
240
307
|
// envelope (isError:true + content[].text JSON) so the calling agent can
|
|
241
308
|
// self-correct.
|
|
242
309
|
// ---------------------------------------------------------------------------
|
|
310
|
+
// `rationale` (≤300 chars) is OPTIONAL on every tool — appended once in
|
|
311
|
+
// invalidInputEnvelope rather than repeated per tool.
|
|
243
312
|
const INVALID_INPUT_HINTS = {
|
|
244
|
-
build_map: 'Required: query (string, ≥1 char)
|
|
245
|
-
mutate_map: 'Required: map_id, instruction
|
|
246
|
-
get_state: 'Required: map_id
|
|
247
|
-
add_layer: 'Required: map_id, layerPlan (object)
|
|
248
|
-
filter_layer: 'Required: map_id, layerId, predicate (object)
|
|
249
|
-
select_within: 'Required: map_id, sourceLayerId, newLayerId, selection ({type:Polygon|MultiPolygon, coordinates})
|
|
250
|
-
focus_area: 'Required: map_id, layerId, areaRef ({kind:"corridor", street, from?, to?} | {kind:"place", name} | {kind:"aroundIntersection", street, cross})
|
|
251
|
-
query_features: 'Required: map_id, layerId, predicate (object)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
313
|
+
build_map: 'Required: query (string, ≥1 char). No other fields accepted.',
|
|
314
|
+
mutate_map: 'Required: map_id, instruction. No other fields accepted.',
|
|
315
|
+
get_state: 'Required: map_id. No other fields accepted.',
|
|
316
|
+
add_layer: 'Required: map_id, layerPlan (object). Optional: intent (enum). No other fields accepted.',
|
|
317
|
+
filter_layer: 'Required: map_id, layerId, predicate (object). Optional: append (boolean), intent (enum).',
|
|
318
|
+
select_within: 'Required: map_id, sourceLayerId, newLayerId, selection ({type:Polygon|MultiPolygon, coordinates}). Optional: inheritStyle.',
|
|
319
|
+
focus_area: 'Required: map_id, layerId, areaRef ({kind:"corridor", street, from?, to?} | {kind:"place", name} | {kind:"aroundIntersection", street, cross}). Optional: mode (filter|select|clip). Pass NAMES — the server resolves geometry from OSM.',
|
|
320
|
+
query_features: 'Required: map_id, layerId, predicate (object). Optional: limit (1–1000 integer).',
|
|
321
|
+
save_workflow: 'Required: name, steps (1–12 {instruction} objects). Optional: params (declared {slot}s). Every {slot} used in steps must be declared.',
|
|
322
|
+
list_workflows: 'No required fields. No other fields accepted.',
|
|
323
|
+
run_workflow: 'Required: workflow_id. Optional: params (values for declared parameters), batch ({param, values[2..20]} — sweeps one param, bills steps × values).',
|
|
324
|
+
remove_layer: 'Required: map_id, layerId, confirmed:true (destructive — layer ids are tombstoned).',
|
|
325
|
+
clear_filters: 'Required: map_id, layerId, confirmed:true (sensitive — autonomous filter wipe).',
|
|
326
|
+
rename_layer: 'Required: map_id, oldLayerId, newLayerId.',
|
|
327
|
+
restyle_layer: 'Required: map_id, layerId. Optional: color (string), opacity (0–1).',
|
|
328
|
+
set_camera: 'Required: map_id, camera (mode:fitBounds|flyTo with required fields).',
|
|
329
|
+
set_time: 'Required: map_id, field, rangeStart (number), rangeEnd (number). Optional: format (unix_ms|iso|integer).',
|
|
330
|
+
tag: 'Required: map_id, name.',
|
|
331
|
+
add_annotation: 'Required: map_id, id, coords ([lng,lat]). Optional: label, color.',
|
|
332
|
+
remove_annotation: 'Required: map_id, annotationId.',
|
|
333
|
+
export_workspace: 'Required: map_id.',
|
|
334
|
+
export_layer_data: 'Required: map_id, layer_id, format ("csv" or "geojson"). No other fields accepted.',
|
|
335
|
+
export_image: 'Required: map_id. Optional: format ("png" default | "jpeg" | "pdf"), legend (boolean default true). No other fields accepted.',
|
|
336
|
+
add_fusion_layer: 'Required: map_id, layer_a_id, layer_b_id. Optional: value_field_a, value_field_b, new_layer_id. Mode is locked to "bivariate" in v1. No other fields accepted.',
|
|
337
|
+
correlate_layers: 'Required: map_id, layer_a_id, layer_b_id. Optional: value_field_a, value_field_b. Pure read — no layer added, no credit charged. No other fields accepted.',
|
|
338
|
+
undo: 'Required: map_id, confirmed:true (sensitive — autonomous undo can erase prior work).',
|
|
339
|
+
redo: 'Required: map_id.',
|
|
340
|
+
report_blocker: 'Required: what_i_was_trying, what_i_tried, where_i_got_stuck. Optional: map_id, suggested_capability.',
|
|
269
341
|
};
|
|
270
342
|
function buildInvalidInputResponse(toolName, error) {
|
|
271
343
|
const issues = error.issues.map((iss) => ({
|
|
@@ -277,7 +349,7 @@ function buildInvalidInputResponse(toolName, error) {
|
|
|
277
349
|
error: 'invalid_input',
|
|
278
350
|
tool: toolName,
|
|
279
351
|
issues,
|
|
280
|
-
hint: INVALID_INPUT_HINTS[toolName] ?? 'Check the tool schema and remove unknown keys.'
|
|
352
|
+
hint: `${INVALID_INPUT_HINTS[toolName] ?? 'Check the tool schema and remove unknown keys.'} Optional on every tool: rationale (≤300 chars).`,
|
|
281
353
|
};
|
|
282
354
|
return {
|
|
283
355
|
isError: true,
|
|
@@ -350,8 +422,32 @@ function withConfirmation(toolName, handler) {
|
|
|
350
422
|
return handler(rest, extra);
|
|
351
423
|
});
|
|
352
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
|
+
}
|
|
353
449
|
async function apiFetch(baseUrl, apiKey, path, body) {
|
|
354
|
-
const resp = await
|
|
450
|
+
const resp = await fetchNoRedirect(`${baseUrl}${path}`, {
|
|
355
451
|
method: 'POST',
|
|
356
452
|
headers: {
|
|
357
453
|
'Content-Type': 'application/json',
|
|
@@ -366,7 +462,7 @@ async function apiFetch(baseUrl, apiKey, path, body) {
|
|
|
366
462
|
// success (one of four parallel build_map calls lost its map this way,
|
|
367
463
|
// 2026-08-01).
|
|
368
464
|
if (!resp.ok) {
|
|
369
|
-
throw new Error(`${path} ${resp.status}: ${text
|
|
465
|
+
throw new Error(`${path} ${resp.status}: ${describeErrorBody(text)}`);
|
|
370
466
|
}
|
|
371
467
|
try {
|
|
372
468
|
return JSON.parse(text);
|
|
@@ -398,7 +494,11 @@ async function apiFetch(baseUrl, apiKey, path, body) {
|
|
|
398
494
|
// own viz reads.
|
|
399
495
|
const SHARE_MAX_VERTICES_PER_RING = 60;
|
|
400
496
|
const SHARE_MAX_POLYS_PER_MULTI = 3;
|
|
401
|
-
|
|
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;
|
|
402
502
|
const SHARE_BASE_KEEP_PROPS = new Set(['value', 'name', 'iso3', 'label', '_dataVintage']);
|
|
403
503
|
function roundCoord(n) {
|
|
404
504
|
return Math.round(n * 10_000) / 10_000;
|
|
@@ -442,39 +542,144 @@ function compactGeometry(geom) {
|
|
|
442
542
|
}
|
|
443
543
|
return geom;
|
|
444
544
|
}
|
|
445
|
-
/**
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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]];
|
|
452
594
|
}
|
|
453
|
-
|
|
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,
|
|
601
|
+
/** Fields the layer's filter reads — /api/maps applies it after this strip. */
|
|
602
|
+
filterFields = []) {
|
|
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) => ({
|
|
454
605
|
...f,
|
|
455
606
|
geometry: compactGeometry(f.geometry),
|
|
456
607
|
properties: Object.fromEntries(Object.entries(f.properties ?? {}).filter(([k]) => keep.has(k) || k.startsWith('_smo_'))),
|
|
457
608
|
}));
|
|
458
609
|
return { type: fc.type ?? 'FeatureCollection', features };
|
|
459
610
|
}
|
|
460
|
-
|
|
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
|
+
}
|
|
626
|
+
/** Every `field` a predicate tree reads (attribute/temporal leaves). */
|
|
627
|
+
export function predicateFields(predicate, out = []) {
|
|
628
|
+
if (Array.isArray(predicate)) {
|
|
629
|
+
for (const p of predicate)
|
|
630
|
+
predicateFields(p, out);
|
|
631
|
+
}
|
|
632
|
+
else if (predicate && typeof predicate === 'object') {
|
|
633
|
+
for (const [k, v] of Object.entries(predicate)) {
|
|
634
|
+
if (k === 'field' && typeof v === 'string')
|
|
635
|
+
out.push(v);
|
|
636
|
+
else if (v && typeof v === 'object')
|
|
637
|
+
predicateFields(v, out);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return out;
|
|
641
|
+
}
|
|
642
|
+
async function persistShareableMap(baseUrl, apiKey, mapId, mapPlan, queryOrLabel, story,
|
|
643
|
+
// The workspace's filters (materialize keeps them beside the plan). Sent so
|
|
644
|
+
// /api/maps stores only the features the map shows — without them the
|
|
645
|
+
// share link held every feature after filter_layer (2026-10-09).
|
|
646
|
+
layerFilters) {
|
|
461
647
|
if (!mapPlan || !Array.isArray(mapPlan.layers))
|
|
462
648
|
return false;
|
|
649
|
+
// Callers pass the build query, or an edit's optional rationale label.
|
|
650
|
+
const query = queryOrLabel || mapPlan.title || 'Map';
|
|
463
651
|
const geojsonLayers = {};
|
|
652
|
+
const totals = {};
|
|
653
|
+
const temporalField = mapPlan.temporalHint?.field;
|
|
464
654
|
const strippedLayers = mapPlan.layers.map((raw) => {
|
|
465
655
|
const layer = raw;
|
|
466
656
|
if (layer?.source?.kind === 'geojson' && layer.source.data && layer.id) {
|
|
467
|
-
|
|
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] : [])]);
|
|
468
662
|
return { ...layer, source: { ...layer.source, data: undefined } };
|
|
469
663
|
}
|
|
470
664
|
return layer;
|
|
471
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);
|
|
472
669
|
const title = mapPlan.title ?? mapPlan.layers[0]?.title ?? 'Untitled Map';
|
|
473
670
|
const body = {
|
|
474
671
|
id: mapId,
|
|
475
672
|
title,
|
|
476
673
|
query,
|
|
477
|
-
blob: {
|
|
674
|
+
blob: {
|
|
675
|
+
mapPlan: { ...mapPlan, layers: strippedLayers },
|
|
676
|
+
geojsonLayers,
|
|
677
|
+
...(Object.keys(sampled).length ? { sampledLayers: sampled } : {}),
|
|
678
|
+
query,
|
|
679
|
+
version: 1,
|
|
680
|
+
...(story ? { story } : {}),
|
|
681
|
+
...(layerFilters && Object.keys(layerFilters).length ? { layerFilters } : {}),
|
|
682
|
+
},
|
|
478
683
|
isPublic: true,
|
|
479
684
|
};
|
|
480
685
|
// Two attempts: the second exists for exactly the failure that lost a map
|
|
@@ -497,14 +702,79 @@ async function persistShareableMap(baseUrl, apiKey, mapId, mapPlan, query, story
|
|
|
497
702
|
}
|
|
498
703
|
return false;
|
|
499
704
|
}
|
|
705
|
+
/** Safe map id: workspace UUIDs, nanoid /m/ ids, `ws_…` test ids. Rejects
|
|
706
|
+
* path segments (`..`, `a/b`) before the id reaches a URL. */
|
|
707
|
+
const MAP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
708
|
+
/** `cursor` and `op_id` pin a version together — exactly one is ambiguous. */
|
|
709
|
+
const pinPairSchema = z
|
|
710
|
+
.object({ cursor: z.number().optional(), op_id: z.string().optional() })
|
|
711
|
+
.refine((v) => (v.cursor === undefined) === (v.op_id === undefined), {
|
|
712
|
+
message: 'cursor and op_id must be given together (a version is the pair), or both omitted for the latest',
|
|
713
|
+
path: ['op_id'],
|
|
714
|
+
});
|
|
715
|
+
/** Headroom under MAX_STRUCTURED_BYTES for the view envelope (title, camera,
|
|
716
|
+
* legend, stripped layer stubs, the text summary, JSON-RPC framing). */
|
|
717
|
+
const VIEW_ENVELOPE_HEADROOM = 50_000;
|
|
718
|
+
/**
|
|
719
|
+
* A non-owner's (or LRU-evicted session's) view: the public /m/ snapshot,
|
|
720
|
+
* always the latest version. Deliberately NOT apiFetch — that attaches the
|
|
721
|
+
* caller's bearer, and a public read must not depend on it. Mirrors
|
|
722
|
+
* `hydrateSharedPlan` (src/lib/share/saveMapSnapshot.ts), which this package
|
|
723
|
+
* cannot import: a geojson layer with no inline data gets
|
|
724
|
+
* `blob.geojsonLayers[layer.id]`, top level and both comparison panes.
|
|
725
|
+
*
|
|
726
|
+
* NB: GET /api/maps/[id] answers a private map with 404 (it hides existence),
|
|
727
|
+
* so today a private map reads as `deleted`; 401/403 → `private` is kept for
|
|
728
|
+
* the day the route distinguishes them.
|
|
729
|
+
*/
|
|
730
|
+
async function fetchPublicSnapshot(baseUrl, mapId) {
|
|
731
|
+
const path = `/api/maps/${encodeURIComponent(mapId)}`;
|
|
732
|
+
// X-SMO-No-View-Count: a card load is not a /m/ page view — the route
|
|
733
|
+
// skips incrementViews for it.
|
|
734
|
+
const resp = await fetchNoRedirect(`${baseUrl}${path}`, {
|
|
735
|
+
headers: { Accept: 'application/json', 'X-SMO-No-View-Count': '1' },
|
|
736
|
+
});
|
|
737
|
+
if (resp.status === 401 || resp.status === 403)
|
|
738
|
+
return { status: 'private', plan: null };
|
|
739
|
+
if (resp.status === 404)
|
|
740
|
+
return { status: 'deleted', plan: null };
|
|
741
|
+
const text = await resp.text();
|
|
742
|
+
if (!resp.ok)
|
|
743
|
+
throw new Error(`${path} ${resp.status}: ${describeErrorBody(text)}`);
|
|
744
|
+
const body = JSON.parse(text);
|
|
745
|
+
const plan = body.blob?.mapPlan;
|
|
746
|
+
if (!plan)
|
|
747
|
+
throw new Error(`${path} returned no mapPlan`);
|
|
748
|
+
const geo = body.blob?.geojsonLayers ?? {};
|
|
749
|
+
const hydrate = (l) => l.source?.kind === 'geojson' && !l.source.data && geo[l.id] ? { ...l, source: { ...l.source, data: geo[l.id] } } : l;
|
|
750
|
+
return {
|
|
751
|
+
status: 'public_snapshot',
|
|
752
|
+
plan: {
|
|
753
|
+
...plan,
|
|
754
|
+
layers: (plan.layers ?? []).map(hydrate),
|
|
755
|
+
...(plan.comparison
|
|
756
|
+
? {
|
|
757
|
+
comparison: {
|
|
758
|
+
left: { ...plan.comparison.left, layers: plan.comparison.left.layers.map(hydrate) },
|
|
759
|
+
right: { ...plan.comparison.right, layers: plan.comparison.right.layers.map(hydrate) },
|
|
760
|
+
},
|
|
761
|
+
}
|
|
762
|
+
: {}),
|
|
763
|
+
},
|
|
764
|
+
};
|
|
765
|
+
}
|
|
500
766
|
// MCP tool annotations — safety hints every tool advertises so clients can
|
|
501
767
|
// reason about read-only vs mutating vs destructive behavior (and the model
|
|
502
768
|
// picks tools correctly). `destructiveHint` derives from MCP_TOOL_META (the
|
|
503
769
|
// single source of truth, kept in sync with the agent-side meta); the rest
|
|
504
770
|
// from a small intent classification.
|
|
771
|
+
// report_blocker is deliberately NOT here: it sends agent-written text to the
|
|
772
|
+
// SMO team, so a host should ask before running it rather than auto-approve it
|
|
773
|
+
// as a read (Claude auto-runs readOnlyHint tools; directory review checks this).
|
|
505
774
|
const READ_ONLY_TOOLS = new Set([
|
|
506
775
|
'get_state', 'query_features', 'select_within', 'correlate_layers',
|
|
507
|
-
'export_workspace', 'export_layer_data', 'export_image', '
|
|
776
|
+
'export_workspace', 'export_layer_data', 'export_image', 'list_workflows',
|
|
777
|
+
'get_map_view',
|
|
508
778
|
]);
|
|
509
779
|
// Tools that reach external/open-world data sources, vs operating purely on
|
|
510
780
|
// already-loaded workspace state.
|
|
@@ -527,16 +797,72 @@ function toolAnnotations(name) {
|
|
|
527
797
|
export function createServer(opts) {
|
|
528
798
|
const baseUrl = opts.baseUrl;
|
|
529
799
|
const apiKey = opts.apiKey;
|
|
530
|
-
|
|
800
|
+
// Maps whose /m/ share was confirmed written — the only links a
|
|
801
|
+
// too-large error may hand out (share-store honesty, 2026-08-01).
|
|
802
|
+
const sharedMapIds = new Set();
|
|
803
|
+
const store = withGeneralization(opts.sessionStore ?? createMemorySessionStore(), (mapId) => (sharedMapIds.has(mapId) ? `${baseUrl}/m/${mapId}` : null));
|
|
804
|
+
const viewer = opts.viewer;
|
|
531
805
|
const server = new McpServer({
|
|
532
806
|
name: opts.name ?? 'showmeonmap',
|
|
533
807
|
version: opts.version ?? '0.1.0',
|
|
534
|
-
});
|
|
808
|
+
}, { instructions: SERVER_INSTRUCTIONS });
|
|
535
809
|
// Stamp MCP annotations onto every tool from the central classification so we
|
|
536
810
|
// don't thread them through 24 registerTool call sites. `title` mirrors the
|
|
537
811
|
// tool's own title; any inline annotations a call sets win over the defaults.
|
|
812
|
+
//
|
|
813
|
+
// With `viewer` set, map-producing tools (VIEWER_TOOLS) are also tagged with
|
|
814
|
+
// the MCP App resource (`ui.resourceUri` + the legacy flat `ui/resourceUri`
|
|
815
|
+
// key) and their results gain a hidden view pointer under `_meta`.
|
|
816
|
+
//
|
|
817
|
+
// Also with `viewer` set, every tool except the app-only `get_map_view` is
|
|
818
|
+
// stamped `_meta.ui.visibility: ['model']`. ext-apps defaults visibility to
|
|
819
|
+
// ["model","app"], which would let the card call credit-spending or
|
|
820
|
+
// mutating tools through the host; the card's one tool is get_map_view.
|
|
821
|
+
// Without `viewer` nothing changes (stdio / npm builds).
|
|
538
822
|
const baseRegisterTool = server.registerTool.bind(server);
|
|
539
|
-
const registerTool = (name, config, cb) =>
|
|
823
|
+
const registerTool = (name, config, cb) => {
|
|
824
|
+
const tagged = !!viewer && VIEWER_TOOLS.has(name);
|
|
825
|
+
const existingUi = (config._meta?.ui ?? {});
|
|
826
|
+
const ui = {
|
|
827
|
+
...existingUi,
|
|
828
|
+
...(tagged ? { resourceUri: VIEWER_URI } : {}),
|
|
829
|
+
...(viewer && name !== 'get_map_view' ? { visibility: ['model'] } : {}),
|
|
830
|
+
};
|
|
831
|
+
const cfg = {
|
|
832
|
+
...config,
|
|
833
|
+
annotations: { title: config.title, ...toolAnnotations(name), ...config.annotations },
|
|
834
|
+
...(viewer ? { _meta: { ...config._meta, ui, ...(tagged ? { 'ui/resourceUri': VIEWER_URI } : {}) } } : {}),
|
|
835
|
+
};
|
|
836
|
+
if (!tagged)
|
|
837
|
+
return baseRegisterTool(name, cfg, cb);
|
|
838
|
+
const wrapped = (async (...args) => {
|
|
839
|
+
const result = await cb(...args);
|
|
840
|
+
return attachViewPointer(result);
|
|
841
|
+
});
|
|
842
|
+
return baseRegisterTool(name, cfg, wrapped);
|
|
843
|
+
};
|
|
844
|
+
/** Read map_id from the envelope text, then cursor/opId from the store the
|
|
845
|
+
* tool just wrote — authoritative even when the envelope was truncated. */
|
|
846
|
+
async function attachViewPointer(result) {
|
|
847
|
+
if (result?.isError)
|
|
848
|
+
return result;
|
|
849
|
+
try {
|
|
850
|
+
const text = result.content?.[0]?.text;
|
|
851
|
+
const mapId = text ? JSON.parse(text).map_id : undefined;
|
|
852
|
+
if (!mapId)
|
|
853
|
+
return result;
|
|
854
|
+
const entry = await store.get(mapId);
|
|
855
|
+
const ws = entry?.workspace;
|
|
856
|
+
const cursor = ws?.cursor;
|
|
857
|
+
const opId = typeof cursor === 'number' ? ws?.opsLog?.[cursor]?.opId : undefined;
|
|
858
|
+
if (typeof cursor !== 'number' || !opId)
|
|
859
|
+
return result;
|
|
860
|
+
return { ...result, _meta: { ...result._meta, [VIEW_META_KEY]: { map_id: mapId, cursor, opId } } };
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
return result; // a pointer failure must never fail the map-producing call
|
|
864
|
+
}
|
|
865
|
+
}
|
|
540
866
|
// -------------------------------------------------------------------------
|
|
541
867
|
// Resource: workspace-ops-spec
|
|
542
868
|
// -------------------------------------------------------------------------
|
|
@@ -558,6 +884,139 @@ export function createServer(opts) {
|
|
|
558
884
|
],
|
|
559
885
|
}));
|
|
560
886
|
// -------------------------------------------------------------------------
|
|
887
|
+
// Resource: MCP App map viewer (only when `viewer` is configured)
|
|
888
|
+
// -------------------------------------------------------------------------
|
|
889
|
+
// A tiny HTML shell that loads the hashed viewer bundle from assetOrigin.
|
|
890
|
+
// `_meta.ui.domain` is deliberately never set — Claude derives the sandbox
|
|
891
|
+
// origin itself and the app's CORS matches it.
|
|
892
|
+
if (viewer) {
|
|
893
|
+
server.registerResource('map-viewer', VIEWER_URI, { title: 'ShowMeOnMap map viewer', description: 'Interactive map card for ShowMeOnMap results.', mimeType: MCP_APP_MIME }, async (uri) => ({
|
|
894
|
+
contents: [{ uri: uri.href, mimeType: MCP_APP_MIME, text: viewerShellHtml(viewer.assetOrigin), _meta: viewerResourceMeta(viewer) }],
|
|
895
|
+
}));
|
|
896
|
+
// -----------------------------------------------------------------------
|
|
897
|
+
// Tool: get_map_view — app-only (hidden from the model), read-only, free.
|
|
898
|
+
// The card loads a pinned version `(cursor, opId)`. Owner → from the
|
|
899
|
+
// owner-scoped session store; anyone else → the public /m/ snapshot.
|
|
900
|
+
//
|
|
901
|
+
// Budget: the view rides ONLY in `structuredContent` (what the card reads);
|
|
902
|
+
// `content[0].text` is a short summary. Duplicating the view into the text
|
|
903
|
+
// would double the result (plus JSON-escaping) and halve the usable layer
|
|
904
|
+
// budget. Layer data gets MAX_STRUCTURED_BYTES − VIEW_ENVELOPE_HEADROOM
|
|
905
|
+
// (UTF-8 bytes); a final guard re-measures the whole result.
|
|
906
|
+
// -----------------------------------------------------------------------
|
|
907
|
+
const getMapViewShape = {
|
|
908
|
+
map_id: z.string().regex(MAP_ID_RE),
|
|
909
|
+
cursor: z.number().int().min(0).optional(),
|
|
910
|
+
op_id: z.string().min(1).optional(),
|
|
911
|
+
layer_ids: z.array(z.string().min(1)).max(50).optional(),
|
|
912
|
+
};
|
|
913
|
+
registerTool('get_map_view', {
|
|
914
|
+
title: 'Map view (viewer only)',
|
|
915
|
+
description: 'Used by the ShowMeOnMap map card to load a map version. Not for the model.',
|
|
916
|
+
inputSchema: getMapViewShape,
|
|
917
|
+
_meta: { ui: { visibility: ['app'] } },
|
|
918
|
+
}, withValidation('get_map_view', getMapViewShape, async ({ map_id, cursor, op_id, layer_ids }) => {
|
|
919
|
+
const pair = pinPairSchema.safeParse({ cursor, op_id });
|
|
920
|
+
// Same invalid_input envelope withValidation produces (same cast too).
|
|
921
|
+
if (!pair.success)
|
|
922
|
+
return buildInvalidInputResponse('get_map_view', pair.error);
|
|
923
|
+
const entry = await store.get(map_id);
|
|
924
|
+
let status = 'ok';
|
|
925
|
+
let plan = null;
|
|
926
|
+
let latest = null;
|
|
927
|
+
let extras = { layerFilters: {} };
|
|
928
|
+
if (entry) {
|
|
929
|
+
const ws = entry.workspace;
|
|
930
|
+
const headOpId = ws.opsLog[ws.cursor]?.opId;
|
|
931
|
+
latest = typeof headOpId === 'string' ? { map_id, cursor: ws.cursor, opId: headOpId } : null;
|
|
932
|
+
const pinned = typeof cursor === 'number' && typeof op_id === 'string';
|
|
933
|
+
const pinOk = pinned && ws.opsLog[cursor]?.opId === op_id;
|
|
934
|
+
if (pinned && !pinOk)
|
|
935
|
+
status = 'replaced';
|
|
936
|
+
// ALWAYS materialize (free, pure): filters, time window and
|
|
937
|
+
// annotations live beside mapPlan, and the stored entry.mapPlan
|
|
938
|
+
// alone would draw a filter/set_time/annotation card wrong (R14).
|
|
939
|
+
const resp = (await apiFetch(baseUrl, apiKey, '/api/workspace/materialize', {
|
|
940
|
+
workspace: { ...ws, cursor: pinOk ? cursor : ws.cursor },
|
|
941
|
+
}));
|
|
942
|
+
// A failed materialize is an error, never `ok` with no plan.
|
|
943
|
+
if (!resp.ok || !resp.mapPlan) {
|
|
944
|
+
throw new Error(`materialize failed: ${resp.error?.kind ?? 'unknown'} — ${resp.error?.message ?? 'no detail'}`);
|
|
945
|
+
}
|
|
946
|
+
plan = resp.mapPlan;
|
|
947
|
+
extras = {
|
|
948
|
+
layerFilters: resp.layerFilters ?? {},
|
|
949
|
+
...(resp.timeOverride ? { timeOverride: resp.timeOverride } : {}),
|
|
950
|
+
...(resp.annotations ? { annotations: resp.annotations } : {}),
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
else {
|
|
954
|
+
const snap = await fetchPublicSnapshot(baseUrl, map_id);
|
|
955
|
+
status = snap.status;
|
|
956
|
+
plan = snap.plan;
|
|
957
|
+
}
|
|
958
|
+
let layers = plan?.layers ?? [];
|
|
959
|
+
if (layer_ids?.length)
|
|
960
|
+
layers = layers.filter((l) => layer_ids.includes(l.id));
|
|
961
|
+
// Filters / time window / annotations ride in the same result, so
|
|
962
|
+
// they come out of the layer budget (the final guard re-measures).
|
|
963
|
+
const extrasBytes = utf8Bytes(JSON.stringify(extras));
|
|
964
|
+
const split = splitLayersByBudget(layers, Math.max(0, MAX_STRUCTURED_BYTES - VIEW_ENVELOPE_HEADROOM - extrasBytes));
|
|
965
|
+
const view = {
|
|
966
|
+
status,
|
|
967
|
+
viewSchema: 1,
|
|
968
|
+
title: plan?.title ?? 'Map',
|
|
969
|
+
mapPlan: plan ? { ...plan, layers: split.inline } : null,
|
|
970
|
+
deferredLayerIds: split.deferred,
|
|
971
|
+
oversizeLayerIds: split.oversize,
|
|
972
|
+
latest,
|
|
973
|
+
...extras,
|
|
974
|
+
};
|
|
975
|
+
const toResult = () => ({
|
|
976
|
+
content: [{
|
|
977
|
+
type: 'text',
|
|
978
|
+
text: JSON.stringify({
|
|
979
|
+
status: view.status,
|
|
980
|
+
title: view.title,
|
|
981
|
+
layers: view.mapPlan?.layers.length ?? 0,
|
|
982
|
+
deferred: view.deferredLayerIds.length,
|
|
983
|
+
oversize: view.oversizeLayerIds.length,
|
|
984
|
+
}),
|
|
985
|
+
}],
|
|
986
|
+
structuredContent: view,
|
|
987
|
+
});
|
|
988
|
+
const fits = () => utf8Bytes(JSON.stringify(toResult())) < MAX_STRUCTURED_BYTES;
|
|
989
|
+
// Final guard — the envelope outgrew the headroom (e.g. a comparison
|
|
990
|
+
// plan's pane data, which the split does not see). Degrade honestly:
|
|
991
|
+
// strip pane data (undeliverable in the card → oversize), then all
|
|
992
|
+
// top-level data (deferred → fetchable one by one), then fail.
|
|
993
|
+
if (!fits() && view.mapPlan?.comparison) {
|
|
994
|
+
const c = view.mapPlan.comparison;
|
|
995
|
+
const stripPane = (ls) => ls.map((l) => {
|
|
996
|
+
if (!l.source?.data)
|
|
997
|
+
return l;
|
|
998
|
+
view.oversizeLayerIds.push(l.id);
|
|
999
|
+
return { ...l, source: { ...l.source, data: undefined } };
|
|
1000
|
+
});
|
|
1001
|
+
view.mapPlan = { ...view.mapPlan, comparison: { left: { ...c.left, layers: stripPane(c.left.layers) }, right: { ...c.right, layers: stripPane(c.right.layers) } } };
|
|
1002
|
+
}
|
|
1003
|
+
if (!fits() && view.mapPlan) {
|
|
1004
|
+
view.mapPlan = {
|
|
1005
|
+
...view.mapPlan,
|
|
1006
|
+
layers: view.mapPlan.layers.map((l) => {
|
|
1007
|
+
if (!l.source?.data)
|
|
1008
|
+
return l;
|
|
1009
|
+
view.deferredLayerIds.push(l.id);
|
|
1010
|
+
return { ...l, source: { ...l.source, data: undefined } };
|
|
1011
|
+
}),
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
if (!fits())
|
|
1015
|
+
throw new Error(`map view for ${map_id} exceeds the ${MAX_STRUCTURED_BYTES}-byte result budget even without layer data`);
|
|
1016
|
+
return toResult();
|
|
1017
|
+
}));
|
|
1018
|
+
}
|
|
1019
|
+
// -------------------------------------------------------------------------
|
|
561
1020
|
// Shared `rationale` schema + stderr logger.
|
|
562
1021
|
// ---------------------------------------------------------------------------
|
|
563
1022
|
// Required on every MCP tool per Teddy Riker's "Designing for Agents"
|
|
@@ -568,7 +1027,8 @@ export function createServer(opts) {
|
|
|
568
1027
|
.string()
|
|
569
1028
|
.min(1)
|
|
570
1029
|
.max(300)
|
|
571
|
-
.
|
|
1030
|
+
.optional()
|
|
1031
|
+
.describe('Optional short label (≤300 chars) for the map edit history, e.g. "add wildfire layer". Omit it to send nothing beyond the tool arguments.');
|
|
572
1032
|
/**
|
|
573
1033
|
* Log a tool call's rationale (and any op-shaped extras) to BOTH stderr
|
|
574
1034
|
* and the per-day NDJSON sink. Every tool calls this — direct-op tools
|
|
@@ -580,6 +1040,10 @@ export function createServer(opts) {
|
|
|
580
1040
|
* `extra.opKind` lands in the NDJSON when this call emits a typed Op.
|
|
581
1041
|
*/
|
|
582
1042
|
function logRationale(toolName, mapId, rationale, extra) {
|
|
1043
|
+
// Optional since 2026-09-28 (Claude directory: collect nothing beyond what
|
|
1044
|
+
// the tool needs) — no label, nothing logged.
|
|
1045
|
+
if (!rationale)
|
|
1046
|
+
return;
|
|
583
1047
|
const safe = rationale.replace(/[\r\n\t]+/g, ' ').slice(0, 300);
|
|
584
1048
|
process.stderr.write(`[mcp:rationale] tool=${toolName} mapId=${mapId ?? '-'} rationale="${safe}"${extra?.intent ? ` intent=${extra.intent}` : ''}${extra?.opKind ? ` op=${extra.opKind}` : ''}\n`);
|
|
585
1049
|
appendRationaleNdjson({
|
|
@@ -600,8 +1064,7 @@ export function createServer(opts) {
|
|
|
600
1064
|
};
|
|
601
1065
|
registerTool('build_map', {
|
|
602
1066
|
title: 'Build a map',
|
|
603
|
-
description:
|
|
604
|
-
'For the full Op catalog and predicate grammar that downstream tools (mutate_map, filter_layer, etc.) accept, fetch the MCP resource at showmeonmap://docs/workspace-ops-spec — do NOT guess the shapes.',
|
|
1067
|
+
description: BUILD_MAP_DESCRIPTION,
|
|
605
1068
|
inputSchema: buildMapShape,
|
|
606
1069
|
}, withValidation('build_map', buildMapShape, async ({ query, rationale }) => {
|
|
607
1070
|
logRationale('build_map', null, rationale);
|
|
@@ -614,21 +1077,26 @@ export function createServer(opts) {
|
|
|
614
1077
|
throw new Error(`build_map failed: ${JSON.stringify(resp.error ?? resp.message ?? resp).slice(0, 300)}`);
|
|
615
1078
|
}
|
|
616
1079
|
const mapId = asSessionId(resp.workspace.workspaceId ?? randomUUID());
|
|
617
|
-
|
|
1080
|
+
// Share FIRST, from the API's full-resolution plan: if the session
|
|
1081
|
+
// write is then refused as too large, the error can still hand out a
|
|
1082
|
+
// link that works.
|
|
1083
|
+
const shared = await persistShareableMap(baseUrl, apiKey, mapId, resp.mapPlan, query);
|
|
1084
|
+
if (shared)
|
|
1085
|
+
sharedMapIds.add(mapId);
|
|
1086
|
+
const prepared = prepareEntry({
|
|
618
1087
|
workspace: resp.workspace,
|
|
619
1088
|
mapPlan: resp.mapPlan ?? null,
|
|
620
1089
|
layerFilters: {},
|
|
621
1090
|
createdAt: Date.now(),
|
|
622
1091
|
lastAccessed: Date.now(),
|
|
623
1092
|
});
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
return toContent(envelope(baseUrl, mapId, resp.workspace, resp.mapPlan, nextActions, shared ? {} : { shareUnpersisted: true }));
|
|
1093
|
+
await store.put(mapId, prepared.entry);
|
|
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);
|
|
1098
|
+
const note = generalizedNote(prepared.generalized);
|
|
1099
|
+
return toContent(envelope(baseUrl, mapId, prepared.entry.workspace, prepared.entry.mapPlan, nextActions, { ...(shared ? {} : { shareUnpersisted: true }), ...(note ? { note } : {}) }));
|
|
632
1100
|
}));
|
|
633
1101
|
const mutateMapShape = {
|
|
634
1102
|
map_id: z.string().min(1).describe('map_id from a prior build_map call'),
|
|
@@ -641,7 +1109,7 @@ export function createServer(opts) {
|
|
|
641
1109
|
registerTool('mutate_map', {
|
|
642
1110
|
title: 'Mutate a map',
|
|
643
1111
|
description: 'Apply a natural-language instruction to an existing workspace. Valid mutations: filter a layer, remove a layer, change camera, change basemap. For ambiguous instructions the tool emits a clarification field. For unrelated instructions it emits outOfScope — build a new map in that case. ' +
|
|
644
|
-
'
|
|
1112
|
+
'The full Op catalog and predicate grammar are documented in the MCP resource showmeonmap://docs/workspace-ops-spec.',
|
|
645
1113
|
inputSchema: mutateMapShape,
|
|
646
1114
|
}, withValidation('mutate_map', mutateMapShape, async ({ map_id, instruction, rationale }) => {
|
|
647
1115
|
const entry = await store.get(map_id);
|
|
@@ -668,7 +1136,7 @@ export function createServer(opts) {
|
|
|
668
1136
|
});
|
|
669
1137
|
const mutMapPlan = (resp.mapPlan ?? entry.mapPlan);
|
|
670
1138
|
// Update the shareable /m/<map_id> map so the link reflects the mutation.
|
|
671
|
-
const shared = await persistShareableMap(baseUrl, apiKey, map_id, mutMapPlan, instruction);
|
|
1139
|
+
const shared = await persistShareableMap(baseUrl, apiKey, map_id, mutMapPlan, instruction, null, resp.layerFilters);
|
|
672
1140
|
return toContent(envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), mutMapPlan, resp.nextActions, {
|
|
673
1141
|
...(resp.clarification ? { clarification: resp.clarification } : {}),
|
|
674
1142
|
...(shared ? {} : { shareUnpersisted: true }),
|
|
@@ -718,7 +1186,7 @@ export function createServer(opts) {
|
|
|
718
1186
|
});
|
|
719
1187
|
const opMapPlan = (resp.mapPlan ?? entry.mapPlan);
|
|
720
1188
|
// Update the shareable /m/<map_id> map so the link reflects the mutation.
|
|
721
|
-
const shared = await persistShareableMap(baseUrl, apiKey, map_id, opMapPlan, opMapPlan?.title ?? '');
|
|
1189
|
+
const shared = await persistShareableMap(baseUrl, apiKey, map_id, opMapPlan, opMapPlan?.title ?? '', null, resp.layerFilters);
|
|
722
1190
|
return envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), opMapPlan, undefined, shared ? {} : { shareUnpersisted: true });
|
|
723
1191
|
}
|
|
724
1192
|
/**
|
|
@@ -732,7 +1200,7 @@ export function createServer(opts) {
|
|
|
732
1200
|
turnId: randomUUID(),
|
|
733
1201
|
emittedAt: new Date().toISOString(),
|
|
734
1202
|
emittedBy,
|
|
735
|
-
rationale,
|
|
1203
|
+
...(rationale ? { rationale } : {}),
|
|
736
1204
|
};
|
|
737
1205
|
}
|
|
738
1206
|
// --- predicate schema (shared by filter_layer + query_features) ------------
|
|
@@ -769,8 +1237,8 @@ export function createServer(opts) {
|
|
|
769
1237
|
title: 'Add a layer (direct)',
|
|
770
1238
|
description: 'Append a fully-constructed LayerPlan to an existing workspace without going through NL. ' +
|
|
771
1239
|
'Pass the same shape the workspace pipeline emits — { id, title, source, viz, geometry, ... }. ' +
|
|
772
|
-
'
|
|
773
|
-
'
|
|
1240
|
+
'Takes a structured plan; natural-language edits go through mutate_map. ' +
|
|
1241
|
+
'The full LayerPlan and Op spec are documented in the MCP resource showmeonmap://docs/workspace-ops-spec.',
|
|
774
1242
|
inputSchema: addLayerShape,
|
|
775
1243
|
}, withValidation('add_layer', addLayerShape, async ({ map_id, layerPlan, rationale, intent }) => {
|
|
776
1244
|
logRationale('add_layer', map_id, rationale, { opKind: 'addLayer', ...(intent ? { intent } : {}) });
|
|
@@ -798,12 +1266,11 @@ export function createServer(opts) {
|
|
|
798
1266
|
title: 'Add bivariate fusion layer',
|
|
799
1267
|
description: 'Spatially join two existing layers and add a new layer that ' +
|
|
800
1268
|
'encodes both values per region as a 3x3 bivariate choropleth. ' +
|
|
801
|
-
'Both layers must already exist in the workspace
|
|
802
|
-
'
|
|
1269
|
+
'Both layers must already exist in the workspace. Auto-detects ' +
|
|
1270
|
+
'the numeric value field from ' +
|
|
803
1271
|
'each layer\'s viz config; pass value_field_a / value_field_b to ' +
|
|
804
1272
|
'override. Returns the new layer_id, correlation r, and ' +
|
|
805
|
-
'matched-feature count. Required: map_id, layer_a_id, '
|
|
806
|
-
'layer_b_id, rationale.',
|
|
1273
|
+
'matched-feature count. Required: map_id, layer_a_id, layer_b_id.',
|
|
807
1274
|
inputSchema: addFusionLayerShape,
|
|
808
1275
|
}, withValidation('add_fusion_layer', addFusionLayerShape, async ({ map_id, layer_a_id, layer_b_id, value_field_a, value_field_b, new_layer_id, rationale, }) => {
|
|
809
1276
|
const entry = await store.get(map_id);
|
|
@@ -817,7 +1284,7 @@ export function createServer(opts) {
|
|
|
817
1284
|
...(value_field_a ? { valueFieldA: value_field_a } : {}),
|
|
818
1285
|
...(value_field_b ? { valueFieldB: value_field_b } : {}),
|
|
819
1286
|
...(new_layer_id ? { newLayerId: new_layer_id } : {}),
|
|
820
|
-
header: { rationale },
|
|
1287
|
+
...(rationale ? { header: { rationale } } : {}),
|
|
821
1288
|
});
|
|
822
1289
|
if (!data.ok) {
|
|
823
1290
|
throw new Error(`add_fusion_layer failed: ${data.error?.kind ?? 'unknown'} — ${data.error?.reason ?? data.error?.message ?? 'no detail'}`);
|
|
@@ -851,10 +1318,9 @@ export function createServer(opts) {
|
|
|
851
1318
|
'Pearson correlation coefficient r, sample size n, two-sided ' +
|
|
852
1319
|
'p-value approximation, plus human-readable strength and ' +
|
|
853
1320
|
'direction labels. Pure read — no layer is added, no op is ' +
|
|
854
|
-
'committed, no credit charge.
|
|
855
|
-
'
|
|
856
|
-
'
|
|
857
|
-
'layer_b_id, rationale.',
|
|
1321
|
+
'committed, no credit charge. Returns numbers only; ' +
|
|
1322
|
+
'add_fusion_layer renders the same join as a map layer. ' +
|
|
1323
|
+
'Required: map_id, layer_a_id, layer_b_id.',
|
|
858
1324
|
inputSchema: correlateLayersShape,
|
|
859
1325
|
}, withValidation('correlate_layers', correlateLayersShape, async ({ map_id, layer_a_id, layer_b_id, value_field_a, value_field_b, rationale, }) => {
|
|
860
1326
|
const entry = await store.get(map_id);
|
|
@@ -1023,7 +1489,7 @@ export function createServer(opts) {
|
|
|
1023
1489
|
lastAccessed: Date.now(),
|
|
1024
1490
|
});
|
|
1025
1491
|
const focusedPlan = (resp.mapPlan ?? entry.mapPlan);
|
|
1026
|
-
const shared = await persistShareableMap(baseUrl, apiKey, map_id, focusedPlan, rationale);
|
|
1492
|
+
const shared = await persistShareableMap(baseUrl, apiKey, map_id, focusedPlan, rationale, null, resp.layerFilters);
|
|
1027
1493
|
return toContent(envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), focusedPlan, undefined, {
|
|
1028
1494
|
...(resp.opsApplied ? { opsApplied: resp.opsApplied } : {}),
|
|
1029
1495
|
...(resp.contextAnswer ? { contextAnswer: resp.contextAnswer } : {}),
|
|
@@ -1033,9 +1499,9 @@ export function createServer(opts) {
|
|
|
1033
1499
|
const runAnalysisShape = {
|
|
1034
1500
|
map_id: z.string().min(1),
|
|
1035
1501
|
analysis: z
|
|
1036
|
-
.enum(['hotspot', 'zonal', 'enrich'])
|
|
1037
|
-
.describe("Which analysis to run. 'hotspot' = Getis-Ord Gi* with FDR-corrected significance classes. 'zonal' = aggregate a point layer into polygon zones (count/sum/mean/min/max choropleth). 'enrich' = join a US Census indicator onto each feature by containment (tract/county/state)."),
|
|
1038
|
-
layerId: z.string().min(1).describe('The workspace layer to analyze (geojson-backed; the point source for zonal).'),
|
|
1502
|
+
.enum(['hotspot', 'zonal', 'enrich', 'join', 'merge', 'near', 'dissolve'])
|
|
1503
|
+
.describe("Which analysis to run. 'hotspot' = Getis-Ord Gi* with FDR-corrected significance classes. 'zonal' = aggregate a point layer into polygon zones (count/sum/mean/min/max choropleth). 'enrich' = join a US Census indicator onto each feature by containment (tract/county/state). 'join' = spatial join (attribute transfer): each POINT in layerId takes the listed properties from the polygon in joinLayerId that contains it — the aggregate direction (points INTO polygons) is 'zonal'. 'merge' = append layerId + mergeLayerId (same geometry class) into ONE dataset, each row stamped with its source layer. 'near' = each POINT in layerId gains the great-circle distance to (and identity of) its nearest feature in nearLayerId. 'dissolve' = union POLYGONS sharing a dissolveField value into single features (omit the field to dissolve all into one)."),
|
|
1504
|
+
layerId: z.string().min(1).describe('The workspace layer to analyze (geojson-backed; the point source for zonal; the point TARGET for join; the styling donor for merge).'),
|
|
1039
1505
|
valueField: z
|
|
1040
1506
|
.string()
|
|
1041
1507
|
.min(1)
|
|
@@ -1074,6 +1540,63 @@ export function createServer(opts) {
|
|
|
1074
1540
|
.enum(['state', 'county', 'tract'])
|
|
1075
1541
|
.optional()
|
|
1076
1542
|
.describe('enrich only: containment geography (default tract). Tract/county fetch only the states the data touches, capped at 3 — filter first or use state for wider layers.'),
|
|
1543
|
+
joinLayerId: z
|
|
1544
|
+
.string()
|
|
1545
|
+
.min(1)
|
|
1546
|
+
.optional()
|
|
1547
|
+
.describe('join only (required there): the POLYGON layer providing the attributes.'),
|
|
1548
|
+
fields: z
|
|
1549
|
+
.array(z.string().min(1).max(80))
|
|
1550
|
+
.min(1)
|
|
1551
|
+
.max(12)
|
|
1552
|
+
.optional()
|
|
1553
|
+
.describe('join only: which join-layer properties to transfer (default: auto-select up to 12 non-internal properties, overflow reported).'),
|
|
1554
|
+
prefix: z
|
|
1555
|
+
.string()
|
|
1556
|
+
.min(1)
|
|
1557
|
+
.max(20)
|
|
1558
|
+
.optional()
|
|
1559
|
+
.describe('join only: prefix for a joined field whose name collides with an existing target property (default "join_").'),
|
|
1560
|
+
mergeLayerId: z
|
|
1561
|
+
.string()
|
|
1562
|
+
.min(1)
|
|
1563
|
+
.optional()
|
|
1564
|
+
.describe('merge only (required there): the second layer — same geometry class (point/line/polygon) as layerId.'),
|
|
1565
|
+
sourceField: z
|
|
1566
|
+
.string()
|
|
1567
|
+
.min(1)
|
|
1568
|
+
.max(40)
|
|
1569
|
+
.optional()
|
|
1570
|
+
.describe('merge only: name of the per-row provenance column naming each row\'s source layer (default "merge_source"; renamed on collision, reported).'),
|
|
1571
|
+
nearLayerId: z
|
|
1572
|
+
.string()
|
|
1573
|
+
.min(1)
|
|
1574
|
+
.optional()
|
|
1575
|
+
.describe('near only (required there): the POINT layer to search for each target point\'s nearest neighbor.'),
|
|
1576
|
+
nearField: z
|
|
1577
|
+
.string()
|
|
1578
|
+
.min(1)
|
|
1579
|
+
.max(80)
|
|
1580
|
+
.optional()
|
|
1581
|
+
.describe('near only: property of the nearest feature to copy as its identity (default: first name-like property).'),
|
|
1582
|
+
maxDistanceM: z
|
|
1583
|
+
.number()
|
|
1584
|
+
.min(1)
|
|
1585
|
+
.max(20_000_000)
|
|
1586
|
+
.optional()
|
|
1587
|
+
.describe('near only: search radius in meters — a point whose nearest neighbor is farther carries NO near fields (reported).'),
|
|
1588
|
+
dissolveField: z
|
|
1589
|
+
.string()
|
|
1590
|
+
.min(1)
|
|
1591
|
+
.max(80)
|
|
1592
|
+
.optional()
|
|
1593
|
+
.describe('dissolve only: group-by property (omit = dissolve ALL into one footprint); missing values form their own "(no value)" group.'),
|
|
1594
|
+
sumFields: z
|
|
1595
|
+
.array(z.string().min(1).max(80))
|
|
1596
|
+
.min(1)
|
|
1597
|
+
.max(5)
|
|
1598
|
+
.optional()
|
|
1599
|
+
.describe('dissolve only: numeric properties to SUM per group — skipped values reported, a group with none present carries NO sum.'),
|
|
1077
1600
|
annotate: z
|
|
1078
1601
|
.boolean()
|
|
1079
1602
|
.optional()
|
|
@@ -1094,11 +1617,17 @@ export function createServer(opts) {
|
|
|
1094
1617
|
'count/sum/mean/min/max per zone as a quantile choropleth where zero and no-data are ' +
|
|
1095
1618
|
'distinct classes. enrich = join a US Census ACS indicator (median income, home value, ' +
|
|
1096
1619
|
'rent, …) onto each feature by tract/county/state containment — unmatched features stay ' +
|
|
1097
|
-
'null, never zero-filled.
|
|
1620
|
+
'null, never zero-filled. join = spatial join (attribute transfer): each point in layerId ' +
|
|
1621
|
+
'takes properties from the joinLayerId polygon containing it (collisions prefixed, unmatched ' +
|
|
1622
|
+
'points carry NO joined fields, zero overlap is a typed rejection). merge = append two ' +
|
|
1623
|
+
'same-geometry-class layers into one dataset with a per-row source column (schemas never ' +
|
|
1624
|
+
'null-filled; class mismatch is a typed failure). near = nearest-neighbor distance columns ' +
|
|
1625
|
+
'(great-circle meters, stated — never presented as road distance). dissolve = true polygon union ' +
|
|
1626
|
+
'per attribute group (missing values form their own group; partial sums reported). Returns a grounded memo + stats payload — every number is computed, ' +
|
|
1098
1627
|
'never generated; findings keep the map. Costs 1 credit when the result lands; typed ' +
|
|
1099
1628
|
'failures (unknown layer, too few features, >10,000-feature cap) are refunded.',
|
|
1100
1629
|
inputSchema: runAnalysisShape,
|
|
1101
|
-
}, withValidation('run_analysis', runAnalysisShape, async ({ map_id, analysis, layerId, valueField, k, hexMeters, zoneLayerId, zones, stats, indicator, geoLevel, annotate, story, rationale }) => {
|
|
1630
|
+
}, withValidation('run_analysis', runAnalysisShape, async ({ map_id, analysis, layerId, valueField, k, hexMeters, zoneLayerId, zones, stats, indicator, geoLevel, joinLayerId, fields, prefix, mergeLayerId, sourceField, nearLayerId, nearField, maxDistanceM, dissolveField, sumFields, annotate, story, rationale }) => {
|
|
1102
1631
|
const entry = await store.get(map_id);
|
|
1103
1632
|
if (!entry)
|
|
1104
1633
|
throw new Error(`Unknown map_id "${map_id}". Call build_map first.`);
|
|
@@ -1115,6 +1644,16 @@ export function createServer(opts) {
|
|
|
1115
1644
|
...(stats !== undefined ? { stats } : {}),
|
|
1116
1645
|
...(indicator !== undefined ? { indicator } : {}),
|
|
1117
1646
|
...(geoLevel !== undefined ? { geoLevel } : {}),
|
|
1647
|
+
...(joinLayerId !== undefined ? { joinLayerId } : {}),
|
|
1648
|
+
...(fields !== undefined ? { fields } : {}),
|
|
1649
|
+
...(prefix !== undefined ? { prefix } : {}),
|
|
1650
|
+
...(mergeLayerId !== undefined ? { mergeLayerId } : {}),
|
|
1651
|
+
...(sourceField !== undefined ? { sourceField } : {}),
|
|
1652
|
+
...(nearLayerId !== undefined ? { nearLayerId } : {}),
|
|
1653
|
+
...(nearField !== undefined ? { nearField } : {}),
|
|
1654
|
+
...(maxDistanceM !== undefined ? { maxDistanceM } : {}),
|
|
1655
|
+
...(dissolveField !== undefined ? { dissolveField } : {}),
|
|
1656
|
+
...(sumFields !== undefined ? { sumFields } : {}),
|
|
1118
1657
|
...(annotate !== undefined ? { annotate } : {}),
|
|
1119
1658
|
rationale,
|
|
1120
1659
|
}));
|
|
@@ -1139,7 +1678,7 @@ export function createServer(opts) {
|
|
|
1139
1678
|
resultLayerId: resp.resultLayerId,
|
|
1140
1679
|
})
|
|
1141
1680
|
: null;
|
|
1142
|
-
const shared = await persistShareableMap(baseUrl, apiKey, map_id, analyzedPlan, rationale, storyDraft);
|
|
1681
|
+
const shared = await persistShareableMap(baseUrl, apiKey, map_id, analyzedPlan, rationale, storyDraft, resp.layerFilters);
|
|
1143
1682
|
return toContent(envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), analyzedPlan, undefined, {
|
|
1144
1683
|
...(resp.opsApplied ? { opsApplied: resp.opsApplied } : {}),
|
|
1145
1684
|
...(resp.contextAnswer ? { contextAnswer: resp.contextAnswer } : {}),
|
|
@@ -1236,8 +1775,8 @@ export function createServer(opts) {
|
|
|
1236
1775
|
.boolean()
|
|
1237
1776
|
.optional()
|
|
1238
1777
|
.describe('Set to true to confirm this sensitive action. If omitted or false, the call ' +
|
|
1239
|
-
'returns a needs_confirmation envelope.
|
|
1240
|
-
'
|
|
1778
|
+
'returns a needs_confirmation envelope. A cleared filter chain can be ' +
|
|
1779
|
+
'restored with undo.'),
|
|
1241
1780
|
};
|
|
1242
1781
|
registerTool('clear_filters', {
|
|
1243
1782
|
title: 'Clear all filters on a layer (direct, gated)',
|
|
@@ -1319,8 +1858,12 @@ export function createServer(opts) {
|
|
|
1319
1858
|
const cameraPlanSchema = z.discriminatedUnion('mode', [
|
|
1320
1859
|
z.object({
|
|
1321
1860
|
mode: z.literal('fitBounds'),
|
|
1861
|
+
// Fixed-length arrays, NOT z.tuple: a tuple compiles to the draft-04
|
|
1862
|
+
// `items: [..]` form, which the Anthropic API rejects — Claude then drops
|
|
1863
|
+
// the whole tool (anthropicSchemaCompat.test.ts).
|
|
1322
1864
|
bounds: z
|
|
1323
|
-
.
|
|
1865
|
+
.array(z.number())
|
|
1866
|
+
.length(4)
|
|
1324
1867
|
.describe('[west, south, east, north] bbox in WGS84 degrees.'),
|
|
1325
1868
|
padding: z
|
|
1326
1869
|
.number()
|
|
@@ -1335,7 +1878,7 @@ export function createServer(opts) {
|
|
|
1335
1878
|
}),
|
|
1336
1879
|
z.object({
|
|
1337
1880
|
mode: z.literal('flyTo'),
|
|
1338
|
-
center: z.
|
|
1881
|
+
center: z.array(z.number()).length(2).describe('[lng, lat] in WGS84.'),
|
|
1339
1882
|
zoom: z.number(),
|
|
1340
1883
|
pitch: z.number().optional().describe('Tilt in degrees, 0-60.'),
|
|
1341
1884
|
bearing: z.number().optional().describe('Rotation in degrees, 0-360.'),
|
|
@@ -1356,8 +1899,8 @@ export function createServer(opts) {
|
|
|
1356
1899
|
description: 'Override the workspace camera. Latest set_camera op wins during replay. ' +
|
|
1357
1900
|
'Two modes: `fitBounds` with a [w,s,e,n] bbox (plus optional padding/animateMs), ' +
|
|
1358
1901
|
'or `flyTo` with [lng,lat] center + zoom (plus optional pitch/bearing/animateMs). ' +
|
|
1359
|
-
'
|
|
1360
|
-
'
|
|
1902
|
+
'Covers requests like "zoom to Japan", "center on Paris at z=10", or framing ' +
|
|
1903
|
+
'a filtered layer. Equivalent to the camera move mutate_map would emit, but ' +
|
|
1361
1904
|
'skips the NL roundtrip.',
|
|
1362
1905
|
inputSchema: setCameraShape,
|
|
1363
1906
|
}, withValidation('set_camera', setCameraShape, async ({ map_id, camera, rationale }) => {
|
|
@@ -1386,7 +1929,7 @@ export function createServer(opts) {
|
|
|
1386
1929
|
registerTool('set_time', {
|
|
1387
1930
|
title: 'Set the time window (direct)',
|
|
1388
1931
|
description: 'Override the current time-range filter. Latest set_time op wins during replay. ' +
|
|
1389
|
-
'
|
|
1932
|
+
'Covers requests like "filter to 2020-2024" or "show only the last 7 days". Not yet ' +
|
|
1390
1933
|
'consumed by the renderer — the UI drives the slider directly — but the value is ' +
|
|
1391
1934
|
'captured in the ops log and queryable via get_state.',
|
|
1392
1935
|
inputSchema: setTimeShape,
|
|
@@ -1431,7 +1974,8 @@ export function createServer(opts) {
|
|
|
1431
1974
|
map_id: z.string().min(1),
|
|
1432
1975
|
id: z.string().min(1).describe('Stable id for this annotation — caller-supplied.'),
|
|
1433
1976
|
coords: z
|
|
1434
|
-
.
|
|
1977
|
+
.array(z.number())
|
|
1978
|
+
.length(2) // not z.tuple — see cameraPlanSchema
|
|
1435
1979
|
.describe('[lng, lat] in WGS84.'),
|
|
1436
1980
|
label: z.string().optional().describe('Optional text rendered at the point.'),
|
|
1437
1981
|
color: z
|
|
@@ -1508,7 +2052,7 @@ export function createServer(opts) {
|
|
|
1508
2052
|
});
|
|
1509
2053
|
const rematPlan = resp.mapPlan;
|
|
1510
2054
|
// Keep the shareable /m/<map_id> map in sync after undo/redo.
|
|
1511
|
-
const shared = await persistShareableMap(baseUrl, apiKey, map_id, rematPlan, rematPlan?.title ?? '');
|
|
2055
|
+
const shared = await persistShareableMap(baseUrl, apiKey, map_id, rematPlan, rematPlan?.title ?? '', null, resp.layerFilters);
|
|
1512
2056
|
return envelope(baseUrl, map_id, workspace, rematPlan, undefined, shared ? {} : { shareUnpersisted: true });
|
|
1513
2057
|
}
|
|
1514
2058
|
const exportWorkspaceShape = {
|
|
@@ -1519,9 +2063,9 @@ export function createServer(opts) {
|
|
|
1519
2063
|
title: 'Export the workspace JSON',
|
|
1520
2064
|
description: 'Return the current workspace document (ops log + cursor + meta) plus the ' +
|
|
1521
2065
|
'materialized mapPlan and layerFilters, in a shape suitable for save-to-file / ' +
|
|
1522
|
-
'share-by-URL / inspection. Pure read — no mutation, no credit charge.
|
|
1523
|
-
'
|
|
1524
|
-
'
|
|
2066
|
+
'share-by-URL / inspection. Pure read — no mutation, no credit charge. This is ' +
|
|
2067
|
+
'the full raw state for serialization; get_state returns a compact summary ' +
|
|
2068
|
+
'for display.',
|
|
1525
2069
|
inputSchema: exportWorkspaceShape,
|
|
1526
2070
|
}, withValidation('export_workspace', exportWorkspaceShape, async ({ map_id, rationale }) => {
|
|
1527
2071
|
const entry = await store.get(map_id);
|
|
@@ -1549,8 +2093,8 @@ export function createServer(opts) {
|
|
|
1549
2093
|
'a URL with Content-Disposition: attachment so the user\'s browser ' +
|
|
1550
2094
|
'downloads it. CSV drops geometry beyond a (lng, lat) centroid — ' +
|
|
1551
2095
|
'for full polygon/line geometry, use format: "geojson". The URL is an ' +
|
|
1552
|
-
'unguessable capability link that does NOT expire
|
|
1553
|
-
'
|
|
2096
|
+
'unguessable capability link that does NOT expire: anyone with the link ' +
|
|
2097
|
+
'can download the file. Required: map_id, layer_id, format.',
|
|
1554
2098
|
inputSchema: exportLayerDataShape,
|
|
1555
2099
|
}, withValidation('export_layer_data', exportLayerDataShape, async ({ map_id, layer_id, format, rationale }) => {
|
|
1556
2100
|
logRationale('export_layer_data', map_id, rationale);
|
|
@@ -1572,7 +2116,7 @@ export function createServer(opts) {
|
|
|
1572
2116
|
'link, the browser renders the map at full resolution with the ' +
|
|
1573
2117
|
'legend composited (if legend: true), and the file downloads. ' +
|
|
1574
2118
|
'There is no server-side rendering; the user\'s browser is the ' +
|
|
1575
|
-
'renderer. Required: map_id
|
|
2119
|
+
'renderer. Required: map_id. Optional: format (png ' +
|
|
1576
2120
|
'default), legend (true default).',
|
|
1577
2121
|
inputSchema: exportImageShape,
|
|
1578
2122
|
}, withValidation('export_image', exportImageShape, async ({ map_id, format, legend, rationale }) => {
|
|
@@ -1601,8 +2145,8 @@ export function createServer(opts) {
|
|
|
1601
2145
|
.boolean()
|
|
1602
2146
|
.optional()
|
|
1603
2147
|
.describe('Set to true to confirm this sensitive action. If omitted or false, the call ' +
|
|
1604
|
-
'returns a needs_confirmation envelope.
|
|
1605
|
-
'
|
|
2148
|
+
'returns a needs_confirmation envelope. Undo rewinds the most recent change; ' +
|
|
2149
|
+
'redo restores it.'),
|
|
1606
2150
|
};
|
|
1607
2151
|
registerTool('undo', {
|
|
1608
2152
|
title: 'Undo the most recent op (gated)',
|
|
@@ -1691,13 +2235,11 @@ export function createServer(opts) {
|
|
|
1691
2235
|
};
|
|
1692
2236
|
registerTool('report_blocker', {
|
|
1693
2237
|
title: 'Report a blocker (agent feedback)',
|
|
1694
|
-
description: '
|
|
1695
|
-
'unsupported intent, a missing tool, an unclear schema, an op
|
|
1696
|
-
'confusing way.
|
|
1697
|
-
'
|
|
1698
|
-
'
|
|
1699
|
-
'session, but feeds future capability work. No map_id required — file the report ' +
|
|
1700
|
-
'even if no map was built.',
|
|
2238
|
+
description: 'Send the ShowMeOnMap team a report about a request this server could not ' +
|
|
2239
|
+
'complete — an unsupported intent, a missing tool, an unclear schema, or an op ' +
|
|
2240
|
+
'that failed in a confusing way. Records the report to a server-side log for ' +
|
|
2241
|
+
'future capability work; it does not change the current map or session. ' +
|
|
2242
|
+
'map_id is optional, so a report can be filed before any map exists.',
|
|
1701
2243
|
inputSchema: reportBlockerShape,
|
|
1702
2244
|
}, withValidation('report_blocker', reportBlockerShape, async ({ map_id, what_i_was_trying, what_i_tried, where_i_got_stuck, suggested_capability, rationale }) => {
|
|
1703
2245
|
// Mirror to the rationale NDJSON so the May-1 clustering pass sees a
|
|
@@ -1721,6 +2263,131 @@ export function createServer(opts) {
|
|
|
1721
2263
|
...(resp.sink ? { sink: resp.sink } : {}),
|
|
1722
2264
|
});
|
|
1723
2265
|
}));
|
|
2266
|
+
// ── Workflows (D2 phase 6): save / list / run saved instruction streams ──
|
|
2267
|
+
// Semantic replay: templates store {param}-slotted INSTRUCTIONS, never
|
|
2268
|
+
// resolved ops; a run re-executes them through the live pipelines, so
|
|
2269
|
+
// every step bills exactly like typing it (credits per existing rules).
|
|
2270
|
+
const saveWorkflowShape = {
|
|
2271
|
+
name: z.string().min(1).max(80).describe('Workflow name.'),
|
|
2272
|
+
steps: z
|
|
2273
|
+
.array(z.object({ instruction: z.string().min(1).max(4000) }))
|
|
2274
|
+
.min(1)
|
|
2275
|
+
.max(12)
|
|
2276
|
+
.describe('Ordered NL instructions. Step 1 builds the map; later steps mutate it. Wrap values in {braces} to declare parameters.'),
|
|
2277
|
+
params: z
|
|
2278
|
+
.array(z.object({
|
|
2279
|
+
name: z.string().min(1).max(40),
|
|
2280
|
+
label: z.string().min(1).max(80),
|
|
2281
|
+
kind: z.enum(['place', 'number', 'text', 'timerange']),
|
|
2282
|
+
default: z.string().max(200).optional(),
|
|
2283
|
+
}))
|
|
2284
|
+
.max(8)
|
|
2285
|
+
.optional()
|
|
2286
|
+
.describe('Declared parameters — every {slot} used in steps must be declared here.'),
|
|
2287
|
+
rationale: rationaleSchema,
|
|
2288
|
+
};
|
|
2289
|
+
registerTool('save_workflow', {
|
|
2290
|
+
title: 'Save a workflow',
|
|
2291
|
+
description: 'Save a re-runnable, parameterized workflow: an ordered list of natural-language instructions (step 1 builds, later steps mutate). Wrap values in {braces} and declare them in params. Running it later re-executes the instructions on FRESH data and bills per step like typing.',
|
|
2292
|
+
inputSchema: saveWorkflowShape,
|
|
2293
|
+
}, withValidation('save_workflow', saveWorkflowShape, async ({ name, steps, params, rationale }) => {
|
|
2294
|
+
logRationale('save_workflow', null, rationale);
|
|
2295
|
+
const resp = (await apiFetch(baseUrl, apiKey, '/api/workflows', {
|
|
2296
|
+
name,
|
|
2297
|
+
steps,
|
|
2298
|
+
params: params ?? [],
|
|
2299
|
+
}));
|
|
2300
|
+
if (!resp.ok || !resp.template)
|
|
2301
|
+
throw new Error(`save_workflow failed: ${resp.error ?? 'unknown'}`);
|
|
2302
|
+
return toContent({
|
|
2303
|
+
ok: true,
|
|
2304
|
+
workflow_id: resp.template.id,
|
|
2305
|
+
estimated_credits: resp.template.estimatedCredits,
|
|
2306
|
+
note: 'Estimate = step count; escalations can cost more. Run with run_workflow.',
|
|
2307
|
+
});
|
|
2308
|
+
}));
|
|
2309
|
+
const listWorkflowsShape = { rationale: rationaleSchema };
|
|
2310
|
+
registerTool('list_workflows', {
|
|
2311
|
+
title: 'List saved workflows',
|
|
2312
|
+
description: 'List the caller\'s saved workflows with their parameters and per-run credit estimates.',
|
|
2313
|
+
inputSchema: listWorkflowsShape,
|
|
2314
|
+
}, withValidation('list_workflows', listWorkflowsShape, async ({ rationale }) => {
|
|
2315
|
+
logRationale('list_workflows', null, rationale);
|
|
2316
|
+
const resp = await fetchNoRedirect(`${baseUrl}/api/workflows`, {
|
|
2317
|
+
headers: { ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) },
|
|
2318
|
+
});
|
|
2319
|
+
const text = await resp.text();
|
|
2320
|
+
if (!resp.ok)
|
|
2321
|
+
throw new Error(`/api/workflows ${resp.status}: ${describeErrorBody(text)}`);
|
|
2322
|
+
const data = JSON.parse(text);
|
|
2323
|
+
return toContent({
|
|
2324
|
+
ok: true,
|
|
2325
|
+
workflows: (data.templates ?? []).map((t) => ({
|
|
2326
|
+
workflow_id: t.id,
|
|
2327
|
+
name: t.name,
|
|
2328
|
+
steps: t.steps.length,
|
|
2329
|
+
params: t.params,
|
|
2330
|
+
estimated_credits: t.estimatedCredits,
|
|
2331
|
+
})),
|
|
2332
|
+
});
|
|
2333
|
+
}));
|
|
2334
|
+
const runWorkflowShape = {
|
|
2335
|
+
workflow_id: z.string().min(1).describe('From save_workflow or list_workflows.'),
|
|
2336
|
+
params: z.record(z.string(), z.string()).optional().describe('Values for the workflow\'s declared parameters.'),
|
|
2337
|
+
batch: z
|
|
2338
|
+
.object({
|
|
2339
|
+
param: z.string().min(1).describe('The ONE declared param to sweep (must not also be in params).'),
|
|
2340
|
+
values: z.array(z.string().min(1)).min(2).max(20).describe('2–20 values; each is a FULL independent billed run.'),
|
|
2341
|
+
})
|
|
2342
|
+
.optional()
|
|
2343
|
+
.describe('Batch mode: run the workflow once per value ("each of the 12 districts"). Bills steps × values.'),
|
|
2344
|
+
rationale: rationaleSchema,
|
|
2345
|
+
};
|
|
2346
|
+
registerTool('run_workflow', {
|
|
2347
|
+
title: 'Run a saved workflow',
|
|
2348
|
+
description: 'Execute a saved workflow with parameter values. PAID: bills ~1 credit per step through the normal build/mutate paths (the estimate is in list_workflows). Optional batch mode sweeps ONE param across 2–20 values — each value is a full independent run (steps × values credits); failed/paused values are reported per value and the batch continues. A clarification pauses a single run (status "paused" + the question); failures stop at the failing step. On success the result is a live map with a shareable URL (batch: the last successful value\'s map).',
|
|
2349
|
+
inputSchema: runWorkflowShape,
|
|
2350
|
+
}, withValidation('run_workflow', runWorkflowShape, async ({ workflow_id, params, batch, rationale }) => {
|
|
2351
|
+
logRationale('run_workflow', null, rationale);
|
|
2352
|
+
const resp = (await apiFetch(baseUrl, apiKey, `/api/workflows/${workflow_id}/run`, {
|
|
2353
|
+
params: params ?? {},
|
|
2354
|
+
...(batch ? { batch } : {}),
|
|
2355
|
+
stream: false,
|
|
2356
|
+
}));
|
|
2357
|
+
if (resp.status === 'paused') {
|
|
2358
|
+
return toContent({ ok: false, status: 'paused', question: resp.question, events: resp.events });
|
|
2359
|
+
}
|
|
2360
|
+
if ((resp.status !== 'done' && resp.status !== 'partial') || !resp.workspace) {
|
|
2361
|
+
return toContent({
|
|
2362
|
+
ok: false,
|
|
2363
|
+
status: 'failed',
|
|
2364
|
+
error: resp.error ?? 'run failed',
|
|
2365
|
+
...(resp.batch ? { batch: resp.batch } : {}),
|
|
2366
|
+
events: resp.events,
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
const mapId = asSessionId(resp.workspace.workspaceId ?? randomUUID());
|
|
2370
|
+
await store.put(mapId, {
|
|
2371
|
+
workspace: resp.workspace,
|
|
2372
|
+
mapPlan: resp.mapPlan ?? null,
|
|
2373
|
+
layerFilters: {},
|
|
2374
|
+
createdAt: Date.now(),
|
|
2375
|
+
lastAccessed: Date.now(),
|
|
2376
|
+
});
|
|
2377
|
+
const shared = resp.mapPlan
|
|
2378
|
+
? await persistShareableMap(baseUrl, apiKey, mapId, resp.mapPlan, `workflow ${workflow_id}`)
|
|
2379
|
+
: false;
|
|
2380
|
+
return toContent(envelope(baseUrl, mapId, resp.workspace, (resp.mapPlan ?? null), undefined, {
|
|
2381
|
+
contextAnswer: resp.batch
|
|
2382
|
+
? `Batch: ${resp.batch.succeeded}/${resp.batch.total} value(s) succeeded` +
|
|
2383
|
+
(resp.batch.failed ? `, ${resp.batch.failed} failed` : '') +
|
|
2384
|
+
(resp.batch.paused ? `, ${resp.batch.paused} paused` : '') +
|
|
2385
|
+
` — map shows "${resp.workspaceValue ?? '?'}"; per-value outcomes in batch.results.`
|
|
2386
|
+
: `Workflow ran ${(resp.events ?? []).filter((e) => e.type === 'step_done').length} step(s).`,
|
|
2387
|
+
...(resp.batch ? { batch: resp.batch } : {}),
|
|
2388
|
+
...(shared ? {} : { shareUnpersisted: true }),
|
|
2389
|
+
}));
|
|
2390
|
+
}));
|
|
1724
2391
|
// ── JSON Schema draft normalization (2026-07-16) ─────────────────────────
|
|
1725
2392
|
// zod-to-json-schema (inside the SDK) stamps every tool's inputSchema with a
|
|
1726
2393
|
// draft-07 `$schema` header. The Anthropic API validates tool schemas as
|