@showmeonmap/mcp 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,6 +37,8 @@ The server speaks standard MCP/stdio; it's a thin proxy to the ShowMeOnMap HTTP
37
37
  }
38
38
  ```
39
39
 
40
+ **In Claude, no key at all:** add `https://showmeonmap.com/api/mcp` as a custom connector (Claude Code: `claude mcp add --transport http showmeonmap https://showmeonmap.com/api/mcp`, then `/mcp`). Claude signs you in with OAuth the first time it needs a map and bills your credits the same way; disconnect from the account menu → API keys → Connected apps.
41
+
40
42
  The hosted endpoint speaks streamable HTTP (stateless), is rate-limited to 30 requests/min per key, and works anywhere remote MCP servers do — Claude custom connectors, the OpenAI Responses API (`server_url`), Gemini managed agents (`mcp_server`), Cursor, Copilot Studio, Bedrock.
41
43
 
42
44
  ---
@@ -90,13 +92,16 @@ Ask Claude Desktop things like:
90
92
 
91
93
  ---
92
94
 
93
- ## Tool surface (26 tools)
95
+ ## Tool surface (29 tools)
94
96
 
95
97
  **Workspace lifecycle (6)**: `build_map`, `mutate_map`, `get_state`, `export_workspace`, `undo`, `redo`.
96
98
  **Direct ops (14)**: `add_layer`, `filter_layer`, `select_within`, `focus_area`, `query_features`, `remove_layer`, `clear_filters`, `rename_layer`, `restyle_layer`, `set_camera`, `set_time`, `tag`, `add_annotation`, `remove_annotation`.
97
99
  **Multi-source analysis (3)**: `add_fusion_layer`, `correlate_layers`, `run_analysis`.
98
100
  **Export (2)**: `export_layer_data`, `export_image`.
99
101
  **Feedback channel (1)**: `report_blocker`.
102
+ **Saved workflows (3)**: `save_workflow`, `list_workflows`, `run_workflow`.
103
+
104
+ **0.7.0** (everything merged since 0.6.0): the three workflow tools (with `batch` runs over a value list); `run_analysis` adds spatial join, merge/append, near and dissolve; `rationale` is optional on every tool; no tuple schemas (Claude dropped `set_camera` / `add_annotation`); fact-only tool descriptions; `export_layer_data` works on a saved map; share links store the features the map shows after `filter_layer` and its counts follow the filter; the server reports its real version; and an npm install no longer writes rationale logs into your `node_modules`.
100
105
 
101
106
  `focus_area` (0.4.0) refines an existing map to a **named** area — no coordinates required. Pass a `layerId` plus an `areaRef` (`{kind:'corridor', street, from?, to?}`, `{kind:'place', name}`, or `{kind:'aroundIntersection', street, cross}`) and the server resolves the real geometry from OpenStreetMap, then applies one of three `mode`s: `filter` (hide non-matching), `select` (extract to a new layer), or `clip` (geometrically trim — lines are split at the boundary). The reply carries a grounded `contextAnswer` such as `"kept 37 of 150 features"`. A resolver miss returns a `clarification` with real nearby candidates rather than a guessed location.
102
107
 
@@ -162,7 +167,7 @@ Spawns a fresh server process, exercises `initialize` → `tools/list` → every
162
167
  - **In-memory session cache.** `map_id` → workspace mapping lives in the server process; restarts lose state. Phase I-3 (in the parent repo's plan) wires D1 persistence so `shareable_url` actually resolves across sessions.
163
168
  - **No preview PNG.** The envelope's `shareable_url` points at the app's `/m/<id>` viewer, but workspaces aren't yet persisted in the gallery. Until I-3, share the raw structured response.
164
169
  - **stdio bin + hosted remote endpoint.** This package's bin is stdio; remote hosts use `https://showmeonmap.com/api/mcp` (streamable HTTP, stateless). The standalone Node HTTP entry (`packages/mcp-server/src/http.ts`) remains for self-hosting a checkout.
165
- - **Auth is per-user API keys** (`smo_live_…`, minted at showmeonmap.com). The key travels as a bearer on every backend call and bills the key's owner.
170
+ - **Auth is a per-user bearer**: an API key (`smo_live_…`, minted at showmeonmap.com) or, on the hosted endpoint, an OAuth access token from Claude's sign-in. Either travels as a bearer on every backend call and bills its owner.
166
171
 
167
172
  ---
168
173
 
@@ -0,0 +1,16 @@
1
+ /**
2
+ * fetch() that refuses to follow redirects — portable to Cloudflare Workers,
3
+ * where this package also runs (the hosted /api/mcp route). The Workers
4
+ * runtime rejects `redirect: 'error'` outright, so every self-POST threw in
5
+ * production (build_map broken after #949, fixed 2026-09-28). Uses
6
+ * `redirect: 'manual'` and refuses any 3xx instead. Mirrors
7
+ * src/lib/http/fetchNoRedirect.ts (this package cannot import from the app).
8
+ */
9
+ export async function fetchNoRedirect(input, init = {}) {
10
+ const response = await fetch(input, { ...init, redirect: 'manual' });
11
+ if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
12
+ void response.body?.cancel().catch(() => { });
13
+ throw new Error(`Refused to follow a redirect (HTTP ${response.status}) from ${new URL(input).pathname}`);
14
+ }
15
+ return response;
16
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Generalize oversize line/polygon layers before a map enters the MCP session
3
+ * store. Design: docs/superpowers/specs/2026-10-06-mcp-geometry-generalization-design.md
4
+ *
5
+ * The global choropleth handlers inline 10m world-atlas geometry — EU-27 is
6
+ * 1.8 MB, the world 21 MB — and the hosted store keeps the whole workspace in
7
+ * ONE `mcp_sessions.workspace` cell, which D1 caps at 2 MB. "Map GDP per
8
+ * capita across Europe" therefore failed with SQLITE_TOOBIG in the first
9
+ * real-host Claude test (2026-10-06), and the EU-27 retry that did fit was
10
+ * over the card's ~850 KB budget ("too large to show here").
11
+ *
12
+ * Rules (pinned by __tests__/tools/generalizeGeometry.test.ts):
13
+ * - only a layer whose serialized data exceeds LAYER_BUDGET (600 KB) is touched;
14
+ * - points are never altered;
15
+ * - no feature is dropped and no property value changes — only geometry;
16
+ * - every ring keeps ≥ 4 points and stays closed;
17
+ * - within a feature, parts/holes smaller than 3 × tolerance are dropped,
18
+ * never the feature's largest part;
19
+ * - the first rung of the ladder that fits wins (least simplification);
20
+ * - a generalized layer says so: `_smo_generalized` on its data and a
21
+ * legend subtitle suffix.
22
+ * Dependency-free: this package ships to npm with only the SDK and zod.
23
+ */
24
+ /** Per-layer target. The card takes ~850 KB of layers per response (deferred
25
+ * layers load separately), and the world 10m choropleth fits at 0.1° (557 KB);
26
+ * 500 KB pushed it to 0.2° (≈ 22 km), visibly coarse. */
27
+ export const LAYER_BUDGET = 600_000;
28
+ export const WORKSPACE_CEILING = 1_800_000;
29
+ /** Douglas–Peucker tolerances in degrees, tried after quantize-only (0). */
30
+ export const TOLERANCES = [0, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2];
31
+ export const SIMPLIFIED_NOTE = 'boundaries simplified for this view';
32
+ function round4(n) {
33
+ return Math.round(n * 1e4) / 1e4;
34
+ }
35
+ /** Iterative Douglas–Peucker on an open or closed polyline. */
36
+ function douglasPeucker(pts, tol) {
37
+ if (pts.length <= 2 || tol <= 0)
38
+ return pts;
39
+ const keep = new Uint8Array(pts.length);
40
+ keep[0] = 1;
41
+ keep[pts.length - 1] = 1;
42
+ const t2 = tol * tol;
43
+ const stack = [[0, pts.length - 1]];
44
+ while (stack.length) {
45
+ const [a, b] = stack.pop();
46
+ const [x1, y1] = pts[a];
47
+ const [x2, y2] = pts[b];
48
+ const dx = x2 - x1;
49
+ const dy = y2 - y1;
50
+ const len2 = dx * dx + dy * dy;
51
+ let maxD = 0;
52
+ let maxI = -1;
53
+ for (let i = a + 1; i < b; i++) {
54
+ const [x, y] = pts[i];
55
+ let t = len2 ? ((x - x1) * dx + (y - y1) * dy) / len2 : 0;
56
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
57
+ const ex = x - x1 - t * dx;
58
+ const ey = y - y1 - t * dy;
59
+ const d = ex * ex + ey * ey;
60
+ if (d > maxD) {
61
+ maxD = d;
62
+ maxI = i;
63
+ }
64
+ }
65
+ if (maxD > t2 && maxI > 0) {
66
+ keep[maxI] = 1;
67
+ stack.push([a, maxI], [maxI, b]);
68
+ }
69
+ }
70
+ const out = [];
71
+ for (let i = 0; i < pts.length; i++)
72
+ if (keep[i])
73
+ out.push(pts[i]);
74
+ return out;
75
+ }
76
+ function extent(ring) {
77
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
78
+ for (const [x, y] of ring) {
79
+ if (x < minX)
80
+ minX = x;
81
+ if (y < minY)
82
+ minY = y;
83
+ if (x > maxX)
84
+ maxX = x;
85
+ if (y > maxY)
86
+ maxY = y;
87
+ }
88
+ return Math.hypot(maxX - minX, maxY - minY);
89
+ }
90
+ function quantize(pts) {
91
+ const out = [];
92
+ for (const p of pts) {
93
+ const q = [round4(p[0]), round4(p[1]), ...p.slice(2)];
94
+ const prev = out[out.length - 1];
95
+ if (prev && prev[0] === q[0] && prev[1] === q[1])
96
+ continue;
97
+ out.push(q);
98
+ }
99
+ return out;
100
+ }
101
+ /** A closed ring with ≥ 4 points — falls back to a minimal closed ring from the original. */
102
+ function simplifyRing(ring, tol) {
103
+ let s = douglasPeucker(quantize(ring), tol);
104
+ if (s.length && (s[0][0] !== s[s.length - 1][0] || s[0][1] !== s[s.length - 1][1]))
105
+ s = [...s, s[0]];
106
+ if (s.length >= 4)
107
+ return s;
108
+ if (ring.length < 4)
109
+ return ring;
110
+ const n = ring.length;
111
+ return [ring[0], ring[Math.floor(n / 3)], ring[Math.floor((2 * n) / 3)], ring[0]];
112
+ }
113
+ function simplifyPolygon(rings, tol, dropBelow) {
114
+ return rings
115
+ .filter((r, i) => i === 0 || dropBelow <= 0 || extent(r) >= dropBelow)
116
+ .map((r) => simplifyRing(r, tol));
117
+ }
118
+ function simplifyLine(line, tol) {
119
+ const s = douglasPeucker(quantize(line), tol);
120
+ return s.length >= 2 ? s : line;
121
+ }
122
+ function simplifyGeometry(g, tol) {
123
+ const dropBelow = 3 * tol;
124
+ const c = g.coordinates;
125
+ switch (g.type) {
126
+ case 'Polygon':
127
+ return { ...g, coordinates: simplifyPolygon(c, tol, dropBelow) };
128
+ case 'MultiPolygon': {
129
+ const polys = c;
130
+ const sizes = polys.map((p) => (p[0] ? extent(p[0]) : 0));
131
+ const largest = sizes.indexOf(Math.max(...sizes));
132
+ const kept = polys.filter((_, i) => i === largest || dropBelow <= 0 || sizes[i] >= dropBelow);
133
+ return { ...g, coordinates: kept.map((p) => simplifyPolygon(p, tol, dropBelow)) };
134
+ }
135
+ case 'LineString':
136
+ return { ...g, coordinates: simplifyLine(c, tol) };
137
+ case 'MultiLineString':
138
+ return { ...g, coordinates: c.map((l) => simplifyLine(l, tol)) };
139
+ default:
140
+ return g; // points and anything else are never altered
141
+ }
142
+ }
143
+ function hasShapeGeometry(fc) {
144
+ return fc.features.some((f) => /Polygon|LineString/.test(f.geometry?.type ?? ''));
145
+ }
146
+ function size(v) {
147
+ return JSON.stringify(v).length;
148
+ }
149
+ /**
150
+ * The least-simplified version of `fc` that fits `budget`, or null when it
151
+ * already fits / has no line or polygon geometry. Returns the last rung when
152
+ * nothing fits — the caller's ceiling check decides what happens then.
153
+ */
154
+ export function generalizeCollection(fc, budget = LAYER_BUDGET, ladder = TOLERANCES) {
155
+ if (fc._smo_generalized || size(fc) <= budget || !hasShapeGeometry(fc))
156
+ return null;
157
+ let last = fc;
158
+ let lastTol = 0;
159
+ for (const tol of ladder) {
160
+ last = {
161
+ ...fc,
162
+ features: fc.features.map((f) => (f.geometry ? { ...f, geometry: simplifyGeometry(f.geometry, tol) } : f)),
163
+ };
164
+ lastTol = tol;
165
+ if (size(last) <= budget)
166
+ break;
167
+ }
168
+ return { data: { ...last, _smo_generalized: { toleranceDeg: lastTol } }, toleranceDeg: lastTol };
169
+ }
170
+ function isInlineCollection(layer) {
171
+ const d = layer.source?.data;
172
+ return !!d && d.type === 'FeatureCollection' && Array.isArray(d.features);
173
+ }
174
+ function withNote(subtitle) {
175
+ return typeof subtitle === 'string' && subtitle.trim()
176
+ ? `${subtitle} · ${SIMPLIFIED_NOTE}`
177
+ : SIMPLIFIED_NOTE.charAt(0).toUpperCase() + SIMPLIFIED_NOTE.slice(1);
178
+ }
179
+ /**
180
+ * Walk `root` (a workspace or a mapPlan) and generalize every oversize inline
181
+ * layer. Copy-on-write: unchanged branches are shared, `root` is never mutated.
182
+ */
183
+ export function generalizeLayers(root, budget = LAYER_BUDGET) {
184
+ const generalized = [];
185
+ const visit = (node) => {
186
+ if (Array.isArray(node)) {
187
+ let changed = false;
188
+ const out = node.map((n) => {
189
+ const v = visit(n);
190
+ if (v !== n)
191
+ changed = true;
192
+ return v;
193
+ });
194
+ return changed ? out : node;
195
+ }
196
+ if (!node || typeof node !== 'object')
197
+ return node;
198
+ const obj = node;
199
+ if (obj.source && isInlineCollection(obj)) {
200
+ const g = generalizeCollection(obj.source.data, budget);
201
+ if (!g)
202
+ return node;
203
+ generalized.push({
204
+ layerId: String(obj.id ?? ''),
205
+ ...(typeof obj.title === 'string' ? { title: obj.title } : {}),
206
+ toleranceDeg: g.toleranceDeg,
207
+ });
208
+ return { ...obj, subtitle: withNote(obj.subtitle), source: { ...obj.source, data: g.data } };
209
+ }
210
+ let changed = false;
211
+ const out = {};
212
+ for (const [k, v] of Object.entries(obj)) {
213
+ const nv = visit(v);
214
+ if (nv !== v)
215
+ changed = true;
216
+ out[k] = nv;
217
+ }
218
+ return changed ? out : node;
219
+ };
220
+ return { value: visit(root), generalized };
221
+ }
222
+ /** Approximate on-the-wire size of a stored value, in UTF-8 bytes. */
223
+ export function storedBytes(v) {
224
+ return new TextEncoder().encode(JSON.stringify(v ?? null)).length;
225
+ }
226
+ /** "≈ 500 m" / "≈ 11 km" for a tolerance in degrees (1° ≈ 111 km). */
227
+ export function describeTolerance(deg) {
228
+ if (deg <= 0)
229
+ return '≈ 10 m';
230
+ const m = deg * 111_000;
231
+ return m < 1000 ? `≈ ${Math.round(m / 100) * 100} m` : `≈ ${Math.round(m / 1000)} km`;
232
+ }
package/dist/http.js CHANGED
@@ -45,6 +45,7 @@ import { createServer as createHttpServer } from 'node:http';
45
45
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
46
46
  import { randomUUID } from 'node:crypto';
47
47
  import { createServer as createMcpServer } from "./tools.js";
48
+ import { PACKAGE_VERSION } from "./version.js";
48
49
  const BASE_URL = process.env.SHOWMEONMAP_BASE_URL ?? 'http://localhost:3000';
49
50
  const API_KEY = process.env.SHOWMEONMAP_API_KEY;
50
51
  const HTTP_PORT = Number(process.env.MCP_HTTP_PORT ?? 3333);
@@ -199,6 +200,7 @@ async function handleRequest(req, res) {
199
200
  baseUrl: BASE_URL,
200
201
  apiKey: API_KEY,
201
202
  sessionStore: SHARED_SESSION_STORE,
203
+ version: PACKAGE_VERSION,
202
204
  });
203
205
  await mcpServer.connect(transport);
204
206
  await transport.handleRequest(req, res, body);
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@
29
29
  */
30
30
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
31
31
  import { createServer } from "./tools.js";
32
+ import { PACKAGE_VERSION } from "./version.js";
32
33
  // Defaults to production so `npx @showmeonmap/mcp` works with only an API key.
33
34
  // Override with SHOWMEONMAP_BASE_URL=http://localhost:3000 for local dev.
34
35
  const BASE_URL = process.env.SHOWMEONMAP_BASE_URL ?? 'https://showmeonmap.com';
@@ -40,7 +41,7 @@ async function main() {
40
41
  // HTTP transport opts into D1 separately via http.ts. Callers can override
41
42
  // by injecting their own SessionStore into createServer if needed.
42
43
  const sessionStore = undefined;
43
- const server = createServer({ baseUrl: BASE_URL, apiKey: API_KEY, sessionStore });
44
+ const server = createServer({ baseUrl: BASE_URL, apiKey: API_KEY, sessionStore, version: PACKAGE_VERSION });
44
45
  const transport = new StdioServerTransport();
45
46
  await server.connect(transport);
46
47
  process.stderr.write(`[showmeonmap-mcp] stdio ready · base=${BASE_URL}\n`);
@@ -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