@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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @showmeonmap/mcp
2
2
 
3
- [Model Context Protocol](https://modelcontextprotocol.io/) server for [ShowMeOnMap](https://github.com/FrankAIUser/showmeonmap). 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.
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
- **Spec:** `docs/superpowers/specs/2026-04-17-mcp-server-design.md` (in the parent repo).
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
- * If set, every request must include `Authorization: Bearer <token>`. If
17
- * unset the server runs open — fine for localhost dev, unacceptable for a
18
- * public deployment.
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 true; // open mode
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
- writeJson(res, 500, { error: 'internal', message: err.message });
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
- const authStatus = AUTH_TOKEN ? 'bearer-auth enabled' : 'OPEN (no auth — dev only)';
189
- process.stderr.write(`[showmeonmap-mcp-http] listening on http://127.0.0.1:${HTTP_PORT}${ENDPOINT_PATH} · base=${BASE_URL} · ${authStatus}\n`);
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() {
@@ -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
- await d1
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
- .bind(row.id, row.workspace_id, row.workspace, row.map_plan, row.layer_filters, row.created_at, row.last_seen_at)
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
- await d1.prepare(`DELETE FROM mcp_sessions WHERE id = ?`).bind(mapId).run();
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
- shareable_url: `${baseUrl}/m/${mapId}`,
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
- try {
375
- const geojsonLayers = {};
376
- const strippedLayers = mapPlan.layers.map((raw) => {
377
- const layer = raw;
378
- if (layer?.source?.kind === 'geojson' && layer.source.data && layer.id) {
379
- geojsonLayers[layer.id] = layer.source.data;
380
- return { ...layer, source: { ...layer.source, data: undefined } };
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
- return layer;
383
- });
384
- const title = mapPlan.title ?? mapPlan.layers[0]?.title ?? 'Untitled Map';
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, resp.clarification ? { clarification: resp.clarification } : {}));
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
- 'valid for 24 hours. Required: map_id, layer_id, format, rationale.',
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.1",
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.29.0",
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
- "repository": {
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",