@showmeonmap/mcp 0.2.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/LICENSE +202 -0
- package/README.md +166 -0
- package/dist/http.js +204 -0
- package/dist/index.js +50 -0
- package/dist/sessionStoreD1.js +191 -0
- package/dist/toolMeta.js +182 -0
- package/dist/tools.js +1443 -0
- package/dist/workspaceOpsSpec.md +250 -0
- package/package.json +50 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,1443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared tool implementations for the ShowMeOnMap MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Pulled out of index.ts so both the stdio transport (index.ts) and the
|
|
5
|
+
* HTTP/SSE transport (http.ts) can register the same tool surface without
|
|
6
|
+
* duplicating the handler bodies. Exports:
|
|
7
|
+
*
|
|
8
|
+
* - createServer({ baseUrl, apiKey, sessionStore? })
|
|
9
|
+
* returns a configured McpServer with build_map, mutate_map, get_state
|
|
10
|
+
* already registered. Caller attaches whichever transport they want.
|
|
11
|
+
*
|
|
12
|
+
* - sessionStore helpers: createMemorySessionStore() gives the default
|
|
13
|
+
* LRU-in-memory behavior used by the stdio prototype. HTTP callers
|
|
14
|
+
* will eventually swap this for a D1-backed store once Phase I-3 ships.
|
|
15
|
+
*
|
|
16
|
+
* Spec: docs/superpowers/specs/2026-04-17-mcp-server-design.md
|
|
17
|
+
*/
|
|
18
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
import { readFileSync, mkdirSync, appendFileSync } from 'node:fs';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { dirname, resolve } from 'node:path';
|
|
24
|
+
import { MCP_TOOL_META, mcpRequiresConfirmation } from "./toolMeta.js";
|
|
25
|
+
// -----------------------------------------------------------------------------
|
|
26
|
+
// Agent-facing spec resource (Teddy Riker, "Designing for Agents" §2,
|
|
27
|
+
// 2026-04-23). Loaded once at module init from a sibling .md file. Tool
|
|
28
|
+
// descriptions for build_map / mutate_map / add_layer / filter_layer point
|
|
29
|
+
// agents at this resource so they fetch shapes (predicate grammar, LayerPlan
|
|
30
|
+
// fields, refusal surfaces) at the moment of need instead of guessing.
|
|
31
|
+
// -----------------------------------------------------------------------------
|
|
32
|
+
const WORKSPACE_OPS_SPEC_URI = 'showmeonmap://docs/workspace-ops-spec';
|
|
33
|
+
const WORKSPACE_OPS_SPEC_TEXT = (() => {
|
|
34
|
+
try {
|
|
35
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
return readFileSync(resolve(here, 'workspaceOpsSpec.md'), 'utf8');
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
process.stderr.write(`[showmeonmap-mcp] WARN: failed to load workspaceOpsSpec.md: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
40
|
+
return '# Workspace ops spec not available — please update the MCP server.\n';
|
|
41
|
+
}
|
|
42
|
+
})();
|
|
43
|
+
// -----------------------------------------------------------------------------
|
|
44
|
+
// NDJSON rationale sink — `reports/mcp-rationale/YYYY-MM-DD.ndjson`.
|
|
45
|
+
// -----------------------------------------------------------------------------
|
|
46
|
+
// Article §3.1: rationale is only useful if it sticks around. Stderr alone is
|
|
47
|
+
// transient — the launcher (Claude Desktop, Cursor, etc.) controls what
|
|
48
|
+
// happens to it. We additionally append every tool call to a per-day NDJSON
|
|
49
|
+
// file alongside the existing `reports/agent-blockers/` sink so the same
|
|
50
|
+
// pattern applies to both feedback channels.
|
|
51
|
+
//
|
|
52
|
+
// Path is resolved relative to THIS module so we land in the repo regardless
|
|
53
|
+
// of how the user invoked the MCP server (cwd is unspecified for stdio
|
|
54
|
+
// children). All filesystem writes are best-effort: a failed append never
|
|
55
|
+
// breaks a tool call. Date is UTC to match the agent-blockers route.
|
|
56
|
+
// -----------------------------------------------------------------------------
|
|
57
|
+
const MCP_RATIONALE_DIR = (() => {
|
|
58
|
+
try {
|
|
59
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
60
|
+
// tools.ts sits at packages/mcp-server/src/. Climb three levels
|
|
61
|
+
// (src → mcp-server → packages → <repo>) to land at <repo>/reports/
|
|
62
|
+
// mcp-rationale, alongside the agent-blockers sink and the
|
|
63
|
+
// *.seed.ndjson committed for clustering passes. Previously climbed
|
|
64
|
+
// only two levels and wrote to packages/reports/mcp-rationale,
|
|
65
|
+
// which was gitignored and invisible to the audit pipeline (gap #1
|
|
66
|
+
// in the 2026-05-01 agent-design pattern pass).
|
|
67
|
+
return resolve(here, '..', '..', '..', 'reports', 'mcp-rationale');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
function ymdUtc(d) {
|
|
74
|
+
return [
|
|
75
|
+
d.getUTCFullYear(),
|
|
76
|
+
String(d.getUTCMonth() + 1).padStart(2, '0'),
|
|
77
|
+
String(d.getUTCDate()).padStart(2, '0'),
|
|
78
|
+
].join('-');
|
|
79
|
+
}
|
|
80
|
+
function appendRationaleNdjson(rec) {
|
|
81
|
+
if (!MCP_RATIONALE_DIR)
|
|
82
|
+
return;
|
|
83
|
+
try {
|
|
84
|
+
mkdirSync(MCP_RATIONALE_DIR, { recursive: true });
|
|
85
|
+
const file = resolve(MCP_RATIONALE_DIR, `${ymdUtc(new Date(rec.ts))}.ndjson`);
|
|
86
|
+
appendFileSync(file, JSON.stringify(rec) + '\n', 'utf8');
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Best-effort — a tool call must never fail because the disk is full or
|
|
90
|
+
// the dir is unwriteable. Stderr line above already carries the info.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function asSessionId(value) {
|
|
94
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
95
|
+
throw new TypeError('SessionId: expected non-empty string');
|
|
96
|
+
}
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
export function createMemorySessionStore(maxSize = 256) {
|
|
100
|
+
const cache = new Map();
|
|
101
|
+
return {
|
|
102
|
+
async get(mapId) {
|
|
103
|
+
const e = cache.get(asSessionId(mapId));
|
|
104
|
+
if (!e)
|
|
105
|
+
return null;
|
|
106
|
+
e.lastAccessed = Date.now();
|
|
107
|
+
return e;
|
|
108
|
+
},
|
|
109
|
+
async put(mapId, entry) {
|
|
110
|
+
const key = asSessionId(mapId);
|
|
111
|
+
cache.set(key, entry);
|
|
112
|
+
if (cache.size > maxSize) {
|
|
113
|
+
let oldestId = null;
|
|
114
|
+
let oldestAt = Infinity;
|
|
115
|
+
for (const [id, e] of cache) {
|
|
116
|
+
if (e.lastAccessed < oldestAt) {
|
|
117
|
+
oldestAt = e.lastAccessed;
|
|
118
|
+
oldestId = id;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (oldestId)
|
|
122
|
+
cache.delete(oldestId);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
async delete(mapId) {
|
|
126
|
+
cache.delete(asSessionId(mapId));
|
|
127
|
+
},
|
|
128
|
+
async size() {
|
|
129
|
+
return cache.size;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// ── Tool-result compaction (1MB cap, 2026-07-16) ────────────────────────────
|
|
134
|
+
// The tool-result channel on claude.ai / Claude Desktop is capped at ~1MB.
|
|
135
|
+
// Fully-hydrated layers — notably build_map's own `smo_earthquakes_plates`
|
|
136
|
+
// tectonic-plate overlay, large regardless of match count — blow past it, so
|
|
137
|
+
// the `map_id` never reaches the caller (real failure, first Claude Desktop
|
|
138
|
+
// dogfood). `structured` is the REASONING channel: the agent needs to know
|
|
139
|
+
// what layers exist, their viz, and how many features — NOT the raw geometry.
|
|
140
|
+
// The full data lives server-side and renders via `shareable_url` (/m/[id])
|
|
141
|
+
// and `export_layer_data` (returns a URL, never inline bytes).
|
|
142
|
+
//
|
|
143
|
+
// summarizeGeoData walks the structure and replaces every GeoJSON
|
|
144
|
+
// FeatureCollection with a compact summary, preserving all other fields
|
|
145
|
+
// (layer ids, titles, viz, op kinds, cursor) that agents and tests rely on.
|
|
146
|
+
const MAX_STRUCTURED_BYTES = 900_000;
|
|
147
|
+
function summarizeGeoData(node) {
|
|
148
|
+
if (Array.isArray(node))
|
|
149
|
+
return node.map(summarizeGeoData);
|
|
150
|
+
if (node && typeof node === 'object') {
|
|
151
|
+
const rec = node;
|
|
152
|
+
if (rec.type === 'FeatureCollection' && Array.isArray(rec.features)) {
|
|
153
|
+
const feats = rec.features;
|
|
154
|
+
const geometryTypes = Array.from(new Set(feats.map((f) => f?.geometry?.type).filter((t) => typeof t === 'string')));
|
|
155
|
+
return { type: 'FeatureCollection', _smo_compacted: true, feature_count: feats.length, geometry_types: geometryTypes };
|
|
156
|
+
}
|
|
157
|
+
const out = {};
|
|
158
|
+
for (const [k, v] of Object.entries(rec))
|
|
159
|
+
out[k] = summarizeGeoData(v);
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
return node;
|
|
163
|
+
}
|
|
164
|
+
function compactStructured(workspace, mapPlan) {
|
|
165
|
+
const compacted = summarizeGeoData({ workspace, mapPlan });
|
|
166
|
+
// Safety net: if a non-FeatureCollection heavy blob still pushes us near the
|
|
167
|
+
// cap, degrade to minimal layer stubs so map_id ALWAYS returns. Full data
|
|
168
|
+
// remains at shareable_url + export_layer_data.
|
|
169
|
+
try {
|
|
170
|
+
if (JSON.stringify(compacted).length > MAX_STRUCTURED_BYTES) {
|
|
171
|
+
const stubs = Array.isArray(mapPlan?.layers)
|
|
172
|
+
? mapPlan.layers.map((l) => {
|
|
173
|
+
const layer = l;
|
|
174
|
+
return { id: layer.id, title: layer.title, viz: layer.viz, _smo_truncated: true };
|
|
175
|
+
})
|
|
176
|
+
: [];
|
|
177
|
+
return {
|
|
178
|
+
workspace: { cursor: workspace?.cursor, _smo_truncated: true },
|
|
179
|
+
mapPlan: { title: mapPlan?.title, layers: stubs, _smo_truncated: true },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
/* stringify blew up — return the compacted best-effort */
|
|
185
|
+
}
|
|
186
|
+
return compacted;
|
|
187
|
+
}
|
|
188
|
+
function envelope(baseUrl, mapId, workspace, mapPlan, nextActionStrings, extras = {}) {
|
|
189
|
+
const layerCount = Array.isArray(mapPlan?.layers) ? mapPlan.layers.length : 0;
|
|
190
|
+
const opsCount = Array.isArray(workspace?.opsLog) ? workspace.opsLog.length : 0;
|
|
191
|
+
return {
|
|
192
|
+
ok: true,
|
|
193
|
+
map_id: mapId,
|
|
194
|
+
version: opsCount,
|
|
195
|
+
structured: compactStructured(workspace, mapPlan),
|
|
196
|
+
shareable_url: `${baseUrl}/m/${mapId}`,
|
|
197
|
+
metadata: {
|
|
198
|
+
title: mapPlan?.title,
|
|
199
|
+
layer_count: layerCount,
|
|
200
|
+
last_updated: new Date().toISOString(),
|
|
201
|
+
},
|
|
202
|
+
next_actions: (nextActionStrings ?? []).slice(0, 3).map((label) => ({
|
|
203
|
+
label,
|
|
204
|
+
argsTemplate: { instruction: label },
|
|
205
|
+
})),
|
|
206
|
+
...(extras.outOfScope ? { outOfScope: extras.outOfScope } : {}),
|
|
207
|
+
...(extras.clarification ? { clarification: extras.clarification } : {}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function toContent(obj) {
|
|
211
|
+
return {
|
|
212
|
+
content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Runtime input validation (W1/C2, 2026-04-27)
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// The MCP SDK runs `safeParseAsync` against `inputSchema` at the wire layer
|
|
219
|
+
// (server/mcp.js :: validateToolInput), but two gaps slip through:
|
|
220
|
+
//
|
|
221
|
+
// 1. `z.object(shape)` defaults to passthrough — unknown keys are silently
|
|
222
|
+
// kept. We want strict rejection so an agent that hallucinates a
|
|
223
|
+
// `radiusField` (or any other dead key) gets a self-correctable error
|
|
224
|
+
// instead of a silently-ignored argument.
|
|
225
|
+
// 2. Direct handler invocations (the unit-test harness, future internal
|
|
226
|
+
// callers) bypass the SDK's wire-layer validation entirely.
|
|
227
|
+
//
|
|
228
|
+
// `withValidation` closes both gaps. Each tool registers its raw zod-shape
|
|
229
|
+
// inputSchema as before (keeps the SDK happy) AND wraps its handler in a
|
|
230
|
+
// strict-object validator. On failure we return a structured `invalid_input`
|
|
231
|
+
// envelope (isError:true + content[].text JSON) so the calling agent can
|
|
232
|
+
// self-correct.
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
const INVALID_INPUT_HINTS = {
|
|
235
|
+
build_map: 'Required: query (string, ≥1 char), rationale (string, 1–300 chars). No other fields accepted.',
|
|
236
|
+
mutate_map: 'Required: map_id, instruction, rationale. No other fields accepted.',
|
|
237
|
+
get_state: 'Required: map_id, rationale. No other fields accepted.',
|
|
238
|
+
add_layer: 'Required: map_id, layerPlan (object), rationale. Optional: intent (enum). No other fields accepted.',
|
|
239
|
+
filter_layer: 'Required: map_id, layerId, predicate (object), rationale. Optional: append (boolean), intent (enum).',
|
|
240
|
+
select_within: 'Required: map_id, sourceLayerId, newLayerId, selection ({type:Polygon|MultiPolygon, coordinates}), rationale. Optional: inheritStyle.',
|
|
241
|
+
query_features: 'Required: map_id, layerId, predicate (object), rationale. Optional: limit (1–1000 integer).',
|
|
242
|
+
remove_layer: 'Required: map_id, layerId, rationale, confirmed:true (destructive — layer ids are tombstoned).',
|
|
243
|
+
clear_filters: 'Required: map_id, layerId, rationale, confirmed:true (sensitive — autonomous filter wipe).',
|
|
244
|
+
rename_layer: 'Required: map_id, oldLayerId, newLayerId, rationale.',
|
|
245
|
+
restyle_layer: 'Required: map_id, layerId, rationale. Optional: color (string), opacity (0–1).',
|
|
246
|
+
set_camera: 'Required: map_id, camera (mode:fitBounds|flyTo with required fields), rationale.',
|
|
247
|
+
set_time: 'Required: map_id, field, rangeStart (number), rangeEnd (number), rationale. Optional: format (unix_ms|iso|integer).',
|
|
248
|
+
tag: 'Required: map_id, name, rationale.',
|
|
249
|
+
add_annotation: 'Required: map_id, id, coords ([lng,lat]), rationale. Optional: label, color.',
|
|
250
|
+
remove_annotation: 'Required: map_id, annotationId, rationale.',
|
|
251
|
+
export_workspace: 'Required: map_id, rationale.',
|
|
252
|
+
export_layer_data: 'Required: map_id, layer_id, rationale, format ("csv" or "geojson"). No other fields accepted.',
|
|
253
|
+
export_image: 'Required: map_id, rationale. Optional: format ("png" default | "jpeg" | "pdf"), legend (boolean default true). No other fields accepted.',
|
|
254
|
+
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.',
|
|
255
|
+
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.',
|
|
256
|
+
undo: 'Required: map_id, rationale, confirmed:true (sensitive — autonomous undo can erase prior work).',
|
|
257
|
+
redo: 'Required: map_id, rationale.',
|
|
258
|
+
report_blocker: 'Required: what_i_was_trying, what_i_tried, where_i_got_stuck. Optional: map_id, suggested_capability.',
|
|
259
|
+
};
|
|
260
|
+
function buildInvalidInputResponse(toolName, error) {
|
|
261
|
+
const issues = error.issues.map((iss) => ({
|
|
262
|
+
path: iss.path,
|
|
263
|
+
code: iss.code,
|
|
264
|
+
message: iss.message,
|
|
265
|
+
}));
|
|
266
|
+
const payload = {
|
|
267
|
+
error: 'invalid_input',
|
|
268
|
+
tool: toolName,
|
|
269
|
+
issues,
|
|
270
|
+
hint: INVALID_INPUT_HINTS[toolName] ?? 'Check the tool schema and remove unknown keys.',
|
|
271
|
+
};
|
|
272
|
+
return {
|
|
273
|
+
isError: true,
|
|
274
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Wrap a tool handler in a strict-object zod validator. Returns the
|
|
279
|
+
* `invalid_input` envelope on parse failure; otherwise hands the parsed
|
|
280
|
+
* data to the inner handler.
|
|
281
|
+
*
|
|
282
|
+
* The MCP SDK's `inputSchema` config field still receives the same raw
|
|
283
|
+
* shape so its wire-layer validation continues to work. This wrapper is
|
|
284
|
+
* the second layer that catches direct invocations and adds
|
|
285
|
+
* unknown-key rejection (`.strict()`).
|
|
286
|
+
*
|
|
287
|
+
* Typing: we accept any return type from the inner handler (the SDK's
|
|
288
|
+
* CallToolResult union is broader than what we emit) and merge it with
|
|
289
|
+
* the InvalidInputEnvelope branch. The cast at the bottom keeps the
|
|
290
|
+
* SDK's strict overload happy without silently widening the schema —
|
|
291
|
+
* the runtime parser is the source of truth.
|
|
292
|
+
*/
|
|
293
|
+
function withValidation(toolName, shape, handler) {
|
|
294
|
+
const parser = z.object(shape).strict();
|
|
295
|
+
return ((args, extra) => {
|
|
296
|
+
const parsed = parser.safeParse(args);
|
|
297
|
+
if (!parsed.success) {
|
|
298
|
+
return buildInvalidInputResponse(toolName, parsed.error);
|
|
299
|
+
}
|
|
300
|
+
return handler(parsed.data, extra);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
function buildNeedsConfirmationResponse(toolName) {
|
|
304
|
+
const meta = MCP_TOOL_META[toolName];
|
|
305
|
+
const payload = {
|
|
306
|
+
error: 'needs_confirmation',
|
|
307
|
+
tool: toolName,
|
|
308
|
+
message: 'This tool is marked destructive or sensitive. Re-call with confirmed:true to proceed.',
|
|
309
|
+
meta: {
|
|
310
|
+
destructive: meta?.destructive ?? false,
|
|
311
|
+
requires_confirmation: meta?.requires_confirmation ?? true,
|
|
312
|
+
rationale: meta?.rationale,
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
return {
|
|
316
|
+
isError: true,
|
|
317
|
+
content: [{ type: 'text', text: JSON.stringify(payload) }],
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Wrap a (post-validation) tool handler so that gated tools require
|
|
322
|
+
* `confirmed: true`. Composes outside `withValidation` — the inner
|
|
323
|
+
* handler never sees the `confirmed` field (it has fulfilled its
|
|
324
|
+
* gating purpose by the time we delegate).
|
|
325
|
+
*/
|
|
326
|
+
function withConfirmation(toolName, handler) {
|
|
327
|
+
// Non-gated tools: pass through untouched. Avoids any per-call overhead.
|
|
328
|
+
if (!mcpRequiresConfirmation(toolName)) {
|
|
329
|
+
return handler;
|
|
330
|
+
}
|
|
331
|
+
return ((args, extra) => {
|
|
332
|
+
if (args?.confirmed !== true) {
|
|
333
|
+
return buildNeedsConfirmationResponse(toolName);
|
|
334
|
+
}
|
|
335
|
+
// Strip `confirmed` so downstream handlers + telemetry don't see it.
|
|
336
|
+
// We keep the rest of the args intact. We use a destructuring rest
|
|
337
|
+
// pattern + intentional throwaway binding for the dropped key.
|
|
338
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
339
|
+
const { confirmed, ...rest } = args;
|
|
340
|
+
return handler(rest, extra);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
async function apiFetch(baseUrl, apiKey, path, body) {
|
|
344
|
+
const resp = await fetch(`${baseUrl}${path}`, {
|
|
345
|
+
method: 'POST',
|
|
346
|
+
headers: {
|
|
347
|
+
'Content-Type': 'application/json',
|
|
348
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
349
|
+
},
|
|
350
|
+
body: JSON.stringify(body),
|
|
351
|
+
});
|
|
352
|
+
const text = await resp.text();
|
|
353
|
+
try {
|
|
354
|
+
return JSON.parse(text);
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
throw new Error(`${path} returned non-JSON (status ${resp.status}): ${text.slice(0, 200)}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Persist the map to the shareable `maps` store (best-effort) so the returned
|
|
362
|
+
* `shareable_url` (/m/<mapId>) actually renders — without this the workspace
|
|
363
|
+
* lives only in the MCP session cache and the /m/ viewer 404s. Saved under the
|
|
364
|
+
* WORKSPACE id so the envelope link resolves unchanged, public=link-reachable
|
|
365
|
+
* (unlisted), attributed to the key owner via the forwarded bearer. The blob
|
|
366
|
+
* mirrors the web save shape (src/lib/share/saveMapSnapshot.ts): mapPlan with
|
|
367
|
+
* geojson `source.data` stripped + a `geojsonLayers` map keyed by layer id +
|
|
368
|
+
* `version`, so /m/[id] rehydrates it faithfully. NEVER throws — a failed
|
|
369
|
+
* share must not fail the build/mutation.
|
|
370
|
+
*/
|
|
371
|
+
async function persistShareableMap(baseUrl, apiKey, mapId, mapPlan, query) {
|
|
372
|
+
if (!mapPlan || !Array.isArray(mapPlan.layers))
|
|
373
|
+
return;
|
|
374
|
+
try {
|
|
375
|
+
const geojsonLayers = {};
|
|
376
|
+
const strippedLayers = mapPlan.layers.map((raw) => {
|
|
377
|
+
const layer = raw;
|
|
378
|
+
if (layer?.source?.kind === 'geojson' && layer.source.data && layer.id) {
|
|
379
|
+
geojsonLayers[layer.id] = layer.source.data;
|
|
380
|
+
return { ...layer, source: { ...layer.source, data: undefined } };
|
|
381
|
+
}
|
|
382
|
+
return layer;
|
|
383
|
+
});
|
|
384
|
+
const title = mapPlan.title ?? mapPlan.layers[0]?.title ?? 'Untitled Map';
|
|
385
|
+
await apiFetch(baseUrl, apiKey, '/api/maps', {
|
|
386
|
+
id: mapId,
|
|
387
|
+
title,
|
|
388
|
+
query,
|
|
389
|
+
blob: { mapPlan: { ...mapPlan, layers: strippedLayers }, geojsonLayers, query, version: 1 },
|
|
390
|
+
isPublic: true,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
/* best-effort — a share-save failure never fails the build */
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
export function createServer(opts) {
|
|
398
|
+
const baseUrl = opts.baseUrl;
|
|
399
|
+
const apiKey = opts.apiKey;
|
|
400
|
+
const store = opts.sessionStore ?? createMemorySessionStore();
|
|
401
|
+
const server = new McpServer({
|
|
402
|
+
name: opts.name ?? 'showmeonmap',
|
|
403
|
+
version: opts.version ?? '0.1.0',
|
|
404
|
+
});
|
|
405
|
+
// -------------------------------------------------------------------------
|
|
406
|
+
// Resource: workspace-ops-spec
|
|
407
|
+
// -------------------------------------------------------------------------
|
|
408
|
+
// Notion-style "fetch the spec at the moment of need" pattern (Teddy Riker,
|
|
409
|
+
// "Designing for Agents" §2, 2026-04-23). Tool descriptions reference this
|
|
410
|
+
// URI so calling agents pull shapes from us rather than guessing.
|
|
411
|
+
// -------------------------------------------------------------------------
|
|
412
|
+
server.registerResource('workspace-ops-spec', WORKSPACE_OPS_SPEC_URI, {
|
|
413
|
+
title: 'ShowMeOnMap workspace ops spec',
|
|
414
|
+
description: 'Predicate grammar, LayerPlan shape, Op catalog, refusal surfaces, and common pitfalls for ShowMeOnMap. Fetch this before constructing structured arguments to filter_layer / add_layer / select_within / set_time / mutate_map.',
|
|
415
|
+
mimeType: 'text/markdown',
|
|
416
|
+
}, async (uri) => ({
|
|
417
|
+
contents: [
|
|
418
|
+
{
|
|
419
|
+
uri: uri.href,
|
|
420
|
+
mimeType: 'text/markdown',
|
|
421
|
+
text: WORKSPACE_OPS_SPEC_TEXT,
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
}));
|
|
425
|
+
// -------------------------------------------------------------------------
|
|
426
|
+
// Shared `rationale` schema + stderr logger.
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// Required on every MCP tool per Teddy Riker's "Designing for Agents"
|
|
429
|
+
// (2026-04-23 §3.1). Hoisted here so the very first registerTool call below
|
|
430
|
+
// can reference it (TS would otherwise flag use-before-declaration).
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
const rationaleSchema = z
|
|
433
|
+
.string()
|
|
434
|
+
.min(1)
|
|
435
|
+
.max(300)
|
|
436
|
+
.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.');
|
|
437
|
+
/**
|
|
438
|
+
* Log a tool call's rationale (and any op-shaped extras) to BOTH stderr
|
|
439
|
+
* and the per-day NDJSON sink. Every tool calls this — direct-op tools
|
|
440
|
+
* also emit an Op carrying the same rationale on `OpBase.rationale`, so
|
|
441
|
+
* the NDJSON is the unified observability stream and the ops log is the
|
|
442
|
+
* authoritative state.
|
|
443
|
+
*
|
|
444
|
+
* `extra.intent` lands in the NDJSON for filter_layer / add_layer.
|
|
445
|
+
* `extra.opKind` lands in the NDJSON when this call emits a typed Op.
|
|
446
|
+
*/
|
|
447
|
+
function logRationale(toolName, mapId, rationale, extra) {
|
|
448
|
+
const safe = rationale.replace(/[\r\n\t]+/g, ' ').slice(0, 300);
|
|
449
|
+
process.stderr.write(`[mcp:rationale] tool=${toolName} mapId=${mapId ?? '-'} rationale="${safe}"${extra?.intent ? ` intent=${extra.intent}` : ''}${extra?.opKind ? ` op=${extra.opKind}` : ''}\n`);
|
|
450
|
+
appendRationaleNdjson({
|
|
451
|
+
ts: new Date().toISOString(),
|
|
452
|
+
tool: toolName,
|
|
453
|
+
...(mapId ? { map_id: mapId } : {}),
|
|
454
|
+
rationale: safe,
|
|
455
|
+
...(extra?.intent ? { intent: extra.intent } : {}),
|
|
456
|
+
...(extra?.opKind ? { op_kind: extra.opKind } : {}),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
const buildMapShape = {
|
|
460
|
+
query: z
|
|
461
|
+
.string()
|
|
462
|
+
.min(1)
|
|
463
|
+
.describe('Natural language: "coffee shops in Tokyo", "GDP by country", "volcanoes in Indonesia", etc.'),
|
|
464
|
+
rationale: rationaleSchema,
|
|
465
|
+
};
|
|
466
|
+
server.registerTool('build_map', {
|
|
467
|
+
title: 'Build a map',
|
|
468
|
+
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. ' +
|
|
469
|
+
'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.',
|
|
470
|
+
inputSchema: buildMapShape,
|
|
471
|
+
}, withValidation('build_map', buildMapShape, async ({ query, rationale }) => {
|
|
472
|
+
logRationale('build_map', null, rationale);
|
|
473
|
+
const resp = (await apiFetch(baseUrl, apiKey, '/api/workspace/new', {
|
|
474
|
+
message: query,
|
|
475
|
+
stream: false,
|
|
476
|
+
rationale,
|
|
477
|
+
}));
|
|
478
|
+
if (resp.type !== 'visualization' || !resp.workspace) {
|
|
479
|
+
throw new Error(`build_map failed: ${JSON.stringify(resp.error ?? resp.message ?? resp).slice(0, 300)}`);
|
|
480
|
+
}
|
|
481
|
+
const mapId = asSessionId(resp.workspace.workspaceId ?? randomUUID());
|
|
482
|
+
await store.put(mapId, {
|
|
483
|
+
workspace: resp.workspace,
|
|
484
|
+
mapPlan: resp.mapPlan ?? null,
|
|
485
|
+
layerFilters: {},
|
|
486
|
+
createdAt: Date.now(),
|
|
487
|
+
lastAccessed: Date.now(),
|
|
488
|
+
});
|
|
489
|
+
const nextActions = [
|
|
490
|
+
'Filter the layer to features where name is not empty',
|
|
491
|
+
'Change the basemap to satellite',
|
|
492
|
+
'Zoom the camera closer',
|
|
493
|
+
];
|
|
494
|
+
// Persist to the shareable store so the returned /m/<mapId> link renders.
|
|
495
|
+
await persistShareableMap(baseUrl, apiKey, mapId, resp.mapPlan, query);
|
|
496
|
+
return toContent(envelope(baseUrl, mapId, resp.workspace, resp.mapPlan, nextActions));
|
|
497
|
+
}));
|
|
498
|
+
const mutateMapShape = {
|
|
499
|
+
map_id: z.string().min(1).describe('map_id from a prior build_map call'),
|
|
500
|
+
instruction: z
|
|
501
|
+
.string()
|
|
502
|
+
.min(1)
|
|
503
|
+
.describe('e.g., "filter to where magnitude is greater than 5", "change basemap to dark"'),
|
|
504
|
+
rationale: rationaleSchema,
|
|
505
|
+
};
|
|
506
|
+
server.registerTool('mutate_map', {
|
|
507
|
+
title: 'Mutate a map',
|
|
508
|
+
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. ' +
|
|
509
|
+
'For the full Op catalog and predicate grammar, fetch the MCP resource showmeonmap://docs/workspace-ops-spec.',
|
|
510
|
+
inputSchema: mutateMapShape,
|
|
511
|
+
}, withValidation('mutate_map', mutateMapShape, async ({ map_id, instruction, rationale }) => {
|
|
512
|
+
const entry = await store.get(map_id);
|
|
513
|
+
if (!entry)
|
|
514
|
+
throw new Error(`Unknown map_id "${map_id}". Call build_map first.`);
|
|
515
|
+
logRationale('mutate_map', map_id, rationale);
|
|
516
|
+
const resp = (await apiFetch(baseUrl, apiKey, '/api/workspace/mutate', {
|
|
517
|
+
workspace: entry.workspace,
|
|
518
|
+
instruction,
|
|
519
|
+
rationale,
|
|
520
|
+
}));
|
|
521
|
+
if (!resp.ok) {
|
|
522
|
+
throw new Error(`mutate_map failed: ${resp.error?.kind ?? 'unknown'} — ${resp.error?.message ?? 'no detail'}`);
|
|
523
|
+
}
|
|
524
|
+
if (resp.outOfScope && (!resp.opsEmitted || resp.opsEmitted.length === 0)) {
|
|
525
|
+
return toContent(envelope(baseUrl, map_id, entry.workspace, entry.mapPlan, resp.nextActions, { outOfScope: resp.outOfScope }));
|
|
526
|
+
}
|
|
527
|
+
await store.put(map_id, {
|
|
528
|
+
workspace: resp.workspace ?? entry.workspace,
|
|
529
|
+
mapPlan: resp.mapPlan ?? entry.mapPlan,
|
|
530
|
+
layerFilters: resp.layerFilters ?? {},
|
|
531
|
+
createdAt: entry.createdAt,
|
|
532
|
+
lastAccessed: Date.now(),
|
|
533
|
+
});
|
|
534
|
+
const mutMapPlan = (resp.mapPlan ?? entry.mapPlan);
|
|
535
|
+
// Update the shareable /m/<map_id> map so the link reflects the mutation.
|
|
536
|
+
await persistShareableMap(baseUrl, apiKey, map_id, mutMapPlan, instruction);
|
|
537
|
+
return toContent(envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), mutMapPlan, resp.nextActions, resp.clarification ? { clarification: resp.clarification } : {}));
|
|
538
|
+
}));
|
|
539
|
+
const getStateShape = {
|
|
540
|
+
map_id: z.string().min(1),
|
|
541
|
+
rationale: rationaleSchema,
|
|
542
|
+
};
|
|
543
|
+
server.registerTool('get_state', {
|
|
544
|
+
title: 'Read map state',
|
|
545
|
+
description: 'Return the current workspace, map plan, and active filters for a given map_id.',
|
|
546
|
+
inputSchema: getStateShape,
|
|
547
|
+
}, withValidation('get_state', getStateShape, async ({ map_id, rationale }) => {
|
|
548
|
+
const entry = await store.get(map_id);
|
|
549
|
+
if (!entry)
|
|
550
|
+
throw new Error(`Unknown map_id "${map_id}". Call build_map first.`);
|
|
551
|
+
logRationale('get_state', map_id, rationale);
|
|
552
|
+
return toContent(envelope(baseUrl, map_id, entry.workspace, entry.mapPlan, undefined));
|
|
553
|
+
}));
|
|
554
|
+
// ===========================================================================
|
|
555
|
+
// V2 direct-op tools (spec §4, 2026-04-17 MCP design)
|
|
556
|
+
// ---------------------------------------------------------------------------
|
|
557
|
+
// These skip the NL roundtrip that mutate_map needs. Strong-model callers
|
|
558
|
+
// construct a typed Op or predicate and we apply it via the reducer directly.
|
|
559
|
+
// Each calls POST /api/workspace/apply-op (write) or /api/workspace/
|
|
560
|
+
// query-features (read). Shared envelope shape with v1 tools.
|
|
561
|
+
// ===========================================================================
|
|
562
|
+
/** Shared helper — POST a constructed Op and fold the result into the store. */
|
|
563
|
+
async function applyOp(map_id, op) {
|
|
564
|
+
const entry = await store.get(map_id);
|
|
565
|
+
if (!entry)
|
|
566
|
+
throw new Error(`Unknown map_id "${map_id}". Call build_map first.`);
|
|
567
|
+
const resp = (await apiFetch(baseUrl, apiKey, '/api/workspace/apply-op', {
|
|
568
|
+
workspace: entry.workspace,
|
|
569
|
+
op,
|
|
570
|
+
}));
|
|
571
|
+
if (!resp.ok) {
|
|
572
|
+
throw new Error(`apply-op failed: ${resp.error?.kind ?? 'unknown'} — ${resp.error?.message ?? 'no detail'}`);
|
|
573
|
+
}
|
|
574
|
+
await store.put(map_id, {
|
|
575
|
+
workspace: resp.workspace ?? entry.workspace,
|
|
576
|
+
mapPlan: resp.mapPlan ?? entry.mapPlan,
|
|
577
|
+
layerFilters: resp.layerFilters ?? {},
|
|
578
|
+
createdAt: entry.createdAt,
|
|
579
|
+
lastAccessed: Date.now(),
|
|
580
|
+
});
|
|
581
|
+
const opMapPlan = (resp.mapPlan ?? entry.mapPlan);
|
|
582
|
+
// Update the shareable /m/<map_id> map so the link reflects the mutation.
|
|
583
|
+
await persistShareableMap(baseUrl, apiKey, map_id, opMapPlan, opMapPlan?.title ?? '');
|
|
584
|
+
return envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), opMapPlan, undefined);
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Shared op-header builder — every direct-op tool fills these fields.
|
|
588
|
+
* `rationale` is required at the MCP tool layer (Teddy Riker, "Designing for
|
|
589
|
+
* Agents" §3.1, 2026-04-23) and passed straight through to OpBase.rationale.
|
|
590
|
+
*/
|
|
591
|
+
function opHeader(rationale, emittedBy = 'agent') {
|
|
592
|
+
return {
|
|
593
|
+
opId: randomUUID(),
|
|
594
|
+
turnId: randomUUID(),
|
|
595
|
+
emittedAt: new Date().toISOString(),
|
|
596
|
+
emittedBy,
|
|
597
|
+
rationale,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
// --- predicate schema (shared by filter_layer + query_features) ------------
|
|
601
|
+
// Keeping this structural — the server re-validates via the zod predicate
|
|
602
|
+
// schema, so the MCP-facing shape can be liberal to avoid client-side
|
|
603
|
+
// schema drift.
|
|
604
|
+
const predicateInputSchema = z
|
|
605
|
+
.record(z.string(), z.unknown())
|
|
606
|
+
.describe('Predicate in the Mongo-shape grammar. Examples: {op:"eq",field:"category",value:"cafe"}, ' +
|
|
607
|
+
'{op:"gt",field:"magnitude",value:5}, {op:"within",geometry:{type:"Polygon",coordinates:[...]}}, ' +
|
|
608
|
+
'{op:"and",children:[...]}. See docs/superpowers/specs/2026-04-17-workspace-ops-log-design.md §6.');
|
|
609
|
+
const addLayerShape = {
|
|
610
|
+
map_id: z.string().min(1).describe('map_id from a prior build_map call'),
|
|
611
|
+
layerPlan: z
|
|
612
|
+
.record(z.string(), z.unknown())
|
|
613
|
+
.describe('Full LayerPlan object. Must include id, title, source, viz, geometry.'),
|
|
614
|
+
rationale: rationaleSchema,
|
|
615
|
+
// intent hint (article §3.3) — optional mental-model marker so we
|
|
616
|
+
// can later route / telemeter based on what the calling agent
|
|
617
|
+
// believed it was doing. Calling agents that don't know which
|
|
618
|
+
// value to send can omit it; we never reject for missing intent.
|
|
619
|
+
intent: z
|
|
620
|
+
.enum(['primary-data', 'context-overlay', 'reference-boundary', 'annotation', 'comparison', 'other'])
|
|
621
|
+
.optional()
|
|
622
|
+
.describe('Optional. Which mental model is this add_layer operating under? ' +
|
|
623
|
+
'"primary-data" = the layer the user actually asked to see; ' +
|
|
624
|
+
'"context-overlay" = supporting data layered on top of a primary; ' +
|
|
625
|
+
'"reference-boundary" = admin/geo boundary used as a backdrop; ' +
|
|
626
|
+
'"annotation" = caller-placed callout / pin layer; ' +
|
|
627
|
+
'"comparison" = a layer added to compare against another; ' +
|
|
628
|
+
'"other" = none of the above.'),
|
|
629
|
+
};
|
|
630
|
+
server.registerTool('add_layer', {
|
|
631
|
+
title: 'Add a layer (direct)',
|
|
632
|
+
description: 'Append a fully-constructed LayerPlan to an existing workspace without going through NL. ' +
|
|
633
|
+
'Pass the same shape the workspace pipeline emits — { id, title, source, viz, geometry, ... }. ' +
|
|
634
|
+
'Prefer this when you already have a structured plan; otherwise use mutate_map. ' +
|
|
635
|
+
'For the full LayerPlan and Op spec, fetch the MCP resource showmeonmap://docs/workspace-ops-spec.',
|
|
636
|
+
inputSchema: addLayerShape,
|
|
637
|
+
}, withValidation('add_layer', addLayerShape, async ({ map_id, layerPlan, rationale, intent }) => {
|
|
638
|
+
logRationale('add_layer', map_id, rationale, { opKind: 'addLayer', ...(intent ? { intent } : {}) });
|
|
639
|
+
const op = {
|
|
640
|
+
...opHeader(rationale),
|
|
641
|
+
kind: 'addLayer',
|
|
642
|
+
args: {
|
|
643
|
+
layerPlan,
|
|
644
|
+
...(intent ? { intent } : {}),
|
|
645
|
+
},
|
|
646
|
+
};
|
|
647
|
+
return toContent(await applyOp(map_id, op));
|
|
648
|
+
}));
|
|
649
|
+
const addFusionLayerShape = {
|
|
650
|
+
map_id: z.string().min(1),
|
|
651
|
+
layer_a_id: z.string().min(1),
|
|
652
|
+
layer_b_id: z.string().min(1),
|
|
653
|
+
mode: z.literal('bivariate').default('bivariate'),
|
|
654
|
+
value_field_a: z.string().optional(),
|
|
655
|
+
value_field_b: z.string().optional(),
|
|
656
|
+
new_layer_id: z.string().optional(),
|
|
657
|
+
rationale: rationaleSchema,
|
|
658
|
+
};
|
|
659
|
+
server.registerTool('add_fusion_layer', {
|
|
660
|
+
title: 'Add bivariate fusion layer',
|
|
661
|
+
description: 'Spatially join two existing layers and add a new layer that ' +
|
|
662
|
+
'encodes both values per region as a 3x3 bivariate choropleth. ' +
|
|
663
|
+
'Both layers must already exist in the workspace (call build_map ' +
|
|
664
|
+
'or add_layer first). Auto-detects the numeric value field from ' +
|
|
665
|
+
'each layer\'s viz config; pass value_field_a / value_field_b to ' +
|
|
666
|
+
'override. Returns the new layer_id, correlation r, and ' +
|
|
667
|
+
'matched-feature count. Required: map_id, layer_a_id, ' +
|
|
668
|
+
'layer_b_id, rationale.',
|
|
669
|
+
inputSchema: addFusionLayerShape,
|
|
670
|
+
}, withValidation('add_fusion_layer', addFusionLayerShape, async ({ map_id, layer_a_id, layer_b_id, value_field_a, value_field_b, new_layer_id, rationale, }) => {
|
|
671
|
+
const entry = await store.get(map_id);
|
|
672
|
+
if (!entry)
|
|
673
|
+
throw new Error(`Unknown map_id "${map_id}". Call build_map first.`);
|
|
674
|
+
logRationale('add_fusion_layer', map_id, rationale, { opKind: 'addLayer' });
|
|
675
|
+
const data = await apiFetch(baseUrl, apiKey, '/api/workspace/apply-fusion-op', {
|
|
676
|
+
workspace: entry.workspace,
|
|
677
|
+
sourceLayerA: layer_a_id,
|
|
678
|
+
sourceLayerB: layer_b_id,
|
|
679
|
+
...(value_field_a ? { valueFieldA: value_field_a } : {}),
|
|
680
|
+
...(value_field_b ? { valueFieldB: value_field_b } : {}),
|
|
681
|
+
...(new_layer_id ? { newLayerId: new_layer_id } : {}),
|
|
682
|
+
header: { rationale },
|
|
683
|
+
});
|
|
684
|
+
if (!data.ok) {
|
|
685
|
+
throw new Error(`add_fusion_layer failed: ${data.error?.kind ?? 'unknown'} — ${data.error?.reason ?? data.error?.message ?? 'no detail'}`);
|
|
686
|
+
}
|
|
687
|
+
await store.put(map_id, {
|
|
688
|
+
workspace: data.workspace,
|
|
689
|
+
mapPlan: data.mapPlan,
|
|
690
|
+
layerFilters: data.layerFilters ?? {},
|
|
691
|
+
createdAt: entry.createdAt,
|
|
692
|
+
lastAccessed: Date.now(),
|
|
693
|
+
});
|
|
694
|
+
return toContent({
|
|
695
|
+
ok: true,
|
|
696
|
+
layer_id: data.layer_id,
|
|
697
|
+
correlation: data.correlation,
|
|
698
|
+
matchedCount: data.matchedCount,
|
|
699
|
+
outliers: data.outliers,
|
|
700
|
+
});
|
|
701
|
+
}));
|
|
702
|
+
const correlateLayersShape = {
|
|
703
|
+
map_id: z.string().min(1),
|
|
704
|
+
layer_a_id: z.string().min(1),
|
|
705
|
+
layer_b_id: z.string().min(1),
|
|
706
|
+
value_field_a: z.string().optional(),
|
|
707
|
+
value_field_b: z.string().optional(),
|
|
708
|
+
rationale: rationaleSchema,
|
|
709
|
+
};
|
|
710
|
+
server.registerTool('correlate_layers', {
|
|
711
|
+
title: 'Compute correlation between two layers',
|
|
712
|
+
description: 'Spatially join two existing workspace layers and return the ' +
|
|
713
|
+
'Pearson correlation coefficient r, sample size n, two-sided ' +
|
|
714
|
+
'p-value approximation, plus human-readable strength and ' +
|
|
715
|
+
'direction labels. Pure read — no layer is added, no op is ' +
|
|
716
|
+
'committed, no credit charge. Use this when you want a numeric ' +
|
|
717
|
+
'answer without rendering; use add_fusion_layer when the user ' +
|
|
718
|
+
'wants the visualization. Required: map_id, layer_a_id, ' +
|
|
719
|
+
'layer_b_id, rationale.',
|
|
720
|
+
inputSchema: correlateLayersShape,
|
|
721
|
+
}, withValidation('correlate_layers', correlateLayersShape, async ({ map_id, layer_a_id, layer_b_id, value_field_a, value_field_b, rationale, }) => {
|
|
722
|
+
const entry = await store.get(map_id);
|
|
723
|
+
if (!entry)
|
|
724
|
+
throw new Error(`Unknown map_id "${map_id}". Call build_map first.`);
|
|
725
|
+
logRationale('correlate_layers', map_id, rationale);
|
|
726
|
+
const data = await apiFetch(baseUrl, apiKey, '/api/workspace/correlate-layers', {
|
|
727
|
+
workspace: entry.workspace,
|
|
728
|
+
sourceLayerA: layer_a_id,
|
|
729
|
+
sourceLayerB: layer_b_id,
|
|
730
|
+
...(value_field_a ? { valueFieldA: value_field_a } : {}),
|
|
731
|
+
...(value_field_b ? { valueFieldB: value_field_b } : {}),
|
|
732
|
+
});
|
|
733
|
+
if (!data.ok) {
|
|
734
|
+
throw new Error(`correlate_layers failed: ${data.error?.kind ?? 'unknown'} — ${data.error?.reason ?? data.error?.message ?? 'no detail'}`);
|
|
735
|
+
}
|
|
736
|
+
// Pure read — do NOT update the session-store entry. Subsequent
|
|
737
|
+
// get_state / mutate_map calls see the same workspace they would
|
|
738
|
+
// have seen before this call.
|
|
739
|
+
return toContent({
|
|
740
|
+
ok: true,
|
|
741
|
+
r: data.r,
|
|
742
|
+
n: data.n,
|
|
743
|
+
pValue: data.pValue,
|
|
744
|
+
matchedCount: data.matchedCount,
|
|
745
|
+
strength: data.strength,
|
|
746
|
+
direction: data.direction,
|
|
747
|
+
outliers: data.outliers,
|
|
748
|
+
});
|
|
749
|
+
}));
|
|
750
|
+
const filterLayerShape = {
|
|
751
|
+
map_id: z.string().min(1),
|
|
752
|
+
layerId: z.string().min(1).describe('id of the layer to filter'),
|
|
753
|
+
predicate: predicateInputSchema,
|
|
754
|
+
append: z
|
|
755
|
+
.boolean()
|
|
756
|
+
.optional()
|
|
757
|
+
.describe('Default true. false replaces the layer\'s existing filter chain.'),
|
|
758
|
+
rationale: rationaleSchema,
|
|
759
|
+
// intent hint (article §3.3) — optional mental-model marker so we
|
|
760
|
+
// can later route / telemeter based on what the calling agent
|
|
761
|
+
// believed it was doing. Calling agents that don't know which
|
|
762
|
+
// value to send can omit it; we never reject for missing intent.
|
|
763
|
+
intent: z
|
|
764
|
+
.enum(['narrow', 'isolate-outliers', 'compare-subset', 'preview-data', 'other'])
|
|
765
|
+
.optional()
|
|
766
|
+
.describe('Optional. Which mental model is this filter operating under? ' +
|
|
767
|
+
'"narrow" = generic shrink-the-set; ' +
|
|
768
|
+
'"isolate-outliers" = pull out anomalies for inspection; ' +
|
|
769
|
+
'"compare-subset" = pre-step before a side-by-side compare; ' +
|
|
770
|
+
'"preview-data" = exploratory peek; ' +
|
|
771
|
+
'"other" = none of the above.'),
|
|
772
|
+
};
|
|
773
|
+
server.registerTool('filter_layer', {
|
|
774
|
+
title: 'Filter a layer (direct)',
|
|
775
|
+
description: 'Narrow a layer to features matching a structured predicate. `append:true` (default) conjoins ' +
|
|
776
|
+
'with any existing filter; `append:false` replaces the chain.',
|
|
777
|
+
inputSchema: filterLayerShape,
|
|
778
|
+
}, withValidation('filter_layer', filterLayerShape, async ({ map_id, layerId, predicate, append, rationale, intent }) => {
|
|
779
|
+
logRationale('filter_layer', map_id, rationale, { opKind: 'filterLayer', ...(intent ? { intent } : {}) });
|
|
780
|
+
const op = {
|
|
781
|
+
...opHeader(rationale),
|
|
782
|
+
kind: 'filterLayer',
|
|
783
|
+
args: {
|
|
784
|
+
layerId,
|
|
785
|
+
predicate,
|
|
786
|
+
...(typeof append === 'boolean' ? { append } : {}),
|
|
787
|
+
...(intent ? { intent } : {}),
|
|
788
|
+
},
|
|
789
|
+
};
|
|
790
|
+
return toContent(await applyOp(map_id, op));
|
|
791
|
+
}));
|
|
792
|
+
const selectWithinShape = {
|
|
793
|
+
map_id: z.string().min(1),
|
|
794
|
+
sourceLayerId: z.string().min(1),
|
|
795
|
+
newLayerId: z
|
|
796
|
+
.string()
|
|
797
|
+
.min(1)
|
|
798
|
+
.describe('Id for the new layer. Must not already be in use in this workspace.'),
|
|
799
|
+
selection: z
|
|
800
|
+
.object({
|
|
801
|
+
type: z.enum(['Polygon', 'MultiPolygon']),
|
|
802
|
+
coordinates: z.array(z.any()),
|
|
803
|
+
})
|
|
804
|
+
.describe('GeoJSON Polygon or MultiPolygon in [lng,lat] coordinate order.'),
|
|
805
|
+
inheritStyle: z
|
|
806
|
+
.boolean()
|
|
807
|
+
.optional()
|
|
808
|
+
.describe('Default true. false emits a new layer with minimal default styling.'),
|
|
809
|
+
rationale: rationaleSchema,
|
|
810
|
+
};
|
|
811
|
+
server.registerTool('select_within', {
|
|
812
|
+
title: 'Subset a layer to a polygon selection',
|
|
813
|
+
description: 'Create a new layer containing the source layer\'s features that lie within the supplied ' +
|
|
814
|
+
'polygon. The source layer is untouched. Useful for "show me only the X inside this ' +
|
|
815
|
+
'country/bbox/hand-drawn shape."',
|
|
816
|
+
inputSchema: selectWithinShape,
|
|
817
|
+
}, withValidation('select_within', selectWithinShape, async ({ map_id, sourceLayerId, newLayerId, selection, inheritStyle, rationale }) => {
|
|
818
|
+
logRationale('select_within', map_id, rationale, { opKind: 'subsetToSelection' });
|
|
819
|
+
const op = {
|
|
820
|
+
...opHeader(rationale),
|
|
821
|
+
kind: 'subsetToSelection',
|
|
822
|
+
args: {
|
|
823
|
+
sourceLayerId,
|
|
824
|
+
newLayerId,
|
|
825
|
+
selection,
|
|
826
|
+
...(typeof inheritStyle === 'boolean' ? { inheritStyle } : {}),
|
|
827
|
+
},
|
|
828
|
+
};
|
|
829
|
+
return toContent(await applyOp(map_id, op));
|
|
830
|
+
}));
|
|
831
|
+
const queryFeaturesShape = {
|
|
832
|
+
map_id: z.string().min(1),
|
|
833
|
+
layerId: z.string().min(1),
|
|
834
|
+
predicate: predicateInputSchema,
|
|
835
|
+
limit: z
|
|
836
|
+
.number()
|
|
837
|
+
.int()
|
|
838
|
+
.positive()
|
|
839
|
+
.max(1000)
|
|
840
|
+
.optional()
|
|
841
|
+
.describe('Default 100, max 1000. Response sets `truncated:true` when clipped.'),
|
|
842
|
+
rationale: rationaleSchema,
|
|
843
|
+
};
|
|
844
|
+
server.registerTool('query_features', {
|
|
845
|
+
title: 'Query features from a layer (read-only)',
|
|
846
|
+
description: 'Evaluate a predicate against a layer\'s inline features and return the matching subset. ' +
|
|
847
|
+
'No Op is emitted and the workspace is not mutated. Handy for introspection ("how many X ' +
|
|
848
|
+
'match Y?") before deciding whether to filter, subset, or build a new layer.',
|
|
849
|
+
inputSchema: queryFeaturesShape,
|
|
850
|
+
}, withValidation('query_features', queryFeaturesShape, async ({ map_id, layerId, predicate, limit, rationale }) => {
|
|
851
|
+
const entry = await store.get(map_id);
|
|
852
|
+
if (!entry)
|
|
853
|
+
throw new Error(`Unknown map_id "${map_id}". Call build_map first.`);
|
|
854
|
+
logRationale('query_features', map_id, rationale);
|
|
855
|
+
const resp = (await apiFetch(baseUrl, apiKey, '/api/workspace/query-features', {
|
|
856
|
+
workspace: entry.workspace,
|
|
857
|
+
layerId,
|
|
858
|
+
predicate,
|
|
859
|
+
...(typeof limit === 'number' ? { limit } : {}),
|
|
860
|
+
}));
|
|
861
|
+
if (!resp.ok) {
|
|
862
|
+
throw new Error(`query_features failed: ${resp.error?.kind ?? 'unknown'} — ${resp.error?.message ?? 'no detail'}`);
|
|
863
|
+
}
|
|
864
|
+
return toContent({
|
|
865
|
+
ok: true,
|
|
866
|
+
map_id,
|
|
867
|
+
layerId,
|
|
868
|
+
totalMatched: resp.totalMatched ?? 0,
|
|
869
|
+
truncated: resp.truncated ?? false,
|
|
870
|
+
features: resp.features ?? [],
|
|
871
|
+
});
|
|
872
|
+
}));
|
|
873
|
+
// ===========================================================================
|
|
874
|
+
// V2 direct-op tools — catalog completion (remaining 8 ops).
|
|
875
|
+
// ---------------------------------------------------------------------------
|
|
876
|
+
// Same pattern as add_layer / filter_layer / select_within — each wraps a
|
|
877
|
+
// single Op kind, constructs it with opHeader(), and ships through
|
|
878
|
+
// /api/workspace/apply-op. These exist so strong-model callers can skip
|
|
879
|
+
// the NL roundtrip for any op in the spec catalog.
|
|
880
|
+
// ===========================================================================
|
|
881
|
+
const removeLayerShape = {
|
|
882
|
+
map_id: z.string().min(1),
|
|
883
|
+
layerId: z.string().min(1),
|
|
884
|
+
rationale: rationaleSchema,
|
|
885
|
+
confirmed: z
|
|
886
|
+
.boolean()
|
|
887
|
+
.optional()
|
|
888
|
+
.describe('Set to true to confirm this destructive action. If omitted or false, the call ' +
|
|
889
|
+
'returns a needs_confirmation envelope. Layer ids are tombstoned — once removed, ' +
|
|
890
|
+
'the same id cannot be re-added later in this workspace.'),
|
|
891
|
+
};
|
|
892
|
+
server.registerTool('remove_layer', {
|
|
893
|
+
title: 'Remove a layer (direct, destructive)',
|
|
894
|
+
description: 'Drop a layer from the workspace by id. Strict: removing an already-removed ' +
|
|
895
|
+
'or never-added layer fails. Layer ids do NOT recycle — a removed id cannot be ' +
|
|
896
|
+
're-added later in the same workspace. ' +
|
|
897
|
+
'GATED: this tool is destructive. Pass confirmed:true to proceed; otherwise ' +
|
|
898
|
+
'a needs_confirmation envelope is returned.',
|
|
899
|
+
inputSchema: removeLayerShape,
|
|
900
|
+
}, withValidation('remove_layer', removeLayerShape, withConfirmation('remove_layer', async ({ map_id, layerId, rationale }) => {
|
|
901
|
+
logRationale('remove_layer', map_id, rationale, { opKind: 'removeLayer' });
|
|
902
|
+
const op = {
|
|
903
|
+
...opHeader(rationale),
|
|
904
|
+
kind: 'removeLayer',
|
|
905
|
+
args: { layerId },
|
|
906
|
+
};
|
|
907
|
+
return toContent(await applyOp(map_id, op));
|
|
908
|
+
})));
|
|
909
|
+
const clearFiltersShape = {
|
|
910
|
+
map_id: z.string().min(1),
|
|
911
|
+
layerId: z.string().min(1),
|
|
912
|
+
rationale: rationaleSchema,
|
|
913
|
+
confirmed: z
|
|
914
|
+
.boolean()
|
|
915
|
+
.optional()
|
|
916
|
+
.describe('Set to true to confirm this sensitive action. If omitted or false, the call ' +
|
|
917
|
+
'returns a needs_confirmation envelope. Wiping a filter chain is recoverable ' +
|
|
918
|
+
'via undo, but autonomous agents lack a UI surface to confirm intent.'),
|
|
919
|
+
};
|
|
920
|
+
server.registerTool('clear_filters', {
|
|
921
|
+
title: 'Clear all filters on a layer (direct, gated)',
|
|
922
|
+
description: 'Wipe every predicate currently applied to a layer\'s filter chain, making ' +
|
|
923
|
+
'every source feature visible again. Inverse of filter_layer. No-op on layers ' +
|
|
924
|
+
'that have no existing filter. ' +
|
|
925
|
+
'GATED: pass confirmed:true to proceed; otherwise a needs_confirmation envelope is returned.',
|
|
926
|
+
inputSchema: clearFiltersShape,
|
|
927
|
+
}, withValidation('clear_filters', clearFiltersShape, withConfirmation('clear_filters', async ({ map_id, layerId, rationale }) => {
|
|
928
|
+
logRationale('clear_filters', map_id, rationale, { opKind: 'clearFilters' });
|
|
929
|
+
const op = {
|
|
930
|
+
...opHeader(rationale),
|
|
931
|
+
kind: 'clearFilters',
|
|
932
|
+
args: { layerId },
|
|
933
|
+
};
|
|
934
|
+
return toContent(await applyOp(map_id, op));
|
|
935
|
+
})));
|
|
936
|
+
const renameLayerShape = {
|
|
937
|
+
map_id: z.string().min(1),
|
|
938
|
+
oldLayerId: z.string().min(1),
|
|
939
|
+
newLayerId: z.string().min(1).describe('New id. Must be unique within the workspace.'),
|
|
940
|
+
rationale: rationaleSchema,
|
|
941
|
+
};
|
|
942
|
+
server.registerTool('rename_layer', {
|
|
943
|
+
title: 'Rename a layer (direct)',
|
|
944
|
+
description: 'Change a layer\'s id from oldLayerId to newLayerId. Re-keys any existing filter ' +
|
|
945
|
+
'chain and preserves insertion order among sibling layers. Strict: newLayerId must ' +
|
|
946
|
+
'not already be in use (current OR tombstoned) in this workspace. Subsequent ops ' +
|
|
947
|
+
'must reference the layer by its new id.',
|
|
948
|
+
inputSchema: renameLayerShape,
|
|
949
|
+
}, withValidation('rename_layer', renameLayerShape, async ({ map_id, oldLayerId, newLayerId, rationale }) => {
|
|
950
|
+
logRationale('rename_layer', map_id, rationale, { opKind: 'renameLayer' });
|
|
951
|
+
const op = {
|
|
952
|
+
...opHeader(rationale),
|
|
953
|
+
kind: 'renameLayer',
|
|
954
|
+
args: { oldLayerId, newLayerId },
|
|
955
|
+
};
|
|
956
|
+
return toContent(await applyOp(map_id, op));
|
|
957
|
+
}));
|
|
958
|
+
const restyleLayerShape = {
|
|
959
|
+
map_id: z.string().min(1),
|
|
960
|
+
layerId: z.string().min(1),
|
|
961
|
+
color: z
|
|
962
|
+
.string()
|
|
963
|
+
.min(1)
|
|
964
|
+
.optional()
|
|
965
|
+
.describe('CSS color string: hex (#RRGGBB), rgb/rgba, or named color.'),
|
|
966
|
+
opacity: z
|
|
967
|
+
.number()
|
|
968
|
+
.min(0)
|
|
969
|
+
.max(1)
|
|
970
|
+
.optional()
|
|
971
|
+
.describe('0 (transparent) to 1 (opaque).'),
|
|
972
|
+
rationale: rationaleSchema,
|
|
973
|
+
};
|
|
974
|
+
server.registerTool('restyle_layer', {
|
|
975
|
+
title: 'Restyle a layer (direct)',
|
|
976
|
+
description: 'Adjust a layer\'s color and/or opacity. Within-palette changes only — the ' +
|
|
977
|
+
'renderer silently ignores fields the current viz variant doesn\'t support. ' +
|
|
978
|
+
'Pass at least one of color / opacity; passing neither is an accepted no-op.',
|
|
979
|
+
inputSchema: restyleLayerShape,
|
|
980
|
+
}, withValidation('restyle_layer', restyleLayerShape, async ({ map_id, layerId, color, opacity, rationale }) => {
|
|
981
|
+
logRationale('restyle_layer', map_id, rationale, { opKind: 'restyleLayer' });
|
|
982
|
+
const op = {
|
|
983
|
+
...opHeader(rationale),
|
|
984
|
+
kind: 'restyleLayer',
|
|
985
|
+
args: {
|
|
986
|
+
layerId,
|
|
987
|
+
...(typeof color === 'string' ? { color } : {}),
|
|
988
|
+
...(typeof opacity === 'number' ? { opacity } : {}),
|
|
989
|
+
},
|
|
990
|
+
};
|
|
991
|
+
return toContent(await applyOp(map_id, op));
|
|
992
|
+
}));
|
|
993
|
+
// CameraPlan mirrors src/lib/types/mapPlan.ts :: CameraPlan. Keeping the
|
|
994
|
+
// discriminated union local to the tool — the reducer re-validates
|
|
995
|
+
// structurally (workspace.ts declares args.camera as z.unknown) so this
|
|
996
|
+
// schema is purely a client-side shape hint.
|
|
997
|
+
const cameraPlanSchema = z.discriminatedUnion('mode', [
|
|
998
|
+
z.object({
|
|
999
|
+
mode: z.literal('fitBounds'),
|
|
1000
|
+
bounds: z
|
|
1001
|
+
.tuple([z.number(), z.number(), z.number(), z.number()])
|
|
1002
|
+
.describe('[west, south, east, north] bbox in WGS84 degrees.'),
|
|
1003
|
+
padding: z
|
|
1004
|
+
.number()
|
|
1005
|
+
.nonnegative()
|
|
1006
|
+
.optional()
|
|
1007
|
+
.describe('Pixel padding around the bbox. Default is the renderer default.'),
|
|
1008
|
+
animateMs: z
|
|
1009
|
+
.number()
|
|
1010
|
+
.nonnegative()
|
|
1011
|
+
.optional()
|
|
1012
|
+
.describe('Animation duration in ms. 0 = instant jump.'),
|
|
1013
|
+
}),
|
|
1014
|
+
z.object({
|
|
1015
|
+
mode: z.literal('flyTo'),
|
|
1016
|
+
center: z.tuple([z.number(), z.number()]).describe('[lng, lat] in WGS84.'),
|
|
1017
|
+
zoom: z.number(),
|
|
1018
|
+
pitch: z.number().optional().describe('Tilt in degrees, 0-60.'),
|
|
1019
|
+
bearing: z.number().optional().describe('Rotation in degrees, 0-360.'),
|
|
1020
|
+
animateMs: z
|
|
1021
|
+
.number()
|
|
1022
|
+
.nonnegative()
|
|
1023
|
+
.optional()
|
|
1024
|
+
.describe('Animation duration in ms. 0 = instant jump.'),
|
|
1025
|
+
}),
|
|
1026
|
+
]);
|
|
1027
|
+
const setCameraShape = {
|
|
1028
|
+
map_id: z.string().min(1),
|
|
1029
|
+
camera: cameraPlanSchema,
|
|
1030
|
+
rationale: rationaleSchema,
|
|
1031
|
+
};
|
|
1032
|
+
server.registerTool('set_camera', {
|
|
1033
|
+
title: 'Set the map camera (direct)',
|
|
1034
|
+
description: 'Override the workspace camera. Latest set_camera op wins during replay. ' +
|
|
1035
|
+
'Two modes: `fitBounds` with a [w,s,e,n] bbox (plus optional padding/animateMs), ' +
|
|
1036
|
+
'or `flyTo` with [lng,lat] center + zoom (plus optional pitch/bearing/animateMs). ' +
|
|
1037
|
+
'Use for "zoom to Japan", "center on Paris at z=10", or finishing a drill-down ' +
|
|
1038
|
+
'after filter_layer. Equivalent to the camera move mutate_map would emit, but ' +
|
|
1039
|
+
'skips the NL roundtrip.',
|
|
1040
|
+
inputSchema: setCameraShape,
|
|
1041
|
+
}, withValidation('set_camera', setCameraShape, async ({ map_id, camera, rationale }) => {
|
|
1042
|
+
logRationale('set_camera', map_id, rationale, { opKind: 'setCamera' });
|
|
1043
|
+
const op = {
|
|
1044
|
+
...opHeader(rationale),
|
|
1045
|
+
kind: 'setCamera',
|
|
1046
|
+
args: { camera },
|
|
1047
|
+
};
|
|
1048
|
+
return toContent(await applyOp(map_id, op));
|
|
1049
|
+
}));
|
|
1050
|
+
const setTimeShape = {
|
|
1051
|
+
map_id: z.string().min(1),
|
|
1052
|
+
field: z
|
|
1053
|
+
.string()
|
|
1054
|
+
.min(1)
|
|
1055
|
+
.describe('Feature property carrying the time value (e.g. "date", "year", "timestamp").'),
|
|
1056
|
+
rangeStart: z.number().describe('Inclusive lower bound of the visible window.'),
|
|
1057
|
+
rangeEnd: z.number().describe('Inclusive upper bound of the visible window.'),
|
|
1058
|
+
format: z
|
|
1059
|
+
.enum(['unix_ms', 'iso', 'integer'])
|
|
1060
|
+
.optional()
|
|
1061
|
+
.describe('How rangeStart/rangeEnd encode time. Default: unix_ms at render time.'),
|
|
1062
|
+
rationale: rationaleSchema,
|
|
1063
|
+
};
|
|
1064
|
+
server.registerTool('set_time', {
|
|
1065
|
+
title: 'Set the time window (direct)',
|
|
1066
|
+
description: 'Override the current time-range filter. Latest set_time op wins during replay. ' +
|
|
1067
|
+
'Use for "filter to 2020-2024" or "show only the last 7 days" intents. Not yet ' +
|
|
1068
|
+
'consumed by the renderer — the UI drives the slider directly — but the value is ' +
|
|
1069
|
+
'captured in the ops log and queryable via get_state.',
|
|
1070
|
+
inputSchema: setTimeShape,
|
|
1071
|
+
}, withValidation('set_time', setTimeShape, async ({ map_id, field, rangeStart, rangeEnd, format, rationale }) => {
|
|
1072
|
+
logRationale('set_time', map_id, rationale, { opKind: 'setTime' });
|
|
1073
|
+
const op = {
|
|
1074
|
+
...opHeader(rationale),
|
|
1075
|
+
kind: 'setTime',
|
|
1076
|
+
args: {
|
|
1077
|
+
field,
|
|
1078
|
+
rangeStart,
|
|
1079
|
+
rangeEnd,
|
|
1080
|
+
...(format ? { format } : {}),
|
|
1081
|
+
},
|
|
1082
|
+
};
|
|
1083
|
+
return toContent(await applyOp(map_id, op));
|
|
1084
|
+
}));
|
|
1085
|
+
const tagShape = {
|
|
1086
|
+
map_id: z.string().min(1),
|
|
1087
|
+
name: z
|
|
1088
|
+
.string()
|
|
1089
|
+
.min(1)
|
|
1090
|
+
.describe('Checkpoint label, e.g. "before EV layer added" or "v1.0".'),
|
|
1091
|
+
rationale: rationaleSchema,
|
|
1092
|
+
};
|
|
1093
|
+
server.registerTool('tag', {
|
|
1094
|
+
title: 'Save a named checkpoint (direct)',
|
|
1095
|
+
description: 'Label the current workspace state with a user-supplied name. The label is ' +
|
|
1096
|
+
'mirrored into workspace.meta.tags so UIs can render a named-revision history. ' +
|
|
1097
|
+
'Tagging the same label twice is idempotent — no error, no duplicate entry.',
|
|
1098
|
+
inputSchema: tagShape,
|
|
1099
|
+
}, withValidation('tag', tagShape, async ({ map_id, name, rationale }) => {
|
|
1100
|
+
logRationale('tag', map_id, rationale, { opKind: 'tag' });
|
|
1101
|
+
const op = {
|
|
1102
|
+
...opHeader(rationale),
|
|
1103
|
+
kind: 'tag',
|
|
1104
|
+
args: { name },
|
|
1105
|
+
};
|
|
1106
|
+
return toContent(await applyOp(map_id, op));
|
|
1107
|
+
}));
|
|
1108
|
+
const addAnnotationShape = {
|
|
1109
|
+
map_id: z.string().min(1),
|
|
1110
|
+
id: z.string().min(1).describe('Stable id for this annotation — caller-supplied.'),
|
|
1111
|
+
coords: z
|
|
1112
|
+
.tuple([z.number(), z.number()])
|
|
1113
|
+
.describe('[lng, lat] in WGS84.'),
|
|
1114
|
+
label: z.string().optional().describe('Optional text rendered at the point.'),
|
|
1115
|
+
color: z
|
|
1116
|
+
.string()
|
|
1117
|
+
.min(1)
|
|
1118
|
+
.optional()
|
|
1119
|
+
.describe('Optional CSS color for the marker + label.'),
|
|
1120
|
+
rationale: rationaleSchema,
|
|
1121
|
+
};
|
|
1122
|
+
server.registerTool('add_annotation', {
|
|
1123
|
+
title: 'Place an annotation on the map (direct)',
|
|
1124
|
+
description: 'Add a user-placed label at a specific coordinate. Annotations are independent of ' +
|
|
1125
|
+
'layers and live in their own id namespace. Strict: the id must not already be in ' +
|
|
1126
|
+
'use by another CURRENTLY-placed annotation (removed ids can be recycled).',
|
|
1127
|
+
inputSchema: addAnnotationShape,
|
|
1128
|
+
}, withValidation('add_annotation', addAnnotationShape, async ({ map_id, id, coords, label, color, rationale }) => {
|
|
1129
|
+
logRationale('add_annotation', map_id, rationale, { opKind: 'addAnnotation' });
|
|
1130
|
+
const op = {
|
|
1131
|
+
...opHeader(rationale),
|
|
1132
|
+
kind: 'addAnnotation',
|
|
1133
|
+
args: {
|
|
1134
|
+
annotation: {
|
|
1135
|
+
id,
|
|
1136
|
+
coords: coords,
|
|
1137
|
+
...(label ? { label } : {}),
|
|
1138
|
+
...(color ? { color } : {}),
|
|
1139
|
+
},
|
|
1140
|
+
},
|
|
1141
|
+
};
|
|
1142
|
+
return toContent(await applyOp(map_id, op));
|
|
1143
|
+
}));
|
|
1144
|
+
const removeAnnotationShape = {
|
|
1145
|
+
map_id: z.string().min(1),
|
|
1146
|
+
annotationId: z.string().min(1),
|
|
1147
|
+
rationale: rationaleSchema,
|
|
1148
|
+
};
|
|
1149
|
+
server.registerTool('remove_annotation', {
|
|
1150
|
+
title: 'Remove an annotation (direct)',
|
|
1151
|
+
description: 'Drop a previously-placed annotation by id. Strict: removing an already-removed ' +
|
|
1152
|
+
'or never-placed id fails. After removal, the id CAN be re-used by a later ' +
|
|
1153
|
+
'add_annotation (unlike layer ids, which are tombstoned for the session).',
|
|
1154
|
+
inputSchema: removeAnnotationShape,
|
|
1155
|
+
}, withValidation('remove_annotation', removeAnnotationShape, async ({ map_id, annotationId, rationale }) => {
|
|
1156
|
+
logRationale('remove_annotation', map_id, rationale, { opKind: 'removeAnnotation' });
|
|
1157
|
+
const op = {
|
|
1158
|
+
...opHeader(rationale),
|
|
1159
|
+
kind: 'removeAnnotation',
|
|
1160
|
+
args: { annotationId },
|
|
1161
|
+
};
|
|
1162
|
+
return toContent(await applyOp(map_id, op));
|
|
1163
|
+
}));
|
|
1164
|
+
// ===========================================================================
|
|
1165
|
+
// V1 completion — export + undo + redo.
|
|
1166
|
+
// ---------------------------------------------------------------------------
|
|
1167
|
+
// Export is read-only; undo/redo manipulate the workspace cursor without
|
|
1168
|
+
// modifying the ops log, then re-materialize via /api/workspace/materialize
|
|
1169
|
+
// so the caller sees the refreshed mapPlan for the rewound state.
|
|
1170
|
+
// ===========================================================================
|
|
1171
|
+
/** Shared helper — re-materialize a workspace at whatever cursor it carries. */
|
|
1172
|
+
async function rematerialize(map_id, workspace) {
|
|
1173
|
+
const resp = (await apiFetch(baseUrl, apiKey, '/api/workspace/materialize', {
|
|
1174
|
+
workspace,
|
|
1175
|
+
}));
|
|
1176
|
+
if (!resp.ok) {
|
|
1177
|
+
throw new Error(`materialize failed: ${resp.error?.kind ?? 'unknown'} — ${resp.error?.message ?? 'no detail'}`);
|
|
1178
|
+
}
|
|
1179
|
+
const entry = await store.get(map_id);
|
|
1180
|
+
await store.put(map_id, {
|
|
1181
|
+
workspace,
|
|
1182
|
+
mapPlan: resp.mapPlan ?? null,
|
|
1183
|
+
layerFilters: resp.layerFilters ?? {},
|
|
1184
|
+
createdAt: entry?.createdAt ?? Date.now(),
|
|
1185
|
+
lastAccessed: Date.now(),
|
|
1186
|
+
});
|
|
1187
|
+
const rematPlan = resp.mapPlan;
|
|
1188
|
+
// Keep the shareable /m/<map_id> map in sync after undo/redo.
|
|
1189
|
+
await persistShareableMap(baseUrl, apiKey, map_id, rematPlan, rematPlan?.title ?? '');
|
|
1190
|
+
return envelope(baseUrl, map_id, workspace, rematPlan, undefined);
|
|
1191
|
+
}
|
|
1192
|
+
const exportWorkspaceShape = {
|
|
1193
|
+
map_id: z.string().min(1),
|
|
1194
|
+
rationale: rationaleSchema,
|
|
1195
|
+
};
|
|
1196
|
+
server.registerTool('export_workspace', {
|
|
1197
|
+
title: 'Export the workspace JSON',
|
|
1198
|
+
description: 'Return the current workspace document (ops log + cursor + meta) plus the ' +
|
|
1199
|
+
'materialized mapPlan and layerFilters, in a shape suitable for save-to-file / ' +
|
|
1200
|
+
'share-by-URL / inspection. Pure read — no mutation, no credit charge. Use ' +
|
|
1201
|
+
'this when you want the full raw state for serialization; use get_state when ' +
|
|
1202
|
+
'you want the compact envelope for display.',
|
|
1203
|
+
inputSchema: exportWorkspaceShape,
|
|
1204
|
+
}, withValidation('export_workspace', exportWorkspaceShape, async ({ map_id, rationale }) => {
|
|
1205
|
+
const entry = await store.get(map_id);
|
|
1206
|
+
if (!entry)
|
|
1207
|
+
throw new Error(`Unknown map_id "${map_id}". Call build_map first.`);
|
|
1208
|
+
logRationale('export_workspace', map_id, rationale);
|
|
1209
|
+
return toContent({
|
|
1210
|
+
ok: true,
|
|
1211
|
+
map_id,
|
|
1212
|
+
workspace: entry.workspace,
|
|
1213
|
+
mapPlan: entry.mapPlan,
|
|
1214
|
+
layerFilters: entry.layerFilters,
|
|
1215
|
+
exportedAt: new Date().toISOString(),
|
|
1216
|
+
});
|
|
1217
|
+
}));
|
|
1218
|
+
const exportLayerDataShape = {
|
|
1219
|
+
map_id: z.string().min(1),
|
|
1220
|
+
layer_id: z.string().min(1),
|
|
1221
|
+
format: z.enum(['csv', 'geojson']),
|
|
1222
|
+
rationale: rationaleSchema,
|
|
1223
|
+
};
|
|
1224
|
+
server.registerTool('export_layer_data', {
|
|
1225
|
+
title: 'Export layer data',
|
|
1226
|
+
description: 'Export a single layer\'s features as a downloadable file. Returns ' +
|
|
1227
|
+
'a URL with Content-Disposition: attachment so the user\'s browser ' +
|
|
1228
|
+
'downloads it. CSV drops geometry beyond a (lng, lat) centroid — ' +
|
|
1229
|
+
'for full polygon/line geometry, use format: "geojson". URL is ' +
|
|
1230
|
+
'valid for 24 hours. Required: map_id, layer_id, format, rationale.',
|
|
1231
|
+
inputSchema: exportLayerDataShape,
|
|
1232
|
+
}, withValidation('export_layer_data', exportLayerDataShape, async ({ map_id, layer_id, format, rationale }) => {
|
|
1233
|
+
logRationale('export_layer_data', map_id, rationale);
|
|
1234
|
+
const data = await apiFetch(baseUrl, apiKey, '/api/export/layer-data', {
|
|
1235
|
+
map_id, layer_id, format, rationale,
|
|
1236
|
+
});
|
|
1237
|
+
return toContent({ ok: true, ...data });
|
|
1238
|
+
}));
|
|
1239
|
+
const exportImageShape = {
|
|
1240
|
+
map_id: z.string().min(1),
|
|
1241
|
+
format: z.enum(['png', 'jpeg', 'pdf']).default('png'),
|
|
1242
|
+
legend: z.boolean().default(true),
|
|
1243
|
+
rationale: rationaleSchema,
|
|
1244
|
+
};
|
|
1245
|
+
server.registerTool('export_image', {
|
|
1246
|
+
title: 'Export map as image',
|
|
1247
|
+
description: 'Returns a deep-link URL that opens the map in the user\'s browser ' +
|
|
1248
|
+
'and automatically triggers a PNG download. The user clicks the ' +
|
|
1249
|
+
'link, the browser renders the map at full resolution with the ' +
|
|
1250
|
+
'legend composited (if legend: true), and the file downloads. ' +
|
|
1251
|
+
'There is no server-side rendering; the user\'s browser is the ' +
|
|
1252
|
+
'renderer. Required: map_id, rationale. Optional: format (png ' +
|
|
1253
|
+
'default), legend (true default).',
|
|
1254
|
+
inputSchema: exportImageShape,
|
|
1255
|
+
}, withValidation('export_image', exportImageShape, async ({ map_id, format, legend, rationale }) => {
|
|
1256
|
+
logRationale('export_image', map_id, rationale);
|
|
1257
|
+
// Auto-share check — guarantees /m/[id] resolves.
|
|
1258
|
+
try {
|
|
1259
|
+
await apiFetch(baseUrl, apiKey, '/api/export/ensure-shared', { map_id, rationale });
|
|
1260
|
+
}
|
|
1261
|
+
catch (err) {
|
|
1262
|
+
// Surface as a soft warning; the URL still composes, but the user
|
|
1263
|
+
// may get a 404 if the workspace isn't shareable yet.
|
|
1264
|
+
console.warn('[export_image] ensure-shared failed:', err);
|
|
1265
|
+
}
|
|
1266
|
+
const url = `${baseUrl}/m/${encodeURIComponent(map_id)}?autoExport=${format}&legend=${legend ? 'on' : 'off'}`;
|
|
1267
|
+
return toContent({
|
|
1268
|
+
ok: true,
|
|
1269
|
+
format,
|
|
1270
|
+
url,
|
|
1271
|
+
note: 'Open this URL in a browser to download the image.',
|
|
1272
|
+
});
|
|
1273
|
+
}));
|
|
1274
|
+
const undoShape = {
|
|
1275
|
+
map_id: z.string().min(1),
|
|
1276
|
+
rationale: rationaleSchema,
|
|
1277
|
+
confirmed: z
|
|
1278
|
+
.boolean()
|
|
1279
|
+
.optional()
|
|
1280
|
+
.describe('Set to true to confirm this sensitive action. If omitted or false, the call ' +
|
|
1281
|
+
'returns a needs_confirmation envelope. Autonomous undo without user intent is ' +
|
|
1282
|
+
'a footgun — an agent that decides to "fix" something can erase prior work.'),
|
|
1283
|
+
};
|
|
1284
|
+
server.registerTool('undo', {
|
|
1285
|
+
title: 'Undo the most recent op (gated)',
|
|
1286
|
+
description: 'Rewind the workspace cursor by one, re-materializing the state as it was before ' +
|
|
1287
|
+
'the last mutation. Does NOT delete any ops — redo will fast-forward. Calling ' +
|
|
1288
|
+
'undo at the workspace genesis (cursor === 0, only the CreateWorkspace op is in ' +
|
|
1289
|
+
'scope) is a no-op that surfaces an error rather than silently doing nothing. ' +
|
|
1290
|
+
'GATED: pass confirmed:true to proceed; otherwise a needs_confirmation envelope is returned.',
|
|
1291
|
+
inputSchema: undoShape,
|
|
1292
|
+
}, withValidation('undo', undoShape, withConfirmation('undo', async ({ map_id, rationale }) => {
|
|
1293
|
+
const entry = await store.get(map_id);
|
|
1294
|
+
if (!entry)
|
|
1295
|
+
throw new Error(`Unknown map_id "${map_id}". Call build_map first.`);
|
|
1296
|
+
logRationale('undo', map_id, rationale);
|
|
1297
|
+
const ws = entry.workspace;
|
|
1298
|
+
const currentCursor = typeof ws.cursor === 'number' ? ws.cursor : 0;
|
|
1299
|
+
if (currentCursor <= 0) {
|
|
1300
|
+
throw new Error('Nothing to undo — cursor is already at workspace genesis.');
|
|
1301
|
+
}
|
|
1302
|
+
const undone = { ...ws, cursor: currentCursor - 1 };
|
|
1303
|
+
return toContent(await rematerialize(map_id, undone));
|
|
1304
|
+
})));
|
|
1305
|
+
const redoShape = {
|
|
1306
|
+
map_id: z.string().min(1),
|
|
1307
|
+
rationale: rationaleSchema,
|
|
1308
|
+
};
|
|
1309
|
+
server.registerTool('redo', {
|
|
1310
|
+
title: 'Redo a previously-undone op',
|
|
1311
|
+
description: 'Advance the workspace cursor by one toward the tail of the ops log, replaying ' +
|
|
1312
|
+
'the next op that was previously undone. Calling redo when the cursor is already ' +
|
|
1313
|
+
'at the tail (no undone ops to fast-forward) fails rather than silently no-op. ' +
|
|
1314
|
+
'Note: any new op applied via apply-op / mutate-map after an undo truncates the ' +
|
|
1315
|
+
'redo tail (standard edit-stack semantics).',
|
|
1316
|
+
inputSchema: redoShape,
|
|
1317
|
+
}, withValidation('redo', redoShape, async ({ map_id, rationale }) => {
|
|
1318
|
+
const entry = await store.get(map_id);
|
|
1319
|
+
if (!entry)
|
|
1320
|
+
throw new Error(`Unknown map_id "${map_id}". Call build_map first.`);
|
|
1321
|
+
logRationale('redo', map_id, rationale);
|
|
1322
|
+
const ws = entry.workspace;
|
|
1323
|
+
const opsLen = Array.isArray(ws.opsLog) ? ws.opsLog.length : 0;
|
|
1324
|
+
const currentCursor = typeof ws.cursor === 'number' ? ws.cursor : 0;
|
|
1325
|
+
if (currentCursor >= opsLen - 1) {
|
|
1326
|
+
throw new Error('Nothing to redo — cursor is already at the end of the ops log.');
|
|
1327
|
+
}
|
|
1328
|
+
const redone = { ...ws, cursor: currentCursor + 1 };
|
|
1329
|
+
return toContent(await rematerialize(map_id, redone));
|
|
1330
|
+
}));
|
|
1331
|
+
// ===========================================================================
|
|
1332
|
+
// Agent feedback loop (article §3.2)
|
|
1333
|
+
// ---------------------------------------------------------------------------
|
|
1334
|
+
// Standalone tool that a calling agent uses to file "I'm stuck" reports.
|
|
1335
|
+
// No map_id required (an agent stuck *before* any map is built must still
|
|
1336
|
+
// be able to report). Persists to reports/agent-blockers/YYYY-MM-DD.ndjson
|
|
1337
|
+
// via POST /api/agent/report-blocker. Returns a friendly envelope — no
|
|
1338
|
+
// need to mimic build_map's three-channel shape.
|
|
1339
|
+
// ===========================================================================
|
|
1340
|
+
const reportBlockerShape = {
|
|
1341
|
+
map_id: z
|
|
1342
|
+
.string()
|
|
1343
|
+
.min(1)
|
|
1344
|
+
.optional()
|
|
1345
|
+
.describe('Optional. The map_id you were working on, if any.'),
|
|
1346
|
+
what_i_was_trying: z
|
|
1347
|
+
.string()
|
|
1348
|
+
.min(1)
|
|
1349
|
+
.max(500)
|
|
1350
|
+
.describe('High-level intent in one or two sentences. e.g. "merge two filtered choropleths into a fused bivariate map".'),
|
|
1351
|
+
what_i_tried: z
|
|
1352
|
+
.string()
|
|
1353
|
+
.min(1)
|
|
1354
|
+
.max(1000)
|
|
1355
|
+
.describe('Which tools / inputs you attempted, in order. Be specific — tool name + key arg shape is ideal.'),
|
|
1356
|
+
where_i_got_stuck: z
|
|
1357
|
+
.string()
|
|
1358
|
+
.min(1)
|
|
1359
|
+
.max(500)
|
|
1360
|
+
.describe('The specific failure: what came back, what was missing, why you could not proceed.'),
|
|
1361
|
+
suggested_capability: z
|
|
1362
|
+
.string()
|
|
1363
|
+
.min(1)
|
|
1364
|
+
.max(300)
|
|
1365
|
+
.optional()
|
|
1366
|
+
.describe('Optional. A new tool, op, or schema field that would have unblocked you.'),
|
|
1367
|
+
};
|
|
1368
|
+
server.registerTool('report_blocker', {
|
|
1369
|
+
title: 'Report a blocker (agent feedback)',
|
|
1370
|
+
description: 'Tell the ShowMeOnMap team your agent hit something it could not accomplish — an ' +
|
|
1371
|
+
'unsupported intent, a missing tool, an unclear schema, an op that failed in a ' +
|
|
1372
|
+
'confusing way. Use this when build_map / mutate_map / a direct-op tool returns ' +
|
|
1373
|
+
'something you cannot recover from, OR when you would have wanted a different tool ' +
|
|
1374
|
+
'to exist. Records the report to a server-side log; does not unblock the current ' +
|
|
1375
|
+
'session, but feeds future capability work. No map_id required — file the report ' +
|
|
1376
|
+
'even if no map was built.',
|
|
1377
|
+
inputSchema: reportBlockerShape,
|
|
1378
|
+
}, withValidation('report_blocker', reportBlockerShape, async ({ map_id, what_i_was_trying, what_i_tried, where_i_got_stuck, suggested_capability }) => {
|
|
1379
|
+
// Mirror to the rationale NDJSON so the May-1 clustering pass sees a
|
|
1380
|
+
// unified stream of "what did agents try, where did they get stuck".
|
|
1381
|
+
// The agent-blockers route persists the full record separately.
|
|
1382
|
+
logRationale('report_blocker', map_id ?? null, `BLOCKER: ${what_i_was_trying} — stuck: ${where_i_got_stuck}`, { opKind: 'reportBlocker' });
|
|
1383
|
+
const resp = (await apiFetch(baseUrl, apiKey, '/api/agent/report-blocker', {
|
|
1384
|
+
...(map_id ? { map_id } : {}),
|
|
1385
|
+
what_i_was_trying,
|
|
1386
|
+
what_i_tried,
|
|
1387
|
+
where_i_got_stuck,
|
|
1388
|
+
...(suggested_capability ? { suggested_capability } : {}),
|
|
1389
|
+
source: 'mcp',
|
|
1390
|
+
}));
|
|
1391
|
+
if (!resp.ok) {
|
|
1392
|
+
throw new Error(`report_blocker failed: ${resp.error?.kind ?? 'unknown'} — ${resp.error?.message ?? 'no detail'}`);
|
|
1393
|
+
}
|
|
1394
|
+
return toContent({
|
|
1395
|
+
ok: true,
|
|
1396
|
+
message: `Blocker recorded — thanks. Recorded at ${resp.recorded_at ?? new Date().toISOString()}.`,
|
|
1397
|
+
});
|
|
1398
|
+
}));
|
|
1399
|
+
// ── JSON Schema draft normalization (2026-07-16) ─────────────────────────
|
|
1400
|
+
// zod-to-json-schema (inside the SDK) stamps every tool's inputSchema with a
|
|
1401
|
+
// draft-07 `$schema` header. The Anthropic API validates tool schemas as
|
|
1402
|
+
// draft 2020-12 and REJECTS the draft-07 header ("tools.N.custom.input_
|
|
1403
|
+
// schema: JSON schema is invalid. It must match JSON Schema draft 2020-12"),
|
|
1404
|
+
// which breaks EVERY live client (Claude Desktop, the OpenAI Responses API,
|
|
1405
|
+
// …). The MCP wire layer never validates this, so the protocol roundtrip
|
|
1406
|
+
// tests pass while real clients fail. Strip the header on the way out — a
|
|
1407
|
+
// schema with no `$schema` defaults to 2020-12, which validates. Done at the
|
|
1408
|
+
// transport boundary so all three entrypoints (stdio, standalone HTTP, and
|
|
1409
|
+
// the app's /api/mcp route) are covered by this one shared factory.
|
|
1410
|
+
const stripDraftSchema = (node) => {
|
|
1411
|
+
if (!node || typeof node !== 'object')
|
|
1412
|
+
return;
|
|
1413
|
+
if (Array.isArray(node)) {
|
|
1414
|
+
for (const el of node)
|
|
1415
|
+
stripDraftSchema(el);
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
const rec = node;
|
|
1419
|
+
delete rec.$schema;
|
|
1420
|
+
for (const v of Object.values(rec))
|
|
1421
|
+
stripDraftSchema(v);
|
|
1422
|
+
};
|
|
1423
|
+
const originalConnect = server.connect.bind(server);
|
|
1424
|
+
server.connect = ((transport) => {
|
|
1425
|
+
const t = transport;
|
|
1426
|
+
const originalSend = t.send?.bind(t);
|
|
1427
|
+
if (originalSend) {
|
|
1428
|
+
t.send = (message, options) => {
|
|
1429
|
+
const tools = message?.result?.tools;
|
|
1430
|
+
if (Array.isArray(tools)) {
|
|
1431
|
+
for (const tool of tools) {
|
|
1432
|
+
const tt = tool;
|
|
1433
|
+
stripDraftSchema(tt.inputSchema);
|
|
1434
|
+
stripDraftSchema(tt.outputSchema);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
return originalSend(message, options);
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
return originalConnect(transport);
|
|
1441
|
+
});
|
|
1442
|
+
return server;
|
|
1443
|
+
}
|