@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
|
@@ -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
|
+
}
|
package/dist/toolMeta.js
ADDED
|
@@ -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
|
+
}
|