@showmeonmap/mcp 0.3.1 → 0.4.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 +2 -2
- package/dist/http.js +50 -9
- package/dist/sessionStoreD1.js +42 -11
- package/dist/toolMeta.js +6 -0
- package/dist/tools.js +199 -33
- package/dist/workspaceOpsSpec.md +3 -1
- package/package.json +3 -8
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @showmeonmap/mcp
|
|
2
2
|
|
|
3
|
-
[Model Context Protocol](https://modelcontextprotocol.io/) server for [ShowMeOnMap](https://
|
|
3
|
+
[Model Context Protocol](https://modelcontextprotocol.io/) server for [ShowMeOnMap](https://showmeonmap.com). Lets any MCP-speaking agent — Claude Desktop, Cursor, Zed, Windsurf, ChatGPT Apps SDK, custom LangGraph / CrewAI / Autogen agents — build and mutate geospatial maps over a typed tool surface.
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**Docs & keys:** https://showmeonmap.com/developers · Design spec: `docs/superpowers/specs/2026-04-17-mcp-server-design.md` (internal).
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
package/dist/http.js
CHANGED
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
* - Mcp-Session-Id header — opaque per-session id returned on init
|
|
14
14
|
*
|
|
15
15
|
* Auth (baseline): single shared bearer token via MCP_HTTP_AUTH_TOKEN env var.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Every request must include `Authorization: Bearer <token>`. With no token
|
|
17
|
+
* set the server FAILS CLOSED and refuses everything; running without auth is
|
|
18
|
+
* possible but has to be requested explicitly with MCP_HTTP_ALLOW_OPEN=1.
|
|
19
|
+
*
|
|
20
|
+
* Binds 127.0.0.1 by default — override with MCP_HTTP_HOST (audit F8: it used
|
|
21
|
+
* to bind every interface while logging loopback).
|
|
19
22
|
*
|
|
20
23
|
* Run:
|
|
21
24
|
* MCP_HTTP_AUTH_TOKEN=dev npx tsx packages/mcp-server/src/http.ts
|
|
@@ -47,6 +50,27 @@ const API_KEY = process.env.SHOWMEONMAP_API_KEY;
|
|
|
47
50
|
const HTTP_PORT = Number(process.env.MCP_HTTP_PORT ?? 3333);
|
|
48
51
|
const AUTH_TOKEN = process.env.MCP_HTTP_AUTH_TOKEN;
|
|
49
52
|
const ENDPOINT_PATH = '/mcp';
|
|
53
|
+
/**
|
|
54
|
+
* Bind LOOPBACK by default (audit F8).
|
|
55
|
+
*
|
|
56
|
+
* `server.listen(port, cb)` with no host binds every interface, while the
|
|
57
|
+
* startup log said `http://127.0.0.1:…`. A developer running this on café or
|
|
58
|
+
* coworking wifi therefore had a process reachable at `http://<laptop-ip>:3333/mcp`
|
|
59
|
+
* from the whole LAN, and believed the log saying otherwise. That process holds
|
|
60
|
+
* `SHOWMEONMAP_API_KEY`, so a stranger could drive `/api/workspace/new` — burning
|
|
61
|
+
* the developer's credits — and write `/api/maps` as them.
|
|
62
|
+
*
|
|
63
|
+
* Override deliberately (containers, LAN testing) with MCP_HTTP_HOST=0.0.0.0.
|
|
64
|
+
* The log below prints the host actually bound, so it can never disagree again.
|
|
65
|
+
*/
|
|
66
|
+
const HTTP_HOST = process.env.MCP_HTTP_HOST ?? '127.0.0.1';
|
|
67
|
+
/**
|
|
68
|
+
* Open mode must be ASKED FOR (audit F8). `isAuthorized` returned true whenever
|
|
69
|
+
* `MCP_HTTP_AUTH_TOKEN` was unset, i.e. auth failed OPEN — and unset was the
|
|
70
|
+
* documented dev default, so the safe-looking path was the unauthenticated one.
|
|
71
|
+
* Now an unset token is a hard refusal unless this flag says otherwise.
|
|
72
|
+
*/
|
|
73
|
+
const ALLOW_OPEN = process.env.MCP_HTTP_ALLOW_OPEN === '1';
|
|
50
74
|
/**
|
|
51
75
|
* Default to the in-memory store. Production deployments running under a
|
|
52
76
|
* Cloudflare Worker (where a D1 binding exists) should construct their
|
|
@@ -71,9 +95,12 @@ const SHARED_SESSION_STORE = undefined;
|
|
|
71
95
|
// transport.
|
|
72
96
|
const transports = new Map();
|
|
73
97
|
// ── Auth check ────────────────────────────────────────────────────────
|
|
74
|
-
function isAuthorized(req) {
|
|
98
|
+
export function isAuthorized(req) {
|
|
99
|
+
// Fails CLOSED with no token. Open mode is still available for local dev,
|
|
100
|
+
// but you have to say MCP_HTTP_ALLOW_OPEN=1 — the point is that nobody ends
|
|
101
|
+
// up unauthenticated by simply not setting something.
|
|
75
102
|
if (!AUTH_TOKEN)
|
|
76
|
-
return
|
|
103
|
+
return ALLOW_OPEN;
|
|
77
104
|
const hdr = req.headers['authorization'];
|
|
78
105
|
if (typeof hdr !== 'string')
|
|
79
106
|
return false;
|
|
@@ -180,13 +207,27 @@ const server = createHttpServer((req, res) => {
|
|
|
180
207
|
handleRequest(req, res).catch((err) => {
|
|
181
208
|
process.stderr.write(`[showmeonmap-mcp-http] unhandled: ${err instanceof Error ? err.stack : String(err)}\n`);
|
|
182
209
|
if (!res.headersSent) {
|
|
183
|
-
|
|
210
|
+
// Generic string only — an internal exception can carry D1 table/column
|
|
211
|
+
// names, and this handler is reachable before auth resolves. Matches the
|
|
212
|
+
// house pattern applied to the workspace routes (audit F13). The detail
|
|
213
|
+
// still reaches the operator via stderr above.
|
|
214
|
+
writeJson(res, 500, { error: 'internal', message: 'internal error' });
|
|
184
215
|
}
|
|
185
216
|
});
|
|
186
217
|
});
|
|
187
|
-
server.listen(HTTP_PORT, () => {
|
|
188
|
-
|
|
189
|
-
|
|
218
|
+
server.listen(HTTP_PORT, HTTP_HOST, () => {
|
|
219
|
+
// Report the host actually bound, never a hardcoded one — the previous log
|
|
220
|
+
// claimed 127.0.0.1 while listening on every interface, and that gap is what
|
|
221
|
+
// made F8 dangerous rather than merely wrong.
|
|
222
|
+
const authStatus = AUTH_TOKEN
|
|
223
|
+
? 'bearer-auth enabled'
|
|
224
|
+
: ALLOW_OPEN
|
|
225
|
+
? 'OPEN (MCP_HTTP_ALLOW_OPEN=1 — no auth)'
|
|
226
|
+
: 'REFUSING ALL REQUESTS (set MCP_HTTP_AUTH_TOKEN, or MCP_HTTP_ALLOW_OPEN=1 for local dev)';
|
|
227
|
+
process.stderr.write(`[showmeonmap-mcp-http] listening on http://${HTTP_HOST}:${HTTP_PORT}${ENDPOINT_PATH} · base=${BASE_URL} · ${authStatus}\n`);
|
|
228
|
+
if (HTTP_HOST !== '127.0.0.1' && HTTP_HOST !== 'localhost' && !AUTH_TOKEN) {
|
|
229
|
+
process.stderr.write(`[showmeonmap-mcp-http] ⚠️ bound to ${HTTP_HOST} with NO bearer token — this process holds SHOWMEONMAP_API_KEY and anyone who can reach this port can spend its credits\n`);
|
|
230
|
+
}
|
|
190
231
|
});
|
|
191
232
|
// Graceful shutdown — close every live session so clients see DELETE, not a socket hangup.
|
|
192
233
|
async function shutdown() {
|
package/dist/sessionStoreD1.js
CHANGED
|
@@ -94,6 +94,15 @@ export function createD1SessionStore(d1, opts = {}) {
|
|
|
94
94
|
const maxSize = opts.maxSize ?? 1000;
|
|
95
95
|
const evictionBatch = opts.evictionBatch ?? 50;
|
|
96
96
|
const onEvict = opts.onEvict;
|
|
97
|
+
const ownerId = opts.ownerId ?? null;
|
|
98
|
+
// Tenancy predicate appended to every single-row accessor. When an owner is
|
|
99
|
+
// set, a row matches only if its user_id equals that owner — which NULL
|
|
100
|
+
// never does, so migration 0018's un-backfilled legacy rows fail CLOSED
|
|
101
|
+
// rather than being readable by whoever asks first. When no owner is set
|
|
102
|
+
// (stdio: one process, one user) the predicate degrades to a no-op and the
|
|
103
|
+
// SQL is byte-identical to what shipped before.
|
|
104
|
+
const scope = ownerId ? ' AND user_id = ?' : '';
|
|
105
|
+
const scopeArgs = ownerId ? [ownerId] : [];
|
|
97
106
|
/**
|
|
98
107
|
* LRU sweep — best-effort. Counts rows, drops the oldest by last_seen_at
|
|
99
108
|
* if over capacity. Fire-and-forget: callers don't await this.
|
|
@@ -143,17 +152,19 @@ export function createD1SessionStore(d1, opts = {}) {
|
|
|
143
152
|
const row = await d1
|
|
144
153
|
.prepare(`SELECT id, workspace_id, workspace, map_plan, layer_filters, created_at, last_seen_at
|
|
145
154
|
FROM mcp_sessions
|
|
146
|
-
WHERE id =
|
|
147
|
-
.bind(mapId)
|
|
155
|
+
WHERE id = ?${scope}`)
|
|
156
|
+
.bind(mapId, ...scopeArgs)
|
|
148
157
|
.first();
|
|
158
|
+
// A row owned by someone else is indistinguishable from one that does
|
|
159
|
+
// not exist — no existence oracle for another tenant's map ids.
|
|
149
160
|
if (!row)
|
|
150
161
|
return null;
|
|
151
162
|
const entry = rowToEntry(row);
|
|
152
163
|
// Touch last_seen_at so the LRU treats this as recently accessed.
|
|
153
164
|
// Fire-and-forget: a failed touch shouldn't break the read.
|
|
154
165
|
const now = Date.now();
|
|
155
|
-
d1.prepare(`UPDATE mcp_sessions SET last_seen_at = ? WHERE id =
|
|
156
|
-
.bind(now, mapId)
|
|
166
|
+
d1.prepare(`UPDATE mcp_sessions SET last_seen_at = ? WHERE id = ?${scope}`)
|
|
167
|
+
.bind(now, mapId, ...scopeArgs)
|
|
157
168
|
.run()
|
|
158
169
|
.catch(() => undefined);
|
|
159
170
|
entry.lastAccessed = now;
|
|
@@ -161,25 +172,45 @@ export function createD1SessionStore(d1, opts = {}) {
|
|
|
161
172
|
},
|
|
162
173
|
async put(mapId, entry) {
|
|
163
174
|
const row = entryToRow(mapId, entry);
|
|
164
|
-
// UPSERT — D1 supports ON CONFLICT(id) DO UPDATE
|
|
165
|
-
|
|
175
|
+
// UPSERT — D1 supports ON CONFLICT(id) DO UPDATE, including the optional
|
|
176
|
+
// WHERE on the DO UPDATE arm. That WHERE is the whole tenancy guard on
|
|
177
|
+
// the write path: if the id already belongs to a different owner the
|
|
178
|
+
// update arm matches nothing, so a cross-tenant overwrite is impossible
|
|
179
|
+
// ATOMICALLY, without a read-then-write race.
|
|
180
|
+
const result = await d1
|
|
166
181
|
.prepare(`INSERT INTO mcp_sessions
|
|
167
|
-
(id, workspace_id, workspace, map_plan, layer_filters, created_at, last_seen_at)
|
|
168
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
182
|
+
(id, user_id, workspace_id, workspace, map_plan, layer_filters, created_at, last_seen_at)
|
|
183
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
169
184
|
ON CONFLICT(id) DO UPDATE SET
|
|
170
185
|
workspace_id = excluded.workspace_id,
|
|
171
186
|
workspace = excluded.workspace,
|
|
172
187
|
map_plan = excluded.map_plan,
|
|
173
188
|
layer_filters = excluded.layer_filters,
|
|
174
|
-
last_seen_at = excluded.last_seen_at
|
|
175
|
-
|
|
189
|
+
last_seen_at = excluded.last_seen_at
|
|
190
|
+
${ownerId ? 'WHERE mcp_sessions.user_id = ?' : ''}`)
|
|
191
|
+
.bind(row.id, ownerId, row.workspace_id, row.workspace, row.map_plan, row.layer_filters, row.created_at, row.last_seen_at, ...scopeArgs)
|
|
176
192
|
.run();
|
|
193
|
+
// A refused DO UPDATE reports zero changes and would otherwise look
|
|
194
|
+
// exactly like a successful write — the caller would go on to report a
|
|
195
|
+
// saved workspace that was never saved. Make it loud. This is the
|
|
196
|
+
// silent-failure shape this repo has been bitten by repeatedly; an
|
|
197
|
+
// unwritable session must not be indistinguishable from a written one.
|
|
198
|
+
if (ownerId && (result.meta?.changes ?? 0) === 0) {
|
|
199
|
+
throw new Error(`mcp session ${mapId} belongs to another account (write refused)`);
|
|
200
|
+
}
|
|
177
201
|
// Best-effort LRU sweep. Don't await — a slow eviction must not block
|
|
178
202
|
// the write that triggered it. Failures are absorbed inside maybeEvict.
|
|
179
203
|
void maybeEvict();
|
|
180
204
|
},
|
|
181
205
|
async delete(mapId) {
|
|
182
|
-
|
|
206
|
+
// Owner-scoped, so a non-owner's delete silently affects zero rows
|
|
207
|
+
// rather than destroying someone else's session. No error here on
|
|
208
|
+
// purpose: delete is idempotent by contract, and "already gone" and
|
|
209
|
+
// "not yours" should look the same to a caller who owns neither.
|
|
210
|
+
await d1
|
|
211
|
+
.prepare(`DELETE FROM mcp_sessions WHERE id = ?${scope}`)
|
|
212
|
+
.bind(mapId, ...scopeArgs)
|
|
213
|
+
.run();
|
|
183
214
|
},
|
|
184
215
|
async size() {
|
|
185
216
|
const row = await d1
|
package/dist/toolMeta.js
CHANGED
|
@@ -72,6 +72,12 @@ export const MCP_TOOL_META = {
|
|
|
72
72
|
long_running: false,
|
|
73
73
|
rationale: 'Adds a new derived layer; original layer untouched.',
|
|
74
74
|
},
|
|
75
|
+
focus_area: {
|
|
76
|
+
requires_confirmation: false,
|
|
77
|
+
destructive: false,
|
|
78
|
+
long_running: false,
|
|
79
|
+
rationale: 'Filters or derives a layer to a server-resolved named area; reversible via undo/clear_filters.',
|
|
80
|
+
},
|
|
75
81
|
query_features: {
|
|
76
82
|
requires_confirmation: false,
|
|
77
83
|
destructive: false,
|
package/dist/tools.js
CHANGED
|
@@ -193,7 +193,9 @@ function envelope(baseUrl, mapId, workspace, mapPlan, nextActionStrings, extras
|
|
|
193
193
|
map_id: mapId,
|
|
194
194
|
version: opsCount,
|
|
195
195
|
structured: compactStructured(workspace, mapPlan),
|
|
196
|
-
|
|
196
|
+
...(extras.shareUnpersisted
|
|
197
|
+
? { share_status: 'not_persisted' }
|
|
198
|
+
: { shareable_url: `${baseUrl}/m/${mapId}` }),
|
|
197
199
|
metadata: {
|
|
198
200
|
title: mapPlan?.title,
|
|
199
201
|
layer_count: layerCount,
|
|
@@ -205,6 +207,8 @@ function envelope(baseUrl, mapId, workspace, mapPlan, nextActionStrings, extras
|
|
|
205
207
|
})),
|
|
206
208
|
...(extras.outOfScope ? { outOfScope: extras.outOfScope } : {}),
|
|
207
209
|
...(extras.clarification ? { clarification: extras.clarification } : {}),
|
|
210
|
+
...(extras.opsApplied ? { opsApplied: extras.opsApplied } : {}),
|
|
211
|
+
...(extras.contextAnswer ? { contextAnswer: extras.contextAnswer } : {}),
|
|
208
212
|
};
|
|
209
213
|
}
|
|
210
214
|
function toContent(obj) {
|
|
@@ -238,6 +242,7 @@ const INVALID_INPUT_HINTS = {
|
|
|
238
242
|
add_layer: 'Required: map_id, layerPlan (object), rationale. Optional: intent (enum). No other fields accepted.',
|
|
239
243
|
filter_layer: 'Required: map_id, layerId, predicate (object), rationale. Optional: append (boolean), intent (enum).',
|
|
240
244
|
select_within: 'Required: map_id, sourceLayerId, newLayerId, selection ({type:Polygon|MultiPolygon, coordinates}), rationale. Optional: inheritStyle.',
|
|
245
|
+
focus_area: 'Required: map_id, layerId, areaRef ({kind:"corridor", street, from?, to?} | {kind:"place", name} | {kind:"aroundIntersection", street, cross}), rationale. Optional: mode (filter|select|clip). Pass NAMES — the server resolves geometry from OSM.',
|
|
241
246
|
query_features: 'Required: map_id, layerId, predicate (object), rationale. Optional: limit (1–1000 integer).',
|
|
242
247
|
remove_layer: 'Required: map_id, layerId, rationale, confirmed:true (destructive — layer ids are tombstoned).',
|
|
243
248
|
clear_filters: 'Required: map_id, layerId, rationale, confirmed:true (sensitive — autonomous filter wipe).',
|
|
@@ -350,6 +355,14 @@ async function apiFetch(baseUrl, apiKey, path, body) {
|
|
|
350
355
|
body: JSON.stringify(body),
|
|
351
356
|
});
|
|
352
357
|
const text = await resp.text();
|
|
358
|
+
// A non-2xx is a FAILURE even when the body is valid JSON — /api/maps
|
|
359
|
+
// returns a JSON error body for its IP rate-limit 429, which parsed
|
|
360
|
+
// "successfully" here and made a failed share-store write look like a
|
|
361
|
+
// success (one of four parallel build_map calls lost its map this way,
|
|
362
|
+
// 2026-08-01).
|
|
363
|
+
if (!resp.ok) {
|
|
364
|
+
throw new Error(`${path} ${resp.status}: ${text.slice(0, 200)}`);
|
|
365
|
+
}
|
|
353
366
|
try {
|
|
354
367
|
return JSON.parse(text);
|
|
355
368
|
}
|
|
@@ -368,31 +381,116 @@ async function apiFetch(baseUrl, apiKey, path, body) {
|
|
|
368
381
|
* `version`, so /m/[id] rehydrates it faithfully. NEVER throws — a failed
|
|
369
382
|
* share must not fail the build/mutation.
|
|
370
383
|
*/
|
|
384
|
+
// ── share-blob compaction ──────────────────────────────────────────────────
|
|
385
|
+
// /api/maps caps blobs at 5 MB. A fusion map's inline world geometry is
|
|
386
|
+
// 16.5 MB MEASURED (2026-08-01) — the persist POST was rejected with
|
|
387
|
+
// "blob too large" on every attempt and the old code swallowed it, which is
|
|
388
|
+
// how the same map vanished twice while its siblings persisted. Recipe
|
|
389
|
+
// mirrors build-statistical-goldens + saveMapSnapshot: ring decimation,
|
|
390
|
+
// largest-polygons-only, 4-decimal coordinates (~11 m — invisible at the
|
|
391
|
+
// zooms a snapshot serves; the /m/ page offers "Re-run live" for full
|
|
392
|
+
// fidelity), and a property whitelist of base fields + whatever the layer's
|
|
393
|
+
// own viz reads.
|
|
394
|
+
const SHARE_MAX_VERTICES_PER_RING = 60;
|
|
395
|
+
const SHARE_MAX_POLYS_PER_MULTI = 3;
|
|
396
|
+
const SHARE_MAX_FEATURES_PER_LAYER = 2000;
|
|
397
|
+
const SHARE_BASE_KEEP_PROPS = new Set(['value', 'name', 'iso3', 'label', '_dataVintage']);
|
|
398
|
+
function roundCoord(n) {
|
|
399
|
+
return Math.round(n * 10_000) / 10_000;
|
|
400
|
+
}
|
|
401
|
+
function decimateRing(ring) {
|
|
402
|
+
const rounded = ring.map(([x, y]) => [roundCoord(x), roundCoord(y)]);
|
|
403
|
+
if (rounded.length <= SHARE_MAX_VERTICES_PER_RING)
|
|
404
|
+
return rounded;
|
|
405
|
+
const out = [];
|
|
406
|
+
for (let i = 0; i < SHARE_MAX_VERTICES_PER_RING; i++) {
|
|
407
|
+
out.push(rounded[Math.floor((i * (rounded.length - 1)) / (SHARE_MAX_VERTICES_PER_RING - 1))]);
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
function ringArea(ring) {
|
|
412
|
+
let area = 0;
|
|
413
|
+
for (let i = 0; i < ring.length - 1; i++) {
|
|
414
|
+
area += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1];
|
|
415
|
+
}
|
|
416
|
+
return Math.abs(area / 2);
|
|
417
|
+
}
|
|
418
|
+
function compactGeometry(geom) {
|
|
419
|
+
if (geom?.type === 'Polygon') {
|
|
420
|
+
return { ...geom, coordinates: geom.coordinates.map(decimateRing) };
|
|
421
|
+
}
|
|
422
|
+
if (geom?.type === 'MultiPolygon') {
|
|
423
|
+
const polys = [...geom.coordinates]
|
|
424
|
+
.sort((a, b) => ringArea(b[0]) - ringArea(a[0]))
|
|
425
|
+
.slice(0, SHARE_MAX_POLYS_PER_MULTI);
|
|
426
|
+
return { ...geom, coordinates: polys.map((poly) => poly.map(decimateRing)) };
|
|
427
|
+
}
|
|
428
|
+
if (geom?.type === 'LineString') {
|
|
429
|
+
return { ...geom, coordinates: decimateRing(geom.coordinates) };
|
|
430
|
+
}
|
|
431
|
+
if (geom?.type === 'MultiLineString') {
|
|
432
|
+
return { ...geom, coordinates: geom.coordinates.map(decimateRing) };
|
|
433
|
+
}
|
|
434
|
+
if (geom?.type === 'Point') {
|
|
435
|
+
const [x, y] = geom.coordinates;
|
|
436
|
+
return { ...geom, coordinates: [roundCoord(x), roundCoord(y)] };
|
|
437
|
+
}
|
|
438
|
+
return geom;
|
|
439
|
+
}
|
|
440
|
+
/** Base whitelist + every field name the layer's viz reads + `_smo_*`
|
|
441
|
+
* internals (tooltip/popup dispatch). Exported for tests. */
|
|
442
|
+
export function compactFeatureCollection(fc, viz) {
|
|
443
|
+
const keep = new Set(SHARE_BASE_KEEP_PROPS);
|
|
444
|
+
for (const [k, v] of Object.entries(viz ?? {})) {
|
|
445
|
+
if (typeof v === 'string' && /(Field|^field[A-Z])/.test(k))
|
|
446
|
+
keep.add(v);
|
|
447
|
+
}
|
|
448
|
+
const features = (fc.features ?? []).slice(0, SHARE_MAX_FEATURES_PER_LAYER).map((f) => ({
|
|
449
|
+
...f,
|
|
450
|
+
geometry: compactGeometry(f.geometry),
|
|
451
|
+
properties: Object.fromEntries(Object.entries(f.properties ?? {}).filter(([k]) => keep.has(k) || k.startsWith('_smo_'))),
|
|
452
|
+
}));
|
|
453
|
+
return { type: fc.type ?? 'FeatureCollection', features };
|
|
454
|
+
}
|
|
371
455
|
async function persistShareableMap(baseUrl, apiKey, mapId, mapPlan, query) {
|
|
372
456
|
if (!mapPlan || !Array.isArray(mapPlan.layers))
|
|
373
|
-
return;
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
457
|
+
return false;
|
|
458
|
+
const geojsonLayers = {};
|
|
459
|
+
const strippedLayers = mapPlan.layers.map((raw) => {
|
|
460
|
+
const layer = raw;
|
|
461
|
+
if (layer?.source?.kind === 'geojson' && layer.source.data && layer.id) {
|
|
462
|
+
geojsonLayers[layer.id] = compactFeatureCollection(layer.source.data, layer.viz);
|
|
463
|
+
return { ...layer, source: { ...layer.source, data: undefined } };
|
|
464
|
+
}
|
|
465
|
+
return layer;
|
|
466
|
+
});
|
|
467
|
+
const title = mapPlan.title ?? mapPlan.layers[0]?.title ?? 'Untitled Map';
|
|
468
|
+
const body = {
|
|
469
|
+
id: mapId,
|
|
470
|
+
title,
|
|
471
|
+
query,
|
|
472
|
+
blob: { mapPlan: { ...mapPlan, layers: strippedLayers }, geojsonLayers, query, version: 1 },
|
|
473
|
+
isPublic: true,
|
|
474
|
+
};
|
|
475
|
+
// Two attempts: the second exists for exactly the failure that lost a map
|
|
476
|
+
// live — parallel build_map calls tripping /api/maps' burst rate limit.
|
|
477
|
+
// A share-save failure still never fails the BUILD, but it must never be
|
|
478
|
+
// silent either, and the caller decides whether to hand out the URL.
|
|
479
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
480
|
+
try {
|
|
481
|
+
await apiFetch(baseUrl, apiKey, '/api/maps', body);
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
if (attempt === 1) {
|
|
486
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
487
|
+
continue;
|
|
381
488
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
await apiFetch(baseUrl, apiKey, '/api/maps', {
|
|
386
|
-
id: mapId,
|
|
387
|
-
title,
|
|
388
|
-
query,
|
|
389
|
-
blob: { mapPlan: { ...mapPlan, layers: strippedLayers }, geojsonLayers, query, version: 1 },
|
|
390
|
-
isPublic: true,
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
catch {
|
|
394
|
-
/* best-effort — a share-save failure never fails the build */
|
|
489
|
+
console.warn(`[mcp] share-store write FAILED for map ${mapId} after retry — ` +
|
|
490
|
+
`the /m/ link would be dead, omitting it: ${err instanceof Error ? err.message : err}`);
|
|
491
|
+
}
|
|
395
492
|
}
|
|
493
|
+
return false;
|
|
396
494
|
}
|
|
397
495
|
// MCP tool annotations — safety hints every tool advertises so clients can
|
|
398
496
|
// reason about read-only vs mutating vs destructive behavior (and the model
|
|
@@ -405,7 +503,7 @@ const READ_ONLY_TOOLS = new Set([
|
|
|
405
503
|
]);
|
|
406
504
|
// Tools that reach external/open-world data sources, vs operating purely on
|
|
407
505
|
// already-loaded workspace state.
|
|
408
|
-
const OPEN_WORLD_TOOLS = new Set(['build_map', 'mutate_map', 'add_layer', 'add_fusion_layer']);
|
|
506
|
+
const OPEN_WORLD_TOOLS = new Set(['build_map', 'mutate_map', 'add_layer', 'add_fusion_layer', 'focus_area']);
|
|
409
507
|
// Mutating-but-idempotent setters: re-applying the same call converges to the
|
|
410
508
|
// same state (unlike append-style build_map / add_annotation / tag / undo).
|
|
411
509
|
const IDEMPOTENT_SETTERS = new Set([
|
|
@@ -524,8 +622,8 @@ export function createServer(opts) {
|
|
|
524
622
|
'Zoom the camera closer',
|
|
525
623
|
];
|
|
526
624
|
// Persist to the shareable store so the returned /m/<mapId> link renders.
|
|
527
|
-
await persistShareableMap(baseUrl, apiKey, mapId, resp.mapPlan, query);
|
|
528
|
-
return toContent(envelope(baseUrl, mapId, resp.workspace, resp.mapPlan, nextActions));
|
|
625
|
+
const shared = await persistShareableMap(baseUrl, apiKey, mapId, resp.mapPlan, query);
|
|
626
|
+
return toContent(envelope(baseUrl, mapId, resp.workspace, resp.mapPlan, nextActions, shared ? {} : { shareUnpersisted: true }));
|
|
529
627
|
}));
|
|
530
628
|
const mutateMapShape = {
|
|
531
629
|
map_id: z.string().min(1).describe('map_id from a prior build_map call'),
|
|
@@ -565,8 +663,11 @@ export function createServer(opts) {
|
|
|
565
663
|
});
|
|
566
664
|
const mutMapPlan = (resp.mapPlan ?? entry.mapPlan);
|
|
567
665
|
// Update the shareable /m/<map_id> map so the link reflects the mutation.
|
|
568
|
-
await persistShareableMap(baseUrl, apiKey, map_id, mutMapPlan, instruction);
|
|
569
|
-
return toContent(envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), mutMapPlan, resp.nextActions,
|
|
666
|
+
const shared = await persistShareableMap(baseUrl, apiKey, map_id, mutMapPlan, instruction);
|
|
667
|
+
return toContent(envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), mutMapPlan, resp.nextActions, {
|
|
668
|
+
...(resp.clarification ? { clarification: resp.clarification } : {}),
|
|
669
|
+
...(shared ? {} : { shareUnpersisted: true }),
|
|
670
|
+
}));
|
|
570
671
|
}));
|
|
571
672
|
const getStateShape = {
|
|
572
673
|
map_id: z.string().min(1),
|
|
@@ -612,8 +713,8 @@ export function createServer(opts) {
|
|
|
612
713
|
});
|
|
613
714
|
const opMapPlan = (resp.mapPlan ?? entry.mapPlan);
|
|
614
715
|
// Update the shareable /m/<map_id> map so the link reflects the mutation.
|
|
615
|
-
await persistShareableMap(baseUrl, apiKey, map_id, opMapPlan, opMapPlan?.title ?? '');
|
|
616
|
-
return envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), opMapPlan, undefined);
|
|
716
|
+
const shared = await persistShareableMap(baseUrl, apiKey, map_id, opMapPlan, opMapPlan?.title ?? '');
|
|
717
|
+
return envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), opMapPlan, undefined, shared ? {} : { shareUnpersisted: true });
|
|
617
718
|
}
|
|
618
719
|
/**
|
|
619
720
|
* Shared op-header builder — every direct-op tool fills these fields.
|
|
@@ -860,6 +961,70 @@ export function createServer(opts) {
|
|
|
860
961
|
};
|
|
861
962
|
return toContent(await applyOp(map_id, op));
|
|
862
963
|
}));
|
|
964
|
+
const focusAreaAreaRefShape = z.discriminatedUnion('kind', [
|
|
965
|
+
z.object({ kind: z.literal('place'), name: z.string().min(1) }),
|
|
966
|
+
z.object({
|
|
967
|
+
kind: z.literal('corridor'),
|
|
968
|
+
street: z.string().min(1),
|
|
969
|
+
from: z.string().min(1).optional(),
|
|
970
|
+
to: z.string().min(1).optional(),
|
|
971
|
+
bufferMeters: z.number().min(30).max(1000).optional(),
|
|
972
|
+
}),
|
|
973
|
+
z.object({
|
|
974
|
+
kind: z.literal('aroundIntersection'),
|
|
975
|
+
street: z.string().min(1),
|
|
976
|
+
cross: z.string().min(1),
|
|
977
|
+
radiusMeters: z.number().min(50).max(5000).optional(),
|
|
978
|
+
}),
|
|
979
|
+
]);
|
|
980
|
+
const focusAreaShape = {
|
|
981
|
+
map_id: z.string().min(1),
|
|
982
|
+
layerId: z.string().min(1),
|
|
983
|
+
areaRef: focusAreaAreaRefShape,
|
|
984
|
+
mode: z
|
|
985
|
+
.enum(['filter', 'select', 'clip'])
|
|
986
|
+
.optional()
|
|
987
|
+
.describe('filter (default): hide features outside, reversible. select: copy matches into a new layer. clip: new layer with geometry trimmed at the boundary.'),
|
|
988
|
+
rationale: rationaleSchema,
|
|
989
|
+
};
|
|
990
|
+
registerTool('focus_area', {
|
|
991
|
+
title: 'Focus a layer on a named area',
|
|
992
|
+
description: 'Refine an existing layer to a NAMED area the server resolves from OpenStreetMap — a street corridor ("Broadway from Arbutus Street to Main Street"), a place ("Mount Pleasant"), or an intersection surroundings. Pass names only, never coordinates; unresolvable names return a clarification with real nearby candidates instead of guessed geometry. Emits ordinary undoable ops (filter / new layer + camera) and a grounded summary like "kept 37 of 150 features". Costs 1 credit when a mutation lands; clarifications are refunded.',
|
|
993
|
+
inputSchema: focusAreaShape,
|
|
994
|
+
}, withValidation('focus_area', focusAreaShape, async ({ map_id, layerId, areaRef, mode, rationale }) => {
|
|
995
|
+
const entry = await store.get(map_id);
|
|
996
|
+
if (!entry)
|
|
997
|
+
throw new Error(`Unknown map_id "${map_id}". Call build_map first.`);
|
|
998
|
+
logRationale('focus_area', map_id, rationale, { opKind: `focus_area:${areaRef.kind ?? 'unknown'}:${mode ?? 'filter'}` });
|
|
999
|
+
const resp = (await apiFetch(baseUrl, apiKey, '/api/workspace/focus-area', {
|
|
1000
|
+
workspace: entry.workspace,
|
|
1001
|
+
layerId,
|
|
1002
|
+
areaRef,
|
|
1003
|
+
...(mode ? { mode } : {}),
|
|
1004
|
+
rationale,
|
|
1005
|
+
}));
|
|
1006
|
+
if (!resp.ok) {
|
|
1007
|
+
throw new Error(`focus_area failed: ${resp.error?.kind ?? 'unknown'} — ${resp.error?.message ?? 'no detail'}`);
|
|
1008
|
+
}
|
|
1009
|
+
// Honest resolver failure: nothing changed; surface the clarification.
|
|
1010
|
+
if (resp.mutated === false) {
|
|
1011
|
+
return toContent(envelope(baseUrl, map_id, entry.workspace, entry.mapPlan, undefined, { ...(resp.clarification ? { clarification: resp.clarification } : {}) }));
|
|
1012
|
+
}
|
|
1013
|
+
await store.put(map_id, {
|
|
1014
|
+
workspace: resp.workspace ?? entry.workspace,
|
|
1015
|
+
mapPlan: resp.mapPlan ?? entry.mapPlan,
|
|
1016
|
+
layerFilters: resp.layerFilters ?? {},
|
|
1017
|
+
createdAt: entry.createdAt,
|
|
1018
|
+
lastAccessed: Date.now(),
|
|
1019
|
+
});
|
|
1020
|
+
const focusedPlan = (resp.mapPlan ?? entry.mapPlan);
|
|
1021
|
+
const shared = await persistShareableMap(baseUrl, apiKey, map_id, focusedPlan, rationale);
|
|
1022
|
+
return toContent(envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), focusedPlan, undefined, {
|
|
1023
|
+
...(resp.opsApplied ? { opsApplied: resp.opsApplied } : {}),
|
|
1024
|
+
...(resp.contextAnswer ? { contextAnswer: resp.contextAnswer } : {}),
|
|
1025
|
+
...(shared ? {} : { shareUnpersisted: true }),
|
|
1026
|
+
}));
|
|
1027
|
+
}));
|
|
863
1028
|
const queryFeaturesShape = {
|
|
864
1029
|
map_id: z.string().min(1),
|
|
865
1030
|
layerId: z.string().min(1),
|
|
@@ -1218,8 +1383,8 @@ export function createServer(opts) {
|
|
|
1218
1383
|
});
|
|
1219
1384
|
const rematPlan = resp.mapPlan;
|
|
1220
1385
|
// Keep the shareable /m/<map_id> map in sync after undo/redo.
|
|
1221
|
-
await persistShareableMap(baseUrl, apiKey, map_id, rematPlan, rematPlan?.title ?? '');
|
|
1222
|
-
return envelope(baseUrl, map_id, workspace, rematPlan, undefined);
|
|
1386
|
+
const shared = await persistShareableMap(baseUrl, apiKey, map_id, rematPlan, rematPlan?.title ?? '');
|
|
1387
|
+
return envelope(baseUrl, map_id, workspace, rematPlan, undefined, shared ? {} : { shareUnpersisted: true });
|
|
1223
1388
|
}
|
|
1224
1389
|
const exportWorkspaceShape = {
|
|
1225
1390
|
map_id: z.string().min(1),
|
|
@@ -1258,8 +1423,9 @@ export function createServer(opts) {
|
|
|
1258
1423
|
description: 'Export a single layer\'s features as a downloadable file. Returns ' +
|
|
1259
1424
|
'a URL with Content-Disposition: attachment so the user\'s browser ' +
|
|
1260
1425
|
'downloads it. CSV drops geometry beyond a (lng, lat) centroid — ' +
|
|
1261
|
-
'for full polygon/line geometry, use format: "geojson". URL is ' +
|
|
1262
|
-
'
|
|
1426
|
+
'for full polygon/line geometry, use format: "geojson". The URL is an ' +
|
|
1427
|
+
'unguessable capability link that does NOT expire — treat it as ' +
|
|
1428
|
+
'shareable-forever, not temporary. Required: map_id, layer_id, format, rationale.',
|
|
1263
1429
|
inputSchema: exportLayerDataShape,
|
|
1264
1430
|
}, withValidation('export_layer_data', exportLayerDataShape, async ({ map_id, layer_id, format, rationale }) => {
|
|
1265
1431
|
logRationale('export_layer_data', map_id, rationale);
|
package/dist/workspaceOpsSpec.md
CHANGED
|
@@ -57,6 +57,7 @@ Every op carries common fields:
|
|
|
57
57
|
| `renameLayer` | `{ oldLayerId, newLayerId }` — re-keys filters. newLayerId must be unique (incl. tombstones). |
|
|
58
58
|
| `restyleLayer` | `{ layerId, color?: string, opacity?: number }` — within-palette style change. |
|
|
59
59
|
| `subsetToSelection` | `{ sourceLayerId, newLayerId, selection: GeoJSONPolygon, inheritStyle?: boolean }` |
|
|
60
|
+
| `clipLayer` | `{ sourceLayerId, newLayerId, clip: GeoJSONPolygon, inheritStyle?: boolean }` — new layer with geometry TRIMMED at the clip boundary (crossing lines keep only their inside span; polygons intersected; outside features dropped). Requires an inline-geojson source. Prefer the `focus_area` tool for NAMED areas — it resolves streets/places server-side so you never hand-build the polygon. |
|
|
60
61
|
| `setCamera` | `{ camera: CameraPlan }` — latest setCamera op wins on replay. |
|
|
61
62
|
| `setBasemap` | `{ basemap: 'light'\|'dark'\|'streets'\|'satellite'\|'engineering' }` |
|
|
62
63
|
| `setTime` | `{ field, rangeStart, rangeEnd, format?: 'unix_ms'\|'iso'\|'integer' }` |
|
|
@@ -92,8 +93,9 @@ exceeds the GPU's expressiveness (e.g. spatial `within`).
|
|
|
92
93
|
### Spatial predicates
|
|
93
94
|
|
|
94
95
|
```ts
|
|
95
|
-
{ op: 'within', geometry: { type: 'Polygon', coordinates: [[ [lng,lat], ... ]] } }
|
|
96
|
+
{ op: 'within', geometry: { type: 'Polygon', coordinates: [[ [lng,lat], ... ]] } } // feature ENTIRELY inside
|
|
96
97
|
{ op: 'intersects', layerId: 'country_borders' } // intersect with another layer's geometry
|
|
98
|
+
{ op: 'intersects', geometry: { type: 'Polygon', coordinates: [[ ... ]] } } // intersect an inline polygon — keeps features crossing the boundary (e.g. a street-corridor focus); pass EXACTLY ONE of layerId | geometry
|
|
97
99
|
{ op: 'withinDistance', center: [lng, lat], meters: 5000 }
|
|
98
100
|
{ op: 'bbox', box: [west, south, east, north] }
|
|
99
101
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@showmeonmap/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"mcpName": "com.showmeonmap/maps",
|
|
5
5
|
"description": "MCP server for ShowMeOnMap \u2014 geospatial visualization tools for any MCP-speaking agent (Claude Desktop, Cursor, Zed, ChatGPT Apps SDK)",
|
|
6
6
|
"type": "module",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"test": "npm run test:unit"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
28
28
|
"zod": "^4.3.5"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
@@ -32,12 +32,7 @@
|
|
|
32
32
|
"typescript": "^5.4.0"
|
|
33
33
|
},
|
|
34
34
|
"license": "Apache-2.0",
|
|
35
|
-
"
|
|
36
|
-
"type": "git",
|
|
37
|
-
"url": "git+https://github.com/FrankAIUser/showmeonmap.git",
|
|
38
|
-
"directory": "packages/mcp-server"
|
|
39
|
-
},
|
|
40
|
-
"homepage": "https://github.com/FrankAIUser/showmeonmap/tree/main/packages/mcp-server#readme",
|
|
35
|
+
"homepage": "https://showmeonmap.com/developers",
|
|
41
36
|
"keywords": [
|
|
42
37
|
"mcp",
|
|
43
38
|
"model-context-protocol",
|