@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.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * D1-backed SessionStore for the ShowMeOnMap MCP server (Wave 4 / Task C3,
3
+ * 2026-04-27).
4
+ *
5
+ * Why this exists
6
+ * ---------------
7
+ * The default store (createMemorySessionStore in tools.ts) is a per-process
8
+ * Map. Sessions evaporate on Worker restart and can't be shared across
9
+ * horizontally-scaled instances. createD1SessionStore swaps the backing
10
+ * store for a Cloudflare D1 binding so a session opened on one Worker can
11
+ * be served by any other.
12
+ *
13
+ * Schema
14
+ * ------
15
+ * Migration: migrations/0006_mcp_sessions.sql
16
+ *
17
+ * CREATE TABLE mcp_sessions (
18
+ * id TEXT PRIMARY KEY,
19
+ * workspace_id TEXT,
20
+ * workspace TEXT NOT NULL, -- JSON Workspace
21
+ * map_plan TEXT, -- JSON MapPlan
22
+ * layer_filters TEXT, -- JSON Record<layerId, Predicate>
23
+ * created_at INTEGER NOT NULL,
24
+ * last_seen_at INTEGER NOT NULL
25
+ * );
26
+ *
27
+ * LRU eviction
28
+ * ------------
29
+ * Best-effort. On every `put` we count rows; if the count exceeds
30
+ * `maxSize` (default 1000) we delete the oldest rows by `last_seen_at`.
31
+ * `toDrop = max(overflow, evictionBatch)` so a single sweep amortizes
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.
36
+ *
37
+ * Independence
38
+ * ------------
39
+ * This file deliberately does NOT import from src/lib/db/d1Client. The
40
+ * MCP package has its own tsconfig with rootDir=./src and is published
41
+ * standalone; pulling in main-app modules would break the build. Instead
42
+ * we declare a minimal D1Database shape inline that matches both
43
+ * @cloudflare/workers-types and the main app's d1Client binding.
44
+ */
45
+ // ---------------------------------------------------------------------------
46
+ // Helpers
47
+ // ---------------------------------------------------------------------------
48
+ function rowToEntry(row) {
49
+ return {
50
+ workspace: JSON.parse(row.workspace),
51
+ mapPlan: row.map_plan ? JSON.parse(row.map_plan) : null,
52
+ layerFilters: row.layer_filters
53
+ ? JSON.parse(row.layer_filters)
54
+ : {},
55
+ createdAt: row.created_at,
56
+ lastAccessed: row.last_seen_at,
57
+ };
58
+ }
59
+ function entryToRow(id, entry) {
60
+ // Best-effort extraction of the workspace's own id for cross-table joins.
61
+ // Not a FK — the D1 row may persist after the canonical workspaces row is
62
+ // gone, and that's fine for a hot cache.
63
+ let workspaceId = null;
64
+ const ws = entry.workspace;
65
+ if (ws && typeof ws === 'object' && typeof ws.workspaceId === 'string') {
66
+ workspaceId = ws.workspaceId;
67
+ }
68
+ // mapPlan can blow past the D1 500KB row cap. If JSON.stringify throws
69
+ // RangeError or the result is huge we drop it; the next get will trigger
70
+ // a re-materialize on the API side. workspace itself stays — it's the
71
+ // ops log, which is small.
72
+ let mapPlanJson = null;
73
+ if (entry.mapPlan != null) {
74
+ try {
75
+ const candidate = JSON.stringify(entry.mapPlan);
76
+ if (candidate.length < 400_000)
77
+ mapPlanJson = candidate;
78
+ }
79
+ catch {
80
+ // Stringify failed (circular ref?). Leave null.
81
+ }
82
+ }
83
+ return {
84
+ id,
85
+ workspace_id: workspaceId,
86
+ workspace: JSON.stringify(entry.workspace ?? null),
87
+ map_plan: mapPlanJson,
88
+ layer_filters: JSON.stringify(entry.layerFilters ?? {}),
89
+ created_at: entry.createdAt,
90
+ last_seen_at: entry.lastAccessed,
91
+ };
92
+ }
93
+ export function createD1SessionStore(d1, opts = {}) {
94
+ const maxSize = opts.maxSize ?? 1000;
95
+ const evictionBatch = opts.evictionBatch ?? 50;
96
+ const onEvict = opts.onEvict;
97
+ /**
98
+ * LRU sweep — best-effort. Counts rows, drops the oldest by last_seen_at
99
+ * if over capacity. Fire-and-forget: callers don't await this.
100
+ */
101
+ async function maybeEvict() {
102
+ try {
103
+ const countRow = await d1
104
+ .prepare(`SELECT COUNT(*) AS n FROM mcp_sessions`)
105
+ .first();
106
+ const count = countRow?.n ?? 0;
107
+ if (count <= maxSize)
108
+ return;
109
+ // Drop at least the overflow; bump up to evictionBatch so we amortize
110
+ // the cost of running an eviction sweep. Concretely: count=4, maxSize=3,
111
+ // evictionBatch=2 → toDrop = max(1, 2) = 2 (lands at count=2).
112
+ const overflow = count - maxSize;
113
+ const toDrop = Math.max(overflow, evictionBatch);
114
+ // Identify the N oldest, then delete by id (DELETE ... ORDER BY LIMIT
115
+ // is not portable across all D1 versions; this two-step is safe).
116
+ const oldest = await d1
117
+ .prepare(`SELECT id FROM mcp_sessions ORDER BY last_seen_at ASC LIMIT ?`)
118
+ .bind(toDrop)
119
+ .all();
120
+ const ids = oldest.results.map((r) => r.id);
121
+ if (!ids.length)
122
+ return;
123
+ // Single DELETE with IN-list keeps the round-trip count low even on
124
+ // larger eviction batches. D1 has no documented limit on IN-list size
125
+ // beyond the SQL statement length cap, which we are nowhere near at
126
+ // batches of <1000 ids.
127
+ const placeholders = ids.map(() => '?').join(',');
128
+ const result = await d1
129
+ .prepare(`DELETE FROM mcp_sessions WHERE id IN (${placeholders})`)
130
+ .bind(...ids)
131
+ .run();
132
+ if (onEvict)
133
+ onEvict({ deleted: result.meta.changes ?? ids.length });
134
+ }
135
+ catch (err) {
136
+ // Eviction is best-effort — never let it bubble out of put().
137
+ if (onEvict)
138
+ onEvict({ deleted: 0, error: err });
139
+ }
140
+ }
141
+ return {
142
+ async get(mapId) {
143
+ const row = await d1
144
+ .prepare(`SELECT id, workspace_id, workspace, map_plan, layer_filters, created_at, last_seen_at
145
+ FROM mcp_sessions
146
+ WHERE id = ?`)
147
+ .bind(mapId)
148
+ .first();
149
+ if (!row)
150
+ return null;
151
+ const entry = rowToEntry(row);
152
+ // Touch last_seen_at so the LRU treats this as recently accessed.
153
+ // Fire-and-forget: a failed touch shouldn't break the read.
154
+ const now = Date.now();
155
+ d1.prepare(`UPDATE mcp_sessions SET last_seen_at = ? WHERE id = ?`)
156
+ .bind(now, mapId)
157
+ .run()
158
+ .catch(() => undefined);
159
+ entry.lastAccessed = now;
160
+ return entry;
161
+ },
162
+ async put(mapId, entry) {
163
+ const row = entryToRow(mapId, entry);
164
+ // UPSERT — D1 supports ON CONFLICT(id) DO UPDATE.
165
+ await d1
166
+ .prepare(`INSERT INTO mcp_sessions
167
+ (id, workspace_id, workspace, map_plan, layer_filters, created_at, last_seen_at)
168
+ VALUES (?, ?, ?, ?, ?, ?, ?)
169
+ ON CONFLICT(id) DO UPDATE SET
170
+ workspace_id = excluded.workspace_id,
171
+ workspace = excluded.workspace,
172
+ map_plan = excluded.map_plan,
173
+ layer_filters = excluded.layer_filters,
174
+ last_seen_at = excluded.last_seen_at`)
175
+ .bind(row.id, row.workspace_id, row.workspace, row.map_plan, row.layer_filters, row.created_at, row.last_seen_at)
176
+ .run();
177
+ // Best-effort LRU sweep. Don't await — a slow eviction must not block
178
+ // the write that triggered it. Failures are absorbed inside maybeEvict.
179
+ void maybeEvict();
180
+ },
181
+ async delete(mapId) {
182
+ await d1.prepare(`DELETE FROM mcp_sessions WHERE id = ?`).bind(mapId).run();
183
+ },
184
+ async size() {
185
+ const row = await d1
186
+ .prepare(`SELECT COUNT(*) AS n FROM mcp_sessions`)
187
+ .first();
188
+ return row?.n ?? 0;
189
+ },
190
+ };
191
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Tool confirmation metadata — MCP package mirror of
3
+ * `src/core/agent/tools/meta.ts` in the parent repo.
4
+ *
5
+ * Why a mirror and not a direct import:
6
+ * The MCP server (`@showmeonmap/mcp`) is npm-publishable from
7
+ * `packages/mcp-server`. Its `tsconfig.json` has `rootDir: ./src`
8
+ * so it can ship a clean `dist/` independent of the parent. Reaching
9
+ * up into `../../src/core/agent/tools/meta.ts` would force the package
10
+ * to depend on the parent repo's source layout, breaking the
11
+ * publishable boundary.
12
+ *
13
+ * Sync invariant:
14
+ * The agent-side meta file is the source of truth. A unit test in
15
+ * `src/core/agent/tools/__tests__/meta.test.ts` (parent repo) asserts
16
+ * the gated tool names and required-confirmation flags here match
17
+ * the canonical TOOL_META map. If this file drifts, that test fails.
18
+ *
19
+ * Only the subset MCP actually needs lives here — the gating helpers
20
+ * for `withConfirmation` + the long-running flag for selected tools.
21
+ */
22
+ /**
23
+ * Mirror of the MCP-side entries from src/core/agent/tools/meta.ts.
24
+ * Keep ordering + key set aligned with the canonical map.
25
+ */
26
+ export const MCP_TOOL_META = {
27
+ build_map: {
28
+ requires_confirmation: false,
29
+ destructive: false,
30
+ long_running: true,
31
+ rationale: 'Bootstraps a workspace via /api/workspace/new; runs the full pipeline.',
32
+ },
33
+ mutate_map: {
34
+ requires_confirmation: false,
35
+ destructive: false,
36
+ long_running: true,
37
+ rationale: 'Append-only NL mutation; runs Claude tool-use loop on /api/workspace/mutate.',
38
+ },
39
+ get_state: {
40
+ requires_confirmation: false,
41
+ destructive: false,
42
+ long_running: false,
43
+ rationale: 'Read-only session-store lookup.',
44
+ },
45
+ add_layer: {
46
+ requires_confirmation: false,
47
+ destructive: false,
48
+ long_running: false,
49
+ rationale: 'Append-only LayerPlan insertion via apply-op; reversible with undo.',
50
+ },
51
+ add_fusion_layer: {
52
+ requires_confirmation: false,
53
+ destructive: false,
54
+ long_running: true,
55
+ rationale: 'Spatial join + Pearson r; multi-second on country-scale layers.',
56
+ },
57
+ correlate_layers: {
58
+ requires_confirmation: false,
59
+ destructive: false,
60
+ long_running: false,
61
+ rationale: 'Pure read of pre-joined layer pair; no mutation, no credit charged.',
62
+ },
63
+ filter_layer: {
64
+ requires_confirmation: false,
65
+ destructive: false,
66
+ long_running: false,
67
+ rationale: 'Append-only predicate; reversible with clear_filters or undo.',
68
+ },
69
+ select_within: {
70
+ requires_confirmation: false,
71
+ destructive: false,
72
+ long_running: false,
73
+ rationale: 'Adds a new derived layer; original layer untouched.',
74
+ },
75
+ query_features: {
76
+ requires_confirmation: false,
77
+ destructive: false,
78
+ long_running: false,
79
+ rationale: 'Read-only predicate evaluation against an existing layer.',
80
+ },
81
+ remove_layer: {
82
+ requires_confirmation: true,
83
+ destructive: true,
84
+ long_running: false,
85
+ rationale: 'Layer ids are tombstoned and CANNOT be re-added later in the same workspace. Effect is permanent within the session.',
86
+ },
87
+ clear_filters: {
88
+ requires_confirmation: true,
89
+ destructive: false,
90
+ long_running: false,
91
+ rationale: 'Recoverable via undo, but autonomous agents lack a UI surface to confirm intent. Gate at the wire layer instead.',
92
+ },
93
+ rename_layer: {
94
+ requires_confirmation: false,
95
+ destructive: false,
96
+ long_running: false,
97
+ rationale: 'Rekeys layer id but preserves data; reversible by renaming back.',
98
+ },
99
+ restyle_layer: {
100
+ requires_confirmation: false,
101
+ destructive: false,
102
+ long_running: false,
103
+ rationale: 'Cosmetic patch on viz fields; reversible.',
104
+ },
105
+ set_camera: {
106
+ requires_confirmation: false,
107
+ destructive: false,
108
+ long_running: false,
109
+ rationale: 'Camera-only op; no data mutation.',
110
+ },
111
+ set_time: {
112
+ requires_confirmation: false,
113
+ destructive: false,
114
+ long_running: false,
115
+ rationale: 'Binds a temporal window; reversible by emitting another setTime.',
116
+ },
117
+ tag: {
118
+ requires_confirmation: false,
119
+ destructive: false,
120
+ long_running: false,
121
+ rationale: 'Mirrors a string into workspace.meta.tags; append-only.',
122
+ },
123
+ add_annotation: {
124
+ requires_confirmation: false,
125
+ destructive: false,
126
+ long_running: false,
127
+ rationale: 'Append-only pin; reversible with remove_annotation.',
128
+ },
129
+ remove_annotation: {
130
+ requires_confirmation: false,
131
+ destructive: false,
132
+ long_running: false,
133
+ rationale: 'Annotations are not tombstoned; same id can be re-added.',
134
+ },
135
+ export_workspace: {
136
+ requires_confirmation: false,
137
+ destructive: false,
138
+ long_running: false,
139
+ rationale: 'Read-only export of workspace + mapPlan as JSON.',
140
+ },
141
+ export_layer_data: {
142
+ requires_confirmation: false,
143
+ destructive: false,
144
+ long_running: true,
145
+ rationale: 'Serializes layer + uploads to R2 with 24h TTL; network-bound.',
146
+ },
147
+ export_image: {
148
+ requires_confirmation: false,
149
+ destructive: false,
150
+ long_running: false,
151
+ rationale: 'Returns a pre-signed image URL; rendering is async upstream.',
152
+ },
153
+ undo: {
154
+ requires_confirmation: true,
155
+ destructive: false,
156
+ long_running: false,
157
+ rationale: 'Autonomous undo without user intent is a footgun: an agent that decides to "fix" something can erase the user\'s prior work.',
158
+ },
159
+ redo: {
160
+ requires_confirmation: false,
161
+ destructive: false,
162
+ long_running: false,
163
+ rationale: 'Re-applies a previously undone op; reversible by undoing again.',
164
+ },
165
+ report_blocker: {
166
+ requires_confirmation: false,
167
+ destructive: false,
168
+ long_running: false,
169
+ rationale: 'Append-only NDJSON write to the agent-blockers feedback channel.',
170
+ },
171
+ };
172
+ export function getMcpToolMeta(toolName) {
173
+ return MCP_TOOL_META[toolName];
174
+ }
175
+ export function mcpRequiresConfirmation(toolName) {
176
+ return MCP_TOOL_META[toolName]?.requires_confirmation ?? false;
177
+ }
178
+ export function mcpGatedToolNames() {
179
+ return Object.entries(MCP_TOOL_META)
180
+ .filter(([, meta]) => meta.requires_confirmation)
181
+ .map(([name]) => name);
182
+ }