@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 +1 -1
- package/dist/storyDraft.js +106 -0
- package/dist/tools.js +24 -4
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
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",
|