@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.
@@ -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');
@@ -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 cleanup
34
- * uses fire-and-forget semantics — a failed eviction never blocks
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. Fire-and-forget: callers don't await this.
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
- // Fire-and-forget: a failed touch shouldn't break the read.
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
- void maybeEvict();
223
+ background(maybeEvict());
204
224
  },
205
225
  async delete(mapId) {
206
226
  // Owner-scoped, so a non-owner's delete silently affects zero rows