@showmeonmap/mcp 0.5.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/README.md +8 -3
- 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/serverInstructions.js +34 -0
- package/dist/sessionStoreD1.js +28 -8
- package/dist/storyDraft.js +106 -0
- package/dist/tools.js +675 -89
- package/dist/version.js +3 -0
- package/dist/viewer.js +117 -0
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -22,6 +22,11 @@ import { readFileSync, mkdirSync, appendFileSync } from 'node:fs';
|
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
23
|
import { dirname, resolve } from 'node:path';
|
|
24
24
|
import { MCP_TOOL_META, mcpRequiresConfirmation } from "./toolMeta.js";
|
|
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";
|
|
25
30
|
// -----------------------------------------------------------------------------
|
|
26
31
|
// Agent-facing spec resource (Teddy Riker, "Designing for Agents" §2,
|
|
27
32
|
// 2026-04-23). Loaded once at module init from a sibling .md file. Tool
|
|
@@ -57,6 +62,11 @@ const WORKSPACE_OPS_SPEC_TEXT = (() => {
|
|
|
57
62
|
const MCP_RATIONALE_DIR = (() => {
|
|
58
63
|
try {
|
|
59
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;
|
|
60
70
|
// tools.ts sits at packages/mcp-server/src/. Climb three levels
|
|
61
71
|
// (src → mcp-server → packages → <repo>) to land at <repo>/reports/
|
|
62
72
|
// mcp-rationale, alongside the agent-blockers sink and the
|
|
@@ -96,6 +106,56 @@ export function asSessionId(value) {
|
|
|
96
106
|
}
|
|
97
107
|
return value;
|
|
98
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
|
+
}
|
|
99
159
|
export function createMemorySessionStore(maxSize = 256) {
|
|
100
160
|
const cache = new Map();
|
|
101
161
|
return {
|
|
@@ -185,6 +245,13 @@ function compactStructured(workspace, mapPlan) {
|
|
|
185
245
|
}
|
|
186
246
|
return compacted;
|
|
187
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
|
+
}
|
|
188
255
|
function envelope(baseUrl, mapId, workspace, mapPlan, nextActionStrings, extras = {}) {
|
|
189
256
|
const layerCount = Array.isArray(mapPlan?.layers) ? mapPlan.layers.length : 0;
|
|
190
257
|
const opsCount = Array.isArray(workspace?.opsLog) ? workspace.opsLog.length : 0;
|
|
@@ -213,6 +280,7 @@ function envelope(baseUrl, mapId, workspace, mapPlan, nextActionStrings, extras
|
|
|
213
280
|
...(extras.memo ? { memo: extras.memo } : {}),
|
|
214
281
|
...(extras.stats ? { stats: extras.stats } : {}),
|
|
215
282
|
...(extras.note ? { note: extras.note } : {}),
|
|
283
|
+
...(extras.batch ? { batch: extras.batch } : {}),
|
|
216
284
|
};
|
|
217
285
|
}
|
|
218
286
|
function toContent(obj) {
|
|
@@ -239,32 +307,37 @@ function toContent(obj) {
|
|
|
239
307
|
// envelope (isError:true + content[].text JSON) so the calling agent can
|
|
240
308
|
// self-correct.
|
|
241
309
|
// ---------------------------------------------------------------------------
|
|
310
|
+
// `rationale` (≤300 chars) is OPTIONAL on every tool — appended once in
|
|
311
|
+
// invalidInputEnvelope rather than repeated per tool.
|
|
242
312
|
const INVALID_INPUT_HINTS = {
|
|
243
|
-
build_map: 'Required: query (string, ≥1 char)
|
|
244
|
-
mutate_map: 'Required: map_id, instruction
|
|
245
|
-
get_state: 'Required: map_id
|
|
246
|
-
add_layer: 'Required: map_id, layerPlan (object)
|
|
247
|
-
filter_layer: 'Required: map_id, layerId, predicate (object)
|
|
248
|
-
select_within: 'Required: map_id, sourceLayerId, newLayerId, selection ({type:Polygon|MultiPolygon, coordinates})
|
|
249
|
-
focus_area: 'Required: map_id, layerId, areaRef ({kind:"corridor", street, from?, to?} | {kind:"place", name} | {kind:"aroundIntersection", street, cross})
|
|
250
|
-
query_features: 'Required: map_id, layerId, predicate (object)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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.',
|
|
268
341
|
};
|
|
269
342
|
function buildInvalidInputResponse(toolName, error) {
|
|
270
343
|
const issues = error.issues.map((iss) => ({
|
|
@@ -276,7 +349,7 @@ function buildInvalidInputResponse(toolName, error) {
|
|
|
276
349
|
error: 'invalid_input',
|
|
277
350
|
tool: toolName,
|
|
278
351
|
issues,
|
|
279
|
-
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).`,
|
|
280
353
|
};
|
|
281
354
|
return {
|
|
282
355
|
isError: true,
|
|
@@ -350,7 +423,7 @@ function withConfirmation(toolName, handler) {
|
|
|
350
423
|
});
|
|
351
424
|
}
|
|
352
425
|
async function apiFetch(baseUrl, apiKey, path, body) {
|
|
353
|
-
const resp = await
|
|
426
|
+
const resp = await fetchNoRedirect(`${baseUrl}${path}`, {
|
|
354
427
|
method: 'POST',
|
|
355
428
|
headers: {
|
|
356
429
|
'Content-Type': 'application/json',
|
|
@@ -443,8 +516,10 @@ function compactGeometry(geom) {
|
|
|
443
516
|
}
|
|
444
517
|
/** Base whitelist + every field name the layer's viz reads + `_smo_*`
|
|
445
518
|
* internals (tooltip/popup dispatch). Exported for tests. */
|
|
446
|
-
export function compactFeatureCollection(fc, viz
|
|
447
|
-
|
|
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]);
|
|
448
523
|
for (const [k, v] of Object.entries(viz ?? {})) {
|
|
449
524
|
if (typeof v === 'string' && /(Field|^field[A-Z])/.test(k))
|
|
450
525
|
keep.add(v);
|
|
@@ -456,14 +531,36 @@ export function compactFeatureCollection(fc, viz) {
|
|
|
456
531
|
}));
|
|
457
532
|
return { type: fc.type ?? 'FeatureCollection', features };
|
|
458
533
|
}
|
|
459
|
-
|
|
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) {
|
|
460
555
|
if (!mapPlan || !Array.isArray(mapPlan.layers))
|
|
461
556
|
return false;
|
|
557
|
+
// Callers pass the build query, or an edit's optional rationale label.
|
|
558
|
+
const query = queryOrLabel || mapPlan.title || 'Map';
|
|
462
559
|
const geojsonLayers = {};
|
|
463
560
|
const strippedLayers = mapPlan.layers.map((raw) => {
|
|
464
561
|
const layer = raw;
|
|
465
562
|
if (layer?.source?.kind === 'geojson' && layer.source.data && layer.id) {
|
|
466
|
-
geojsonLayers[layer.id] = compactFeatureCollection(layer.source.data, layer.viz);
|
|
563
|
+
geojsonLayers[layer.id] = compactFeatureCollection(layer.source.data, layer.viz, predicateFields(layerFilters?.[layer.id]));
|
|
467
564
|
return { ...layer, source: { ...layer.source, data: undefined } };
|
|
468
565
|
}
|
|
469
566
|
return layer;
|
|
@@ -473,7 +570,14 @@ async function persistShareableMap(baseUrl, apiKey, mapId, mapPlan, query) {
|
|
|
473
570
|
id: mapId,
|
|
474
571
|
title,
|
|
475
572
|
query,
|
|
476
|
-
blob: {
|
|
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
|
+
},
|
|
477
581
|
isPublic: true,
|
|
478
582
|
};
|
|
479
583
|
// Two attempts: the second exists for exactly the failure that lost a map
|
|
@@ -496,14 +600,79 @@ async function persistShareableMap(baseUrl, apiKey, mapId, mapPlan, query) {
|
|
|
496
600
|
}
|
|
497
601
|
return false;
|
|
498
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
|
+
}
|
|
499
664
|
// MCP tool annotations — safety hints every tool advertises so clients can
|
|
500
665
|
// reason about read-only vs mutating vs destructive behavior (and the model
|
|
501
666
|
// picks tools correctly). `destructiveHint` derives from MCP_TOOL_META (the
|
|
502
667
|
// single source of truth, kept in sync with the agent-side meta); the rest
|
|
503
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).
|
|
504
672
|
const READ_ONLY_TOOLS = new Set([
|
|
505
673
|
'get_state', 'query_features', 'select_within', 'correlate_layers',
|
|
506
|
-
'export_workspace', 'export_layer_data', 'export_image', '
|
|
674
|
+
'export_workspace', 'export_layer_data', 'export_image', 'list_workflows',
|
|
675
|
+
'get_map_view',
|
|
507
676
|
]);
|
|
508
677
|
// Tools that reach external/open-world data sources, vs operating purely on
|
|
509
678
|
// already-loaded workspace state.
|
|
@@ -526,16 +695,72 @@ function toolAnnotations(name) {
|
|
|
526
695
|
export function createServer(opts) {
|
|
527
696
|
const baseUrl = opts.baseUrl;
|
|
528
697
|
const apiKey = opts.apiKey;
|
|
529
|
-
|
|
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;
|
|
530
703
|
const server = new McpServer({
|
|
531
704
|
name: opts.name ?? 'showmeonmap',
|
|
532
705
|
version: opts.version ?? '0.1.0',
|
|
533
|
-
});
|
|
706
|
+
}, { instructions: SERVER_INSTRUCTIONS });
|
|
534
707
|
// Stamp MCP annotations onto every tool from the central classification so we
|
|
535
708
|
// don't thread them through 24 registerTool call sites. `title` mirrors the
|
|
536
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).
|
|
537
720
|
const baseRegisterTool = server.registerTool.bind(server);
|
|
538
|
-
const registerTool = (name, config, 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
|
+
}
|
|
539
764
|
// -------------------------------------------------------------------------
|
|
540
765
|
// Resource: workspace-ops-spec
|
|
541
766
|
// -------------------------------------------------------------------------
|
|
@@ -557,6 +782,139 @@ export function createServer(opts) {
|
|
|
557
782
|
],
|
|
558
783
|
}));
|
|
559
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
|
+
// -------------------------------------------------------------------------
|
|
560
918
|
// Shared `rationale` schema + stderr logger.
|
|
561
919
|
// ---------------------------------------------------------------------------
|
|
562
920
|
// Required on every MCP tool per Teddy Riker's "Designing for Agents"
|
|
@@ -567,7 +925,8 @@ export function createServer(opts) {
|
|
|
567
925
|
.string()
|
|
568
926
|
.min(1)
|
|
569
927
|
.max(300)
|
|
570
|
-
.
|
|
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.');
|
|
571
930
|
/**
|
|
572
931
|
* Log a tool call's rationale (and any op-shaped extras) to BOTH stderr
|
|
573
932
|
* and the per-day NDJSON sink. Every tool calls this — direct-op tools
|
|
@@ -579,6 +938,10 @@ export function createServer(opts) {
|
|
|
579
938
|
* `extra.opKind` lands in the NDJSON when this call emits a typed Op.
|
|
580
939
|
*/
|
|
581
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;
|
|
582
945
|
const safe = rationale.replace(/[\r\n\t]+/g, ' ').slice(0, 300);
|
|
583
946
|
process.stderr.write(`[mcp:rationale] tool=${toolName} mapId=${mapId ?? '-'} rationale="${safe}"${extra?.intent ? ` intent=${extra.intent}` : ''}${extra?.opKind ? ` op=${extra.opKind}` : ''}\n`);
|
|
584
947
|
appendRationaleNdjson({
|
|
@@ -599,8 +962,7 @@ export function createServer(opts) {
|
|
|
599
962
|
};
|
|
600
963
|
registerTool('build_map', {
|
|
601
964
|
title: 'Build a map',
|
|
602
|
-
description:
|
|
603
|
-
'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,
|
|
604
966
|
inputSchema: buildMapShape,
|
|
605
967
|
}, withValidation('build_map', buildMapShape, async ({ query, rationale }) => {
|
|
606
968
|
logRationale('build_map', null, rationale);
|
|
@@ -613,21 +975,27 @@ export function createServer(opts) {
|
|
|
613
975
|
throw new Error(`build_map failed: ${JSON.stringify(resp.error ?? resp.message ?? resp).slice(0, 300)}`);
|
|
614
976
|
}
|
|
615
977
|
const mapId = asSessionId(resp.workspace.workspaceId ?? randomUUID());
|
|
616
|
-
|
|
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({
|
|
617
985
|
workspace: resp.workspace,
|
|
618
986
|
mapPlan: resp.mapPlan ?? null,
|
|
619
987
|
layerFilters: {},
|
|
620
988
|
createdAt: Date.now(),
|
|
621
989
|
lastAccessed: Date.now(),
|
|
622
990
|
});
|
|
991
|
+
await store.put(mapId, prepared.entry);
|
|
623
992
|
const nextActions = [
|
|
624
993
|
'Filter the layer to features where name is not empty',
|
|
625
994
|
'Change the basemap to satellite',
|
|
626
995
|
'Zoom the camera closer',
|
|
627
996
|
];
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
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 } : {}) }));
|
|
631
999
|
}));
|
|
632
1000
|
const mutateMapShape = {
|
|
633
1001
|
map_id: z.string().min(1).describe('map_id from a prior build_map call'),
|
|
@@ -640,7 +1008,7 @@ export function createServer(opts) {
|
|
|
640
1008
|
registerTool('mutate_map', {
|
|
641
1009
|
title: 'Mutate a map',
|
|
642
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. ' +
|
|
643
|
-
'
|
|
1011
|
+
'The full Op catalog and predicate grammar are documented in the MCP resource showmeonmap://docs/workspace-ops-spec.',
|
|
644
1012
|
inputSchema: mutateMapShape,
|
|
645
1013
|
}, withValidation('mutate_map', mutateMapShape, async ({ map_id, instruction, rationale }) => {
|
|
646
1014
|
const entry = await store.get(map_id);
|
|
@@ -667,7 +1035,7 @@ export function createServer(opts) {
|
|
|
667
1035
|
});
|
|
668
1036
|
const mutMapPlan = (resp.mapPlan ?? entry.mapPlan);
|
|
669
1037
|
// Update the shareable /m/<map_id> map so the link reflects the mutation.
|
|
670
|
-
const shared = await persistShareableMap(baseUrl, apiKey, map_id, mutMapPlan, instruction);
|
|
1038
|
+
const shared = await persistShareableMap(baseUrl, apiKey, map_id, mutMapPlan, instruction, null, resp.layerFilters);
|
|
671
1039
|
return toContent(envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), mutMapPlan, resp.nextActions, {
|
|
672
1040
|
...(resp.clarification ? { clarification: resp.clarification } : {}),
|
|
673
1041
|
...(shared ? {} : { shareUnpersisted: true }),
|
|
@@ -717,7 +1085,7 @@ export function createServer(opts) {
|
|
|
717
1085
|
});
|
|
718
1086
|
const opMapPlan = (resp.mapPlan ?? entry.mapPlan);
|
|
719
1087
|
// Update the shareable /m/<map_id> map so the link reflects the mutation.
|
|
720
|
-
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);
|
|
721
1089
|
return envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), opMapPlan, undefined, shared ? {} : { shareUnpersisted: true });
|
|
722
1090
|
}
|
|
723
1091
|
/**
|
|
@@ -731,7 +1099,7 @@ export function createServer(opts) {
|
|
|
731
1099
|
turnId: randomUUID(),
|
|
732
1100
|
emittedAt: new Date().toISOString(),
|
|
733
1101
|
emittedBy,
|
|
734
|
-
rationale,
|
|
1102
|
+
...(rationale ? { rationale } : {}),
|
|
735
1103
|
};
|
|
736
1104
|
}
|
|
737
1105
|
// --- predicate schema (shared by filter_layer + query_features) ------------
|
|
@@ -768,8 +1136,8 @@ export function createServer(opts) {
|
|
|
768
1136
|
title: 'Add a layer (direct)',
|
|
769
1137
|
description: 'Append a fully-constructed LayerPlan to an existing workspace without going through NL. ' +
|
|
770
1138
|
'Pass the same shape the workspace pipeline emits — { id, title, source, viz, geometry, ... }. ' +
|
|
771
|
-
'
|
|
772
|
-
'
|
|
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.',
|
|
773
1141
|
inputSchema: addLayerShape,
|
|
774
1142
|
}, withValidation('add_layer', addLayerShape, async ({ map_id, layerPlan, rationale, intent }) => {
|
|
775
1143
|
logRationale('add_layer', map_id, rationale, { opKind: 'addLayer', ...(intent ? { intent } : {}) });
|
|
@@ -797,12 +1165,11 @@ export function createServer(opts) {
|
|
|
797
1165
|
title: 'Add bivariate fusion layer',
|
|
798
1166
|
description: 'Spatially join two existing layers and add a new layer that ' +
|
|
799
1167
|
'encodes both values per region as a 3x3 bivariate choropleth. ' +
|
|
800
|
-
'Both layers must already exist in the workspace
|
|
801
|
-
'
|
|
1168
|
+
'Both layers must already exist in the workspace. Auto-detects ' +
|
|
1169
|
+
'the numeric value field from ' +
|
|
802
1170
|
'each layer\'s viz config; pass value_field_a / value_field_b to ' +
|
|
803
1171
|
'override. Returns the new layer_id, correlation r, and ' +
|
|
804
|
-
'matched-feature count. Required: map_id, layer_a_id, '
|
|
805
|
-
'layer_b_id, rationale.',
|
|
1172
|
+
'matched-feature count. Required: map_id, layer_a_id, layer_b_id.',
|
|
806
1173
|
inputSchema: addFusionLayerShape,
|
|
807
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, }) => {
|
|
808
1175
|
const entry = await store.get(map_id);
|
|
@@ -816,7 +1183,7 @@ export function createServer(opts) {
|
|
|
816
1183
|
...(value_field_a ? { valueFieldA: value_field_a } : {}),
|
|
817
1184
|
...(value_field_b ? { valueFieldB: value_field_b } : {}),
|
|
818
1185
|
...(new_layer_id ? { newLayerId: new_layer_id } : {}),
|
|
819
|
-
header: { rationale },
|
|
1186
|
+
...(rationale ? { header: { rationale } } : {}),
|
|
820
1187
|
});
|
|
821
1188
|
if (!data.ok) {
|
|
822
1189
|
throw new Error(`add_fusion_layer failed: ${data.error?.kind ?? 'unknown'} — ${data.error?.reason ?? data.error?.message ?? 'no detail'}`);
|
|
@@ -850,10 +1217,9 @@ export function createServer(opts) {
|
|
|
850
1217
|
'Pearson correlation coefficient r, sample size n, two-sided ' +
|
|
851
1218
|
'p-value approximation, plus human-readable strength and ' +
|
|
852
1219
|
'direction labels. Pure read — no layer is added, no op is ' +
|
|
853
|
-
'committed, no credit charge.
|
|
854
|
-
'
|
|
855
|
-
'
|
|
856
|
-
'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.',
|
|
857
1223
|
inputSchema: correlateLayersShape,
|
|
858
1224
|
}, withValidation('correlate_layers', correlateLayersShape, async ({ map_id, layer_a_id, layer_b_id, value_field_a, value_field_b, rationale, }) => {
|
|
859
1225
|
const entry = await store.get(map_id);
|
|
@@ -1022,7 +1388,7 @@ export function createServer(opts) {
|
|
|
1022
1388
|
lastAccessed: Date.now(),
|
|
1023
1389
|
});
|
|
1024
1390
|
const focusedPlan = (resp.mapPlan ?? entry.mapPlan);
|
|
1025
|
-
const shared = await persistShareableMap(baseUrl, apiKey, map_id, focusedPlan, rationale);
|
|
1391
|
+
const shared = await persistShareableMap(baseUrl, apiKey, map_id, focusedPlan, rationale, null, resp.layerFilters);
|
|
1026
1392
|
return toContent(envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), focusedPlan, undefined, {
|
|
1027
1393
|
...(resp.opsApplied ? { opsApplied: resp.opsApplied } : {}),
|
|
1028
1394
|
...(resp.contextAnswer ? { contextAnswer: resp.contextAnswer } : {}),
|
|
@@ -1032,9 +1398,9 @@ export function createServer(opts) {
|
|
|
1032
1398
|
const runAnalysisShape = {
|
|
1033
1399
|
map_id: z.string().min(1),
|
|
1034
1400
|
analysis: z
|
|
1035
|
-
.enum(['hotspot', 'zonal', 'enrich'])
|
|
1036
|
-
.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)."),
|
|
1037
|
-
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).'),
|
|
1038
1404
|
valueField: z
|
|
1039
1405
|
.string()
|
|
1040
1406
|
.min(1)
|
|
@@ -1073,6 +1439,71 @@ export function createServer(opts) {
|
|
|
1073
1439
|
.enum(['state', 'county', 'tract'])
|
|
1074
1440
|
.optional()
|
|
1075
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.'),
|
|
1499
|
+
annotate: z
|
|
1500
|
+
.boolean()
|
|
1501
|
+
.optional()
|
|
1502
|
+
.describe('Opt-in: place ONE callout annotation at the headline feature (hottest cluster / top zone / highest value). Label text comes only from computed values.'),
|
|
1503
|
+
story: z
|
|
1504
|
+
.boolean()
|
|
1505
|
+
.optional()
|
|
1506
|
+
.describe('Opt-in: persist the shared map with a 2-step story rail (grounded overview + analysis memo isolating the result layer) — the /m/ link opens with it.'),
|
|
1076
1507
|
rationale: rationaleSchema,
|
|
1077
1508
|
};
|
|
1078
1509
|
registerTool('run_analysis', {
|
|
@@ -1085,11 +1516,17 @@ export function createServer(opts) {
|
|
|
1085
1516
|
'count/sum/mean/min/max per zone as a quantile choropleth where zero and no-data are ' +
|
|
1086
1517
|
'distinct classes. enrich = join a US Census ACS indicator (median income, home value, ' +
|
|
1087
1518
|
'rent, …) onto each feature by tract/county/state containment — unmatched features stay ' +
|
|
1088
|
-
'null, never zero-filled.
|
|
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, ' +
|
|
1089
1526
|
'never generated; findings keep the map. Costs 1 credit when the result lands; typed ' +
|
|
1090
1527
|
'failures (unknown layer, too few features, >10,000-feature cap) are refunded.',
|
|
1091
1528
|
inputSchema: runAnalysisShape,
|
|
1092
|
-
}, withValidation('run_analysis', runAnalysisShape, async ({ map_id, analysis, layerId, valueField, k, hexMeters, zoneLayerId, zones, stats, indicator, geoLevel, 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 }) => {
|
|
1093
1530
|
const entry = await store.get(map_id);
|
|
1094
1531
|
if (!entry)
|
|
1095
1532
|
throw new Error(`Unknown map_id "${map_id}". Call build_map first.`);
|
|
@@ -1106,6 +1543,17 @@ export function createServer(opts) {
|
|
|
1106
1543
|
...(stats !== undefined ? { stats } : {}),
|
|
1107
1544
|
...(indicator !== undefined ? { indicator } : {}),
|
|
1108
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 } : {}),
|
|
1556
|
+
...(annotate !== undefined ? { annotate } : {}),
|
|
1109
1557
|
rationale,
|
|
1110
1558
|
}));
|
|
1111
1559
|
if (!resp.ok) {
|
|
@@ -1119,7 +1567,17 @@ export function createServer(opts) {
|
|
|
1119
1567
|
lastAccessed: Date.now(),
|
|
1120
1568
|
});
|
|
1121
1569
|
const analyzedPlan = (resp.mapPlan ?? entry.mapPlan);
|
|
1122
|
-
|
|
1570
|
+
// Story knob (0.6.0): draft from the just-computed grounded material,
|
|
1571
|
+
// BEFORE persist strips inline data (the bbox needs the features).
|
|
1572
|
+
const storyDraft = story === true
|
|
1573
|
+
? buildAnalysisStory({
|
|
1574
|
+
plan: analyzedPlan,
|
|
1575
|
+
contextAnswer: resp.contextAnswer,
|
|
1576
|
+
memo: resp.memo,
|
|
1577
|
+
resultLayerId: resp.resultLayerId,
|
|
1578
|
+
})
|
|
1579
|
+
: null;
|
|
1580
|
+
const shared = await persistShareableMap(baseUrl, apiKey, map_id, analyzedPlan, rationale, storyDraft, resp.layerFilters);
|
|
1123
1581
|
return toContent(envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), analyzedPlan, undefined, {
|
|
1124
1582
|
...(resp.opsApplied ? { opsApplied: resp.opsApplied } : {}),
|
|
1125
1583
|
...(resp.contextAnswer ? { contextAnswer: resp.contextAnswer } : {}),
|
|
@@ -1216,8 +1674,8 @@ export function createServer(opts) {
|
|
|
1216
1674
|
.boolean()
|
|
1217
1675
|
.optional()
|
|
1218
1676
|
.describe('Set to true to confirm this sensitive action. If omitted or false, the call ' +
|
|
1219
|
-
'returns a needs_confirmation envelope.
|
|
1220
|
-
'
|
|
1677
|
+
'returns a needs_confirmation envelope. A cleared filter chain can be ' +
|
|
1678
|
+
'restored with undo.'),
|
|
1221
1679
|
};
|
|
1222
1680
|
registerTool('clear_filters', {
|
|
1223
1681
|
title: 'Clear all filters on a layer (direct, gated)',
|
|
@@ -1299,8 +1757,12 @@ export function createServer(opts) {
|
|
|
1299
1757
|
const cameraPlanSchema = z.discriminatedUnion('mode', [
|
|
1300
1758
|
z.object({
|
|
1301
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).
|
|
1302
1763
|
bounds: z
|
|
1303
|
-
.
|
|
1764
|
+
.array(z.number())
|
|
1765
|
+
.length(4)
|
|
1304
1766
|
.describe('[west, south, east, north] bbox in WGS84 degrees.'),
|
|
1305
1767
|
padding: z
|
|
1306
1768
|
.number()
|
|
@@ -1315,7 +1777,7 @@ export function createServer(opts) {
|
|
|
1315
1777
|
}),
|
|
1316
1778
|
z.object({
|
|
1317
1779
|
mode: z.literal('flyTo'),
|
|
1318
|
-
center: z.
|
|
1780
|
+
center: z.array(z.number()).length(2).describe('[lng, lat] in WGS84.'),
|
|
1319
1781
|
zoom: z.number(),
|
|
1320
1782
|
pitch: z.number().optional().describe('Tilt in degrees, 0-60.'),
|
|
1321
1783
|
bearing: z.number().optional().describe('Rotation in degrees, 0-360.'),
|
|
@@ -1336,8 +1798,8 @@ export function createServer(opts) {
|
|
|
1336
1798
|
description: 'Override the workspace camera. Latest set_camera op wins during replay. ' +
|
|
1337
1799
|
'Two modes: `fitBounds` with a [w,s,e,n] bbox (plus optional padding/animateMs), ' +
|
|
1338
1800
|
'or `flyTo` with [lng,lat] center + zoom (plus optional pitch/bearing/animateMs). ' +
|
|
1339
|
-
'
|
|
1340
|
-
'
|
|
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 ' +
|
|
1341
1803
|
'skips the NL roundtrip.',
|
|
1342
1804
|
inputSchema: setCameraShape,
|
|
1343
1805
|
}, withValidation('set_camera', setCameraShape, async ({ map_id, camera, rationale }) => {
|
|
@@ -1366,7 +1828,7 @@ export function createServer(opts) {
|
|
|
1366
1828
|
registerTool('set_time', {
|
|
1367
1829
|
title: 'Set the time window (direct)',
|
|
1368
1830
|
description: 'Override the current time-range filter. Latest set_time op wins during replay. ' +
|
|
1369
|
-
'
|
|
1831
|
+
'Covers requests like "filter to 2020-2024" or "show only the last 7 days". Not yet ' +
|
|
1370
1832
|
'consumed by the renderer — the UI drives the slider directly — but the value is ' +
|
|
1371
1833
|
'captured in the ops log and queryable via get_state.',
|
|
1372
1834
|
inputSchema: setTimeShape,
|
|
@@ -1411,7 +1873,8 @@ export function createServer(opts) {
|
|
|
1411
1873
|
map_id: z.string().min(1),
|
|
1412
1874
|
id: z.string().min(1).describe('Stable id for this annotation — caller-supplied.'),
|
|
1413
1875
|
coords: z
|
|
1414
|
-
.
|
|
1876
|
+
.array(z.number())
|
|
1877
|
+
.length(2) // not z.tuple — see cameraPlanSchema
|
|
1415
1878
|
.describe('[lng, lat] in WGS84.'),
|
|
1416
1879
|
label: z.string().optional().describe('Optional text rendered at the point.'),
|
|
1417
1880
|
color: z
|
|
@@ -1488,7 +1951,7 @@ export function createServer(opts) {
|
|
|
1488
1951
|
});
|
|
1489
1952
|
const rematPlan = resp.mapPlan;
|
|
1490
1953
|
// Keep the shareable /m/<map_id> map in sync after undo/redo.
|
|
1491
|
-
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);
|
|
1492
1955
|
return envelope(baseUrl, map_id, workspace, rematPlan, undefined, shared ? {} : { shareUnpersisted: true });
|
|
1493
1956
|
}
|
|
1494
1957
|
const exportWorkspaceShape = {
|
|
@@ -1499,9 +1962,9 @@ export function createServer(opts) {
|
|
|
1499
1962
|
title: 'Export the workspace JSON',
|
|
1500
1963
|
description: 'Return the current workspace document (ops log + cursor + meta) plus the ' +
|
|
1501
1964
|
'materialized mapPlan and layerFilters, in a shape suitable for save-to-file / ' +
|
|
1502
|
-
'share-by-URL / inspection. Pure read — no mutation, no credit charge.
|
|
1503
|
-
'
|
|
1504
|
-
'
|
|
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.',
|
|
1505
1968
|
inputSchema: exportWorkspaceShape,
|
|
1506
1969
|
}, withValidation('export_workspace', exportWorkspaceShape, async ({ map_id, rationale }) => {
|
|
1507
1970
|
const entry = await store.get(map_id);
|
|
@@ -1529,8 +1992,8 @@ export function createServer(opts) {
|
|
|
1529
1992
|
'a URL with Content-Disposition: attachment so the user\'s browser ' +
|
|
1530
1993
|
'downloads it. CSV drops geometry beyond a (lng, lat) centroid — ' +
|
|
1531
1994
|
'for full polygon/line geometry, use format: "geojson". The URL is an ' +
|
|
1532
|
-
'unguessable capability link that does NOT expire
|
|
1533
|
-
'
|
|
1995
|
+
'unguessable capability link that does NOT expire: anyone with the link ' +
|
|
1996
|
+
'can download the file. Required: map_id, layer_id, format.',
|
|
1534
1997
|
inputSchema: exportLayerDataShape,
|
|
1535
1998
|
}, withValidation('export_layer_data', exportLayerDataShape, async ({ map_id, layer_id, format, rationale }) => {
|
|
1536
1999
|
logRationale('export_layer_data', map_id, rationale);
|
|
@@ -1552,7 +2015,7 @@ export function createServer(opts) {
|
|
|
1552
2015
|
'link, the browser renders the map at full resolution with the ' +
|
|
1553
2016
|
'legend composited (if legend: true), and the file downloads. ' +
|
|
1554
2017
|
'There is no server-side rendering; the user\'s browser is the ' +
|
|
1555
|
-
'renderer. Required: map_id
|
|
2018
|
+
'renderer. Required: map_id. Optional: format (png ' +
|
|
1556
2019
|
'default), legend (true default).',
|
|
1557
2020
|
inputSchema: exportImageShape,
|
|
1558
2021
|
}, withValidation('export_image', exportImageShape, async ({ map_id, format, legend, rationale }) => {
|
|
@@ -1581,8 +2044,8 @@ export function createServer(opts) {
|
|
|
1581
2044
|
.boolean()
|
|
1582
2045
|
.optional()
|
|
1583
2046
|
.describe('Set to true to confirm this sensitive action. If omitted or false, the call ' +
|
|
1584
|
-
'returns a needs_confirmation envelope.
|
|
1585
|
-
'
|
|
2047
|
+
'returns a needs_confirmation envelope. Undo rewinds the most recent change; ' +
|
|
2048
|
+
'redo restores it.'),
|
|
1586
2049
|
};
|
|
1587
2050
|
registerTool('undo', {
|
|
1588
2051
|
title: 'Undo the most recent op (gated)',
|
|
@@ -1671,13 +2134,11 @@ export function createServer(opts) {
|
|
|
1671
2134
|
};
|
|
1672
2135
|
registerTool('report_blocker', {
|
|
1673
2136
|
title: 'Report a blocker (agent feedback)',
|
|
1674
|
-
description: '
|
|
1675
|
-
'unsupported intent, a missing tool, an unclear schema, an op
|
|
1676
|
-
'confusing way.
|
|
1677
|
-
'
|
|
1678
|
-
'
|
|
1679
|
-
'session, but feeds future capability work. No map_id required — file the report ' +
|
|
1680
|
-
'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.',
|
|
1681
2142
|
inputSchema: reportBlockerShape,
|
|
1682
2143
|
}, withValidation('report_blocker', reportBlockerShape, async ({ map_id, what_i_was_trying, what_i_tried, where_i_got_stuck, suggested_capability, rationale }) => {
|
|
1683
2144
|
// Mirror to the rationale NDJSON so the May-1 clustering pass sees a
|
|
@@ -1701,6 +2162,131 @@ export function createServer(opts) {
|
|
|
1701
2162
|
...(resp.sink ? { sink: resp.sink } : {}),
|
|
1702
2163
|
});
|
|
1703
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
|
+
}));
|
|
1704
2290
|
// ── JSON Schema draft normalization (2026-07-16) ─────────────────────────
|
|
1705
2291
|
// zod-to-json-schema (inside the SDK) stamps every tool's inputSchema with a
|
|
1706
2292
|
// draft-07 `$schema` header. The Anthropic API validates tool schemas as
|