@showmeonmap/mcp 0.5.0 → 0.6.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 CHANGED
@@ -100,7 +100,7 @@ Ask Claude Desktop things like:
100
100
 
101
101
  `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
102
 
103
- `run_analysis` (0.5.0) computes a spatial analysis server-side and appends the finished, honestly-legended result as a new layer + camera. `analysis: 'hotspot'` runs **Getis-Ord Gi\***: hot/cold significance classes (99/95/90%, Benjamini-Hochberg FDR-corrected) on a numeric `valueField`, or on hex-binned point density when `valueField` is omitted (fishnet with zero cells, auto-grown under a stated cap). The reply carries a grounded `memo` + `stats` payload (Moran's I, class counts, significant share) — every number is computed, never generated — plus `resultLayerId` and a `contextAnswer` like `"Found 12 hot and 0 cold features among n=72"`. "No significant clustering" is a finding that keeps the map (and the credit); typed failures (unknown layer, too few features, the 10,000-feature cap) are refunded. `analysis: 'zonal'` aggregates a point layer into polygon zones — an existing zone layer (`zoneLayerId`) or a generated h3 hex fishnet (`zones: {kind:'h3', resolution?}`) — as count/sum/mean/min/max per zone, rendered as a quantile choropleth where a zone with zero features and a zone the data never reaches are DIFFERENT legend classes. `analysis: 'enrich'` joins a US Census ACS indicator (`median_household_income`, `median_home_value`, `median_rent`, … or a raw variable code) onto each feature by tract/county/state containment — unmatched features stay null, never zero-filled, and the joined column carries the indicator's human name. In all analyses the result replaces its input layer(s) (undoable; original attributes preserved or aggregated as stated in the memo). The catalog grows behind this one tool.
103
+ `run_analysis` (0.5.0; knobs below 0.6.0) computes a spatial analysis server-side and appends the finished, honestly-legended result as a new layer + camera. `analysis: 'hotspot'` runs **Getis-Ord Gi\***: hot/cold significance classes (99/95/90%, Benjamini-Hochberg FDR-corrected) on a numeric `valueField`, or on hex-binned point density when `valueField` is omitted (fishnet with zero cells, auto-grown under a stated cap). The reply carries a grounded `memo` + `stats` payload (Moran's I, class counts, significant share) — every number is computed, never generated — plus `resultLayerId` and a `contextAnswer` like `"Found 12 hot and 0 cold features among n=72"`. "No significant clustering" is a finding that keeps the map (and the credit); typed failures (unknown layer, too few features, the 10,000-feature cap) are refunded. `analysis: 'zonal'` aggregates a point layer into polygon zones — an existing zone layer (`zoneLayerId`) or a generated h3 hex fishnet (`zones: {kind:'h3', resolution?}`) — as count/sum/mean/min/max per zone, rendered as a quantile choropleth where a zone with zero features and a zone the data never reaches are DIFFERENT legend classes. `analysis: 'enrich'` joins a US Census ACS indicator (`median_household_income`, `median_home_value`, `median_rent`, … or a raw variable code) onto each feature by tract/county/state containment — unmatched features stay null, never zero-filled, and the joined column carries the indicator's human name. In all analyses the result replaces its input layer(s) (undoable; original attributes preserved or aggregated as stated in the memo). The catalog grows behind this one tool. Two opt-in knobs (0.6.0): `annotate: true` places ONE callout annotation at the analysis's headline feature (hottest cluster / top zone / highest joined value — label text only from computed values; no headline → no annotation), and `story: true` persists the shared map with a 2-step story rail (the grounded `contextAnswer` as overview + the memo as an analysis step that isolates the result layer at its real extent) so the `/m/` link opens as a guided read.
104
104
 
105
105
  Every tool returns a **three-channel envelope**:
106
106
 
@@ -0,0 +1,106 @@
1
+ /**
2
+ * MCP-side story drafting — the "MCP export knob" deferred from PR-C3
3
+ * (spec 2026-08-15 §PR-C3, ledger 11; shipped 0.6.0).
4
+ *
5
+ * `run_analysis({ story: true })` persists the shared map with a
6
+ * 2-step story: an overview step carrying the grounded one-line
7
+ * contextAnswer, and an analysis step carrying the grounded memo,
8
+ * isolating the result layer and framing its REAL extent. The same two
9
+ * iron rules as the web drafter (src/lib/share/story.ts — this package
10
+ * is self-contained, so the invariants are restated, not imported):
11
+ *
12
+ * 1. GROUNDED TEXT ONLY — bodies are the contextAnswer and memo the
13
+ * analysis just computed, verbatim (markdown chrome stripped, long
14
+ * memos clamped at a whitespace boundary — a verbatim prefix, never
15
+ * a rewrite). Missing either text → no story (a 1-step story is
16
+ * worse than none; /m/'s render guard rejects < 2 steps anyway).
17
+ * 2. NEVER INVENT GEOMETRY — the analysis step's camera is the bbox of
18
+ * the result layer's own features; no features → no camera.
19
+ */
20
+ /** /m/'s isValidStory caps bodies at 4000 chars — clamp the memo to a
21
+ * verbatim prefix at a whitespace boundary rather than get rejected. */
22
+ const MAX_BODY = 4000;
23
+ /** Strip markdown chrome (headings, bold/italic markers, backticks) —
24
+ * the story rail renders plain text; the words are untouched. */
25
+ export function markdownToPlain(md) {
26
+ return md
27
+ .replace(/^#{1,6}\s+/gm, '')
28
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
29
+ .replace(/\*([^*]+)\*/g, '$1')
30
+ .replace(/`([^`]+)`/g, '$1')
31
+ .replace(/\n{3,}/g, '\n\n')
32
+ .trim();
33
+ }
34
+ function clampBody(text) {
35
+ if (text.length <= MAX_BODY)
36
+ return text;
37
+ const cut = text.lastIndexOf(' ', MAX_BODY - 2);
38
+ return `${text.slice(0, cut > MAX_BODY / 2 ? cut : MAX_BODY - 2).trimEnd()}…`;
39
+ }
40
+ function bboxOfFeatures(features) {
41
+ let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity;
42
+ let seen = false;
43
+ const walk = (g) => {
44
+ if (!Array.isArray(g))
45
+ return;
46
+ if (g.length >= 2 && typeof g[0] === 'number' && typeof g[1] === 'number') {
47
+ if (Number.isFinite(g[0]) && Number.isFinite(g[1])) {
48
+ seen = true;
49
+ if (g[0] < w)
50
+ w = g[0];
51
+ if (g[0] > e)
52
+ e = g[0];
53
+ if (g[1] < s)
54
+ s = g[1];
55
+ if (g[1] > n)
56
+ n = g[1];
57
+ }
58
+ return;
59
+ }
60
+ for (const child of g)
61
+ walk(child);
62
+ };
63
+ for (const f of features) {
64
+ walk(f?.geometry?.coordinates);
65
+ }
66
+ if (!seen)
67
+ return null;
68
+ if (w === e) {
69
+ w -= 0.05;
70
+ e += 0.05;
71
+ }
72
+ if (s === n) {
73
+ s -= 0.05;
74
+ n += 0.05;
75
+ }
76
+ return [w, s, e, n];
77
+ }
78
+ /** Null when the run lacks the grounded material for BOTH steps. */
79
+ export function buildAnalysisStory(input) {
80
+ const overviewBody = input.contextAnswer?.trim();
81
+ const memoBody = input.memo ? markdownToPlain(input.memo) : '';
82
+ if (!overviewBody || !memoBody)
83
+ return null;
84
+ const layers = Array.isArray(input.plan?.layers) ? input.plan.layers : [];
85
+ const resultLayer = input.resultLayerId
86
+ ? layers.find((l) => String(l?.id) === input.resultLayerId)
87
+ : undefined;
88
+ const bounds = resultLayer?.source?.kind === 'geojson' && Array.isArray(resultLayer.source.data?.features)
89
+ ? bboxOfFeatures(resultLayer.source.data.features)
90
+ : null;
91
+ return {
92
+ version: 1,
93
+ steps: [
94
+ {
95
+ title: input.plan?.title?.trim() || 'Overview',
96
+ body: clampBody(overviewBody),
97
+ },
98
+ {
99
+ title: resultLayer?.title?.trim() || 'Analysis',
100
+ body: clampBody(memoBody),
101
+ ...(bounds ? { camera: { bounds } } : {}),
102
+ ...(input.resultLayerId ? { visibleLayerIds: [input.resultLayerId] } : {}),
103
+ },
104
+ ],
105
+ };
106
+ }
package/dist/tools.js CHANGED
@@ -22,6 +22,7 @@ import { readFileSync, mkdirSync, appendFileSync } from 'node:fs';
22
22
  import { fileURLToPath } from 'node:url';
23
23
  import { dirname, resolve } from 'node:path';
24
24
  import { MCP_TOOL_META, mcpRequiresConfirmation } from "./toolMeta.js";
25
+ import { buildAnalysisStory } from "./storyDraft.js";
25
26
  // -----------------------------------------------------------------------------
26
27
  // Agent-facing spec resource (Teddy Riker, "Designing for Agents" §2,
27
28
  // 2026-04-23). Loaded once at module init from a sibling .md file. Tool
@@ -456,7 +457,7 @@ export function compactFeatureCollection(fc, viz) {
456
457
  }));
457
458
  return { type: fc.type ?? 'FeatureCollection', features };
458
459
  }
459
- async function persistShareableMap(baseUrl, apiKey, mapId, mapPlan, query) {
460
+ async function persistShareableMap(baseUrl, apiKey, mapId, mapPlan, query, story) {
460
461
  if (!mapPlan || !Array.isArray(mapPlan.layers))
461
462
  return false;
462
463
  const geojsonLayers = {};
@@ -473,7 +474,7 @@ async function persistShareableMap(baseUrl, apiKey, mapId, mapPlan, query) {
473
474
  id: mapId,
474
475
  title,
475
476
  query,
476
- blob: { mapPlan: { ...mapPlan, layers: strippedLayers }, geojsonLayers, query, version: 1 },
477
+ blob: { mapPlan: { ...mapPlan, layers: strippedLayers }, geojsonLayers, query, version: 1, ...(story ? { story } : {}) },
477
478
  isPublic: true,
478
479
  };
479
480
  // Two attempts: the second exists for exactly the failure that lost a map
@@ -1073,6 +1074,14 @@ export function createServer(opts) {
1073
1074
  .enum(['state', 'county', 'tract'])
1074
1075
  .optional()
1075
1076
  .describe('enrich only: containment geography (default tract). Tract/county fetch only the states the data touches, capped at 3 — filter first or use state for wider layers.'),
1077
+ annotate: z
1078
+ .boolean()
1079
+ .optional()
1080
+ .describe('Opt-in: place ONE callout annotation at the headline feature (hottest cluster / top zone / highest value). Label text comes only from computed values.'),
1081
+ story: z
1082
+ .boolean()
1083
+ .optional()
1084
+ .describe('Opt-in: persist the shared map with a 2-step story rail (grounded overview + analysis memo isolating the result layer) — the /m/ link opens with it.'),
1076
1085
  rationale: rationaleSchema,
1077
1086
  };
1078
1087
  registerTool('run_analysis', {
@@ -1089,7 +1098,7 @@ export function createServer(opts) {
1089
1098
  'never generated; findings keep the map. Costs 1 credit when the result lands; typed ' +
1090
1099
  'failures (unknown layer, too few features, >10,000-feature cap) are refunded.',
1091
1100
  inputSchema: runAnalysisShape,
1092
- }, withValidation('run_analysis', runAnalysisShape, async ({ map_id, analysis, layerId, valueField, k, hexMeters, zoneLayerId, zones, stats, indicator, geoLevel, rationale }) => {
1101
+ }, withValidation('run_analysis', runAnalysisShape, async ({ map_id, analysis, layerId, valueField, k, hexMeters, zoneLayerId, zones, stats, indicator, geoLevel, annotate, story, rationale }) => {
1093
1102
  const entry = await store.get(map_id);
1094
1103
  if (!entry)
1095
1104
  throw new Error(`Unknown map_id "${map_id}". Call build_map first.`);
@@ -1106,6 +1115,7 @@ export function createServer(opts) {
1106
1115
  ...(stats !== undefined ? { stats } : {}),
1107
1116
  ...(indicator !== undefined ? { indicator } : {}),
1108
1117
  ...(geoLevel !== undefined ? { geoLevel } : {}),
1118
+ ...(annotate !== undefined ? { annotate } : {}),
1109
1119
  rationale,
1110
1120
  }));
1111
1121
  if (!resp.ok) {
@@ -1119,7 +1129,17 @@ export function createServer(opts) {
1119
1129
  lastAccessed: Date.now(),
1120
1130
  });
1121
1131
  const analyzedPlan = (resp.mapPlan ?? entry.mapPlan);
1122
- const shared = await persistShareableMap(baseUrl, apiKey, map_id, analyzedPlan, rationale);
1132
+ // Story knob (0.6.0): draft from the just-computed grounded material,
1133
+ // BEFORE persist strips inline data (the bbox needs the features).
1134
+ const storyDraft = story === true
1135
+ ? buildAnalysisStory({
1136
+ plan: analyzedPlan,
1137
+ contextAnswer: resp.contextAnswer,
1138
+ memo: resp.memo,
1139
+ resultLayerId: resp.resultLayerId,
1140
+ })
1141
+ : null;
1142
+ const shared = await persistShareableMap(baseUrl, apiKey, map_id, analyzedPlan, rationale, storyDraft);
1123
1143
  return toContent(envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), analyzedPlan, undefined, {
1124
1144
  ...(resp.opsApplied ? { opsApplied: resp.opsApplied } : {}),
1125
1145
  ...(resp.contextAnswer ? { contextAnswer: resp.contextAnswer } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@showmeonmap/mcp",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",