@showmeonmap/mcp 0.3.1 → 0.3.2
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/tools.js +131 -32
- 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/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,
|
|
@@ -350,6 +352,14 @@ async function apiFetch(baseUrl, apiKey, path, body) {
|
|
|
350
352
|
body: JSON.stringify(body),
|
|
351
353
|
});
|
|
352
354
|
const text = await resp.text();
|
|
355
|
+
// A non-2xx is a FAILURE even when the body is valid JSON — /api/maps
|
|
356
|
+
// returns a JSON error body for its IP rate-limit 429, which parsed
|
|
357
|
+
// "successfully" here and made a failed share-store write look like a
|
|
358
|
+
// success (one of four parallel build_map calls lost its map this way,
|
|
359
|
+
// 2026-08-01).
|
|
360
|
+
if (!resp.ok) {
|
|
361
|
+
throw new Error(`${path} ${resp.status}: ${text.slice(0, 200)}`);
|
|
362
|
+
}
|
|
353
363
|
try {
|
|
354
364
|
return JSON.parse(text);
|
|
355
365
|
}
|
|
@@ -368,31 +378,116 @@ async function apiFetch(baseUrl, apiKey, path, body) {
|
|
|
368
378
|
* `version`, so /m/[id] rehydrates it faithfully. NEVER throws — a failed
|
|
369
379
|
* share must not fail the build/mutation.
|
|
370
380
|
*/
|
|
381
|
+
// ── share-blob compaction ──────────────────────────────────────────────────
|
|
382
|
+
// /api/maps caps blobs at 5 MB. A fusion map's inline world geometry is
|
|
383
|
+
// 16.5 MB MEASURED (2026-08-01) — the persist POST was rejected with
|
|
384
|
+
// "blob too large" on every attempt and the old code swallowed it, which is
|
|
385
|
+
// how the same map vanished twice while its siblings persisted. Recipe
|
|
386
|
+
// mirrors build-statistical-goldens + saveMapSnapshot: ring decimation,
|
|
387
|
+
// largest-polygons-only, 4-decimal coordinates (~11 m — invisible at the
|
|
388
|
+
// zooms a snapshot serves; the /m/ page offers "Re-run live" for full
|
|
389
|
+
// fidelity), and a property whitelist of base fields + whatever the layer's
|
|
390
|
+
// own viz reads.
|
|
391
|
+
const SHARE_MAX_VERTICES_PER_RING = 60;
|
|
392
|
+
const SHARE_MAX_POLYS_PER_MULTI = 3;
|
|
393
|
+
const SHARE_MAX_FEATURES_PER_LAYER = 2000;
|
|
394
|
+
const SHARE_BASE_KEEP_PROPS = new Set(['value', 'name', 'iso3', 'label', '_dataVintage']);
|
|
395
|
+
function roundCoord(n) {
|
|
396
|
+
return Math.round(n * 10_000) / 10_000;
|
|
397
|
+
}
|
|
398
|
+
function decimateRing(ring) {
|
|
399
|
+
const rounded = ring.map(([x, y]) => [roundCoord(x), roundCoord(y)]);
|
|
400
|
+
if (rounded.length <= SHARE_MAX_VERTICES_PER_RING)
|
|
401
|
+
return rounded;
|
|
402
|
+
const out = [];
|
|
403
|
+
for (let i = 0; i < SHARE_MAX_VERTICES_PER_RING; i++) {
|
|
404
|
+
out.push(rounded[Math.floor((i * (rounded.length - 1)) / (SHARE_MAX_VERTICES_PER_RING - 1))]);
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
function ringArea(ring) {
|
|
409
|
+
let area = 0;
|
|
410
|
+
for (let i = 0; i < ring.length - 1; i++) {
|
|
411
|
+
area += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1];
|
|
412
|
+
}
|
|
413
|
+
return Math.abs(area / 2);
|
|
414
|
+
}
|
|
415
|
+
function compactGeometry(geom) {
|
|
416
|
+
if (geom?.type === 'Polygon') {
|
|
417
|
+
return { ...geom, coordinates: geom.coordinates.map(decimateRing) };
|
|
418
|
+
}
|
|
419
|
+
if (geom?.type === 'MultiPolygon') {
|
|
420
|
+
const polys = [...geom.coordinates]
|
|
421
|
+
.sort((a, b) => ringArea(b[0]) - ringArea(a[0]))
|
|
422
|
+
.slice(0, SHARE_MAX_POLYS_PER_MULTI);
|
|
423
|
+
return { ...geom, coordinates: polys.map((poly) => poly.map(decimateRing)) };
|
|
424
|
+
}
|
|
425
|
+
if (geom?.type === 'LineString') {
|
|
426
|
+
return { ...geom, coordinates: decimateRing(geom.coordinates) };
|
|
427
|
+
}
|
|
428
|
+
if (geom?.type === 'MultiLineString') {
|
|
429
|
+
return { ...geom, coordinates: geom.coordinates.map(decimateRing) };
|
|
430
|
+
}
|
|
431
|
+
if (geom?.type === 'Point') {
|
|
432
|
+
const [x, y] = geom.coordinates;
|
|
433
|
+
return { ...geom, coordinates: [roundCoord(x), roundCoord(y)] };
|
|
434
|
+
}
|
|
435
|
+
return geom;
|
|
436
|
+
}
|
|
437
|
+
/** Base whitelist + every field name the layer's viz reads + `_smo_*`
|
|
438
|
+
* internals (tooltip/popup dispatch). Exported for tests. */
|
|
439
|
+
export function compactFeatureCollection(fc, viz) {
|
|
440
|
+
const keep = new Set(SHARE_BASE_KEEP_PROPS);
|
|
441
|
+
for (const [k, v] of Object.entries(viz ?? {})) {
|
|
442
|
+
if (typeof v === 'string' && /(Field|^field[A-Z])/.test(k))
|
|
443
|
+
keep.add(v);
|
|
444
|
+
}
|
|
445
|
+
const features = (fc.features ?? []).slice(0, SHARE_MAX_FEATURES_PER_LAYER).map((f) => ({
|
|
446
|
+
...f,
|
|
447
|
+
geometry: compactGeometry(f.geometry),
|
|
448
|
+
properties: Object.fromEntries(Object.entries(f.properties ?? {}).filter(([k]) => keep.has(k) || k.startsWith('_smo_'))),
|
|
449
|
+
}));
|
|
450
|
+
return { type: fc.type ?? 'FeatureCollection', features };
|
|
451
|
+
}
|
|
371
452
|
async function persistShareableMap(baseUrl, apiKey, mapId, mapPlan, query) {
|
|
372
453
|
if (!mapPlan || !Array.isArray(mapPlan.layers))
|
|
373
|
-
return;
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
454
|
+
return false;
|
|
455
|
+
const geojsonLayers = {};
|
|
456
|
+
const strippedLayers = mapPlan.layers.map((raw) => {
|
|
457
|
+
const layer = raw;
|
|
458
|
+
if (layer?.source?.kind === 'geojson' && layer.source.data && layer.id) {
|
|
459
|
+
geojsonLayers[layer.id] = compactFeatureCollection(layer.source.data, layer.viz);
|
|
460
|
+
return { ...layer, source: { ...layer.source, data: undefined } };
|
|
461
|
+
}
|
|
462
|
+
return layer;
|
|
463
|
+
});
|
|
464
|
+
const title = mapPlan.title ?? mapPlan.layers[0]?.title ?? 'Untitled Map';
|
|
465
|
+
const body = {
|
|
466
|
+
id: mapId,
|
|
467
|
+
title,
|
|
468
|
+
query,
|
|
469
|
+
blob: { mapPlan: { ...mapPlan, layers: strippedLayers }, geojsonLayers, query, version: 1 },
|
|
470
|
+
isPublic: true,
|
|
471
|
+
};
|
|
472
|
+
// Two attempts: the second exists for exactly the failure that lost a map
|
|
473
|
+
// live — parallel build_map calls tripping /api/maps' burst rate limit.
|
|
474
|
+
// A share-save failure still never fails the BUILD, but it must never be
|
|
475
|
+
// silent either, and the caller decides whether to hand out the URL.
|
|
476
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
477
|
+
try {
|
|
478
|
+
await apiFetch(baseUrl, apiKey, '/api/maps', body);
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
if (attempt === 1) {
|
|
483
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
484
|
+
continue;
|
|
381
485
|
}
|
|
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 */
|
|
486
|
+
console.warn(`[mcp] share-store write FAILED for map ${mapId} after retry — ` +
|
|
487
|
+
`the /m/ link would be dead, omitting it: ${err instanceof Error ? err.message : err}`);
|
|
488
|
+
}
|
|
395
489
|
}
|
|
490
|
+
return false;
|
|
396
491
|
}
|
|
397
492
|
// MCP tool annotations — safety hints every tool advertises so clients can
|
|
398
493
|
// reason about read-only vs mutating vs destructive behavior (and the model
|
|
@@ -524,8 +619,8 @@ export function createServer(opts) {
|
|
|
524
619
|
'Zoom the camera closer',
|
|
525
620
|
];
|
|
526
621
|
// 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));
|
|
622
|
+
const shared = await persistShareableMap(baseUrl, apiKey, mapId, resp.mapPlan, query);
|
|
623
|
+
return toContent(envelope(baseUrl, mapId, resp.workspace, resp.mapPlan, nextActions, shared ? {} : { shareUnpersisted: true }));
|
|
529
624
|
}));
|
|
530
625
|
const mutateMapShape = {
|
|
531
626
|
map_id: z.string().min(1).describe('map_id from a prior build_map call'),
|
|
@@ -565,8 +660,11 @@ export function createServer(opts) {
|
|
|
565
660
|
});
|
|
566
661
|
const mutMapPlan = (resp.mapPlan ?? entry.mapPlan);
|
|
567
662
|
// 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,
|
|
663
|
+
const shared = await persistShareableMap(baseUrl, apiKey, map_id, mutMapPlan, instruction);
|
|
664
|
+
return toContent(envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), mutMapPlan, resp.nextActions, {
|
|
665
|
+
...(resp.clarification ? { clarification: resp.clarification } : {}),
|
|
666
|
+
...(shared ? {} : { shareUnpersisted: true }),
|
|
667
|
+
}));
|
|
570
668
|
}));
|
|
571
669
|
const getStateShape = {
|
|
572
670
|
map_id: z.string().min(1),
|
|
@@ -612,8 +710,8 @@ export function createServer(opts) {
|
|
|
612
710
|
});
|
|
613
711
|
const opMapPlan = (resp.mapPlan ?? entry.mapPlan);
|
|
614
712
|
// 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);
|
|
713
|
+
const shared = await persistShareableMap(baseUrl, apiKey, map_id, opMapPlan, opMapPlan?.title ?? '');
|
|
714
|
+
return envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), opMapPlan, undefined, shared ? {} : { shareUnpersisted: true });
|
|
617
715
|
}
|
|
618
716
|
/**
|
|
619
717
|
* Shared op-header builder — every direct-op tool fills these fields.
|
|
@@ -1218,8 +1316,8 @@ export function createServer(opts) {
|
|
|
1218
1316
|
});
|
|
1219
1317
|
const rematPlan = resp.mapPlan;
|
|
1220
1318
|
// 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);
|
|
1319
|
+
const shared = await persistShareableMap(baseUrl, apiKey, map_id, rematPlan, rematPlan?.title ?? '');
|
|
1320
|
+
return envelope(baseUrl, map_id, workspace, rematPlan, undefined, shared ? {} : { shareUnpersisted: true });
|
|
1223
1321
|
}
|
|
1224
1322
|
const exportWorkspaceShape = {
|
|
1225
1323
|
map_id: z.string().min(1),
|
|
@@ -1258,8 +1356,9 @@ export function createServer(opts) {
|
|
|
1258
1356
|
description: 'Export a single layer\'s features as a downloadable file. Returns ' +
|
|
1259
1357
|
'a URL with Content-Disposition: attachment so the user\'s browser ' +
|
|
1260
1358
|
'downloads it. CSV drops geometry beyond a (lng, lat) centroid — ' +
|
|
1261
|
-
'for full polygon/line geometry, use format: "geojson". URL is ' +
|
|
1262
|
-
'
|
|
1359
|
+
'for full polygon/line geometry, use format: "geojson". The URL is an ' +
|
|
1360
|
+
'unguessable capability link that does NOT expire — treat it as ' +
|
|
1361
|
+
'shareable-forever, not temporary. Required: map_id, layer_id, format, rationale.',
|
|
1263
1362
|
inputSchema: exportLayerDataShape,
|
|
1264
1363
|
}, withValidation('export_layer_data', exportLayerDataShape, async ({ map_id, layer_id, format, rationale }) => {
|
|
1265
1364
|
logRationale('export_layer_data', map_id, rationale);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@showmeonmap/mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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",
|