@showmeonmap/mcp 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/dist/fetchNoRedirect.js +16 -0
- package/dist/generalizeGeometry.js +232 -0
- package/dist/http.js +2 -0
- package/dist/index.js +2 -1
- package/dist/nextActions.js +340 -0
- package/dist/serverInstructions.js +34 -0
- package/dist/sessionStoreD1.js +28 -8
- package/dist/tools.js +774 -107
- package/dist/version.js +3 -0
- package/dist/viewer.js +117 -0
- package/package.json +1 -1
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* build_map `next_actions` — state-aware, subject-aware follow-up hints.
|
|
3
|
+
*
|
|
4
|
+
* Until 2026-10-12 every build_map returned the same three constants
|
|
5
|
+
* ("Filter the layer to features where name is not empty", "Change the
|
|
6
|
+
* basemap to satellite", "Zoom the camera closer") whatever the map was. A
|
|
7
|
+
* live MCP test on three maps (M5+ earthquakes near Japan, California
|
|
8
|
+
* earthquakes, wildfires near Vancouver) showed all three were junk: every
|
|
9
|
+
* earthquake and fire detection HAS a name, the wildfire map was ALREADY on
|
|
10
|
+
* satellite, and the plan's own better follow-ups ("Smoke plume", "Air
|
|
11
|
+
* quality") never reached the caller. Agents learned to ignore the field.
|
|
12
|
+
*
|
|
13
|
+
* The rules this module enforces (pinned both directions in
|
|
14
|
+
* __tests__/tools/nextActions.test.ts):
|
|
15
|
+
*
|
|
16
|
+
* - A filter hint names a field that exists on the layer AND varies across
|
|
17
|
+
* its features, and its threshold/value comes from the features
|
|
18
|
+
* themselves (a numeric threshold near the observed median, re-checked so
|
|
19
|
+
* it keeps some features and drops some). Nothing is invented.
|
|
20
|
+
* - Never suggest a state the map is already in (basemap already that
|
|
21
|
+
* style, already zoomed close, already zoomed out).
|
|
22
|
+
* - Surface the plan's own `followups` (handler + companion chips) — with
|
|
23
|
+
* "this area" / "here" rewritten to the place in the caller's query,
|
|
24
|
+
* because a fresh build_map has no "this area". When the place cannot be
|
|
25
|
+
* read back out of the query, a region-neutral follow-up is dropped
|
|
26
|
+
* rather than sent somewhere wrong.
|
|
27
|
+
* - Nothing useful → `[]`. An empty list is a correct answer.
|
|
28
|
+
*
|
|
29
|
+
* Each hint is `{ label, argsTemplate, tool }`: `argsTemplate` is exactly
|
|
30
|
+
* the args (minus `map_id`) of `tool`, so it passes that tool's strict
|
|
31
|
+
* input validation as-is — `filter_layer` gets `{layerId, predicate}`,
|
|
32
|
+
* `mutate_map` gets `{instruction}`, `build_map` gets `{query}`.
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_NEXT_ACTIONS = 3;
|
|
35
|
+
const MAX_FILTER_HINTS = 2;
|
|
36
|
+
const MAX_FOLLOWUP_HINTS = 2;
|
|
37
|
+
// Measures worth thresholding when the layer itself does not name one
|
|
38
|
+
// (valueField / filterConfig always qualify). Keys are normalized
|
|
39
|
+
// (lower-case, alphanumerics only) so "Size (ha)" matches "sizeha".
|
|
40
|
+
const KNOWN_MEASURES = [
|
|
41
|
+
'magnitude', 'mag', 'frp', 'brightness', 'sizeha', 'hectares', 'acres', 'areaha',
|
|
42
|
+
'containment', 'depth', 'significance', 'sig', 'population', 'pop',
|
|
43
|
+
];
|
|
44
|
+
// Measures where SMALLER is the notable end (shallow earthquakes do the damage).
|
|
45
|
+
const LOWER_IS_NOTABLE = new Set(['depth']);
|
|
46
|
+
// Categorical fields worth filtering, and the values that are the story when present.
|
|
47
|
+
const KNOWN_CATEGORICAL = new Set([
|
|
48
|
+
'status', 'confidence', 'severity', 'alert', 'alertlevel', 'urgency', 'certainty', 'firestatus', 'stage',
|
|
49
|
+
]);
|
|
50
|
+
const SALIENT_VALUES = [
|
|
51
|
+
'out of control', 'active', 'high', 'h', 'extreme', 'severe', 'major', 'warning', 'emergency', 'red',
|
|
52
|
+
];
|
|
53
|
+
// Never a measure / filter dimension: coordinates (a coordinate column is
|
|
54
|
+
// never a measure — CLAUDE.md), clocks, ids, links, free text.
|
|
55
|
+
const EXCLUDED_FIELD = new RegExp('^(lat|latitude|lng|lon|long|longitude|x|y|easting|northing|' +
|
|
56
|
+
'time|timestamp|date|datetime|updated|hoursago|epoch|' +
|
|
57
|
+
'id|fid|objectid|gid|eventid|code|url|link|details|' +
|
|
58
|
+
'name|title|place|label|description|incident)$');
|
|
59
|
+
function normKey(k) {
|
|
60
|
+
return k.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
61
|
+
}
|
|
62
|
+
function isInternalField(k) {
|
|
63
|
+
return k.startsWith('_');
|
|
64
|
+
}
|
|
65
|
+
function featuresOf(layer) {
|
|
66
|
+
const data = layer.source?.data;
|
|
67
|
+
if (!data || data.type !== 'FeatureCollection' || !Array.isArray(data.features))
|
|
68
|
+
return [];
|
|
69
|
+
return data.features.map((f) => (f && typeof f.properties === 'object' && f.properties ? f.properties : {}));
|
|
70
|
+
}
|
|
71
|
+
function layerName(layer) {
|
|
72
|
+
return (layer.title ?? layer.id ?? 'the layer').trim();
|
|
73
|
+
}
|
|
74
|
+
/** A threshold that reads cleanly AND still splits the observed values. */
|
|
75
|
+
function niceThreshold(values, lowerIsNotable) {
|
|
76
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
77
|
+
const n = sorted.length;
|
|
78
|
+
const median = n % 2 ? sorted[(n - 1) / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2;
|
|
79
|
+
const allInt = sorted.every((v) => Number.isInteger(v));
|
|
80
|
+
const range = sorted[n - 1] - sorted[0];
|
|
81
|
+
const candidates = [];
|
|
82
|
+
if (allInt)
|
|
83
|
+
candidates.push(Math.round(median));
|
|
84
|
+
else if (range <= 20)
|
|
85
|
+
candidates.push(Math.round(median * 10) / 10);
|
|
86
|
+
else {
|
|
87
|
+
const mag = Math.pow(10, Math.floor(Math.log10(Math.abs(median) || 1)) - 1);
|
|
88
|
+
candidates.push(Math.round(median / mag) * mag);
|
|
89
|
+
}
|
|
90
|
+
candidates.push(median);
|
|
91
|
+
for (const raw of candidates) {
|
|
92
|
+
const t = Number(raw.toPrecision(6));
|
|
93
|
+
const kept = sorted.filter((v) => (lowerIsNotable ? v <= t : v >= t)).length;
|
|
94
|
+
if (kept > 0 && kept < n)
|
|
95
|
+
return { t, kept };
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
function collectFieldStats(props) {
|
|
100
|
+
const stats = new Map();
|
|
101
|
+
for (const p of props) {
|
|
102
|
+
for (const [k, v] of Object.entries(p)) {
|
|
103
|
+
if (v === null || v === undefined || v === '')
|
|
104
|
+
continue;
|
|
105
|
+
let s = stats.get(k);
|
|
106
|
+
if (!s) {
|
|
107
|
+
s = { key: k, numeric: [], strings: new Map() };
|
|
108
|
+
stats.set(k, s);
|
|
109
|
+
}
|
|
110
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
111
|
+
s.numeric.push(v);
|
|
112
|
+
else if (typeof v === 'string')
|
|
113
|
+
s.strings.set(v, (s.strings.get(v) ?? 0) + 1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return stats;
|
|
117
|
+
}
|
|
118
|
+
/** Data-derived filter_layer hints for one layer, best first. */
|
|
119
|
+
function filterHintsForLayer(layer) {
|
|
120
|
+
const layerId = layer.id;
|
|
121
|
+
if (!layerId)
|
|
122
|
+
return [];
|
|
123
|
+
const props = featuresOf(layer);
|
|
124
|
+
const n = props.length;
|
|
125
|
+
if (n < 3)
|
|
126
|
+
return [];
|
|
127
|
+
const stats = collectFieldStats(props);
|
|
128
|
+
const name = layerName(layer);
|
|
129
|
+
const preferred = new Set();
|
|
130
|
+
if (layer.viz?.valueField)
|
|
131
|
+
preferred.add(layer.viz.valueField);
|
|
132
|
+
for (const fc of layer.filterConfig ?? [])
|
|
133
|
+
if (fc.field)
|
|
134
|
+
preferred.add(fc.field);
|
|
135
|
+
const eligible = (k) => !isInternalField(k) && !EXCLUDED_FIELD.test(normKey(k));
|
|
136
|
+
// ── numeric thresholds ────────────────────────────────────────────────
|
|
137
|
+
const numericFields = [...stats.values()]
|
|
138
|
+
.filter((s) => eligible(s.key))
|
|
139
|
+
.filter((s) => s.numeric.length >= Math.ceil(n * 0.8))
|
|
140
|
+
.filter((s) => new Set(s.numeric).size >= 3)
|
|
141
|
+
.filter((s) => preferred.has(s.key) || KNOWN_MEASURES.includes(normKey(s.key)))
|
|
142
|
+
.sort((a, b) => rank(a.key) - rank(b.key));
|
|
143
|
+
function rank(k) {
|
|
144
|
+
if (layer.viz?.valueField === k)
|
|
145
|
+
return -2;
|
|
146
|
+
if (preferred.has(k))
|
|
147
|
+
return -1;
|
|
148
|
+
return KNOWN_MEASURES.indexOf(normKey(k));
|
|
149
|
+
}
|
|
150
|
+
const preferredNumeric = [];
|
|
151
|
+
const otherNumeric = [];
|
|
152
|
+
const seenSignatures = new Set();
|
|
153
|
+
for (const s of numericFields) {
|
|
154
|
+
// A synthetic duplicate (e.g. `value` = magnitude for sizing) says nothing new.
|
|
155
|
+
const signature = s.numeric.join(',');
|
|
156
|
+
if (seenSignatures.has(signature))
|
|
157
|
+
continue;
|
|
158
|
+
seenSignatures.add(signature);
|
|
159
|
+
const lower = LOWER_IS_NOTABLE.has(normKey(s.key));
|
|
160
|
+
const th = niceThreshold(s.numeric, lower);
|
|
161
|
+
if (!th)
|
|
162
|
+
continue;
|
|
163
|
+
const op = lower ? 'lte' : 'gte';
|
|
164
|
+
const sym = lower ? '≤' : '≥';
|
|
165
|
+
(preferred.has(s.key) ? preferredNumeric : otherNumeric).push({
|
|
166
|
+
label: `Show only ${name} with ${s.key} ${sym} ${th.t} (${th.kept} of ${n} features)`,
|
|
167
|
+
argsTemplate: { layerId, predicate: { op, field: s.key, value: th.t } },
|
|
168
|
+
tool: 'filter_layer',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const categorical = [];
|
|
172
|
+
// ── categorical: a salient value that some, not all, features carry ──
|
|
173
|
+
for (const s of stats.values()) {
|
|
174
|
+
if (!eligible(s.key))
|
|
175
|
+
continue;
|
|
176
|
+
const isKnown = KNOWN_CATEGORICAL.has(normKey(s.key)) ||
|
|
177
|
+
(layer.filterConfig ?? []).some((fc) => fc.field === s.key && fc.kind === 'category');
|
|
178
|
+
if (!isKnown)
|
|
179
|
+
continue;
|
|
180
|
+
const totalStrings = [...s.strings.values()].reduce((a, b) => a + b, 0);
|
|
181
|
+
if (totalStrings < Math.ceil(n * 0.8) || s.strings.size < 2 || s.strings.size > 12)
|
|
182
|
+
continue;
|
|
183
|
+
const salient = SALIENT_VALUES
|
|
184
|
+
.map((sv) => [...s.strings.keys()].find((v) => v.trim().toLowerCase() === sv))
|
|
185
|
+
.find((v) => typeof v === 'string');
|
|
186
|
+
if (!salient)
|
|
187
|
+
continue;
|
|
188
|
+
const count = s.strings.get(salient) ?? 0;
|
|
189
|
+
if (count === 0 || count === totalStrings)
|
|
190
|
+
continue;
|
|
191
|
+
categorical.push({
|
|
192
|
+
label: `Show only ${name} where ${s.key} is "${salient}" (${count} of ${n} features)`,
|
|
193
|
+
argsTemplate: { layerId, predicate: { op: 'eq', field: s.key, value: salient } },
|
|
194
|
+
tool: 'filter_layer',
|
|
195
|
+
});
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
// The layer's own encoded measure, then the story value of a status-like
|
|
199
|
+
// field, then other known measures.
|
|
200
|
+
return [...preferredNumeric, ...categorical, ...otherNumeric];
|
|
201
|
+
}
|
|
202
|
+
// ── plan follow-ups ────────────────────────────────────────────────────────
|
|
203
|
+
const TIME_WINDOW_TAIL_RE = /(?:^|\s+)(?:(?:in|over|during|for|within|from|since)\s+)?(?:the\s+)?(?:(?:this|last|past|next|recent|previous|current|coming)\s+|\d+\s+)(?:\d+\s+)?(?:hour|hours|day|days|week|weeks|month|months|year|years|decade|decades)(?:\s+ago)?$/i;
|
|
204
|
+
const SIMPLE_TIME_TAIL_RE = /\s+(?:(?:in|during|since|for)\s+)?(?:today|tonight|now|right now|recently|yesterday|(?:19|20)\d{2})$/i;
|
|
205
|
+
const PRESENTATION_TAIL_RE = /,\s*(?:sized|colou?red|shown|styled|drawn|rendered|displayed|with|using|as|on)\b.*$/i;
|
|
206
|
+
const LOCATION_PREP_RE = /\b(?:in|near|around|across|over|at|within)\s+/gi;
|
|
207
|
+
const BAD_PLACE_START = /^(?:the\s+)?(?:past|last|this|next|recent|previous|current|coming|today|tonight|least|most)\b/i;
|
|
208
|
+
/**
|
|
209
|
+
* The place a build_map query names, or null. Conservative on purpose: a
|
|
210
|
+
* wrong place is worse than no follow-up, so anything doubtful returns null.
|
|
211
|
+
* "earthquakes above magnitude 5 near Japan this month" → "Japan"
|
|
212
|
+
* "earthquakes in California in the past year" → "California"
|
|
213
|
+
* "active wildfires near Vancouver, British Columbia" → "Vancouver, British Columbia"
|
|
214
|
+
*/
|
|
215
|
+
export function placeFromQuery(query) {
|
|
216
|
+
const q = (query ?? '').trim().replace(/[.?!]+$/, '');
|
|
217
|
+
for (const m of q.matchAll(LOCATION_PREP_RE)) {
|
|
218
|
+
let place = q.slice((m.index ?? 0) + m[0].length);
|
|
219
|
+
place = place.replace(PRESENTATION_TAIL_RE, '');
|
|
220
|
+
for (let i = 0; i < 3; i++) {
|
|
221
|
+
const before = place;
|
|
222
|
+
place = place.replace(TIME_WINDOW_TAIL_RE, '').replace(SIMPLE_TIME_TAIL_RE, '').trim();
|
|
223
|
+
if (place === before)
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
if (!place || place.length > 60 || /\d/.test(place) || BAD_PLACE_START.test(place))
|
|
227
|
+
continue;
|
|
228
|
+
return place;
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
// Phrases that only mean something next to the map the user is looking at.
|
|
233
|
+
const CONTEXT_PHRASE_RE = /\b(?:this area|this region|the area|here|nearby|on screen|current view|neighbou?ring)\b/i;
|
|
234
|
+
function localizeMessage(message, place) {
|
|
235
|
+
if (!CONTEXT_PHRASE_RE.test(message))
|
|
236
|
+
return message;
|
|
237
|
+
if (!place)
|
|
238
|
+
return null;
|
|
239
|
+
const out = message
|
|
240
|
+
.replace(/\b(?:this area|this region|the area)\b/gi, place)
|
|
241
|
+
.replace(/\bhere\b/gi, `near ${place}`);
|
|
242
|
+
// "nearby" / "on screen" / "neighbouring" have no faithful rewrite — drop.
|
|
243
|
+
return CONTEXT_PHRASE_RE.test(out) ? null : out;
|
|
244
|
+
}
|
|
245
|
+
const MUTATION_RE = /^(?:filter|only show|show only|zoom|switch|change|set|remove|hide|colou?r|restyle|clear)\b/i;
|
|
246
|
+
const BASEMAP_STYLES = ['satellite', 'dark', 'light', 'streets', 'engineering'];
|
|
247
|
+
/** True when the hint would put the map into a state it is already in. */
|
|
248
|
+
export function isRedundantWithState(text, plan) {
|
|
249
|
+
const t = text.toLowerCase();
|
|
250
|
+
const current = plan.basemap?.style?.toLowerCase();
|
|
251
|
+
const aboutBasemap = /\b(?:basemap|base map|map style|satellite (?:view|imagery|basemap|map))\b/.test(t) ||
|
|
252
|
+
/\b(?:switch|change|set)\s+(?:the\s+)?(?:map\s+)?to\s+(?:satellite|dark|light|streets|engineering)\b/.test(t);
|
|
253
|
+
if (current && aboutBasemap) {
|
|
254
|
+
const target = BASEMAP_STYLES.find((s) => new RegExp(`\\b${s}\\b`).test(t));
|
|
255
|
+
if (target && target === current)
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
const cam = plan.camera;
|
|
259
|
+
if (cam && /\bzoom\b/.test(t)) {
|
|
260
|
+
const zoom = typeof cam.zoom === 'number' ? cam.zoom : undefined;
|
|
261
|
+
const span = cam.mode === 'fitBounds' && Array.isArray(cam.bounds)
|
|
262
|
+
? Math.max(Math.abs(cam.bounds[2] - cam.bounds[0]), Math.abs(cam.bounds[3] - cam.bounds[1]))
|
|
263
|
+
: undefined;
|
|
264
|
+
const close = (zoom !== undefined && zoom >= 12) || (span !== undefined && span <= 0.1);
|
|
265
|
+
const far = cam.mode === 'globalView' || (zoom !== undefined && zoom <= 2) || (span !== undefined && span >= 300);
|
|
266
|
+
if (/\bzoom\b(?:\s+\w+){0,3}?\s+(?:in|closer)\b/.test(t) && close)
|
|
267
|
+
return true;
|
|
268
|
+
if (/\bzoom\b(?:\s+\w+){0,3}?\s+out\b/.test(t) && far)
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
function followupHints(plan, query) {
|
|
274
|
+
const followups = Array.isArray(plan.followups) ? plan.followups : [];
|
|
275
|
+
if (!followups.length)
|
|
276
|
+
return [];
|
|
277
|
+
const place = placeFromQuery(query);
|
|
278
|
+
const hints = [];
|
|
279
|
+
const seen = new Set();
|
|
280
|
+
for (const f of followups) {
|
|
281
|
+
const message = typeof f?.message === 'string' ? f.message.trim() : '';
|
|
282
|
+
const label = typeof f?.label === 'string' && f.label.trim() ? f.label.trim() : message;
|
|
283
|
+
if (!message)
|
|
284
|
+
continue;
|
|
285
|
+
if (isRedundantWithState(`${label} ${message}`, plan))
|
|
286
|
+
continue;
|
|
287
|
+
if (MUTATION_RE.test(message)) {
|
|
288
|
+
if (seen.has(message.toLowerCase()))
|
|
289
|
+
continue;
|
|
290
|
+
seen.add(message.toLowerCase());
|
|
291
|
+
hints.push({ label, argsTemplate: { instruction: message }, tool: 'mutate_map' });
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const localized = localizeMessage(message, place);
|
|
295
|
+
if (!localized || seen.has(localized.toLowerCase()))
|
|
296
|
+
continue;
|
|
297
|
+
seen.add(localized.toLowerCase());
|
|
298
|
+
hints.push({ label: `${label} — new map: "${localized}"`, argsTemplate: { query: localized }, tool: 'build_map' });
|
|
299
|
+
}
|
|
300
|
+
return hints;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* next_actions for a freshly built map. `plan` must be the full-resolution
|
|
304
|
+
* MapPlan the API returned (features inline), `query` the build_map query.
|
|
305
|
+
*/
|
|
306
|
+
export function buildMapNextActions(plan, query) {
|
|
307
|
+
if (!plan)
|
|
308
|
+
return [];
|
|
309
|
+
const layers = (Array.isArray(plan.layers) ? plan.layers : []);
|
|
310
|
+
const subjectLayers = layers
|
|
311
|
+
.filter((l) => l && l.role !== 'context' && !l.hideFromLegend && !l.hiddenByDefault)
|
|
312
|
+
.map((l) => ({ layer: l, count: featuresOf(l).length }))
|
|
313
|
+
.filter((x) => x.count >= 3)
|
|
314
|
+
.sort((a, b) => b.count - a.count);
|
|
315
|
+
// Best hint from each of the busiest layers first (diversity), then a
|
|
316
|
+
// second hint from the busiest layer if room remains.
|
|
317
|
+
const perLayer = subjectLayers.map((x) => filterHintsForLayer(x.layer));
|
|
318
|
+
const filters = [];
|
|
319
|
+
for (const hs of perLayer)
|
|
320
|
+
if (hs[0] && filters.length < MAX_FILTER_HINTS)
|
|
321
|
+
filters.push(hs[0]);
|
|
322
|
+
for (const hs of perLayer) {
|
|
323
|
+
for (const h of hs.slice(1))
|
|
324
|
+
if (filters.length < MAX_FILTER_HINTS)
|
|
325
|
+
filters.push(h);
|
|
326
|
+
}
|
|
327
|
+
const follow = followupHints(plan, query).slice(0, MAX_FOLLOWUP_HINTS);
|
|
328
|
+
// Best data filter, then the plan's own follow-ups, then the rest — so a
|
|
329
|
+
// map that has both surfaces both.
|
|
330
|
+
const out = [...filters.slice(0, 1), ...follow, ...filters.slice(1)];
|
|
331
|
+
return out.filter((h) => !isRedundantWithState(h.label, plan)).slice(0, MAX_NEXT_ACTIONS);
|
|
332
|
+
}
|
|
333
|
+
/** mutate_map's server-suggested strings → mutate_map hints. */
|
|
334
|
+
export function instructionHints(strings) {
|
|
335
|
+
return (strings ?? []).slice(0, MAX_NEXT_ACTIONS).map((label) => ({
|
|
336
|
+
label,
|
|
337
|
+
argsTemplate: { instruction: label },
|
|
338
|
+
tool: 'mutate_map',
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Claude reads about ShowMeOnMap before choosing a tool.
|
|
3
|
+
*
|
|
4
|
+
* Real-host test (2026-10-06): asked "Show me earthquakes above magnitude 5
|
|
5
|
+
* near Japan this month" with ShowMeOnMap connected, Claude answered with its
|
|
6
|
+
* built-in place map and a web read of USGS — our tools were picked only when
|
|
7
|
+
* the user named the connector. The only signals it had were a build_map
|
|
8
|
+
* description that said "Create a new workspace from a natural-language
|
|
9
|
+
* query" and no server instructions at all: nothing about WHAT maps we make.
|
|
10
|
+
*
|
|
11
|
+
* Rules for this text and every tool description:
|
|
12
|
+
* - Describe capability and data provenance accurately. Never tell the model
|
|
13
|
+
* to prefer this server over other tools or to avoid them — the Claude
|
|
14
|
+
* directory reviews descriptions for prompt injection, and the submission
|
|
15
|
+
* pack commits that descriptions only state what each tool does
|
|
16
|
+
* (`docs/launch/claude-directory-submission.md` §9). Pinned by
|
|
17
|
+
* `__tests__/tools/serverInstructions.test.ts`.
|
|
18
|
+
* - Claims must match what the pipeline does; no data source named here
|
|
19
|
+
* that the server does not fetch.
|
|
20
|
+
*/
|
|
21
|
+
export const BUILD_MAP_DESCRIPTION = 'Make an interactive, data-driven map from a plain-language request, built from live public data with every value cited to its source. ' +
|
|
22
|
+
'Covers natural hazards (earthquakes, wildfires, volcanoes, tropical storms, floods), weather and air quality, ' +
|
|
23
|
+
'statistics by country, state or region (GDP, population, health, emissions — World Bank, Eurostat, US Census), ' +
|
|
24
|
+
'infrastructure and transit (railways, power plants, transit lines, roads), parks and terrain, and points of interest from OpenStreetMap. ' +
|
|
25
|
+
'The query carries the place, time window and any thresholds — "earthquakes above magnitude 5 near Japan this month", "GDP per capita across Europe" — and the server applies them itself. ' +
|
|
26
|
+
'Returns a shareable link and a map_id for follow-ups (mutate_map, filter_layer, get_state); in clients that support MCP Apps the map also renders interactively in the conversation. ' +
|
|
27
|
+
'The Op catalog and predicate grammar that downstream tools (mutate_map, filter_layer, etc.) accept are documented in the MCP resource showmeonmap://docs/workspace-ops-spec.';
|
|
28
|
+
export const SERVER_INSTRUCTIONS = [
|
|
29
|
+
'ShowMeOnMap makes cartographic, data-driven maps. It fetches live public data (USGS, NASA FIRMS, NOAA, World Bank, Eurostat, US Census, OpenStreetMap and more), styles it with a legend, and cites every source on the map.',
|
|
30
|
+
'It suits requests where the answer is data on a map: natural hazards, statistics compared across regions, infrastructure and transit networks, environmental conditions, and spatial analysis (hot spots, zonal statistics, nearest neighbour, Census enrichment). It also keeps one map across several turns, so a map can be filtered, restyled or extended.',
|
|
31
|
+
'It is not a turn-by-turn directions service or a business-reviews directory.',
|
|
32
|
+
'Usage: call build_map once with the whole request, including place, time window and thresholds; it applies them itself, so a follow-up filter or camera call is only needed when the user asks for a change. Values that a source cannot supply are shown as unavailable, never estimated.',
|
|
33
|
+
'Building or changing a map spends the user\'s prepaid ShowMeOnMap credits; reading state and exporting are free.',
|
|
34
|
+
].join('\n\n');
|
package/dist/sessionStoreD1.js
CHANGED
|
@@ -30,9 +30,16 @@
|
|
|
30
30
|
* `maxSize` (default 1000) we delete the oldest rows by `last_seen_at`.
|
|
31
31
|
* `toDrop = max(overflow, evictionBatch)` so a single sweep amortizes
|
|
32
32
|
* to clear at least `evictionBatch` (default 50) rows when triggered,
|
|
33
|
-
* avoiding running an eviction on every subsequent put. The
|
|
34
|
-
*
|
|
35
|
-
* the put. The next put will retry.
|
|
33
|
+
* avoiding running an eviction on every subsequent put. The sweep runs
|
|
34
|
+
* in the background — the put does not wait for it, and a failed eviction
|
|
35
|
+
* never blocks or fails the put. The next put will retry.
|
|
36
|
+
*
|
|
37
|
+
* On Cloudflare Workers, un-registered background work is CANCELLED when the
|
|
38
|
+
* response returns, so a hosted caller must pass `waitUntil` (see the option)
|
|
39
|
+
* or the sweep — and the get-touch — may never finish. The COUNT(*) that
|
|
40
|
+
* gates each sweep is deliberately left on every put: it walks the
|
|
41
|
+
* last_seen_at index (≤ ~maxSize rows read), off the response path once
|
|
42
|
+
* registered, which is not worth a sampling scheme to shave.
|
|
36
43
|
*
|
|
37
44
|
* Independence
|
|
38
45
|
* ------------
|
|
@@ -95,6 +102,19 @@ export function createD1SessionStore(d1, opts = {}) {
|
|
|
95
102
|
const evictionBatch = opts.evictionBatch ?? 50;
|
|
96
103
|
const onEvict = opts.onEvict;
|
|
97
104
|
const ownerId = opts.ownerId ?? null;
|
|
105
|
+
const waitUntil = opts.waitUntil;
|
|
106
|
+
/** Run `work` in the background, registered with the runtime when we can be. */
|
|
107
|
+
function background(work) {
|
|
108
|
+
if (!waitUntil)
|
|
109
|
+
return;
|
|
110
|
+
try {
|
|
111
|
+
waitUntil(work);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Runtime refused the registration (context already closed). The work
|
|
115
|
+
// is already running; it may or may not finish. Never fail the caller.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
98
118
|
// Tenancy predicate appended to every single-row accessor. When an owner is
|
|
99
119
|
// set, a row matches only if its user_id equals that owner — which NULL
|
|
100
120
|
// never does, so migration 0018's un-backfilled legacy rows fail CLOSED
|
|
@@ -105,7 +125,7 @@ export function createD1SessionStore(d1, opts = {}) {
|
|
|
105
125
|
const scopeArgs = ownerId ? [ownerId] : [];
|
|
106
126
|
/**
|
|
107
127
|
* LRU sweep — best-effort. Counts rows, drops the oldest by last_seen_at
|
|
108
|
-
* if over capacity.
|
|
128
|
+
* if over capacity. Never rejects; callers hand it to background().
|
|
109
129
|
*/
|
|
110
130
|
async function maybeEvict() {
|
|
111
131
|
try {
|
|
@@ -161,12 +181,12 @@ export function createD1SessionStore(d1, opts = {}) {
|
|
|
161
181
|
return null;
|
|
162
182
|
const entry = rowToEntry(row);
|
|
163
183
|
// Touch last_seen_at so the LRU treats this as recently accessed.
|
|
164
|
-
//
|
|
184
|
+
// Background: a failed touch shouldn't break or slow the read.
|
|
165
185
|
const now = Date.now();
|
|
166
|
-
d1.prepare(`UPDATE mcp_sessions SET last_seen_at = ? WHERE id = ?${scope}`)
|
|
186
|
+
background(d1.prepare(`UPDATE mcp_sessions SET last_seen_at = ? WHERE id = ?${scope}`)
|
|
167
187
|
.bind(now, mapId, ...scopeArgs)
|
|
168
188
|
.run()
|
|
169
|
-
.catch(() => undefined);
|
|
189
|
+
.catch(() => undefined));
|
|
170
190
|
entry.lastAccessed = now;
|
|
171
191
|
return entry;
|
|
172
192
|
},
|
|
@@ -200,7 +220,7 @@ export function createD1SessionStore(d1, opts = {}) {
|
|
|
200
220
|
}
|
|
201
221
|
// Best-effort LRU sweep. Don't await — a slow eviction must not block
|
|
202
222
|
// the write that triggered it. Failures are absorbed inside maybeEvict.
|
|
203
|
-
|
|
223
|
+
background(maybeEvict());
|
|
204
224
|
},
|
|
205
225
|
async delete(mapId) {
|
|
206
226
|
// Owner-scoped, so a non-owner's delete silently affects zero rows
|