@showmeonmap/mcp 0.6.0 → 0.7.1

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
@@ -37,6 +37,8 @@ The server speaks standard MCP/stdio; it's a thin proxy to the ShowMeOnMap HTTP
37
37
  }
38
38
  ```
39
39
 
40
+ **In Claude, no key at all:** add `https://showmeonmap.com/api/mcp` as a custom connector (Claude Code: `claude mcp add --transport http showmeonmap https://showmeonmap.com/api/mcp`, then `/mcp`). Claude signs you in with OAuth the first time it needs a map and bills your credits the same way; disconnect from the account menu → API keys → Connected apps.
41
+
40
42
  The hosted endpoint speaks streamable HTTP (stateless), is rate-limited to 30 requests/min per key, and works anywhere remote MCP servers do — Claude custom connectors, the OpenAI Responses API (`server_url`), Gemini managed agents (`mcp_server`), Cursor, Copilot Studio, Bedrock.
41
43
 
42
44
  ---
@@ -90,13 +92,18 @@ Ask Claude Desktop things like:
90
92
 
91
93
  ---
92
94
 
93
- ## Tool surface (26 tools)
95
+ ## Tool surface (29 tools)
94
96
 
95
97
  **Workspace lifecycle (6)**: `build_map`, `mutate_map`, `get_state`, `export_workspace`, `undo`, `redo`.
96
98
  **Direct ops (14)**: `add_layer`, `filter_layer`, `select_within`, `focus_area`, `query_features`, `remove_layer`, `clear_filters`, `rename_layer`, `restyle_layer`, `set_camera`, `set_time`, `tag`, `add_annotation`, `remove_annotation`.
97
99
  **Multi-source analysis (3)**: `add_fusion_layer`, `correlate_layers`, `run_analysis`.
98
100
  **Export (2)**: `export_layer_data`, `export_image`.
99
101
  **Feedback channel (1)**: `report_blocker`.
102
+ **Saved workflows (3)**: `save_workflow`, `list_workflows`, `run_workflow`.
103
+
104
+ **0.7.1**: `build_map`'s `next_actions` come from the map itself (real data filters and the plan's own follow-ups, not generic suggestions); a Cloudflare error page from the backend reaches the model as a typed message with its code and Ray ID instead of raw HTML; shared maps keep the fields they paint and say when they hold a sample of the features.
105
+
106
+ **0.7.0** (everything merged since 0.6.0): the three workflow tools (with `batch` runs over a value list); `run_analysis` adds spatial join, merge/append, near and dissolve; `rationale` is optional on every tool; no tuple schemas (Claude dropped `set_camera` / `add_annotation`); fact-only tool descriptions; `export_layer_data` works on a saved map; share links store the features the map shows after `filter_layer` and its counts follow the filter; the server reports its real version; and an npm install no longer writes rationale logs into your `node_modules`.
100
107
 
101
108
  `focus_area` (0.4.0) refines an existing map to a **named** area — no coordinates required. Pass a `layerId` plus an `areaRef` (`{kind:'corridor', street, from?, to?}`, `{kind:'place', name}`, or `{kind:'aroundIntersection', street, cross}`) and the server resolves the real geometry from OpenStreetMap, then applies one of three `mode`s: `filter` (hide non-matching), `select` (extract to a new layer), or `clip` (geometrically trim — lines are split at the boundary). The reply carries a grounded `contextAnswer` such as `"kept 37 of 150 features"`. A resolver miss returns a `clarification` with real nearby candidates rather than a guessed location.
102
109
 
@@ -113,14 +120,15 @@ Every tool returns a **three-channel envelope**:
113
120
  "shareable_url": "http://localhost:3000/m/…",
114
121
  "metadata": { "title": "…", "layer_count": 2, "last_updated": "…" },
115
122
  "next_actions": [
116
- { "label": "Filter to features where name is not empty", "argsTemplate": { "instruction": "…" } }
123
+ { "label": "Show only Earthquakes with magnitude ≥ 5.3 (5 of 10 features)", "tool": "filter_layer",
124
+ "argsTemplate": { "layerId": "smo_usgs_earthquakes", "predicate": { "op": "gte", "field": "magnitude", "value": 5.3 } } }
117
125
  ],
118
126
  "outOfScope": { "reason": "…" },
119
127
  "clarification": { "question": "…", "candidates": [ … ] }
120
128
  }
121
129
  ```
122
130
 
123
- The calling agent reasons over `structured`, surfaces `shareable_url` to its end user for interactive drill-down, and uses `next_actions` as typed hints for follow-up tool calls.
131
+ The calling agent reasons over `structured`, surfaces `shareable_url` to its end user for interactive drill-down, and uses `next_actions` as typed hints for follow-up tool calls: each hint's `argsTemplate` is exactly the args (minus `map_id`) of the named `tool`. Hints come from the map's own data, followups and current state; an empty list means nothing useful to suggest.
124
132
 
125
133
  For the full Op catalog and predicate grammar that the V2 tools accept, fetch the MCP resource at `showmeonmap://docs/workspace-ops-spec` — agents should not guess these shapes.
126
134
 
@@ -162,7 +170,7 @@ Spawns a fresh server process, exercises `initialize` → `tools/list` → every
162
170
  - **In-memory session cache.** `map_id` → workspace mapping lives in the server process; restarts lose state. Phase I-3 (in the parent repo's plan) wires D1 persistence so `shareable_url` actually resolves across sessions.
163
171
  - **No preview PNG.** The envelope's `shareable_url` points at the app's `/m/<id>` viewer, but workspaces aren't yet persisted in the gallery. Until I-3, share the raw structured response.
164
172
  - **stdio bin + hosted remote endpoint.** This package's bin is stdio; remote hosts use `https://showmeonmap.com/api/mcp` (streamable HTTP, stateless). The standalone Node HTTP entry (`packages/mcp-server/src/http.ts`) remains for self-hosting a checkout.
165
- - **Auth is per-user API keys** (`smo_live_…`, minted at showmeonmap.com). The key travels as a bearer on every backend call and bills the key's owner.
173
+ - **Auth is a per-user bearer**: an API key (`smo_live_…`, minted at showmeonmap.com) or, on the hosted endpoint, an OAuth access token from Claude's sign-in. Either travels as a bearer on every backend call and bills its owner.
166
174
 
167
175
  ---
168
176
 
@@ -0,0 +1,16 @@
1
+ /**
2
+ * fetch() that refuses to follow redirects — portable to Cloudflare Workers,
3
+ * where this package also runs (the hosted /api/mcp route). The Workers
4
+ * runtime rejects `redirect: 'error'` outright, so every self-POST threw in
5
+ * production (build_map broken after #949, fixed 2026-09-28). Uses
6
+ * `redirect: 'manual'` and refuses any 3xx instead. Mirrors
7
+ * src/lib/http/fetchNoRedirect.ts (this package cannot import from the app).
8
+ */
9
+ export async function fetchNoRedirect(input, init = {}) {
10
+ const response = await fetch(input, { ...init, redirect: 'manual' });
11
+ if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
12
+ void response.body?.cancel().catch(() => { });
13
+ throw new Error(`Refused to follow a redirect (HTTP ${response.status}) from ${new URL(input).pathname}`);
14
+ }
15
+ return response;
16
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Generalize oversize line/polygon layers before a map enters the MCP session
3
+ * store. Design: docs/superpowers/specs/2026-10-06-mcp-geometry-generalization-design.md
4
+ *
5
+ * The global choropleth handlers inline 10m world-atlas geometry — EU-27 is
6
+ * 1.8 MB, the world 21 MB — and the hosted store keeps the whole workspace in
7
+ * ONE `mcp_sessions.workspace` cell, which D1 caps at 2 MB. "Map GDP per
8
+ * capita across Europe" therefore failed with SQLITE_TOOBIG in the first
9
+ * real-host Claude test (2026-10-06), and the EU-27 retry that did fit was
10
+ * over the card's ~850 KB budget ("too large to show here").
11
+ *
12
+ * Rules (pinned by __tests__/tools/generalizeGeometry.test.ts):
13
+ * - only a layer whose serialized data exceeds LAYER_BUDGET (600 KB) is touched;
14
+ * - points are never altered;
15
+ * - no feature is dropped and no property value changes — only geometry;
16
+ * - every ring keeps ≥ 4 points and stays closed;
17
+ * - within a feature, parts/holes smaller than 3 × tolerance are dropped,
18
+ * never the feature's largest part;
19
+ * - the first rung of the ladder that fits wins (least simplification);
20
+ * - a generalized layer says so: `_smo_generalized` on its data and a
21
+ * legend subtitle suffix.
22
+ * Dependency-free: this package ships to npm with only the SDK and zod.
23
+ */
24
+ /** Per-layer target. The card takes ~850 KB of layers per response (deferred
25
+ * layers load separately), and the world 10m choropleth fits at 0.1° (557 KB);
26
+ * 500 KB pushed it to 0.2° (≈ 22 km), visibly coarse. */
27
+ export const LAYER_BUDGET = 600_000;
28
+ export const WORKSPACE_CEILING = 1_800_000;
29
+ /** Douglas–Peucker tolerances in degrees, tried after quantize-only (0). */
30
+ export const TOLERANCES = [0, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2];
31
+ export const SIMPLIFIED_NOTE = 'boundaries simplified for this view';
32
+ function round4(n) {
33
+ return Math.round(n * 1e4) / 1e4;
34
+ }
35
+ /** Iterative Douglas–Peucker on an open or closed polyline. */
36
+ function douglasPeucker(pts, tol) {
37
+ if (pts.length <= 2 || tol <= 0)
38
+ return pts;
39
+ const keep = new Uint8Array(pts.length);
40
+ keep[0] = 1;
41
+ keep[pts.length - 1] = 1;
42
+ const t2 = tol * tol;
43
+ const stack = [[0, pts.length - 1]];
44
+ while (stack.length) {
45
+ const [a, b] = stack.pop();
46
+ const [x1, y1] = pts[a];
47
+ const [x2, y2] = pts[b];
48
+ const dx = x2 - x1;
49
+ const dy = y2 - y1;
50
+ const len2 = dx * dx + dy * dy;
51
+ let maxD = 0;
52
+ let maxI = -1;
53
+ for (let i = a + 1; i < b; i++) {
54
+ const [x, y] = pts[i];
55
+ let t = len2 ? ((x - x1) * dx + (y - y1) * dy) / len2 : 0;
56
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
57
+ const ex = x - x1 - t * dx;
58
+ const ey = y - y1 - t * dy;
59
+ const d = ex * ex + ey * ey;
60
+ if (d > maxD) {
61
+ maxD = d;
62
+ maxI = i;
63
+ }
64
+ }
65
+ if (maxD > t2 && maxI > 0) {
66
+ keep[maxI] = 1;
67
+ stack.push([a, maxI], [maxI, b]);
68
+ }
69
+ }
70
+ const out = [];
71
+ for (let i = 0; i < pts.length; i++)
72
+ if (keep[i])
73
+ out.push(pts[i]);
74
+ return out;
75
+ }
76
+ function extent(ring) {
77
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
78
+ for (const [x, y] of ring) {
79
+ if (x < minX)
80
+ minX = x;
81
+ if (y < minY)
82
+ minY = y;
83
+ if (x > maxX)
84
+ maxX = x;
85
+ if (y > maxY)
86
+ maxY = y;
87
+ }
88
+ return Math.hypot(maxX - minX, maxY - minY);
89
+ }
90
+ function quantize(pts) {
91
+ const out = [];
92
+ for (const p of pts) {
93
+ const q = [round4(p[0]), round4(p[1]), ...p.slice(2)];
94
+ const prev = out[out.length - 1];
95
+ if (prev && prev[0] === q[0] && prev[1] === q[1])
96
+ continue;
97
+ out.push(q);
98
+ }
99
+ return out;
100
+ }
101
+ /** A closed ring with ≥ 4 points — falls back to a minimal closed ring from the original. */
102
+ function simplifyRing(ring, tol) {
103
+ let s = douglasPeucker(quantize(ring), tol);
104
+ if (s.length && (s[0][0] !== s[s.length - 1][0] || s[0][1] !== s[s.length - 1][1]))
105
+ s = [...s, s[0]];
106
+ if (s.length >= 4)
107
+ return s;
108
+ if (ring.length < 4)
109
+ return ring;
110
+ const n = ring.length;
111
+ return [ring[0], ring[Math.floor(n / 3)], ring[Math.floor((2 * n) / 3)], ring[0]];
112
+ }
113
+ function simplifyPolygon(rings, tol, dropBelow) {
114
+ return rings
115
+ .filter((r, i) => i === 0 || dropBelow <= 0 || extent(r) >= dropBelow)
116
+ .map((r) => simplifyRing(r, tol));
117
+ }
118
+ function simplifyLine(line, tol) {
119
+ const s = douglasPeucker(quantize(line), tol);
120
+ return s.length >= 2 ? s : line;
121
+ }
122
+ function simplifyGeometry(g, tol) {
123
+ const dropBelow = 3 * tol;
124
+ const c = g.coordinates;
125
+ switch (g.type) {
126
+ case 'Polygon':
127
+ return { ...g, coordinates: simplifyPolygon(c, tol, dropBelow) };
128
+ case 'MultiPolygon': {
129
+ const polys = c;
130
+ const sizes = polys.map((p) => (p[0] ? extent(p[0]) : 0));
131
+ const largest = sizes.indexOf(Math.max(...sizes));
132
+ const kept = polys.filter((_, i) => i === largest || dropBelow <= 0 || sizes[i] >= dropBelow);
133
+ return { ...g, coordinates: kept.map((p) => simplifyPolygon(p, tol, dropBelow)) };
134
+ }
135
+ case 'LineString':
136
+ return { ...g, coordinates: simplifyLine(c, tol) };
137
+ case 'MultiLineString':
138
+ return { ...g, coordinates: c.map((l) => simplifyLine(l, tol)) };
139
+ default:
140
+ return g; // points and anything else are never altered
141
+ }
142
+ }
143
+ function hasShapeGeometry(fc) {
144
+ return fc.features.some((f) => /Polygon|LineString/.test(f.geometry?.type ?? ''));
145
+ }
146
+ function size(v) {
147
+ return JSON.stringify(v).length;
148
+ }
149
+ /**
150
+ * The least-simplified version of `fc` that fits `budget`, or null when it
151
+ * already fits / has no line or polygon geometry. Returns the last rung when
152
+ * nothing fits — the caller's ceiling check decides what happens then.
153
+ */
154
+ export function generalizeCollection(fc, budget = LAYER_BUDGET, ladder = TOLERANCES) {
155
+ if (fc._smo_generalized || size(fc) <= budget || !hasShapeGeometry(fc))
156
+ return null;
157
+ let last = fc;
158
+ let lastTol = 0;
159
+ for (const tol of ladder) {
160
+ last = {
161
+ ...fc,
162
+ features: fc.features.map((f) => (f.geometry ? { ...f, geometry: simplifyGeometry(f.geometry, tol) } : f)),
163
+ };
164
+ lastTol = tol;
165
+ if (size(last) <= budget)
166
+ break;
167
+ }
168
+ return { data: { ...last, _smo_generalized: { toleranceDeg: lastTol } }, toleranceDeg: lastTol };
169
+ }
170
+ function isInlineCollection(layer) {
171
+ const d = layer.source?.data;
172
+ return !!d && d.type === 'FeatureCollection' && Array.isArray(d.features);
173
+ }
174
+ function withNote(subtitle) {
175
+ return typeof subtitle === 'string' && subtitle.trim()
176
+ ? `${subtitle} · ${SIMPLIFIED_NOTE}`
177
+ : SIMPLIFIED_NOTE.charAt(0).toUpperCase() + SIMPLIFIED_NOTE.slice(1);
178
+ }
179
+ /**
180
+ * Walk `root` (a workspace or a mapPlan) and generalize every oversize inline
181
+ * layer. Copy-on-write: unchanged branches are shared, `root` is never mutated.
182
+ */
183
+ export function generalizeLayers(root, budget = LAYER_BUDGET) {
184
+ const generalized = [];
185
+ const visit = (node) => {
186
+ if (Array.isArray(node)) {
187
+ let changed = false;
188
+ const out = node.map((n) => {
189
+ const v = visit(n);
190
+ if (v !== n)
191
+ changed = true;
192
+ return v;
193
+ });
194
+ return changed ? out : node;
195
+ }
196
+ if (!node || typeof node !== 'object')
197
+ return node;
198
+ const obj = node;
199
+ if (obj.source && isInlineCollection(obj)) {
200
+ const g = generalizeCollection(obj.source.data, budget);
201
+ if (!g)
202
+ return node;
203
+ generalized.push({
204
+ layerId: String(obj.id ?? ''),
205
+ ...(typeof obj.title === 'string' ? { title: obj.title } : {}),
206
+ toleranceDeg: g.toleranceDeg,
207
+ });
208
+ return { ...obj, subtitle: withNote(obj.subtitle), source: { ...obj.source, data: g.data } };
209
+ }
210
+ let changed = false;
211
+ const out = {};
212
+ for (const [k, v] of Object.entries(obj)) {
213
+ const nv = visit(v);
214
+ if (nv !== v)
215
+ changed = true;
216
+ out[k] = nv;
217
+ }
218
+ return changed ? out : node;
219
+ };
220
+ return { value: visit(root), generalized };
221
+ }
222
+ /** Approximate on-the-wire size of a stored value, in UTF-8 bytes. */
223
+ export function storedBytes(v) {
224
+ return new TextEncoder().encode(JSON.stringify(v ?? null)).length;
225
+ }
226
+ /** "≈ 500 m" / "≈ 11 km" for a tolerance in degrees (1° ≈ 111 km). */
227
+ export function describeTolerance(deg) {
228
+ if (deg <= 0)
229
+ return '≈ 10 m';
230
+ const m = deg * 111_000;
231
+ return m < 1000 ? `≈ ${Math.round(m / 100) * 100} m` : `≈ ${Math.round(m / 1000)} km`;
232
+ }
package/dist/http.js CHANGED
@@ -45,6 +45,7 @@ import { createServer as createHttpServer } from 'node:http';
45
45
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
46
46
  import { randomUUID } from 'node:crypto';
47
47
  import { createServer as createMcpServer } from "./tools.js";
48
+ import { PACKAGE_VERSION } from "./version.js";
48
49
  const BASE_URL = process.env.SHOWMEONMAP_BASE_URL ?? 'http://localhost:3000';
49
50
  const API_KEY = process.env.SHOWMEONMAP_API_KEY;
50
51
  const HTTP_PORT = Number(process.env.MCP_HTTP_PORT ?? 3333);
@@ -199,6 +200,7 @@ async function handleRequest(req, res) {
199
200
  baseUrl: BASE_URL,
200
201
  apiKey: API_KEY,
201
202
  sessionStore: SHARED_SESSION_STORE,
203
+ version: PACKAGE_VERSION,
202
204
  });
203
205
  await mcpServer.connect(transport);
204
206
  await transport.handleRequest(req, res, body);
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@
29
29
  */
30
30
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
31
31
  import { createServer } from "./tools.js";
32
+ import { PACKAGE_VERSION } from "./version.js";
32
33
  // Defaults to production so `npx @showmeonmap/mcp` works with only an API key.
33
34
  // Override with SHOWMEONMAP_BASE_URL=http://localhost:3000 for local dev.
34
35
  const BASE_URL = process.env.SHOWMEONMAP_BASE_URL ?? 'https://showmeonmap.com';
@@ -40,7 +41,7 @@ async function main() {
40
41
  // HTTP transport opts into D1 separately via http.ts. Callers can override
41
42
  // by injecting their own SessionStore into createServer if needed.
42
43
  const sessionStore = undefined;
43
- const server = createServer({ baseUrl: BASE_URL, apiKey: API_KEY, sessionStore });
44
+ const server = createServer({ baseUrl: BASE_URL, apiKey: API_KEY, sessionStore, version: PACKAGE_VERSION });
44
45
  const transport = new StdioServerTransport();
45
46
  await server.connect(transport);
46
47
  process.stderr.write(`[showmeonmap-mcp] stdio ready · base=${BASE_URL}\n`);