@showmeonmap/mcp 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -23,6 +23,10 @@ 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 { fetchNoRedirect } from "./fetchNoRedirect.js";
27
+ import { BUILD_MAP_DESCRIPTION, SERVER_INSTRUCTIONS } from "./serverInstructions.js";
28
+ import { describeTolerance, generalizeLayers, storedBytes, WORKSPACE_CEILING } from "./generalizeGeometry.js";
29
+ import { VIEWER_URI, VIEWER_TOOLS, VIEW_META_KEY, MCP_APP_MIME, viewerShellHtml, viewerResourceMeta, splitLayersByBudget, utf8Bytes, } from "./viewer.js";
26
30
  // -----------------------------------------------------------------------------
27
31
  // Agent-facing spec resource (Teddy Riker, "Designing for Agents" §2,
28
32
  // 2026-04-23). Loaded once at module init from a sibling .md file. Tool
@@ -58,6 +62,11 @@ const WORKSPACE_OPS_SPEC_TEXT = (() => {
58
62
  const MCP_RATIONALE_DIR = (() => {
59
63
  try {
60
64
  const here = dirname(fileURLToPath(import.meta.url));
65
+ // Repo checkout only. Installed from npm, three levels up from dist/ is
66
+ // the user's own node_modules — the package must not write their tool
67
+ // rationales to disk there (found preparing 0.7.0; 0.6.0 did).
68
+ if (!/[\\/]packages[\\/]mcp-server[\\/](src|dist)$/.test(here) || /[\\/]node_modules[\\/]/.test(here))
69
+ return null;
61
70
  // tools.ts sits at packages/mcp-server/src/. Climb three levels
62
71
  // (src → mcp-server → packages → <repo>) to land at <repo>/reports/
63
72
  // mcp-rationale, alongside the agent-blockers sink and the
@@ -97,6 +106,56 @@ export function asSessionId(value) {
97
106
  }
98
107
  return value;
99
108
  }
109
+ /** The map's geometry stays over WORKSPACE_CEILING even after generalization. */
110
+ export class MapTooLargeError extends Error {
111
+ mapId;
112
+ bytes;
113
+ constructor(mapId, bytes, shareUrl) {
114
+ super(`This map is too large to keep in the conversation (${(bytes / 1e6).toFixed(1)} MB after simplifying its geometry). ` +
115
+ (shareUrl ? `Open it on ShowMeOnMap: ${shareUrl}` : 'Try a smaller area or fewer layers.'));
116
+ this.mapId = mapId;
117
+ this.bytes = bytes;
118
+ this.name = 'MapTooLargeError';
119
+ }
120
+ }
121
+ /**
122
+ * Generalize an entry's oversize inline layers (workspace ops + mapPlan) —
123
+ * see generalizeGeometry.ts. Idempotent: a generalized layer is marked and
124
+ * skipped next time.
125
+ */
126
+ export function prepareEntry(entry) {
127
+ const ws = generalizeLayers(entry.workspace);
128
+ const mp = generalizeLayers(entry.mapPlan);
129
+ const seen = new Set();
130
+ const generalized = [...ws.generalized, ...mp.generalized].filter((g) => {
131
+ if (seen.has(g.layerId))
132
+ return false;
133
+ seen.add(g.layerId);
134
+ return true;
135
+ });
136
+ return { entry: { ...entry, workspace: ws.value, mapPlan: mp.value }, generalized };
137
+ }
138
+ /**
139
+ * Every write to the session store goes through here, so no call site can
140
+ * forget it: oversize line/polygon layers are generalized, and a workspace
141
+ * still over the ceiling is refused with a clear error instead of the raw
142
+ * D1 `SQLITE_TOOBIG` (one TEXT cell, 2 MB cap). The share link is named in
143
+ * the error only when it is known to exist.
144
+ */
145
+ export function withGeneralization(inner, shareUrlFor = () => null) {
146
+ return {
147
+ get: (mapId) => inner.get(mapId),
148
+ delete: (mapId) => inner.delete(mapId),
149
+ size: () => inner.size(),
150
+ async put(mapId, entry) {
151
+ const prepared = prepareEntry(entry).entry;
152
+ const bytes = storedBytes(prepared.workspace);
153
+ if (bytes > WORKSPACE_CEILING)
154
+ throw new MapTooLargeError(mapId, bytes, shareUrlFor(mapId));
155
+ await inner.put(mapId, prepared);
156
+ },
157
+ };
158
+ }
100
159
  export function createMemorySessionStore(maxSize = 256) {
101
160
  const cache = new Map();
102
161
  return {
@@ -186,6 +245,13 @@ function compactStructured(workspace, mapPlan) {
186
245
  }
187
246
  return compacted;
188
247
  }
248
+ /** Tool-text honesty line for generalized layers, so Claude can say so. */
249
+ function generalizedNote(generalized) {
250
+ if (!generalized.length)
251
+ return undefined;
252
+ const parts = generalized.map((g) => `${g.title ?? g.layerId} (${describeTolerance(g.toleranceDeg)})`);
253
+ 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
+ }
189
255
  function envelope(baseUrl, mapId, workspace, mapPlan, nextActionStrings, extras = {}) {
190
256
  const layerCount = Array.isArray(mapPlan?.layers) ? mapPlan.layers.length : 0;
191
257
  const opsCount = Array.isArray(workspace?.opsLog) ? workspace.opsLog.length : 0;
@@ -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), rationale (string, 1–300 chars). No other fields accepted.',
245
- mutate_map: 'Required: map_id, instruction, rationale. No other fields accepted.',
246
- get_state: 'Required: map_id, rationale. No other fields accepted.',
247
- add_layer: 'Required: map_id, layerPlan (object), rationale. Optional: intent (enum). No other fields accepted.',
248
- filter_layer: 'Required: map_id, layerId, predicate (object), rationale. Optional: append (boolean), intent (enum).',
249
- select_within: 'Required: map_id, sourceLayerId, newLayerId, selection ({type:Polygon|MultiPolygon, coordinates}), rationale. Optional: inheritStyle.',
250
- focus_area: 'Required: map_id, layerId, areaRef ({kind:"corridor", street, from?, to?} | {kind:"place", name} | {kind:"aroundIntersection", street, cross}), rationale. Optional: mode (filter|select|clip). Pass NAMES — the server resolves geometry from OSM.',
251
- query_features: 'Required: map_id, layerId, predicate (object), rationale. Optional: limit (1–1000 integer).',
252
- remove_layer: 'Required: map_id, layerId, rationale, confirmed:true (destructive — layer ids are tombstoned).',
253
- clear_filters: 'Required: map_id, layerId, rationale, confirmed:true (sensitive — autonomous filter wipe).',
254
- rename_layer: 'Required: map_id, oldLayerId, newLayerId, rationale.',
255
- restyle_layer: 'Required: map_id, layerId, rationale. Optional: color (string), opacity (0–1).',
256
- set_camera: 'Required: map_id, camera (mode:fitBounds|flyTo with required fields), rationale.',
257
- set_time: 'Required: map_id, field, rangeStart (number), rangeEnd (number), rationale. Optional: format (unix_ms|iso|integer).',
258
- tag: 'Required: map_id, name, rationale.',
259
- add_annotation: 'Required: map_id, id, coords ([lng,lat]), rationale. Optional: label, color.',
260
- remove_annotation: 'Required: map_id, annotationId, rationale.',
261
- export_workspace: 'Required: map_id, rationale.',
262
- export_layer_data: 'Required: map_id, layer_id, rationale, format ("csv" or "geojson"). No other fields accepted.',
263
- export_image: 'Required: map_id, rationale. Optional: format ("png" default | "jpeg" | "pdf"), legend (boolean default true). No other fields accepted.',
264
- add_fusion_layer: 'Required: map_id, layer_a_id, layer_b_id, rationale. Optional: value_field_a, value_field_b, new_layer_id. Mode is locked to "bivariate" in v1. No other fields accepted.',
265
- correlate_layers: 'Required: map_id, layer_a_id, layer_b_id, rationale. Optional: value_field_a, value_field_b. Pure read — no layer added, no credit charged. No other fields accepted.',
266
- undo: 'Required: map_id, rationale, confirmed:true (sensitive — autonomous undo can erase prior work).',
267
- redo: 'Required: map_id, rationale.',
268
- report_blocker: 'Required: what_i_was_trying, what_i_tried, where_i_got_stuck, rationale. Optional: map_id, suggested_capability.',
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,
@@ -351,7 +423,7 @@ function withConfirmation(toolName, handler) {
351
423
  });
352
424
  }
353
425
  async function apiFetch(baseUrl, apiKey, path, body) {
354
- const resp = await fetch(`${baseUrl}${path}`, {
426
+ const resp = await fetchNoRedirect(`${baseUrl}${path}`, {
355
427
  method: 'POST',
356
428
  headers: {
357
429
  'Content-Type': 'application/json',
@@ -444,8 +516,10 @@ function compactGeometry(geom) {
444
516
  }
445
517
  /** Base whitelist + every field name the layer's viz reads + `_smo_*`
446
518
  * internals (tooltip/popup dispatch). Exported for tests. */
447
- export function compactFeatureCollection(fc, viz) {
448
- const keep = new Set(SHARE_BASE_KEEP_PROPS);
519
+ export function compactFeatureCollection(fc, viz,
520
+ /** Fields the layer's filter reads — /api/maps applies it after this strip. */
521
+ filterFields = []) {
522
+ const keep = new Set([...SHARE_BASE_KEEP_PROPS, ...filterFields]);
449
523
  for (const [k, v] of Object.entries(viz ?? {})) {
450
524
  if (typeof v === 'string' && /(Field|^field[A-Z])/.test(k))
451
525
  keep.add(v);
@@ -457,14 +531,36 @@ export function compactFeatureCollection(fc, viz) {
457
531
  }));
458
532
  return { type: fc.type ?? 'FeatureCollection', features };
459
533
  }
460
- async function persistShareableMap(baseUrl, apiKey, mapId, mapPlan, query, story) {
534
+ /** Every `field` a predicate tree reads (attribute/temporal leaves). */
535
+ export function predicateFields(predicate, out = []) {
536
+ if (Array.isArray(predicate)) {
537
+ for (const p of predicate)
538
+ predicateFields(p, out);
539
+ }
540
+ else if (predicate && typeof predicate === 'object') {
541
+ for (const [k, v] of Object.entries(predicate)) {
542
+ if (k === 'field' && typeof v === 'string')
543
+ out.push(v);
544
+ else if (v && typeof v === 'object')
545
+ predicateFields(v, out);
546
+ }
547
+ }
548
+ return out;
549
+ }
550
+ async function persistShareableMap(baseUrl, apiKey, mapId, mapPlan, queryOrLabel, story,
551
+ // The workspace's filters (materialize keeps them beside the plan). Sent so
552
+ // /api/maps stores only the features the map shows — without them the
553
+ // share link held every feature after filter_layer (2026-10-09).
554
+ layerFilters) {
461
555
  if (!mapPlan || !Array.isArray(mapPlan.layers))
462
556
  return false;
557
+ // Callers pass the build query, or an edit's optional rationale label.
558
+ const query = queryOrLabel || mapPlan.title || 'Map';
463
559
  const geojsonLayers = {};
464
560
  const strippedLayers = mapPlan.layers.map((raw) => {
465
561
  const layer = raw;
466
562
  if (layer?.source?.kind === 'geojson' && layer.source.data && layer.id) {
467
- geojsonLayers[layer.id] = compactFeatureCollection(layer.source.data, layer.viz);
563
+ geojsonLayers[layer.id] = compactFeatureCollection(layer.source.data, layer.viz, predicateFields(layerFilters?.[layer.id]));
468
564
  return { ...layer, source: { ...layer.source, data: undefined } };
469
565
  }
470
566
  return layer;
@@ -474,7 +570,14 @@ async function persistShareableMap(baseUrl, apiKey, mapId, mapPlan, query, story
474
570
  id: mapId,
475
571
  title,
476
572
  query,
477
- blob: { mapPlan: { ...mapPlan, layers: strippedLayers }, geojsonLayers, query, version: 1, ...(story ? { story } : {}) },
573
+ blob: {
574
+ mapPlan: { ...mapPlan, layers: strippedLayers },
575
+ geojsonLayers,
576
+ query,
577
+ version: 1,
578
+ ...(story ? { story } : {}),
579
+ ...(layerFilters && Object.keys(layerFilters).length ? { layerFilters } : {}),
580
+ },
478
581
  isPublic: true,
479
582
  };
480
583
  // Two attempts: the second exists for exactly the failure that lost a map
@@ -497,14 +600,79 @@ async function persistShareableMap(baseUrl, apiKey, mapId, mapPlan, query, story
497
600
  }
498
601
  return false;
499
602
  }
603
+ /** Safe map id: workspace UUIDs, nanoid /m/ ids, `ws_…` test ids. Rejects
604
+ * path segments (`..`, `a/b`) before the id reaches a URL. */
605
+ const MAP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
606
+ /** `cursor` and `op_id` pin a version together — exactly one is ambiguous. */
607
+ const pinPairSchema = z
608
+ .object({ cursor: z.number().optional(), op_id: z.string().optional() })
609
+ .refine((v) => (v.cursor === undefined) === (v.op_id === undefined), {
610
+ message: 'cursor and op_id must be given together (a version is the pair), or both omitted for the latest',
611
+ path: ['op_id'],
612
+ });
613
+ /** Headroom under MAX_STRUCTURED_BYTES for the view envelope (title, camera,
614
+ * legend, stripped layer stubs, the text summary, JSON-RPC framing). */
615
+ const VIEW_ENVELOPE_HEADROOM = 50_000;
616
+ /**
617
+ * A non-owner's (or LRU-evicted session's) view: the public /m/ snapshot,
618
+ * always the latest version. Deliberately NOT apiFetch — that attaches the
619
+ * caller's bearer, and a public read must not depend on it. Mirrors
620
+ * `hydrateSharedPlan` (src/lib/share/saveMapSnapshot.ts), which this package
621
+ * cannot import: a geojson layer with no inline data gets
622
+ * `blob.geojsonLayers[layer.id]`, top level and both comparison panes.
623
+ *
624
+ * NB: GET /api/maps/[id] answers a private map with 404 (it hides existence),
625
+ * so today a private map reads as `deleted`; 401/403 → `private` is kept for
626
+ * the day the route distinguishes them.
627
+ */
628
+ async function fetchPublicSnapshot(baseUrl, mapId) {
629
+ const path = `/api/maps/${encodeURIComponent(mapId)}`;
630
+ // X-SMO-No-View-Count: a card load is not a /m/ page view — the route
631
+ // skips incrementViews for it.
632
+ const resp = await fetchNoRedirect(`${baseUrl}${path}`, {
633
+ headers: { Accept: 'application/json', 'X-SMO-No-View-Count': '1' },
634
+ });
635
+ if (resp.status === 401 || resp.status === 403)
636
+ return { status: 'private', plan: null };
637
+ if (resp.status === 404)
638
+ return { status: 'deleted', plan: null };
639
+ const text = await resp.text();
640
+ if (!resp.ok)
641
+ throw new Error(`${path} ${resp.status}: ${text.slice(0, 200)}`);
642
+ const body = JSON.parse(text);
643
+ const plan = body.blob?.mapPlan;
644
+ if (!plan)
645
+ throw new Error(`${path} returned no mapPlan`);
646
+ const geo = body.blob?.geojsonLayers ?? {};
647
+ const hydrate = (l) => l.source?.kind === 'geojson' && !l.source.data && geo[l.id] ? { ...l, source: { ...l.source, data: geo[l.id] } } : l;
648
+ return {
649
+ status: 'public_snapshot',
650
+ plan: {
651
+ ...plan,
652
+ layers: (plan.layers ?? []).map(hydrate),
653
+ ...(plan.comparison
654
+ ? {
655
+ comparison: {
656
+ left: { ...plan.comparison.left, layers: plan.comparison.left.layers.map(hydrate) },
657
+ right: { ...plan.comparison.right, layers: plan.comparison.right.layers.map(hydrate) },
658
+ },
659
+ }
660
+ : {}),
661
+ },
662
+ };
663
+ }
500
664
  // MCP tool annotations — safety hints every tool advertises so clients can
501
665
  // reason about read-only vs mutating vs destructive behavior (and the model
502
666
  // picks tools correctly). `destructiveHint` derives from MCP_TOOL_META (the
503
667
  // single source of truth, kept in sync with the agent-side meta); the rest
504
668
  // from a small intent classification.
669
+ // report_blocker is deliberately NOT here: it sends agent-written text to the
670
+ // SMO team, so a host should ask before running it rather than auto-approve it
671
+ // as a read (Claude auto-runs readOnlyHint tools; directory review checks this).
505
672
  const READ_ONLY_TOOLS = new Set([
506
673
  'get_state', 'query_features', 'select_within', 'correlate_layers',
507
- 'export_workspace', 'export_layer_data', 'export_image', 'report_blocker',
674
+ 'export_workspace', 'export_layer_data', 'export_image', 'list_workflows',
675
+ 'get_map_view',
508
676
  ]);
509
677
  // Tools that reach external/open-world data sources, vs operating purely on
510
678
  // already-loaded workspace state.
@@ -527,16 +695,72 @@ function toolAnnotations(name) {
527
695
  export function createServer(opts) {
528
696
  const baseUrl = opts.baseUrl;
529
697
  const apiKey = opts.apiKey;
530
- const store = opts.sessionStore ?? createMemorySessionStore();
698
+ // Maps whose /m/ share was confirmed written — the only links a
699
+ // too-large error may hand out (share-store honesty, 2026-08-01).
700
+ const sharedMapIds = new Set();
701
+ const store = withGeneralization(opts.sessionStore ?? createMemorySessionStore(), (mapId) => (sharedMapIds.has(mapId) ? `${baseUrl}/m/${mapId}` : null));
702
+ const viewer = opts.viewer;
531
703
  const server = new McpServer({
532
704
  name: opts.name ?? 'showmeonmap',
533
705
  version: opts.version ?? '0.1.0',
534
- });
706
+ }, { instructions: SERVER_INSTRUCTIONS });
535
707
  // Stamp MCP annotations onto every tool from the central classification so we
536
708
  // don't thread them through 24 registerTool call sites. `title` mirrors the
537
709
  // tool's own title; any inline annotations a call sets win over the defaults.
710
+ //
711
+ // With `viewer` set, map-producing tools (VIEWER_TOOLS) are also tagged with
712
+ // the MCP App resource (`ui.resourceUri` + the legacy flat `ui/resourceUri`
713
+ // key) and their results gain a hidden view pointer under `_meta`.
714
+ //
715
+ // Also with `viewer` set, every tool except the app-only `get_map_view` is
716
+ // stamped `_meta.ui.visibility: ['model']`. ext-apps defaults visibility to
717
+ // ["model","app"], which would let the card call credit-spending or
718
+ // mutating tools through the host; the card's one tool is get_map_view.
719
+ // Without `viewer` nothing changes (stdio / npm builds).
538
720
  const baseRegisterTool = server.registerTool.bind(server);
539
- const registerTool = (name, config, cb) => baseRegisterTool(name, { ...config, annotations: { title: config.title, ...toolAnnotations(name), ...config.annotations } }, cb);
721
+ const registerTool = (name, config, cb) => {
722
+ const tagged = !!viewer && VIEWER_TOOLS.has(name);
723
+ const existingUi = (config._meta?.ui ?? {});
724
+ const ui = {
725
+ ...existingUi,
726
+ ...(tagged ? { resourceUri: VIEWER_URI } : {}),
727
+ ...(viewer && name !== 'get_map_view' ? { visibility: ['model'] } : {}),
728
+ };
729
+ const cfg = {
730
+ ...config,
731
+ annotations: { title: config.title, ...toolAnnotations(name), ...config.annotations },
732
+ ...(viewer ? { _meta: { ...config._meta, ui, ...(tagged ? { 'ui/resourceUri': VIEWER_URI } : {}) } } : {}),
733
+ };
734
+ if (!tagged)
735
+ return baseRegisterTool(name, cfg, cb);
736
+ const wrapped = (async (...args) => {
737
+ const result = await cb(...args);
738
+ return attachViewPointer(result);
739
+ });
740
+ return baseRegisterTool(name, cfg, wrapped);
741
+ };
742
+ /** Read map_id from the envelope text, then cursor/opId from the store the
743
+ * tool just wrote — authoritative even when the envelope was truncated. */
744
+ async function attachViewPointer(result) {
745
+ if (result?.isError)
746
+ return result;
747
+ try {
748
+ const text = result.content?.[0]?.text;
749
+ const mapId = text ? JSON.parse(text).map_id : undefined;
750
+ if (!mapId)
751
+ return result;
752
+ const entry = await store.get(mapId);
753
+ const ws = entry?.workspace;
754
+ const cursor = ws?.cursor;
755
+ const opId = typeof cursor === 'number' ? ws?.opsLog?.[cursor]?.opId : undefined;
756
+ if (typeof cursor !== 'number' || !opId)
757
+ return result;
758
+ return { ...result, _meta: { ...result._meta, [VIEW_META_KEY]: { map_id: mapId, cursor, opId } } };
759
+ }
760
+ catch {
761
+ return result; // a pointer failure must never fail the map-producing call
762
+ }
763
+ }
540
764
  // -------------------------------------------------------------------------
541
765
  // Resource: workspace-ops-spec
542
766
  // -------------------------------------------------------------------------
@@ -558,6 +782,139 @@ export function createServer(opts) {
558
782
  ],
559
783
  }));
560
784
  // -------------------------------------------------------------------------
785
+ // Resource: MCP App map viewer (only when `viewer` is configured)
786
+ // -------------------------------------------------------------------------
787
+ // A tiny HTML shell that loads the hashed viewer bundle from assetOrigin.
788
+ // `_meta.ui.domain` is deliberately never set — Claude derives the sandbox
789
+ // origin itself and the app's CORS matches it.
790
+ if (viewer) {
791
+ server.registerResource('map-viewer', VIEWER_URI, { title: 'ShowMeOnMap map viewer', description: 'Interactive map card for ShowMeOnMap results.', mimeType: MCP_APP_MIME }, async (uri) => ({
792
+ contents: [{ uri: uri.href, mimeType: MCP_APP_MIME, text: viewerShellHtml(viewer.assetOrigin), _meta: viewerResourceMeta(viewer) }],
793
+ }));
794
+ // -----------------------------------------------------------------------
795
+ // Tool: get_map_view — app-only (hidden from the model), read-only, free.
796
+ // The card loads a pinned version `(cursor, opId)`. Owner → from the
797
+ // owner-scoped session store; anyone else → the public /m/ snapshot.
798
+ //
799
+ // Budget: the view rides ONLY in `structuredContent` (what the card reads);
800
+ // `content[0].text` is a short summary. Duplicating the view into the text
801
+ // would double the result (plus JSON-escaping) and halve the usable layer
802
+ // budget. Layer data gets MAX_STRUCTURED_BYTES − VIEW_ENVELOPE_HEADROOM
803
+ // (UTF-8 bytes); a final guard re-measures the whole result.
804
+ // -----------------------------------------------------------------------
805
+ const getMapViewShape = {
806
+ map_id: z.string().regex(MAP_ID_RE),
807
+ cursor: z.number().int().min(0).optional(),
808
+ op_id: z.string().min(1).optional(),
809
+ layer_ids: z.array(z.string().min(1)).max(50).optional(),
810
+ };
811
+ registerTool('get_map_view', {
812
+ title: 'Map view (viewer only)',
813
+ description: 'Used by the ShowMeOnMap map card to load a map version. Not for the model.',
814
+ inputSchema: getMapViewShape,
815
+ _meta: { ui: { visibility: ['app'] } },
816
+ }, withValidation('get_map_view', getMapViewShape, async ({ map_id, cursor, op_id, layer_ids }) => {
817
+ const pair = pinPairSchema.safeParse({ cursor, op_id });
818
+ // Same invalid_input envelope withValidation produces (same cast too).
819
+ if (!pair.success)
820
+ return buildInvalidInputResponse('get_map_view', pair.error);
821
+ const entry = await store.get(map_id);
822
+ let status = 'ok';
823
+ let plan = null;
824
+ let latest = null;
825
+ let extras = { layerFilters: {} };
826
+ if (entry) {
827
+ const ws = entry.workspace;
828
+ const headOpId = ws.opsLog[ws.cursor]?.opId;
829
+ latest = typeof headOpId === 'string' ? { map_id, cursor: ws.cursor, opId: headOpId } : null;
830
+ const pinned = typeof cursor === 'number' && typeof op_id === 'string';
831
+ const pinOk = pinned && ws.opsLog[cursor]?.opId === op_id;
832
+ if (pinned && !pinOk)
833
+ status = 'replaced';
834
+ // ALWAYS materialize (free, pure): filters, time window and
835
+ // annotations live beside mapPlan, and the stored entry.mapPlan
836
+ // alone would draw a filter/set_time/annotation card wrong (R14).
837
+ const resp = (await apiFetch(baseUrl, apiKey, '/api/workspace/materialize', {
838
+ workspace: { ...ws, cursor: pinOk ? cursor : ws.cursor },
839
+ }));
840
+ // A failed materialize is an error, never `ok` with no plan.
841
+ if (!resp.ok || !resp.mapPlan) {
842
+ throw new Error(`materialize failed: ${resp.error?.kind ?? 'unknown'} — ${resp.error?.message ?? 'no detail'}`);
843
+ }
844
+ plan = resp.mapPlan;
845
+ extras = {
846
+ layerFilters: resp.layerFilters ?? {},
847
+ ...(resp.timeOverride ? { timeOverride: resp.timeOverride } : {}),
848
+ ...(resp.annotations ? { annotations: resp.annotations } : {}),
849
+ };
850
+ }
851
+ else {
852
+ const snap = await fetchPublicSnapshot(baseUrl, map_id);
853
+ status = snap.status;
854
+ plan = snap.plan;
855
+ }
856
+ let layers = plan?.layers ?? [];
857
+ if (layer_ids?.length)
858
+ layers = layers.filter((l) => layer_ids.includes(l.id));
859
+ // Filters / time window / annotations ride in the same result, so
860
+ // they come out of the layer budget (the final guard re-measures).
861
+ const extrasBytes = utf8Bytes(JSON.stringify(extras));
862
+ const split = splitLayersByBudget(layers, Math.max(0, MAX_STRUCTURED_BYTES - VIEW_ENVELOPE_HEADROOM - extrasBytes));
863
+ const view = {
864
+ status,
865
+ viewSchema: 1,
866
+ title: plan?.title ?? 'Map',
867
+ mapPlan: plan ? { ...plan, layers: split.inline } : null,
868
+ deferredLayerIds: split.deferred,
869
+ oversizeLayerIds: split.oversize,
870
+ latest,
871
+ ...extras,
872
+ };
873
+ const toResult = () => ({
874
+ content: [{
875
+ type: 'text',
876
+ text: JSON.stringify({
877
+ status: view.status,
878
+ title: view.title,
879
+ layers: view.mapPlan?.layers.length ?? 0,
880
+ deferred: view.deferredLayerIds.length,
881
+ oversize: view.oversizeLayerIds.length,
882
+ }),
883
+ }],
884
+ structuredContent: view,
885
+ });
886
+ const fits = () => utf8Bytes(JSON.stringify(toResult())) < MAX_STRUCTURED_BYTES;
887
+ // Final guard — the envelope outgrew the headroom (e.g. a comparison
888
+ // plan's pane data, which the split does not see). Degrade honestly:
889
+ // strip pane data (undeliverable in the card → oversize), then all
890
+ // top-level data (deferred → fetchable one by one), then fail.
891
+ if (!fits() && view.mapPlan?.comparison) {
892
+ const c = view.mapPlan.comparison;
893
+ const stripPane = (ls) => ls.map((l) => {
894
+ if (!l.source?.data)
895
+ return l;
896
+ view.oversizeLayerIds.push(l.id);
897
+ return { ...l, source: { ...l.source, data: undefined } };
898
+ });
899
+ view.mapPlan = { ...view.mapPlan, comparison: { left: { ...c.left, layers: stripPane(c.left.layers) }, right: { ...c.right, layers: stripPane(c.right.layers) } } };
900
+ }
901
+ if (!fits() && view.mapPlan) {
902
+ view.mapPlan = {
903
+ ...view.mapPlan,
904
+ layers: view.mapPlan.layers.map((l) => {
905
+ if (!l.source?.data)
906
+ return l;
907
+ view.deferredLayerIds.push(l.id);
908
+ return { ...l, source: { ...l.source, data: undefined } };
909
+ }),
910
+ };
911
+ }
912
+ if (!fits())
913
+ throw new Error(`map view for ${map_id} exceeds the ${MAX_STRUCTURED_BYTES}-byte result budget even without layer data`);
914
+ return toResult();
915
+ }));
916
+ }
917
+ // -------------------------------------------------------------------------
561
918
  // Shared `rationale` schema + stderr logger.
562
919
  // ---------------------------------------------------------------------------
563
920
  // Required on every MCP tool per Teddy Riker's "Designing for Agents"
@@ -568,7 +925,8 @@ export function createServer(opts) {
568
925
  .string()
569
926
  .min(1)
570
927
  .max(300)
571
- .describe('Short audit-log label (≤300 chars) stating the user-facing goal this call serves, e.g. "add wildfire layer for the user\'s California query". Required on every call. Stored in the operations log so map edits stay traceable — we never see your chat history.');
928
+ .optional()
929
+ .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
930
  /**
573
931
  * Log a tool call's rationale (and any op-shaped extras) to BOTH stderr
574
932
  * and the per-day NDJSON sink. Every tool calls this — direct-op tools
@@ -580,6 +938,10 @@ export function createServer(opts) {
580
938
  * `extra.opKind` lands in the NDJSON when this call emits a typed Op.
581
939
  */
582
940
  function logRationale(toolName, mapId, rationale, extra) {
941
+ // Optional since 2026-09-28 (Claude directory: collect nothing beyond what
942
+ // the tool needs) — no label, nothing logged.
943
+ if (!rationale)
944
+ return;
583
945
  const safe = rationale.replace(/[\r\n\t]+/g, ' ').slice(0, 300);
584
946
  process.stderr.write(`[mcp:rationale] tool=${toolName} mapId=${mapId ?? '-'} rationale="${safe}"${extra?.intent ? ` intent=${extra.intent}` : ''}${extra?.opKind ? ` op=${extra.opKind}` : ''}\n`);
585
947
  appendRationaleNdjson({
@@ -600,8 +962,7 @@ export function createServer(opts) {
600
962
  };
601
963
  registerTool('build_map', {
602
964
  title: 'Build a map',
603
- description: 'Create a new workspace from a natural-language query. Returns a map_id you can pass to mutate_map or get_state for follow-up actions. ' +
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.',
965
+ description: BUILD_MAP_DESCRIPTION,
605
966
  inputSchema: buildMapShape,
606
967
  }, withValidation('build_map', buildMapShape, async ({ query, rationale }) => {
607
968
  logRationale('build_map', null, rationale);
@@ -614,21 +975,27 @@ export function createServer(opts) {
614
975
  throw new Error(`build_map failed: ${JSON.stringify(resp.error ?? resp.message ?? resp).slice(0, 300)}`);
615
976
  }
616
977
  const mapId = asSessionId(resp.workspace.workspaceId ?? randomUUID());
617
- await store.put(mapId, {
978
+ // Share FIRST, from the API's full-resolution plan: if the session
979
+ // write is then refused as too large, the error can still hand out a
980
+ // link that works.
981
+ const shared = await persistShareableMap(baseUrl, apiKey, mapId, resp.mapPlan, query);
982
+ if (shared)
983
+ sharedMapIds.add(mapId);
984
+ const prepared = prepareEntry({
618
985
  workspace: resp.workspace,
619
986
  mapPlan: resp.mapPlan ?? null,
620
987
  layerFilters: {},
621
988
  createdAt: Date.now(),
622
989
  lastAccessed: Date.now(),
623
990
  });
991
+ await store.put(mapId, prepared.entry);
624
992
  const nextActions = [
625
993
  'Filter the layer to features where name is not empty',
626
994
  'Change the basemap to satellite',
627
995
  'Zoom the camera closer',
628
996
  ];
629
- // Persist to the shareable store so the returned /m/<mapId> link renders.
630
- const shared = await persistShareableMap(baseUrl, apiKey, mapId, resp.mapPlan, query);
631
- return toContent(envelope(baseUrl, mapId, resp.workspace, resp.mapPlan, nextActions, shared ? {} : { shareUnpersisted: true }));
997
+ const note = generalizedNote(prepared.generalized);
998
+ return toContent(envelope(baseUrl, mapId, prepared.entry.workspace, prepared.entry.mapPlan, nextActions, { ...(shared ? {} : { shareUnpersisted: true }), ...(note ? { note } : {}) }));
632
999
  }));
633
1000
  const mutateMapShape = {
634
1001
  map_id: z.string().min(1).describe('map_id from a prior build_map call'),
@@ -641,7 +1008,7 @@ export function createServer(opts) {
641
1008
  registerTool('mutate_map', {
642
1009
  title: 'Mutate a map',
643
1010
  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
- 'For the full Op catalog and predicate grammar, fetch the MCP resource showmeonmap://docs/workspace-ops-spec.',
1011
+ 'The full Op catalog and predicate grammar are documented in the MCP resource showmeonmap://docs/workspace-ops-spec.',
645
1012
  inputSchema: mutateMapShape,
646
1013
  }, withValidation('mutate_map', mutateMapShape, async ({ map_id, instruction, rationale }) => {
647
1014
  const entry = await store.get(map_id);
@@ -668,7 +1035,7 @@ export function createServer(opts) {
668
1035
  });
669
1036
  const mutMapPlan = (resp.mapPlan ?? entry.mapPlan);
670
1037
  // Update the shareable /m/<map_id> map so the link reflects the mutation.
671
- const shared = await persistShareableMap(baseUrl, apiKey, map_id, mutMapPlan, instruction);
1038
+ const shared = await persistShareableMap(baseUrl, apiKey, map_id, mutMapPlan, instruction, null, resp.layerFilters);
672
1039
  return toContent(envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), mutMapPlan, resp.nextActions, {
673
1040
  ...(resp.clarification ? { clarification: resp.clarification } : {}),
674
1041
  ...(shared ? {} : { shareUnpersisted: true }),
@@ -718,7 +1085,7 @@ export function createServer(opts) {
718
1085
  });
719
1086
  const opMapPlan = (resp.mapPlan ?? entry.mapPlan);
720
1087
  // 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 ?? '');
1088
+ const shared = await persistShareableMap(baseUrl, apiKey, map_id, opMapPlan, opMapPlan?.title ?? '', null, resp.layerFilters);
722
1089
  return envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), opMapPlan, undefined, shared ? {} : { shareUnpersisted: true });
723
1090
  }
724
1091
  /**
@@ -732,7 +1099,7 @@ export function createServer(opts) {
732
1099
  turnId: randomUUID(),
733
1100
  emittedAt: new Date().toISOString(),
734
1101
  emittedBy,
735
- rationale,
1102
+ ...(rationale ? { rationale } : {}),
736
1103
  };
737
1104
  }
738
1105
  // --- predicate schema (shared by filter_layer + query_features) ------------
@@ -769,8 +1136,8 @@ export function createServer(opts) {
769
1136
  title: 'Add a layer (direct)',
770
1137
  description: 'Append a fully-constructed LayerPlan to an existing workspace without going through NL. ' +
771
1138
  'Pass the same shape the workspace pipeline emits — { id, title, source, viz, geometry, ... }. ' +
772
- 'Prefer this when you already have a structured plan; otherwise use mutate_map. ' +
773
- 'For the full LayerPlan and Op spec, fetch the MCP resource showmeonmap://docs/workspace-ops-spec.',
1139
+ 'Takes a structured plan; natural-language edits go through mutate_map. ' +
1140
+ 'The full LayerPlan and Op spec are documented in the MCP resource showmeonmap://docs/workspace-ops-spec.',
774
1141
  inputSchema: addLayerShape,
775
1142
  }, withValidation('add_layer', addLayerShape, async ({ map_id, layerPlan, rationale, intent }) => {
776
1143
  logRationale('add_layer', map_id, rationale, { opKind: 'addLayer', ...(intent ? { intent } : {}) });
@@ -798,12 +1165,11 @@ export function createServer(opts) {
798
1165
  title: 'Add bivariate fusion layer',
799
1166
  description: 'Spatially join two existing layers and add a new layer that ' +
800
1167
  'encodes both values per region as a 3x3 bivariate choropleth. ' +
801
- 'Both layers must already exist in the workspace (call build_map ' +
802
- 'or add_layer first). Auto-detects the numeric value field from ' +
1168
+ 'Both layers must already exist in the workspace. Auto-detects ' +
1169
+ 'the numeric value field from ' +
803
1170
  'each layer\'s viz config; pass value_field_a / value_field_b to ' +
804
1171
  'override. Returns the new layer_id, correlation r, and ' +
805
- 'matched-feature count. Required: map_id, layer_a_id, ' +
806
- 'layer_b_id, rationale.',
1172
+ 'matched-feature count. Required: map_id, layer_a_id, layer_b_id.',
807
1173
  inputSchema: addFusionLayerShape,
808
1174
  }, 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
1175
  const entry = await store.get(map_id);
@@ -817,7 +1183,7 @@ export function createServer(opts) {
817
1183
  ...(value_field_a ? { valueFieldA: value_field_a } : {}),
818
1184
  ...(value_field_b ? { valueFieldB: value_field_b } : {}),
819
1185
  ...(new_layer_id ? { newLayerId: new_layer_id } : {}),
820
- header: { rationale },
1186
+ ...(rationale ? { header: { rationale } } : {}),
821
1187
  });
822
1188
  if (!data.ok) {
823
1189
  throw new Error(`add_fusion_layer failed: ${data.error?.kind ?? 'unknown'} — ${data.error?.reason ?? data.error?.message ?? 'no detail'}`);
@@ -851,10 +1217,9 @@ export function createServer(opts) {
851
1217
  'Pearson correlation coefficient r, sample size n, two-sided ' +
852
1218
  'p-value approximation, plus human-readable strength and ' +
853
1219
  'direction labels. Pure read — no layer is added, no op is ' +
854
- 'committed, no credit charge. Use this when you want a numeric ' +
855
- 'answer without rendering; use add_fusion_layer when the user ' +
856
- 'wants the visualization. Required: map_id, layer_a_id, ' +
857
- 'layer_b_id, rationale.',
1220
+ 'committed, no credit charge. Returns numbers only; ' +
1221
+ 'add_fusion_layer renders the same join as a map layer. ' +
1222
+ 'Required: map_id, layer_a_id, layer_b_id.',
858
1223
  inputSchema: correlateLayersShape,
859
1224
  }, withValidation('correlate_layers', correlateLayersShape, async ({ map_id, layer_a_id, layer_b_id, value_field_a, value_field_b, rationale, }) => {
860
1225
  const entry = await store.get(map_id);
@@ -1023,7 +1388,7 @@ export function createServer(opts) {
1023
1388
  lastAccessed: Date.now(),
1024
1389
  });
1025
1390
  const focusedPlan = (resp.mapPlan ?? entry.mapPlan);
1026
- const shared = await persistShareableMap(baseUrl, apiKey, map_id, focusedPlan, rationale);
1391
+ const shared = await persistShareableMap(baseUrl, apiKey, map_id, focusedPlan, rationale, null, resp.layerFilters);
1027
1392
  return toContent(envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), focusedPlan, undefined, {
1028
1393
  ...(resp.opsApplied ? { opsApplied: resp.opsApplied } : {}),
1029
1394
  ...(resp.contextAnswer ? { contextAnswer: resp.contextAnswer } : {}),
@@ -1033,9 +1398,9 @@ export function createServer(opts) {
1033
1398
  const runAnalysisShape = {
1034
1399
  map_id: z.string().min(1),
1035
1400
  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).'),
1401
+ .enum(['hotspot', 'zonal', 'enrich', 'join', 'merge', 'near', 'dissolve'])
1402
+ .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)."),
1403
+ 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
1404
  valueField: z
1040
1405
  .string()
1041
1406
  .min(1)
@@ -1074,6 +1439,63 @@ export function createServer(opts) {
1074
1439
  .enum(['state', 'county', 'tract'])
1075
1440
  .optional()
1076
1441
  .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.'),
1442
+ joinLayerId: z
1443
+ .string()
1444
+ .min(1)
1445
+ .optional()
1446
+ .describe('join only (required there): the POLYGON layer providing the attributes.'),
1447
+ fields: z
1448
+ .array(z.string().min(1).max(80))
1449
+ .min(1)
1450
+ .max(12)
1451
+ .optional()
1452
+ .describe('join only: which join-layer properties to transfer (default: auto-select up to 12 non-internal properties, overflow reported).'),
1453
+ prefix: z
1454
+ .string()
1455
+ .min(1)
1456
+ .max(20)
1457
+ .optional()
1458
+ .describe('join only: prefix for a joined field whose name collides with an existing target property (default "join_").'),
1459
+ mergeLayerId: z
1460
+ .string()
1461
+ .min(1)
1462
+ .optional()
1463
+ .describe('merge only (required there): the second layer — same geometry class (point/line/polygon) as layerId.'),
1464
+ sourceField: z
1465
+ .string()
1466
+ .min(1)
1467
+ .max(40)
1468
+ .optional()
1469
+ .describe('merge only: name of the per-row provenance column naming each row\'s source layer (default "merge_source"; renamed on collision, reported).'),
1470
+ nearLayerId: z
1471
+ .string()
1472
+ .min(1)
1473
+ .optional()
1474
+ .describe('near only (required there): the POINT layer to search for each target point\'s nearest neighbor.'),
1475
+ nearField: z
1476
+ .string()
1477
+ .min(1)
1478
+ .max(80)
1479
+ .optional()
1480
+ .describe('near only: property of the nearest feature to copy as its identity (default: first name-like property).'),
1481
+ maxDistanceM: z
1482
+ .number()
1483
+ .min(1)
1484
+ .max(20_000_000)
1485
+ .optional()
1486
+ .describe('near only: search radius in meters — a point whose nearest neighbor is farther carries NO near fields (reported).'),
1487
+ dissolveField: z
1488
+ .string()
1489
+ .min(1)
1490
+ .max(80)
1491
+ .optional()
1492
+ .describe('dissolve only: group-by property (omit = dissolve ALL into one footprint); missing values form their own "(no value)" group.'),
1493
+ sumFields: z
1494
+ .array(z.string().min(1).max(80))
1495
+ .min(1)
1496
+ .max(5)
1497
+ .optional()
1498
+ .describe('dissolve only: numeric properties to SUM per group — skipped values reported, a group with none present carries NO sum.'),
1077
1499
  annotate: z
1078
1500
  .boolean()
1079
1501
  .optional()
@@ -1094,11 +1516,17 @@ export function createServer(opts) {
1094
1516
  'count/sum/mean/min/max per zone as a quantile choropleth where zero and no-data are ' +
1095
1517
  'distinct classes. enrich = join a US Census ACS indicator (median income, home value, ' +
1096
1518
  'rent, …) onto each feature by tract/county/state containment — unmatched features stay ' +
1097
- 'null, never zero-filled. Returns a grounded memo + stats payload — every number is computed, ' +
1519
+ 'null, never zero-filled. join = spatial join (attribute transfer): each point in layerId ' +
1520
+ 'takes properties from the joinLayerId polygon containing it (collisions prefixed, unmatched ' +
1521
+ 'points carry NO joined fields, zero overlap is a typed rejection). merge = append two ' +
1522
+ 'same-geometry-class layers into one dataset with a per-row source column (schemas never ' +
1523
+ 'null-filled; class mismatch is a typed failure). near = nearest-neighbor distance columns ' +
1524
+ '(great-circle meters, stated — never presented as road distance). dissolve = true polygon union ' +
1525
+ 'per attribute group (missing values form their own group; partial sums reported). Returns a grounded memo + stats payload — every number is computed, ' +
1098
1526
  'never generated; findings keep the map. Costs 1 credit when the result lands; typed ' +
1099
1527
  'failures (unknown layer, too few features, >10,000-feature cap) are refunded.',
1100
1528
  inputSchema: runAnalysisShape,
1101
- }, withValidation('run_analysis', runAnalysisShape, async ({ map_id, analysis, layerId, valueField, k, hexMeters, zoneLayerId, zones, stats, indicator, geoLevel, annotate, story, rationale }) => {
1529
+ }, 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
1530
  const entry = await store.get(map_id);
1103
1531
  if (!entry)
1104
1532
  throw new Error(`Unknown map_id "${map_id}". Call build_map first.`);
@@ -1115,6 +1543,16 @@ export function createServer(opts) {
1115
1543
  ...(stats !== undefined ? { stats } : {}),
1116
1544
  ...(indicator !== undefined ? { indicator } : {}),
1117
1545
  ...(geoLevel !== undefined ? { geoLevel } : {}),
1546
+ ...(joinLayerId !== undefined ? { joinLayerId } : {}),
1547
+ ...(fields !== undefined ? { fields } : {}),
1548
+ ...(prefix !== undefined ? { prefix } : {}),
1549
+ ...(mergeLayerId !== undefined ? { mergeLayerId } : {}),
1550
+ ...(sourceField !== undefined ? { sourceField } : {}),
1551
+ ...(nearLayerId !== undefined ? { nearLayerId } : {}),
1552
+ ...(nearField !== undefined ? { nearField } : {}),
1553
+ ...(maxDistanceM !== undefined ? { maxDistanceM } : {}),
1554
+ ...(dissolveField !== undefined ? { dissolveField } : {}),
1555
+ ...(sumFields !== undefined ? { sumFields } : {}),
1118
1556
  ...(annotate !== undefined ? { annotate } : {}),
1119
1557
  rationale,
1120
1558
  }));
@@ -1139,7 +1577,7 @@ export function createServer(opts) {
1139
1577
  resultLayerId: resp.resultLayerId,
1140
1578
  })
1141
1579
  : null;
1142
- const shared = await persistShareableMap(baseUrl, apiKey, map_id, analyzedPlan, rationale, storyDraft);
1580
+ const shared = await persistShareableMap(baseUrl, apiKey, map_id, analyzedPlan, rationale, storyDraft, resp.layerFilters);
1143
1581
  return toContent(envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), analyzedPlan, undefined, {
1144
1582
  ...(resp.opsApplied ? { opsApplied: resp.opsApplied } : {}),
1145
1583
  ...(resp.contextAnswer ? { contextAnswer: resp.contextAnswer } : {}),
@@ -1236,8 +1674,8 @@ export function createServer(opts) {
1236
1674
  .boolean()
1237
1675
  .optional()
1238
1676
  .describe('Set to true to confirm this sensitive action. If omitted or false, the call ' +
1239
- 'returns a needs_confirmation envelope. Wiping a filter chain is recoverable ' +
1240
- 'via undo, but autonomous agents lack a UI surface to confirm intent.'),
1677
+ 'returns a needs_confirmation envelope. A cleared filter chain can be ' +
1678
+ 'restored with undo.'),
1241
1679
  };
1242
1680
  registerTool('clear_filters', {
1243
1681
  title: 'Clear all filters on a layer (direct, gated)',
@@ -1319,8 +1757,12 @@ export function createServer(opts) {
1319
1757
  const cameraPlanSchema = z.discriminatedUnion('mode', [
1320
1758
  z.object({
1321
1759
  mode: z.literal('fitBounds'),
1760
+ // Fixed-length arrays, NOT z.tuple: a tuple compiles to the draft-04
1761
+ // `items: [..]` form, which the Anthropic API rejects — Claude then drops
1762
+ // the whole tool (anthropicSchemaCompat.test.ts).
1322
1763
  bounds: z
1323
- .tuple([z.number(), z.number(), z.number(), z.number()])
1764
+ .array(z.number())
1765
+ .length(4)
1324
1766
  .describe('[west, south, east, north] bbox in WGS84 degrees.'),
1325
1767
  padding: z
1326
1768
  .number()
@@ -1335,7 +1777,7 @@ export function createServer(opts) {
1335
1777
  }),
1336
1778
  z.object({
1337
1779
  mode: z.literal('flyTo'),
1338
- center: z.tuple([z.number(), z.number()]).describe('[lng, lat] in WGS84.'),
1780
+ center: z.array(z.number()).length(2).describe('[lng, lat] in WGS84.'),
1339
1781
  zoom: z.number(),
1340
1782
  pitch: z.number().optional().describe('Tilt in degrees, 0-60.'),
1341
1783
  bearing: z.number().optional().describe('Rotation in degrees, 0-360.'),
@@ -1356,8 +1798,8 @@ export function createServer(opts) {
1356
1798
  description: 'Override the workspace camera. Latest set_camera op wins during replay. ' +
1357
1799
  'Two modes: `fitBounds` with a [w,s,e,n] bbox (plus optional padding/animateMs), ' +
1358
1800
  'or `flyTo` with [lng,lat] center + zoom (plus optional pitch/bearing/animateMs). ' +
1359
- 'Use for "zoom to Japan", "center on Paris at z=10", or finishing a drill-down ' +
1360
- 'after filter_layer. Equivalent to the camera move mutate_map would emit, but ' +
1801
+ 'Covers requests like "zoom to Japan", "center on Paris at z=10", or framing ' +
1802
+ 'a filtered layer. Equivalent to the camera move mutate_map would emit, but ' +
1361
1803
  'skips the NL roundtrip.',
1362
1804
  inputSchema: setCameraShape,
1363
1805
  }, withValidation('set_camera', setCameraShape, async ({ map_id, camera, rationale }) => {
@@ -1386,7 +1828,7 @@ export function createServer(opts) {
1386
1828
  registerTool('set_time', {
1387
1829
  title: 'Set the time window (direct)',
1388
1830
  description: 'Override the current time-range filter. Latest set_time op wins during replay. ' +
1389
- 'Use for "filter to 2020-2024" or "show only the last 7 days" intents. Not yet ' +
1831
+ 'Covers requests like "filter to 2020-2024" or "show only the last 7 days". Not yet ' +
1390
1832
  'consumed by the renderer — the UI drives the slider directly — but the value is ' +
1391
1833
  'captured in the ops log and queryable via get_state.',
1392
1834
  inputSchema: setTimeShape,
@@ -1431,7 +1873,8 @@ export function createServer(opts) {
1431
1873
  map_id: z.string().min(1),
1432
1874
  id: z.string().min(1).describe('Stable id for this annotation — caller-supplied.'),
1433
1875
  coords: z
1434
- .tuple([z.number(), z.number()])
1876
+ .array(z.number())
1877
+ .length(2) // not z.tuple — see cameraPlanSchema
1435
1878
  .describe('[lng, lat] in WGS84.'),
1436
1879
  label: z.string().optional().describe('Optional text rendered at the point.'),
1437
1880
  color: z
@@ -1508,7 +1951,7 @@ export function createServer(opts) {
1508
1951
  });
1509
1952
  const rematPlan = resp.mapPlan;
1510
1953
  // Keep the shareable /m/<map_id> map in sync after undo/redo.
1511
- const shared = await persistShareableMap(baseUrl, apiKey, map_id, rematPlan, rematPlan?.title ?? '');
1954
+ const shared = await persistShareableMap(baseUrl, apiKey, map_id, rematPlan, rematPlan?.title ?? '', null, resp.layerFilters);
1512
1955
  return envelope(baseUrl, map_id, workspace, rematPlan, undefined, shared ? {} : { shareUnpersisted: true });
1513
1956
  }
1514
1957
  const exportWorkspaceShape = {
@@ -1519,9 +1962,9 @@ export function createServer(opts) {
1519
1962
  title: 'Export the workspace JSON',
1520
1963
  description: 'Return the current workspace document (ops log + cursor + meta) plus the ' +
1521
1964
  'materialized mapPlan and layerFilters, in a shape suitable for save-to-file / ' +
1522
- 'share-by-URL / inspection. Pure read — no mutation, no credit charge. Use ' +
1523
- 'this when you want the full raw state for serialization; use get_state when ' +
1524
- 'you want the compact envelope for display.',
1965
+ 'share-by-URL / inspection. Pure read — no mutation, no credit charge. This is ' +
1966
+ 'the full raw state for serialization; get_state returns a compact summary ' +
1967
+ 'for display.',
1525
1968
  inputSchema: exportWorkspaceShape,
1526
1969
  }, withValidation('export_workspace', exportWorkspaceShape, async ({ map_id, rationale }) => {
1527
1970
  const entry = await store.get(map_id);
@@ -1549,8 +1992,8 @@ export function createServer(opts) {
1549
1992
  'a URL with Content-Disposition: attachment so the user\'s browser ' +
1550
1993
  'downloads it. CSV drops geometry beyond a (lng, lat) centroid — ' +
1551
1994
  'for full polygon/line geometry, use format: "geojson". The URL is an ' +
1552
- 'unguessable capability link that does NOT expire — treat it as ' +
1553
- 'shareable-forever, not temporary. Required: map_id, layer_id, format, rationale.',
1995
+ 'unguessable capability link that does NOT expire: anyone with the link ' +
1996
+ 'can download the file. Required: map_id, layer_id, format.',
1554
1997
  inputSchema: exportLayerDataShape,
1555
1998
  }, withValidation('export_layer_data', exportLayerDataShape, async ({ map_id, layer_id, format, rationale }) => {
1556
1999
  logRationale('export_layer_data', map_id, rationale);
@@ -1572,7 +2015,7 @@ export function createServer(opts) {
1572
2015
  'link, the browser renders the map at full resolution with the ' +
1573
2016
  'legend composited (if legend: true), and the file downloads. ' +
1574
2017
  'There is no server-side rendering; the user\'s browser is the ' +
1575
- 'renderer. Required: map_id, rationale. Optional: format (png ' +
2018
+ 'renderer. Required: map_id. Optional: format (png ' +
1576
2019
  'default), legend (true default).',
1577
2020
  inputSchema: exportImageShape,
1578
2021
  }, withValidation('export_image', exportImageShape, async ({ map_id, format, legend, rationale }) => {
@@ -1601,8 +2044,8 @@ export function createServer(opts) {
1601
2044
  .boolean()
1602
2045
  .optional()
1603
2046
  .describe('Set to true to confirm this sensitive action. If omitted or false, the call ' +
1604
- 'returns a needs_confirmation envelope. Autonomous undo without user intent is ' +
1605
- 'a footgun — an agent that decides to "fix" something can erase prior work.'),
2047
+ 'returns a needs_confirmation envelope. Undo rewinds the most recent change; ' +
2048
+ 'redo restores it.'),
1606
2049
  };
1607
2050
  registerTool('undo', {
1608
2051
  title: 'Undo the most recent op (gated)',
@@ -1691,13 +2134,11 @@ export function createServer(opts) {
1691
2134
  };
1692
2135
  registerTool('report_blocker', {
1693
2136
  title: 'Report a blocker (agent feedback)',
1694
- description: 'Tell the ShowMeOnMap team your agent hit something it could not accomplish — an ' +
1695
- 'unsupported intent, a missing tool, an unclear schema, an op that failed in a ' +
1696
- 'confusing way. Use this when build_map / mutate_map / a direct-op tool returns ' +
1697
- 'something you cannot recover from, OR when you would have wanted a different tool ' +
1698
- 'to exist. Records the report to a server-side log; does not unblock the current ' +
1699
- 'session, but feeds future capability work. No map_id required — file the report ' +
1700
- 'even if no map was built.',
2137
+ description: 'Send the ShowMeOnMap team a report about a request this server could not ' +
2138
+ 'complete — an unsupported intent, a missing tool, an unclear schema, or an op ' +
2139
+ 'that failed in a confusing way. Records the report to a server-side log for ' +
2140
+ 'future capability work; it does not change the current map or session. ' +
2141
+ 'map_id is optional, so a report can be filed before any map exists.',
1701
2142
  inputSchema: reportBlockerShape,
1702
2143
  }, withValidation('report_blocker', reportBlockerShape, async ({ map_id, what_i_was_trying, what_i_tried, where_i_got_stuck, suggested_capability, rationale }) => {
1703
2144
  // Mirror to the rationale NDJSON so the May-1 clustering pass sees a
@@ -1721,6 +2162,131 @@ export function createServer(opts) {
1721
2162
  ...(resp.sink ? { sink: resp.sink } : {}),
1722
2163
  });
1723
2164
  }));
2165
+ // ── Workflows (D2 phase 6): save / list / run saved instruction streams ──
2166
+ // Semantic replay: templates store {param}-slotted INSTRUCTIONS, never
2167
+ // resolved ops; a run re-executes them through the live pipelines, so
2168
+ // every step bills exactly like typing it (credits per existing rules).
2169
+ const saveWorkflowShape = {
2170
+ name: z.string().min(1).max(80).describe('Workflow name.'),
2171
+ steps: z
2172
+ .array(z.object({ instruction: z.string().min(1).max(4000) }))
2173
+ .min(1)
2174
+ .max(12)
2175
+ .describe('Ordered NL instructions. Step 1 builds the map; later steps mutate it. Wrap values in {braces} to declare parameters.'),
2176
+ params: z
2177
+ .array(z.object({
2178
+ name: z.string().min(1).max(40),
2179
+ label: z.string().min(1).max(80),
2180
+ kind: z.enum(['place', 'number', 'text', 'timerange']),
2181
+ default: z.string().max(200).optional(),
2182
+ }))
2183
+ .max(8)
2184
+ .optional()
2185
+ .describe('Declared parameters — every {slot} used in steps must be declared here.'),
2186
+ rationale: rationaleSchema,
2187
+ };
2188
+ registerTool('save_workflow', {
2189
+ title: 'Save a workflow',
2190
+ 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.',
2191
+ inputSchema: saveWorkflowShape,
2192
+ }, withValidation('save_workflow', saveWorkflowShape, async ({ name, steps, params, rationale }) => {
2193
+ logRationale('save_workflow', null, rationale);
2194
+ const resp = (await apiFetch(baseUrl, apiKey, '/api/workflows', {
2195
+ name,
2196
+ steps,
2197
+ params: params ?? [],
2198
+ }));
2199
+ if (!resp.ok || !resp.template)
2200
+ throw new Error(`save_workflow failed: ${resp.error ?? 'unknown'}`);
2201
+ return toContent({
2202
+ ok: true,
2203
+ workflow_id: resp.template.id,
2204
+ estimated_credits: resp.template.estimatedCredits,
2205
+ note: 'Estimate = step count; escalations can cost more. Run with run_workflow.',
2206
+ });
2207
+ }));
2208
+ const listWorkflowsShape = { rationale: rationaleSchema };
2209
+ registerTool('list_workflows', {
2210
+ title: 'List saved workflows',
2211
+ description: 'List the caller\'s saved workflows with their parameters and per-run credit estimates.',
2212
+ inputSchema: listWorkflowsShape,
2213
+ }, withValidation('list_workflows', listWorkflowsShape, async ({ rationale }) => {
2214
+ logRationale('list_workflows', null, rationale);
2215
+ const resp = await fetchNoRedirect(`${baseUrl}/api/workflows`, {
2216
+ headers: { ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) },
2217
+ });
2218
+ const text = await resp.text();
2219
+ if (!resp.ok)
2220
+ throw new Error(`/api/workflows ${resp.status}: ${text.slice(0, 200)}`);
2221
+ const data = JSON.parse(text);
2222
+ return toContent({
2223
+ ok: true,
2224
+ workflows: (data.templates ?? []).map((t) => ({
2225
+ workflow_id: t.id,
2226
+ name: t.name,
2227
+ steps: t.steps.length,
2228
+ params: t.params,
2229
+ estimated_credits: t.estimatedCredits,
2230
+ })),
2231
+ });
2232
+ }));
2233
+ const runWorkflowShape = {
2234
+ workflow_id: z.string().min(1).describe('From save_workflow or list_workflows.'),
2235
+ params: z.record(z.string(), z.string()).optional().describe('Values for the workflow\'s declared parameters.'),
2236
+ batch: z
2237
+ .object({
2238
+ param: z.string().min(1).describe('The ONE declared param to sweep (must not also be in params).'),
2239
+ values: z.array(z.string().min(1)).min(2).max(20).describe('2–20 values; each is a FULL independent billed run.'),
2240
+ })
2241
+ .optional()
2242
+ .describe('Batch mode: run the workflow once per value ("each of the 12 districts"). Bills steps × values.'),
2243
+ rationale: rationaleSchema,
2244
+ };
2245
+ registerTool('run_workflow', {
2246
+ title: 'Run a saved workflow',
2247
+ 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).',
2248
+ inputSchema: runWorkflowShape,
2249
+ }, withValidation('run_workflow', runWorkflowShape, async ({ workflow_id, params, batch, rationale }) => {
2250
+ logRationale('run_workflow', null, rationale);
2251
+ const resp = (await apiFetch(baseUrl, apiKey, `/api/workflows/${workflow_id}/run`, {
2252
+ params: params ?? {},
2253
+ ...(batch ? { batch } : {}),
2254
+ stream: false,
2255
+ }));
2256
+ if (resp.status === 'paused') {
2257
+ return toContent({ ok: false, status: 'paused', question: resp.question, events: resp.events });
2258
+ }
2259
+ if ((resp.status !== 'done' && resp.status !== 'partial') || !resp.workspace) {
2260
+ return toContent({
2261
+ ok: false,
2262
+ status: 'failed',
2263
+ error: resp.error ?? 'run failed',
2264
+ ...(resp.batch ? { batch: resp.batch } : {}),
2265
+ events: resp.events,
2266
+ });
2267
+ }
2268
+ const mapId = asSessionId(resp.workspace.workspaceId ?? randomUUID());
2269
+ await store.put(mapId, {
2270
+ workspace: resp.workspace,
2271
+ mapPlan: resp.mapPlan ?? null,
2272
+ layerFilters: {},
2273
+ createdAt: Date.now(),
2274
+ lastAccessed: Date.now(),
2275
+ });
2276
+ const shared = resp.mapPlan
2277
+ ? await persistShareableMap(baseUrl, apiKey, mapId, resp.mapPlan, `workflow ${workflow_id}`)
2278
+ : false;
2279
+ return toContent(envelope(baseUrl, mapId, resp.workspace, (resp.mapPlan ?? null), undefined, {
2280
+ contextAnswer: resp.batch
2281
+ ? `Batch: ${resp.batch.succeeded}/${resp.batch.total} value(s) succeeded` +
2282
+ (resp.batch.failed ? `, ${resp.batch.failed} failed` : '') +
2283
+ (resp.batch.paused ? `, ${resp.batch.paused} paused` : '') +
2284
+ ` — map shows "${resp.workspaceValue ?? '?'}"; per-value outcomes in batch.results.`
2285
+ : `Workflow ran ${(resp.events ?? []).filter((e) => e.type === 'step_done').length} step(s).`,
2286
+ ...(resp.batch ? { batch: resp.batch } : {}),
2287
+ ...(shared ? {} : { shareUnpersisted: true }),
2288
+ }));
2289
+ }));
1724
2290
  // ── JSON Schema draft normalization (2026-07-16) ─────────────────────────
1725
2291
  // zod-to-json-schema (inside the SDK) stamps every tool's inputSchema with a
1726
2292
  // draft-07 `$schema` header. The Anthropic API validates tool schemas as