@showmeonmap/mcp 0.4.0 → 0.5.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
@@ -90,10 +90,17 @@ Ask Claude Desktop things like:
90
90
 
91
91
  ---
92
92
 
93
- ## Tool surface (24 tools)
93
+ ## Tool surface (26 tools)
94
94
 
95
- **V1 — natural-language tools (3)**: `build_map`, `mutate_map`, `get_state`.
96
- **V2 — direct-op tools (18)**: `add_layer`, `filter_layer`, `clear_filters`, `remove_layer`, `rename_layer`, `restyle_layer`, `set_camera`, `set_time`, `set_basemap`, `select_within`, `subset_to_selection`, `query_features`, `add_annotation`, `remove_annotation`, `tag`, `undo`, `redo`, `report_blocker`.
95
+ **Workspace lifecycle (6)**: `build_map`, `mutate_map`, `get_state`, `export_workspace`, `undo`, `redo`.
96
+ **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
+ **Multi-source analysis (3)**: `add_fusion_layer`, `correlate_layers`, `run_analysis`.
98
+ **Export (2)**: `export_layer_data`, `export_image`.
99
+ **Feedback channel (1)**: `report_blocker`.
100
+
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
+
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.
97
104
 
98
105
  Every tool returns a **three-channel envelope**:
99
106
 
package/dist/toolMeta.js CHANGED
@@ -78,6 +78,12 @@ export const MCP_TOOL_META = {
78
78
  long_running: false,
79
79
  rationale: 'Filters or derives a layer to a server-resolved named area; reversible via undo/clear_filters.',
80
80
  },
81
+ run_analysis: {
82
+ requires_confirmation: false,
83
+ destructive: false,
84
+ long_running: false,
85
+ rationale: 'Appends a computed analysis layer; original layer untouched; reversible via undo/remove_layer.',
86
+ },
81
87
  query_features: {
82
88
  requires_confirmation: false,
83
89
  destructive: false,
package/dist/tools.js CHANGED
@@ -209,6 +209,10 @@ function envelope(baseUrl, mapId, workspace, mapPlan, nextActionStrings, extras
209
209
  ...(extras.clarification ? { clarification: extras.clarification } : {}),
210
210
  ...(extras.opsApplied ? { opsApplied: extras.opsApplied } : {}),
211
211
  ...(extras.contextAnswer ? { contextAnswer: extras.contextAnswer } : {}),
212
+ ...(extras.resultLayerId ? { resultLayerId: extras.resultLayerId } : {}),
213
+ ...(extras.memo ? { memo: extras.memo } : {}),
214
+ ...(extras.stats ? { stats: extras.stats } : {}),
215
+ ...(extras.note ? { note: extras.note } : {}),
212
216
  };
213
217
  }
214
218
  function toContent(obj) {
@@ -260,7 +264,7 @@ const INVALID_INPUT_HINTS = {
260
264
  correlate_layers: 'Required: map_id, layer_a_id, layer_b_id, rationale. Optional: value_field_a, value_field_b. Pure read — no layer added, no credit charged. No other fields accepted.',
261
265
  undo: 'Required: map_id, rationale, confirmed:true (sensitive — autonomous undo can erase prior work).',
262
266
  redo: 'Required: map_id, rationale.',
263
- report_blocker: 'Required: what_i_was_trying, what_i_tried, where_i_got_stuck. Optional: map_id, suggested_capability.',
267
+ report_blocker: 'Required: what_i_was_trying, what_i_tried, where_i_got_stuck, rationale. Optional: map_id, suggested_capability.',
264
268
  };
265
269
  function buildInvalidInputResponse(toolName, error) {
266
270
  const issues = error.issues.map((iss) => ({
@@ -1025,6 +1029,107 @@ export function createServer(opts) {
1025
1029
  ...(shared ? {} : { shareUnpersisted: true }),
1026
1030
  }));
1027
1031
  }));
1032
+ const runAnalysisShape = {
1033
+ map_id: z.string().min(1),
1034
+ analysis: z
1035
+ .enum(['hotspot', 'zonal', 'enrich'])
1036
+ .describe("Which analysis to run. 'hotspot' = Getis-Ord Gi* with FDR-corrected significance classes. 'zonal' = aggregate a point layer into polygon zones (count/sum/mean/min/max choropleth). 'enrich' = join a US Census indicator onto each feature by containment (tract/county/state)."),
1037
+ layerId: z.string().min(1).describe('The workspace layer to analyze (geojson-backed; the point source for zonal).'),
1038
+ valueField: z
1039
+ .string()
1040
+ .min(1)
1041
+ .optional()
1042
+ .describe('Numeric property to analyze. hotspot: omit on point layers for incident-DENSITY mode (hex-binned counts). zonal: omit for count-only aggregation.'),
1043
+ k: z.number().int().min(3).max(24).optional().describe('hotspot only: nearest neighbors per feature (default 8).'),
1044
+ hexMeters: z
1045
+ .number()
1046
+ .min(100)
1047
+ .max(50_000)
1048
+ .optional()
1049
+ .describe('hotspot density mode only: hex cell spacing in meters (default auto from the data extent).'),
1050
+ zoneLayerId: z
1051
+ .string()
1052
+ .min(1)
1053
+ .optional()
1054
+ .describe('zonal only: an existing polygon layer to aggregate into. Provide EXACTLY ONE of zoneLayerId or zones.'),
1055
+ zones: z
1056
+ .object({ kind: z.literal('h3'), resolution: z.number().int().min(3).max(10).optional() })
1057
+ .strict()
1058
+ .optional()
1059
+ .describe('zonal only: generate an h3 hex fishnet over the source extent instead of using a zone layer (resolution 3-10, default auto). Provide EXACTLY ONE of zoneLayerId or zones.'),
1060
+ stats: z
1061
+ .array(z.enum(['count', 'sum', 'mean', 'min', 'max']))
1062
+ .min(1)
1063
+ .max(5)
1064
+ .optional()
1065
+ .describe('zonal only: which statistics to attach per zone (default: count, plus sum/mean/min/max when valueField is set).'),
1066
+ indicator: z
1067
+ .string()
1068
+ .min(1)
1069
+ .max(80)
1070
+ .optional()
1071
+ .describe("enrich only (required there): a US Census ACS preset id (e.g. 'median_household_income', 'median_home_value', 'median_rent') or a raw ACS variable code (e.g. 'B19013_001E')."),
1072
+ geoLevel: z
1073
+ .enum(['state', 'county', 'tract'])
1074
+ .optional()
1075
+ .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.'),
1076
+ rationale: rationaleSchema,
1077
+ };
1078
+ registerTool('run_analysis', {
1079
+ title: 'Run a spatial analysis on a layer',
1080
+ description: 'Compute a spatial analysis server-side and append the finished, honestly-legended result ' +
1081
+ 'as a new layer + camera; the result REPLACES its input layer(s) (undoable). hotspot = ' +
1082
+ 'Getis-Ord Gi*: hot/cold significance classes (99/95/90%, Benjamini-Hochberg FDR-corrected) ' +
1083
+ 'on a numeric field, or on hex-binned point density when valueField is omitted. zonal = ' +
1084
+ 'aggregate points into polygon zones (an existing zone layer OR a generated h3 fishnet): ' +
1085
+ 'count/sum/mean/min/max per zone as a quantile choropleth where zero and no-data are ' +
1086
+ 'distinct classes. enrich = join a US Census ACS indicator (median income, home value, ' +
1087
+ 'rent, …) onto each feature by tract/county/state containment — unmatched features stay ' +
1088
+ 'null, never zero-filled. Returns a grounded memo + stats payload — every number is computed, ' +
1089
+ 'never generated; findings keep the map. Costs 1 credit when the result lands; typed ' +
1090
+ 'failures (unknown layer, too few features, >10,000-feature cap) are refunded.',
1091
+ inputSchema: runAnalysisShape,
1092
+ }, withValidation('run_analysis', runAnalysisShape, async ({ map_id, analysis, layerId, valueField, k, hexMeters, zoneLayerId, zones, stats, indicator, geoLevel, rationale }) => {
1093
+ const entry = await store.get(map_id);
1094
+ if (!entry)
1095
+ throw new Error(`Unknown map_id "${map_id}". Call build_map first.`);
1096
+ logRationale('run_analysis', map_id, rationale, { opKind: `run_analysis:${analysis}` });
1097
+ const resp = (await apiFetch(baseUrl, apiKey, '/api/workspace/run-analysis', {
1098
+ workspace: entry.workspace,
1099
+ analysis,
1100
+ layerId,
1101
+ ...(valueField !== undefined ? { valueField } : {}),
1102
+ ...(k !== undefined ? { k } : {}),
1103
+ ...(hexMeters !== undefined ? { hexMeters } : {}),
1104
+ ...(zoneLayerId !== undefined ? { zoneLayerId } : {}),
1105
+ ...(zones !== undefined ? { zones } : {}),
1106
+ ...(stats !== undefined ? { stats } : {}),
1107
+ ...(indicator !== undefined ? { indicator } : {}),
1108
+ ...(geoLevel !== undefined ? { geoLevel } : {}),
1109
+ rationale,
1110
+ }));
1111
+ if (!resp.ok) {
1112
+ throw new Error(`run_analysis failed: ${resp.error?.kind ?? 'unknown'} — ${resp.error?.message ?? 'no detail'}`);
1113
+ }
1114
+ await store.put(map_id, {
1115
+ workspace: resp.workspace ?? entry.workspace,
1116
+ mapPlan: resp.mapPlan ?? entry.mapPlan,
1117
+ layerFilters: resp.layerFilters ?? {},
1118
+ createdAt: entry.createdAt,
1119
+ lastAccessed: Date.now(),
1120
+ });
1121
+ const analyzedPlan = (resp.mapPlan ?? entry.mapPlan);
1122
+ const shared = await persistShareableMap(baseUrl, apiKey, map_id, analyzedPlan, rationale);
1123
+ return toContent(envelope(baseUrl, map_id, (resp.workspace ?? entry.workspace), analyzedPlan, undefined, {
1124
+ ...(resp.opsApplied ? { opsApplied: resp.opsApplied } : {}),
1125
+ ...(resp.contextAnswer ? { contextAnswer: resp.contextAnswer } : {}),
1126
+ ...(resp.resultLayerId ? { resultLayerId: resp.resultLayerId } : {}),
1127
+ ...(resp.memo ? { memo: resp.memo } : {}),
1128
+ ...(resp.stats ? { stats: resp.stats } : {}),
1129
+ ...(resp.note ? { note: resp.note } : {}),
1130
+ ...(shared ? {} : { shareUnpersisted: true }),
1131
+ }));
1132
+ }));
1028
1133
  const queryFeaturesShape = {
1029
1134
  map_id: z.string().min(1),
1030
1135
  layerId: z.string().min(1),
@@ -1562,6 +1667,7 @@ export function createServer(opts) {
1562
1667
  .max(300)
1563
1668
  .optional()
1564
1669
  .describe('Optional. A new tool, op, or schema field that would have unblocked you.'),
1670
+ rationale: rationaleSchema,
1565
1671
  };
1566
1672
  registerTool('report_blocker', {
1567
1673
  title: 'Report a blocker (agent feedback)',
@@ -1573,11 +1679,11 @@ export function createServer(opts) {
1573
1679
  'session, but feeds future capability work. No map_id required — file the report ' +
1574
1680
  'even if no map was built.',
1575
1681
  inputSchema: reportBlockerShape,
1576
- }, withValidation('report_blocker', reportBlockerShape, async ({ map_id, what_i_was_trying, what_i_tried, where_i_got_stuck, suggested_capability }) => {
1682
+ }, withValidation('report_blocker', reportBlockerShape, async ({ map_id, what_i_was_trying, what_i_tried, where_i_got_stuck, suggested_capability, rationale }) => {
1577
1683
  // Mirror to the rationale NDJSON so the May-1 clustering pass sees a
1578
1684
  // unified stream of "what did agents try, where did they get stuck".
1579
1685
  // The agent-blockers route persists the full record separately.
1580
- logRationale('report_blocker', map_id ?? null, `BLOCKER: ${what_i_was_trying} — stuck: ${where_i_got_stuck}`, { opKind: 'reportBlocker' });
1686
+ logRationale('report_blocker', map_id ?? null, `BLOCKER: ${what_i_was_trying} — stuck: ${where_i_got_stuck} (${rationale})`, { opKind: 'reportBlocker' });
1581
1687
  const resp = (await apiFetch(baseUrl, apiKey, '/api/agent/report-blocker', {
1582
1688
  ...(map_id ? { map_id } : {}),
1583
1689
  what_i_was_trying,
@@ -1592,6 +1698,7 @@ export function createServer(opts) {
1592
1698
  return toContent({
1593
1699
  ok: true,
1594
1700
  message: `Blocker recorded — thanks. Recorded at ${resp.recorded_at ?? new Date().toISOString()}.`,
1701
+ ...(resp.sink ? { sink: resp.sink } : {}),
1595
1702
  });
1596
1703
  }));
1597
1704
  // ── JSON Schema draft normalization (2026-07-16) ─────────────────────────
@@ -50,7 +50,7 @@ Every op carries common fields:
50
50
  | Op kind | args |
51
51
  | -------------------- | ----------------------------------------------------------------------------------------------------- |
52
52
  | `createWorkspace` | `{ originalQuery?, shell: MapPlanShell }` — emitted by the server, never by you. |
53
- | `addLayer` | `{ layerPlan: LayerPlan, intent?: string }` — see LayerPlan below. |
53
+ | `addLayer` | `{ layerPlan: LayerPlan, intent?: string }` — see LayerPlan below. For computed ANALYSIS layers (Gi* hot spots, zonal-statistics choropleths, census enrichment), prefer the `run_analysis` tool — the server computes, classes, and legends the result honestly and replaces the input layer(s), undoably. |
54
54
  | `filterLayer` | `{ layerId, predicate: Predicate, append?: boolean, intent?: string }` — see Predicate. |
55
55
  | `removeLayer` | `{ layerId }` — strict; double-remove rejected. Layer ids tombstone for the session. |
56
56
  | `clearFilters` | `{ layerId }` — wipe predicate chain. Inverse of filterLayer. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@showmeonmap/mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",