bbox-mcp-server 1.4.0 → 1.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/dist/index.js CHANGED
@@ -115,6 +115,14 @@ function safeMax(arr) {
115
115
  // ---------------------------------------------------------------------------
116
116
  // Pure Functions Ported from bbox.js
117
117
  // ---------------------------------------------------------------------------
118
+ const STRICT_NUM_RE = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
119
+ function parseStrictNumber(raw) {
120
+ const trimmed = raw.trim();
121
+ if (!STRICT_NUM_RE.test(trimmed))
122
+ return NaN;
123
+ const n = Number(trimmed);
124
+ return Number.isFinite(n) ? n : NaN;
125
+ }
118
126
  function validateBBox(lat1, lng1, lat2, lng2) {
119
127
  if (isNaN(lat1) || isNaN(lng1) || isNaN(lat2) || isNaN(lng2))
120
128
  return false;
@@ -124,6 +132,14 @@ function validateBBox(lat1, lng1, lat2, lng2) {
124
132
  return false;
125
133
  return true;
126
134
  }
135
+ function hasAntimeridianJump(coordsLngLat) {
136
+ for (let i = 1; i < coordsLngLat.length; i++) {
137
+ if (Math.abs(coordsLngLat[i][0] - coordsLngLat[i - 1][0]) > 180) {
138
+ return true;
139
+ }
140
+ }
141
+ return false;
142
+ }
127
143
  function parseBBox(input, warnings) {
128
144
  if (!input || typeof input !== 'string') {
129
145
  throw new Error(`Invalid bbox input.`);
@@ -132,75 +148,138 @@ function parseBBox(input, warnings) {
132
148
  if (input.length > MAX_INPUT_LENGTH) {
133
149
  throw new Error(`Input too large (${(input.length / 1024).toFixed(0)} KB). Maximum allowed is ${(MAX_INPUT_LENGTH / 1024).toFixed(0)} KB.`);
134
150
  }
151
+ // --- 0. Web Mercator Tile Sniffer (z/x/y) ---
152
+ const tileMatch = input.trim().match(/^(\d{1,2})\s*\/\s*(\d+)\s*\/\s*(\d+)$/);
153
+ if (tileMatch) {
154
+ const z = parseInt(tileMatch[1], 10);
155
+ const x = parseInt(tileMatch[2], 10);
156
+ const y = parseInt(tileMatch[3], 10);
157
+ const maxTile = Math.pow(2, z);
158
+ if (z >= 0 && z <= 22 && x >= 0 && x < maxTile && y >= 0 && y < maxTile) {
159
+ return tileToBBox(z, x, y);
160
+ }
161
+ throw new Error(`Invalid Web Mercator tile coordinates: ${input}`);
162
+ }
135
163
  // --- 1. ogrinfo Extent Sniffer ---
136
164
  // Matches: "Extent: (-74.006000, 40.712800) - (-73.985500, 40.758000)"
137
165
  const ogrMatch = input.match(/Extent\:\s\((.*)\)/);
138
166
  if (ogrMatch && ogrMatch[1]) {
139
- try {
140
- const pairs = ogrMatch[1].split(") - (");
141
- if (pairs.length === 2) {
142
- const minCoords = pairs[0].replace('(', '').split(',').map(s => parseFloat(s.trim()));
143
- const maxCoords = pairs[1].replace(')', '').split(',').map(s => parseFloat(s.trim()));
144
- // Assuming format: (minX, minY) - (maxX, maxY) where X=lng, Y=lat
145
- return ensureValidBBox(minCoords[1], minCoords[0], maxCoords[1], maxCoords[0]);
167
+ const pairs = ogrMatch[1].split(") - (");
168
+ if (pairs.length === 2) {
169
+ const minCoords = pairs[0].replace('(', '').split(',').map(parseStrictNumber);
170
+ const maxCoords = pairs[1].replace(')', '').split(',').map(parseStrictNumber);
171
+ if (minCoords.length !== 2 || maxCoords.length !== 2 || minCoords.some(isNaN) || maxCoords.some(isNaN)) {
172
+ throw new Error(`Invalid numeric values in ogrinfo extent: ${input}`);
146
173
  }
147
- }
148
- catch (e) {
149
- log('info', 'parseBBox: ogrinfo extent parse failed, trying next parser', { error: e?.message });
174
+ if (minCoords[0] > maxCoords[0]) {
175
+ throw new Error(`Antimeridian (date-line) crossing bounding box detected (west=${minCoords[0]} > east=${maxCoords[0]}). Split the region at ±180° longitude.`);
176
+ }
177
+ // Format: (minX, minY) - (maxX, maxY) where X=lng, Y=lat
178
+ return ensureValidBBox(minCoords[1], minCoords[0], maxCoords[1], maxCoords[0]);
150
179
  }
151
180
  }
152
181
  // --- 2. GeoJSON Sniffer ---
153
- try {
154
- const json = JSON.parse(input);
155
- if (json.bbox && json.bbox.length === 4) {
156
- // standard GeoJSON bbox: [minX, minY, maxX, maxY]
157
- return ensureValidBBox(json.bbox[1], json.bbox[0], json.bbox[3], json.bbox[2]);
182
+ if (input.trim().startsWith('{')) {
183
+ let json;
184
+ try {
185
+ json = JSON.parse(input);
186
+ }
187
+ catch (e) {
188
+ throw new Error(`Invalid GeoJSON input: ${e?.message}`);
189
+ }
190
+ if (json.bbox && Array.isArray(json.bbox) && json.bbox.length === 4) {
191
+ const [west, south, east, north] = json.bbox.map((v) => typeof v === 'number' ? v : parseStrictNumber(String(v)));
192
+ if ([west, south, east, north].some(isNaN)) {
193
+ throw new Error(`Invalid numeric values in GeoJSON bbox.`);
194
+ }
195
+ if (west > east) {
196
+ throw new Error(`Antimeridian (date-line) crossing GeoJSON bbox detected (west=${west} > east=${east}). Split the bounding box at ±180° longitude.`);
197
+ }
198
+ if (south > north) {
199
+ throw new Error(`Invalid GeoJSON bbox: south (${south}) > north (${north}).`);
200
+ }
201
+ return ensureValidBBox(south, west, north, east);
158
202
  }
159
- // Multi-Geometry Coordinate Extraction (using shared, stack-safe utility)
160
203
  const allCoords = extractCoords(json);
161
204
  if (allCoords.length > 0) {
205
+ if (hasAntimeridianJump(allCoords)) {
206
+ throw new Error(`Antimeridian (date-line) crossing geometry detected (longitude jump > 180°). Split the geometry at ±180° longitude.`);
207
+ }
162
208
  const lngs = allCoords.map(c => c[0]);
163
209
  const lats = allCoords.map(c => c[1]);
164
210
  return ensureValidBBox(safeMin(lats), safeMin(lngs), safeMax(lats), safeMax(lngs));
165
211
  }
166
- }
167
- catch (e) {
168
- log('info', 'parseBBox: GeoJSON parse failed, trying next parser', { error: e?.message });
212
+ throw new Error(`GeoJSON input contains no valid coordinates or bbox.`);
169
213
  }
170
214
  // --- 3. WKT Sniffer ---
171
- if (input.toUpperCase().includes("POLYGON") || input.toUpperCase().includes("POINT") || input.toUpperCase().includes("LINESTRING")) {
215
+ if (/^\s*(MULTI)?(POLYGON|POINT|LINESTRING)|^\s*GEOMETRYCOLLECTION/i.test(input)) {
216
+ let geojson;
172
217
  try {
173
- const geojson = parseWKT(input);
174
- const allCoords = extractCoords(geojson.coordinates || geojson);
175
- if (allCoords.length > 0) {
176
- const lngs = allCoords.map(c => c[0]);
177
- const lats = allCoords.map(c => c[1]);
178
- return ensureValidBBox(safeMin(lats), safeMin(lngs), safeMax(lats), safeMax(lngs));
179
- }
218
+ geojson = parseWKT(input);
180
219
  }
181
220
  catch (e) {
182
- log('info', 'parseBBox: WKT parse failed, trying next parser', { error: e?.message });
221
+ throw new Error(`Failed to parse WKT geometry: ${e?.message}`);
222
+ }
223
+ const allCoords = extractCoords(geojson?.coordinates ? geojson : { geometry: geojson });
224
+ if (allCoords.length > 0) {
225
+ if (hasAntimeridianJump(allCoords)) {
226
+ throw new Error(`Antimeridian (date-line) crossing WKT geometry detected (longitude jump > 180°). Split the geometry at ±180° longitude.`);
227
+ }
228
+ const lngs = allCoords.map(c => c[0]);
229
+ const lats = allCoords.map(c => c[1]);
230
+ return ensureValidBBox(safeMin(lats), safeMin(lngs), safeMax(lats), safeMax(lngs));
231
+ }
232
+ throw new Error(`Failed to extract coordinates from WKT geometry.`);
233
+ }
234
+ // --- 4. Strict Coordinate Array Sniffer ---
235
+ const trimmed = input.trim();
236
+ // Reject strings containing unexpected characters outside brackets, parens, commas, whitespace, digits, signs, decimal point, exponent
237
+ if (!/^[\[\(\s]*[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?(?:\s*[\s,]\s*[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)*[\]\)\s]*$/i.test(trimmed)) {
238
+ throw new Error(`Failed to parse bounding box: contains invalid coordinate tokens. Input must be valid WKT, GeoJSON, ogrinfo extent, tile "z/x/y", point "lat,lng", or "lat1,lng1,lat2,lng2". Got: ${input.substring(0, 50)}`);
239
+ }
240
+ const cleanInput = trimmed.replace(/^[\[\(\s]+|[\]\)\s]+$/g, '');
241
+ const rawTokens = cleanInput.split(/[\s,]+/).filter(Boolean);
242
+ const parts = rawTokens.map(parseStrictNumber);
243
+ if (parts.some(isNaN)) {
244
+ throw new Error(`Failed to parse bounding box: invalid numeric token in "${input.substring(0, 50)}".`);
245
+ }
246
+ if (parts.length === 2) {
247
+ const p0 = parts[0], p1 = parts[1];
248
+ if (validateBBox(p0, p1, p0, p1)) {
249
+ return ensureValidBBox(p0, p1, p0, p1);
250
+ }
251
+ else if (validateBBox(p1, p0, p1, p0)) {
252
+ warnings?.push(`Detected single point in lng,lat order (${p0}, ${p1}); auto-converted to lat=${p1}, lng=${p0}.`);
253
+ return ensureValidBBox(p1, p0, p1, p0);
183
254
  }
255
+ throw new Error(`Invalid point coordinates: (${p0}, ${p1}). Latitude must be [-90, 90] and longitude [-180, 180].`);
184
256
  }
185
- // --- 4. Standard Coordinate Array Sniffer ---
186
- // Strip brackets/parentheses and split by commas or spaces
187
- const cleanInput = input.replace(/[\[\]\(\)]/g, '').trim();
188
- // Support space-separated (GDAL) or comma-separated
189
- const parts = cleanInput.split(/[\s,]+/).map(s => parseFloat(s.trim())).filter(n => !isNaN(n));
190
257
  if (parts.length === 4) {
191
- // Default interpretation: lat1, lng1, lat2, lng2
192
- // Detect ambiguity: if both (lat,lng) and (lng,lat) orderings are valid,
193
- // the caller may have a transposed box without knowing it.
194
258
  const asLatLng = validateBBox(parts[0], parts[1], parts[2], parts[3]);
195
259
  const asLngLat = validateBBox(parts[1], parts[0], parts[3], parts[2]);
196
- if (asLatLng && asLngLat && (parts[0] !== parts[1] || parts[2] !== parts[3])) {
197
- warnings?.push(`Ambiguous coordinate order: interpreted as lat,lng,lat,lng → (${parts[0]}, ${parts[1]}, ${parts[2]}, ${parts[3]}). ` +
198
- `If your source uses lng,lat order (GeoJSON, GDAL, most GIS tools), the bounding box may be transposed. ` +
199
- `Use GeoJSON or WKT input to avoid ambiguity.`);
260
+ if (!asLatLng && asLngLat) {
261
+ // Check antimeridian crossing in lng,lat,lng,lat order: parts[0] = west, parts[2] = east
262
+ if (parts[0] > parts[2] && (parts[0] - parts[2]) > 180) {
263
+ throw new Error(`Antimeridian (date-line) crossing bounding box detected (west=${parts[0]} > east=${parts[2]}). Split the bounding box at ±180° longitude.`);
264
+ }
265
+ warnings?.push(`Detected lng,lat,lng,lat coordinate order (${parts[0]}, ${parts[1]}, ${parts[2]}, ${parts[3]}) because longitude is outside [-90, 90]. Automatically transposed to lat,lng.`);
266
+ return ensureValidBBox(parts[1], parts[0], parts[3], parts[2]);
267
+ }
268
+ if (asLatLng) {
269
+ // Check antimeridian crossing in lat1,lng1,lat2,lng2 order: parts[1] = west, parts[3] = east
270
+ if (parts[1] > parts[3] && (parts[1] - parts[3]) > 180) {
271
+ throw new Error(`Antimeridian (date-line) crossing bounding box detected (west=${parts[1]} > east=${parts[3]}). Split the bounding box at ±180° longitude.`);
272
+ }
273
+ if (asLngLat && (parts[0] !== parts[1] || parts[2] !== parts[3])) {
274
+ warnings?.push(`Ambiguous coordinate order: interpreted as lat,lng,lat,lng → (${parts[0]}, ${parts[1]}, ${parts[2]}, ${parts[3]}). ` +
275
+ `If your source uses lng,lat order (GeoJSON, GDAL, most GIS tools), the bounding box may be transposed. ` +
276
+ `Use GeoJSON or WKT input to avoid ambiguity.`);
277
+ }
278
+ return ensureValidBBox(parts[0], parts[1], parts[2], parts[3]);
200
279
  }
201
- return ensureValidBBox(parts[0], parts[1], parts[2], parts[3]);
280
+ throw new Error(`Invalid coordinates or out of bounds: lat[-90, 90], lng[-180, 180]. Provided: ${parts.join(', ')}`);
202
281
  }
203
- throw new Error(`Failed to parse bounding box. Input must be one of: WKT, GeoJSON, ogrinfo extent, or "lat1,lng1,lat2,lng2" string. Got: ${input.substring(0, 50)}...`);
282
+ throw new Error(`Failed to parse bounding box. Expected 2 (point) or 4 (bbox) numbers, WKT, GeoJSON, ogrinfo extent, or tile "z/x/y". Got ${parts.length} numbers in: ${input.substring(0, 50)}`);
204
283
  }
205
284
  function ensureValidBBox(lat1, lng1, lat2, lng2) {
206
285
  if (!validateBBox(lat1, lng1, lat2, lng2)) {
@@ -355,14 +434,78 @@ function latLngToTile(lat, lng, zoom) {
355
434
  const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
356
435
  return { z: zoom, x, y };
357
436
  }
358
- function buildShareUrl(bbox) {
359
- const hash = `${bbox.lat1.toFixed(6)},${bbox.lng1.toFixed(6)},${bbox.lat2.toFixed(6)},${bbox.lng2.toFixed(6)}`;
360
- return `https://vibhorsingh.com/boundingbox/#${hash}`;
437
+ function tileToBBox(z, x, y) {
438
+ const n = Math.pow(2, z);
439
+ const lng1 = (x / n) * 360 - 180;
440
+ const lng2 = ((x + 1) / n) * 360 - 180;
441
+ const latRad1 = Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 1)) / n)));
442
+ const latRad2 = Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n)));
443
+ const lat1 = (latRad1 * 180) / Math.PI;
444
+ const lat2 = (latRad2 * 180) / Math.PI;
445
+ return ensureValidBBox(lat1, lng1, lat2, lng2);
361
446
  }
362
- function buildShareUrlWithMarkers(bbox, elements) {
363
- const bboxPart = `${bbox.lat1.toFixed(6)},${bbox.lng1.toFixed(6)},${bbox.lat2.toFixed(6)},${bbox.lng2.toFixed(6)}`;
447
+ function expandBBoxMeters(bbox, bufferMeters) {
448
+ if (!bufferMeters || bufferMeters === 0)
449
+ return bbox;
450
+ const midLat = ((bbox.lat1 + bbox.lat2) / 2) * (Math.PI / 180);
451
+ const dLat = bufferMeters / 111320;
452
+ const cosLat = Math.max(0.01, Math.cos(midLat));
453
+ const dLng = bufferMeters / (111320 * cosLat);
454
+ return ensureValidBBox(Math.max(-90, bbox.lat1 - dLat), Math.max(-180, bbox.lng1 - dLng), Math.min(90, bbox.lat2 + dLat), Math.min(180, bbox.lng2 + dLng));
455
+ }
456
+ /**
457
+ * Extracts polygons (with interior holes preserved) as arrays of [lat, lng] rings
458
+ * for H3 polygonToCells and point-in-polygon checks.
459
+ * Return shape: number[][][][] = Array of Polygons, where each Polygon is [outerRing, ...holeRings],
460
+ * and each Ring is Array of [lat, lng].
461
+ */
462
+ function extractPolygonsWithHolesLatLng(input) {
463
+ try {
464
+ let geom = input;
465
+ if (typeof input === 'string') {
466
+ const trimmed = input.trim();
467
+ if (trimmed.startsWith('{')) {
468
+ geom = JSON.parse(trimmed);
469
+ }
470
+ else if (/^\s*(MULTI)?POLYGON/i.test(trimmed)) {
471
+ geom = parseWKT(trimmed);
472
+ }
473
+ else {
474
+ return null;
475
+ }
476
+ }
477
+ if (!geom || typeof geom !== 'object')
478
+ return null;
479
+ if (geom.type === 'Feature')
480
+ geom = geom.geometry;
481
+ if (geom.type === 'FeatureCollection' && Array.isArray(geom.features) && geom.features.length > 0) {
482
+ const polys = [];
483
+ for (const f of geom.features) {
484
+ const sub = extractPolygonsWithHolesLatLng(f.geometry);
485
+ if (sub)
486
+ polys.push(...sub);
487
+ }
488
+ return polys.length > 0 ? polys : null;
489
+ }
490
+ if (!geom)
491
+ return null;
492
+ if (geom.type === 'Polygon' && Array.isArray(geom.coordinates) && geom.coordinates.length > 0) {
493
+ // Preserve outer ring AND all interior hole rings, converting [lng, lat] -> [lat, lng]
494
+ const ringsLatLng = geom.coordinates.map((ring) => ring.map((c) => [c[1], c[0]]));
495
+ return [ringsLatLng];
496
+ }
497
+ if (geom.type === 'MultiPolygon' && Array.isArray(geom.coordinates) && geom.coordinates.length > 0) {
498
+ return geom.coordinates.map((poly) => poly.map((ring) => ring.map((c) => [c[1], c[0]])));
499
+ }
500
+ }
501
+ catch {
502
+ // Ignore and fall back to bounding box
503
+ }
504
+ return null;
505
+ }
506
+ function formatMarkerParts(elements) {
364
507
  const MAX_MARKERS = 30;
365
- const markerParts = elements
508
+ return elements
366
509
  .filter(e => e.coordinates?.lat !== undefined && e.coordinates?.lon !== undefined)
367
510
  .slice(0, MAX_MARKERS)
368
511
  .map(e => {
@@ -371,10 +514,75 @@ function buildShareUrlWithMarkers(bbox, elements) {
371
514
  name = name.slice(0, 27) + '...';
372
515
  return `${e.coordinates.lat.toFixed(6)},${e.coordinates.lon.toFixed(6)},${encodeURIComponent(name)}`;
373
516
  });
374
- const hashParts = [bboxPart, ...markerParts];
517
+ }
518
+ function buildShareUrl(bbox) {
519
+ const hash = `${bbox.lat1.toFixed(6)},${bbox.lng1.toFixed(6)},${bbox.lat2.toFixed(6)},${bbox.lng2.toFixed(6)}`;
520
+ return `https://vibhorsingh.com/boundingbox/#${hash}`;
521
+ }
522
+ function buildShareUrlWithMarkers(bbox, elements, extraParts) {
523
+ const bboxPart = `${bbox.lat1.toFixed(6)},${bbox.lng1.toFixed(6)},${bbox.lat2.toFixed(6)},${bbox.lng2.toFixed(6)}`;
524
+ const markerParts = formatMarkerParts(elements);
525
+ const hashParts = [bboxPart, ...(extraParts || []), ...markerParts];
526
+ return `https://vibhorsingh.com/boundingbox/#${hashParts.join('|')}`;
527
+ }
528
+ /**
529
+ * Builds a verification map URL without an active orange bounding-box rectangle (empty parts[0]).
530
+ * The viewer renders any PB: polylines/polygons and markers, and auto-fits the map bounds to them.
531
+ */
532
+ function buildShareUrlMarkersOnly(elements, extraParts) {
533
+ const markerParts = formatMarkerParts(elements);
534
+ const hashParts = ["", ...(extraParts || []), ...markerParts];
375
535
  return `https://vibhorsingh.com/boundingbox/#${hashParts.join('|')}`;
376
536
  }
377
- function getH3Cells(bbox, resolution, compact) {
537
+ /**
538
+ * Builds a verification map URL with a polygon boundary (P:lat,lng,...) in parts[0] instead of a rectangle.
539
+ */
540
+ function buildShareUrlPolygon(outerRingLatLng, elements = []) {
541
+ const MAX_RING_PTS = 60;
542
+ let sampled = outerRingLatLng;
543
+ if (outerRingLatLng.length > MAX_RING_PTS) {
544
+ sampled = [];
545
+ for (let i = 0; i < MAX_RING_PTS; i++) {
546
+ const idx = Math.min(outerRingLatLng.length - 1, Math.round((i / (MAX_RING_PTS - 1)) * (outerRingLatLng.length - 1)));
547
+ sampled.push(outerRingLatLng[idx]);
548
+ }
549
+ }
550
+ const polyPart = `P:${sampled.map(([lat, lng]) => `${lat.toFixed(5)},${lng.toFixed(5)}`).join(',')}`;
551
+ const markerParts = formatMarkerParts(elements);
552
+ return `https://vibhorsingh.com/boundingbox/#${[polyPart, ...markerParts].join('|')}`;
553
+ }
554
+ /**
555
+ * Encodes a route LineString ([lng, lat][]) onto the verification map URL using the viewer's
556
+ * PB: layer format (tracing forward and backward so Leaflet renders the exact path line)
557
+ * alongside origin/destination markers, WITHOUT drawing an unnecessary bounding box rectangle.
558
+ */
559
+ function buildShareUrlWithRoute(_bbox, routeCoordsLngLat, markers) {
560
+ const MAX_PATH_PTS = 45;
561
+ let sampled = routeCoordsLngLat;
562
+ if (routeCoordsLngLat.length > MAX_PATH_PTS) {
563
+ sampled = [];
564
+ for (let i = 0; i < MAX_PATH_PTS; i++) {
565
+ const idx = Math.min(routeCoordsLngLat.length - 1, Math.round((i / (MAX_PATH_PTS - 1)) * (routeCoordsLngLat.length - 1)));
566
+ sampled.push(routeCoordsLngLat[idx]);
567
+ }
568
+ }
569
+ const extraParts = [];
570
+ if (sampled.length >= 2) {
571
+ // Trace forward then backward so L.Polygon renders a zero-area polyline trace on the viewer
572
+ const forward = sampled.map(([lng, lat]) => `${lat.toFixed(5)},${lng.toFixed(5)}`);
573
+ const backward = sampled
574
+ .slice(1, -1)
575
+ .reverse()
576
+ .map(([lng, lat]) => `${lat.toFixed(5)},${lng.toFixed(5)}`);
577
+ const allPts = [...forward, ...backward];
578
+ if (allPts.length < 3 && sampled.length === 2) {
579
+ allPts.push(forward[0]);
580
+ }
581
+ extraParts.push(`PB:${allPts.join(',')}`);
582
+ }
583
+ return buildShareUrlMarkersOnly(markers, extraParts);
584
+ }
585
+ function getH3Cells(bbox, resolution, compact, customPolygons) {
378
586
  if (resolution < 0 || resolution > 15 || !Number.isInteger(resolution)) {
379
587
  throw new Error(`Invalid H3 resolution: ${resolution}. Must be an integer between 0 and 15.`);
380
588
  }
@@ -388,15 +596,30 @@ function getH3Cells(bbox, resolution, compact) {
388
596
  if (estimatedCount > MAX_H3_CELLS * 2) {
389
597
  throw new Error(`Area too large for resolution ${resolution}. Estimated ${Math.floor(estimatedCount).toLocaleString()} cells. Maximum allowed is ${MAX_H3_CELLS.toLocaleString()}.`);
390
598
  }
391
- // H3 v4 polygon format: [ [lat, lng]... ]
392
- const polygon = [
393
- [bbox.lat1, bbox.lng1],
394
- [bbox.lat2, bbox.lng1],
395
- [bbox.lat2, bbox.lng2],
396
- [bbox.lat1, bbox.lng2],
397
- [bbox.lat1, bbox.lng1]
398
- ];
399
- let cells = h3.polygonToCells(polygon, resolution);
599
+ let cells = [];
600
+ if (customPolygons && customPolygons.length > 0) {
601
+ const cellSet = new Set();
602
+ for (const polyWithHoles of customPolygons) {
603
+ if (polyWithHoles.length > 0 && polyWithHoles[0].length >= 3) {
604
+ // Pass full [outerRing, ...holeRings] to h3.polygonToCells so holes are excluded
605
+ for (const c of h3.polygonToCells(polyWithHoles, resolution)) {
606
+ cellSet.add(c);
607
+ }
608
+ }
609
+ }
610
+ cells = Array.from(cellSet);
611
+ }
612
+ if (cells.length === 0 && (!customPolygons || customPolygons.length === 0)) {
613
+ // Fallback to rectangular bbox polygon: [ [lat, lng]... ]
614
+ const polygon = [
615
+ [bbox.lat1, bbox.lng1],
616
+ [bbox.lat2, bbox.lng1],
617
+ [bbox.lat2, bbox.lng2],
618
+ [bbox.lat1, bbox.lng2],
619
+ [bbox.lat1, bbox.lng1]
620
+ ];
621
+ cells = h3.polygonToCells(polygon, resolution);
622
+ }
400
623
  if (cells.length > MAX_H3_CELLS) {
401
624
  throw new Error(`Too many cells generated (${cells.length.toLocaleString()}). Maximum allowed is ${MAX_H3_CELLS.toLocaleString()}. Try a lower resolution or smaller bounds.`);
402
625
  }
@@ -431,30 +654,62 @@ function h3CellsToGeoJSON(cells) {
431
654
  features
432
655
  };
433
656
  }
434
- // ---------------------------------------------------------------------------
435
- // Nominatim Rate Limiter, Caching, and Utilities
436
- // ---------------------------------------------------------------------------
437
- let lastNominatimRequestTime = 0;
657
+ const OSM_ATTRIBUTION = "© OpenStreetMap contributors (ODbL - https://www.openstreetmap.org/copyright)";
658
+ const APP_USER_AGENT = process.env.OSM_USER_AGENT || `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`;
659
+ /**
660
+ * Creates a concurrency-safe rate limiter using a serialized promise queue.
661
+ * Concurrent callers are strictly spaced by at least `minIntervalMs`.
662
+ */
663
+ function createRateLimiter(minIntervalMs) {
664
+ let nextAllowedTime = 0;
665
+ let queue = Promise.resolve();
666
+ return () => {
667
+ const run = queue.then(async () => {
668
+ const now = Date.now();
669
+ const waitMs = Math.max(0, nextAllowedTime - now);
670
+ nextAllowedTime = Math.max(now, nextAllowedTime) + minIntervalMs;
671
+ if (waitMs > 0) {
672
+ await new Promise(resolve => setTimeout(resolve, waitMs));
673
+ }
674
+ });
675
+ queue = run.catch(() => { });
676
+ return run;
677
+ };
678
+ }
679
+ const nominatimRateLimit = createRateLimiter(1000);
680
+ const photonRateLimit = createRateLimiter(1000);
681
+ const routingRateLimit = createRateLimiter(1000);
438
682
  const NOMINATIM_CACHE = new Map();
439
683
  const NOMINATIM_TTL_MS = 60 * 60 * 1000; // 1 hour TTL
440
684
  const NOMINATIM_BASE_URL = (process.env.NOMINATIM_API_URL || "https://nominatim.openstreetmap.org").replace(/\/+$/, "");
685
+ const PHOTON_BASE_URL = (process.env.PHOTON_API_URL || "https://photon.komoot.io").replace(/\/+$/, "");
441
686
  async function fetchNominatim(url) {
442
687
  const cached = NOMINATIM_CACHE.get(url);
443
688
  if (cached && (Date.now() - cached.timestamp < NOMINATIM_TTL_MS)) {
444
- return cached.data;
689
+ return {
690
+ data: cached.data,
691
+ cached: true,
692
+ cache_age_ms: Date.now() - cached.timestamp,
693
+ timestamp: new Date(cached.timestamp).toISOString()
694
+ };
445
695
  }
446
- const now = Date.now();
447
- const timeSinceLast = now - lastNominatimRequestTime;
448
- if (timeSinceLast < 1000) {
449
- await new Promise(resolve => setTimeout(resolve, 1000 - timeSinceLast));
696
+ await nominatimRateLimit();
697
+ // Check cache again after waiting in queue in case a concurrent caller populated the same URL
698
+ const cachedAfterWait = NOMINATIM_CACHE.get(url);
699
+ if (cachedAfterWait && (Date.now() - cachedAfterWait.timestamp < NOMINATIM_TTL_MS)) {
700
+ return {
701
+ data: cachedAfterWait.data,
702
+ cached: true,
703
+ cache_age_ms: Date.now() - cachedAfterWait.timestamp,
704
+ timestamp: new Date(cachedAfterWait.timestamp).toISOString()
705
+ };
450
706
  }
451
- lastNominatimRequestTime = Date.now();
452
707
  const controller = new AbortController();
453
708
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
454
709
  try {
455
710
  const response = await fetch(url, {
456
711
  headers: {
457
- 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`,
712
+ 'User-Agent': APP_USER_AGENT,
458
713
  'Accept': 'application/json'
459
714
  },
460
715
  signal: controller.signal
@@ -463,8 +718,14 @@ async function fetchNominatim(url) {
463
718
  throw new Error(`Nominatim request failed with status: ${response.status} ${response.statusText}`);
464
719
  }
465
720
  const data = await response.json();
466
- NOMINATIM_CACHE.set(url, { timestamp: Date.now(), data });
467
- return data;
721
+ const now = Date.now();
722
+ NOMINATIM_CACHE.set(url, { timestamp: now, data });
723
+ return {
724
+ data,
725
+ cached: false,
726
+ cache_age_ms: 0,
727
+ timestamp: new Date(now).toISOString()
728
+ };
468
729
  }
469
730
  catch (err) {
470
731
  if (err.name === 'AbortError') {
@@ -476,11 +737,14 @@ async function fetchNominatim(url) {
476
737
  clearTimeout(timeout);
477
738
  }
478
739
  }
479
- async function geocodeLocation(query) {
740
+ const PHOTON_CACHE = new Map();
741
+ async function geocodeLocation(query, includePolygon = false, warnings) {
480
742
  // 1. Try Nominatim first (free, returns exact OSM area boundaries & alternatives)
481
743
  try {
482
- const nominatimUrl = `${NOMINATIM_BASE_URL}/search?q=${encodeURIComponent(query)}&format=json&addressdetails=1&extratags=1&limit=5`;
483
- const items = await fetchNominatim(nominatimUrl);
744
+ const polyParam = includePolygon ? "&polygon_geojson=1" : "";
745
+ const emailParam = process.env.NOMINATIM_EMAIL ? `&email=${encodeURIComponent(process.env.NOMINATIM_EMAIL)}` : "";
746
+ const nominatimUrl = `${NOMINATIM_BASE_URL}/search?q=${encodeURIComponent(query)}&format=json&addressdetails=1&extratags=1&limit=5${polyParam}${emailParam}`;
747
+ const { data: items, cached, cache_age_ms, timestamp } = await fetchNominatim(nominatimUrl);
484
748
  if (Array.isArray(items) && items.length > 0) {
485
749
  const parsedItems = items.map((item) => {
486
750
  let bbox;
@@ -505,6 +769,7 @@ async function geocodeLocation(query) {
505
769
  else if (item.osm_type === "way" && !isNaN(osmIdNum)) {
506
770
  osm_area_id = 2400000000 + osmIdNum;
507
771
  }
772
+ const polygons_with_holes = item.geojson ? (extractPolygonsWithHolesLatLng(item.geojson) || undefined) : undefined;
508
773
  return {
509
774
  matched_place: item.display_name,
510
775
  bbox,
@@ -513,10 +778,10 @@ async function geocodeLocation(query) {
513
778
  class: item.class,
514
779
  type: item.type,
515
780
  osm_type: item.osm_type,
516
- osm_id: osmIdNum
781
+ osm_id: osmIdNum,
782
+ ...(polygons_with_holes ? { polygons_with_holes } : {})
517
783
  };
518
784
  });
519
- // Boundary relation preference: prefer relation with class=boundary or type=administrative/boundary
520
785
  let primaryIndex = parsedItems.findIndex(i => i.osm_type === "relation" &&
521
786
  (i.class === "boundary" || i.type === "administrative" || i.type === "boundary"));
522
787
  if (primaryIndex === -1)
@@ -525,14 +790,111 @@ async function geocodeLocation(query) {
525
790
  const alternatives = parsedItems.filter((_, idx) => idx !== primaryIndex);
526
791
  return {
527
792
  ...primary,
528
- alternatives
793
+ alternatives,
794
+ provenance: {
795
+ provider: `nominatim (${NOMINATIM_BASE_URL})`,
796
+ attribution: OSM_ATTRIBUTION,
797
+ data_timestamp: timestamp,
798
+ cached,
799
+ cache_age_ms,
800
+ usage_policy: "https://operations.osmfoundation.org/policies/nominatim/ (max 1 req/s, cache results, set NOMINATIM_API_URL for high-volume/shared deployments)"
801
+ }
802
+ };
803
+ }
804
+ }
805
+ catch (err) {
806
+ log('warn', `Nominatim geocoding failed for '${query}', trying fallback provider`, { error: err.message });
807
+ }
808
+ // 2. Fallback to Photon (Komoot) — configurable via PHOTON_API_URL, cached & rate-limited
809
+ try {
810
+ const photonUrl = `${PHOTON_BASE_URL}/api/?q=${encodeURIComponent(query)}&limit=5`;
811
+ let data;
812
+ let cached = false;
813
+ let cache_age_ms = 0;
814
+ let timestamp = new Date().toISOString();
815
+ const cachedEntry = PHOTON_CACHE.get(photonUrl);
816
+ if (cachedEntry && (Date.now() - cachedEntry.timestamp < NOMINATIM_TTL_MS)) {
817
+ data = cachedEntry.data;
818
+ cached = true;
819
+ cache_age_ms = Date.now() - cachedEntry.timestamp;
820
+ timestamp = new Date(cachedEntry.timestamp).toISOString();
821
+ }
822
+ else {
823
+ await photonRateLimit();
824
+ const controller = new AbortController();
825
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
826
+ try {
827
+ const response = await fetch(photonUrl, {
828
+ headers: {
829
+ 'User-Agent': APP_USER_AGENT,
830
+ 'Accept': 'application/json'
831
+ },
832
+ signal: controller.signal
833
+ });
834
+ if (response.ok) {
835
+ data = await response.json();
836
+ const now = Date.now();
837
+ PHOTON_CACHE.set(photonUrl, { timestamp: now, data });
838
+ timestamp = new Date(now).toISOString();
839
+ }
840
+ }
841
+ finally {
842
+ clearTimeout(timeout);
843
+ }
844
+ }
845
+ if (data && Array.isArray(data.features) && data.features.length > 0) {
846
+ if (PHOTON_BASE_URL.includes("photon.komoot.io")) {
847
+ warnings?.push("Used public Photon demo server (photon.komoot.io) as geocoding fallback; extensive usage may be throttled. Configure NOMINATIM_API_URL, PHOTON_API_URL, or MAPBOX_ACCESS_TOKEN for production.");
848
+ }
849
+ const photonItems = data.features.map((f) => {
850
+ const coords = f.geometry?.coordinates || [0, 0];
851
+ const center = { lat: coords[1], lng: coords[0] };
852
+ const props = f.properties || {};
853
+ let bbox;
854
+ if (Array.isArray(props.extent) && props.extent.length === 4) {
855
+ bbox = ensureValidBBox(props.extent[3], props.extent[0], props.extent[1], props.extent[2]);
856
+ }
857
+ else {
858
+ bbox = ensureValidBBox(center.lat - 0.05, center.lng - 0.05, center.lat + 0.05, center.lng + 0.05);
859
+ }
860
+ const osmTypeMap = { R: "relation", W: "way", N: "node" };
861
+ const osm_type = osmTypeMap[props.osm_type] || props.osm_type;
862
+ const osm_id = typeof props.osm_id === 'number' ? props.osm_id : parseInt(props.osm_id, 10);
863
+ let osm_area_id;
864
+ if (osm_type === "relation" && !isNaN(osm_id))
865
+ osm_area_id = 3600000000 + osm_id;
866
+ else if (osm_type === "way" && !isNaN(osm_id))
867
+ osm_area_id = 2400000000 + osm_id;
868
+ const labelParts = [props.name, props.city, props.state, props.country].filter(Boolean);
869
+ return {
870
+ matched_place: labelParts.length > 0 ? labelParts.join(", ") : query,
871
+ bbox,
872
+ center,
873
+ osm_area_id,
874
+ class: props.osm_key,
875
+ type: props.osm_value,
876
+ osm_type,
877
+ osm_id: !isNaN(osm_id) ? osm_id : undefined
878
+ };
879
+ });
880
+ return {
881
+ ...photonItems[0],
882
+ alternatives: photonItems.slice(1),
883
+ provenance: {
884
+ provider: `photon (${PHOTON_BASE_URL})`,
885
+ attribution: OSM_ATTRIBUTION,
886
+ data_timestamp: timestamp,
887
+ cached,
888
+ cache_age_ms,
889
+ usage_policy: "https://github.com/komoot/photon#demo-server (public demo server; extensive usage may be throttled)"
890
+ }
529
891
  };
530
892
  }
531
893
  }
532
894
  catch (err) {
533
- log('warn', `Nominatim geocoding failed for '${query}', trying Mapbox fallback`, { error: err.message });
895
+ log('warn', `Photon fallback geocoding failed for '${query}'`, { error: err.message });
534
896
  }
535
- // 2. Fallback to Mapbox if available
897
+ // 3. Optional fallback to Mapbox if configured
536
898
  if (MAPBOX_ACCESS_TOKEN) {
537
899
  const controller = new AbortController();
538
900
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
@@ -571,7 +933,14 @@ async function geocodeLocation(query) {
571
933
  const alternatives = mapboxItems.slice(1);
572
934
  return {
573
935
  ...primary,
574
- alternatives
936
+ alternatives,
937
+ provenance: {
938
+ provider: "mapbox (api.mapbox.com)",
939
+ attribution: "© Mapbox © OpenStreetMap",
940
+ data_timestamp: new Date().toISOString(),
941
+ cached: false,
942
+ cache_age_ms: 0
943
+ }
575
944
  };
576
945
  }
577
946
  catch (err) {
@@ -591,8 +960,9 @@ async function reverseGeocode(lat, lng, zoom = 18) {
591
960
  throw new Error(`Invalid lat/lng coordinates for reverse geocoding: (${lat}, ${lng})`);
592
961
  }
593
962
  const clampedZoom = Math.max(0, Math.min(18, Math.round(zoom)));
594
- const url = `${NOMINATIM_BASE_URL}/reverse?format=json&lat=${lat}&lon=${lng}&zoom=${clampedZoom}&addressdetails=1`;
595
- const data = await fetchNominatim(url);
963
+ const emailParam = process.env.NOMINATIM_EMAIL ? `&email=${encodeURIComponent(process.env.NOMINATIM_EMAIL)}` : "";
964
+ const url = `${NOMINATIM_BASE_URL}/reverse?format=json&lat=${lat}&lon=${lng}&zoom=${clampedZoom}&addressdetails=1${emailParam}`;
965
+ const { data, cached, cache_age_ms, timestamp } = await fetchNominatim(url);
596
966
  if (!data || !data.display_name) {
597
967
  throw new Error(`No reverse geocoding results found for coordinates: (${lat}, ${lng})`);
598
968
  }
@@ -607,17 +977,27 @@ async function reverseGeocode(lat, lng, zoom = 18) {
607
977
  else {
608
978
  bbox = ensureValidBBox(lat - 0.005, lng - 0.005, lat + 0.005, lng + 0.005);
609
979
  }
610
- const shareUrl = buildShareUrl(bbox);
980
+ const shareUrl = buildShareUrlMarkersOnly([{ coordinates: { lat, lon: lng }, name: data.display_name }]);
611
981
  return {
612
982
  matched_place: data.display_name,
613
983
  center: { lat, lng },
614
984
  bbox,
615
985
  address: data.address || {},
616
- class: data.class,
617
- type: data.type,
618
- osm_type: data.osm_type,
619
- osm_id: data.osm_id ? parseInt(data.osm_id, 10) : undefined,
620
- share_url: shareUrl
986
+ ...(data.class ? { class: String(data.class) } : {}),
987
+ ...(data.type ? { type: String(data.type) } : {}),
988
+ ...(data.osm_type ? { osm_type: String(data.osm_type) } : {}),
989
+ ...(data.osm_id ? { osm_id: parseInt(data.osm_id, 10) } : {}),
990
+ share_url: shareUrl,
991
+ provenance: {
992
+ provider: `nominatim (${NOMINATIM_BASE_URL})`,
993
+ attribution: OSM_ATTRIBUTION,
994
+ data_timestamp: timestamp,
995
+ cached,
996
+ cache_age_ms,
997
+ usage_policy: "https://operations.osmfoundation.org/policies/nominatim/"
998
+ },
999
+ complete: true,
1000
+ warnings: []
621
1001
  };
622
1002
  }
623
1003
  function splitOverpassStatements(query) {
@@ -687,42 +1067,329 @@ function calculateHaversineDistanceMeters(lat1, lng1, lat2, lng2) {
687
1067
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
688
1068
  return R * c;
689
1069
  }
1070
+ function calculateBearingDegrees(lat1, lng1, lat2, lng2) {
1071
+ const phi1 = (lat1 * Math.PI) / 180;
1072
+ const phi2 = (lat2 * Math.PI) / 180;
1073
+ const dLambda = ((lng2 - lng1) * Math.PI) / 180;
1074
+ const y = Math.sin(dLambda) * Math.cos(phi2);
1075
+ const x = Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLambda);
1076
+ const theta = Math.atan2(y, x);
1077
+ const degrees = ((theta * 180) / Math.PI + 360) % 360;
1078
+ const dirs = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"];
1079
+ const cardinal = dirs[Math.round(degrees / 22.5) % 16];
1080
+ return { degrees: Math.round(degrees * 100) / 100, cardinal };
1081
+ }
1082
+ function isPointInPolygonRing(lat, lng, ringLatLng) {
1083
+ let inside = false;
1084
+ for (let i = 0, j = ringLatLng.length - 1; i < ringLatLng.length; j = i++) {
1085
+ const yi = ringLatLng[i][0], xi = ringLatLng[i][1];
1086
+ const yj = ringLatLng[j][0], xj = ringLatLng[j][1];
1087
+ const intersect = ((yi > lat) !== (yj > lat)) &&
1088
+ (lng < ((xj - xi) * (lat - yi)) / (yj - yi + 1e-15) + xi);
1089
+ if (intersect)
1090
+ inside = !inside;
1091
+ }
1092
+ return inside;
1093
+ }
1094
+ function isPointInPolygonWithHoles(lat, lng, polyRingsLatLng) {
1095
+ if (!polyRingsLatLng || polyRingsLatLng.length === 0)
1096
+ return false;
1097
+ if (!isPointInPolygonRing(lat, lng, polyRingsLatLng[0]))
1098
+ return false;
1099
+ for (let h = 1; h < polyRingsLatLng.length; h++) {
1100
+ if (isPointInPolygonRing(lat, lng, polyRingsLatLng[h])) {
1101
+ return false; // Point lies inside an interior hole
1102
+ }
1103
+ }
1104
+ return true;
1105
+ }
1106
+ // ---------------------------------------------------------------------------
1107
+ // Overpass & Taginfo Bounded LRU Caches + Shared Overpass Executor
1108
+ // ---------------------------------------------------------------------------
1109
+ const OVERPASS_CACHE = new Map();
1110
+ const OVERPASS_CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
1111
+ const OVERPASS_CACHE_MAX = 100;
1112
+ const OVERPASS_INFLIGHT = new Map();
1113
+ let preferredOverpassEndpoint = null;
1114
+ const TAGINFO_CACHE = new Map();
1115
+ const TAGINFO_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
1116
+ const ROUTE_CACHE = new Map();
1117
+ const ROUTE_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
1118
+ const ELEVATION_CACHE = new Map();
1119
+ const ELEVATION_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
1120
+ async function executeOverpassQuery(overpassQuery, timeoutSec) {
1121
+ const cached = OVERPASS_CACHE.get(overpassQuery);
1122
+ if (cached && (Date.now() - cached.timestamp < OVERPASS_CACHE_TTL_MS)) {
1123
+ log('info', 'Overpass cache hit');
1124
+ const osmTimestamp = cached.data.osm3s?.timestamp_osm_base || new Date(cached.timestamp).toISOString();
1125
+ return {
1126
+ data: cached.data,
1127
+ provenance: {
1128
+ provider: `overpass (${cached.endpoint})`,
1129
+ attribution: OSM_ATTRIBUTION,
1130
+ data_timestamp: osmTimestamp,
1131
+ cached: true,
1132
+ cache_age_ms: Date.now() - cached.timestamp
1133
+ }
1134
+ };
1135
+ }
1136
+ const existingInflight = OVERPASS_INFLIGHT.get(overpassQuery);
1137
+ if (existingInflight) {
1138
+ log('info', 'Joining in-flight Overpass query');
1139
+ return existingInflight;
1140
+ }
1141
+ const promise = executeOverpassQueryUncached(overpassQuery, timeoutSec).finally(() => {
1142
+ OVERPASS_INFLIGHT.delete(overpassQuery);
1143
+ });
1144
+ OVERPASS_INFLIGHT.set(overpassQuery, promise);
1145
+ return promise;
1146
+ }
1147
+ async function executeOverpassQueryUncached(overpassQuery, timeoutSec) {
1148
+ // Distinct independent operators:
1149
+ // 1. overpass-api.de (FOSSGIS / main German instance)
1150
+ // 2. overpass.private.coffee (Private.coffee — successor to Kumi Systems)
1151
+ // 3. overpass.openstreetmap.fr (OSM France instance)
1152
+ // 4. maps.mail.ru (VK Maps independent Overpass mirror)
1153
+ const defaultEndpoints = [
1154
+ "https://overpass-api.de/api/interpreter",
1155
+ "https://overpass.private.coffee/api/interpreter",
1156
+ "https://overpass.openstreetmap.fr/api/interpreter",
1157
+ "https://maps.mail.ru/osm/tools/overpass/api/interpreter"
1158
+ ];
1159
+ const customEndpoints = process.env.OVERPASS_API_URL
1160
+ ? process.env.OVERPASS_API_URL.split(",").map(s => s.trim()).filter(Boolean)
1161
+ : [];
1162
+ const baseEndpoints = process.env.OVERPASS_DISABLE_PUBLIC_FALLBACK === "1" && customEndpoints.length > 0
1163
+ ? customEndpoints
1164
+ : [...customEndpoints, ...defaultEndpoints];
1165
+ const endpoints = preferredOverpassEndpoint
1166
+ ? [preferredOverpassEndpoint, ...baseEndpoints.filter(e => e !== preferredOverpassEndpoint)]
1167
+ : baseEndpoints;
1168
+ const params = new URLSearchParams();
1169
+ params.append('data', overpassQuery);
1170
+ const bodyStr = params.toString();
1171
+ const clientTimeoutMs = endpoints.length > 1
1172
+ ? Math.min((timeoutSec + 5) * 1000, 18000)
1173
+ : (timeoutSec + 5) * 1000;
1174
+ // Adaptive hedging: start endpoint[0] immediately; start each subsequent endpoint
1175
+ // either after 1200ms OR immediately if the previous endpoint fails early (e.g. HTTP 502/504).
1176
+ const controllers = [];
1177
+ const timers = [];
1178
+ const startCallbacks = [];
1179
+ let settled = false;
1180
+ const queryEndpoint = (endpoint, idx) => new Promise((resolve, reject) => {
1181
+ let started = false;
1182
+ const startRequest = async () => {
1183
+ if (started)
1184
+ return;
1185
+ started = true;
1186
+ if (settled) {
1187
+ reject(new Error("Cancelled"));
1188
+ return;
1189
+ }
1190
+ const controller = new AbortController();
1191
+ controllers.push(controller);
1192
+ const timeout = setTimeout(() => controller.abort(), clientTimeoutMs);
1193
+ try {
1194
+ log('info', `Executing Overpass query at ${endpoint}`);
1195
+ const response = await fetch(endpoint, {
1196
+ method: "POST",
1197
+ body: bodyStr,
1198
+ headers: {
1199
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
1200
+ 'Accept': 'application/json',
1201
+ 'User-Agent': APP_USER_AGENT
1202
+ },
1203
+ signal: controller.signal
1204
+ });
1205
+ if (!response.ok) {
1206
+ throw new Error(`Overpass API returned status: ${response.status} ${response.statusText}`);
1207
+ }
1208
+ const candidate = await response.json();
1209
+ // Detect Overpass runtime errors (e.g. query timeout, out of memory) returned inside HTTP 200 JSON
1210
+ if (typeof candidate?.remark === 'string' && /runtime error|timed out|out of memory|error/i.test(candidate.remark)) {
1211
+ throw new Error(`Overpass runtime error: ${candidate.remark}`);
1212
+ }
1213
+ if (!candidate || !Array.isArray(candidate.elements)) {
1214
+ throw new Error(`Overpass response missing 'elements' array`);
1215
+ }
1216
+ resolve({ data: candidate, endpoint });
1217
+ }
1218
+ catch (err) {
1219
+ if (!settled) {
1220
+ log('warn', `Overpass API failed at ${endpoint}`, { error: err.message });
1221
+ // Immediately wake the next endpoint in line if it hasn't started yet
1222
+ if (startCallbacks[idx + 1]) {
1223
+ startCallbacks[idx + 1]();
1224
+ }
1225
+ }
1226
+ reject(err);
1227
+ }
1228
+ finally {
1229
+ clearTimeout(timeout);
1230
+ }
1231
+ };
1232
+ startCallbacks[idx] = startRequest;
1233
+ if (idx === 0) {
1234
+ startRequest();
1235
+ }
1236
+ else {
1237
+ const t = setTimeout(startRequest, idx * 1200);
1238
+ timers.push(t);
1239
+ }
1240
+ });
1241
+ let data = null;
1242
+ let usedEndpoint = endpoints[0];
1243
+ try {
1244
+ const winner = await Promise.any(endpoints.map((ep, idx) => queryEndpoint(ep, idx)));
1245
+ settled = true;
1246
+ data = winner.data;
1247
+ usedEndpoint = winner.endpoint;
1248
+ preferredOverpassEndpoint = usedEndpoint;
1249
+ }
1250
+ catch (aggErr) {
1251
+ settled = true;
1252
+ const errors = Array.isArray(aggErr?.errors) ? aggErr.errors : [aggErr];
1253
+ const nonAbort = errors.find(e => e && e.name !== 'AbortError' && e.message !== 'Cancelled');
1254
+ if (!nonAbort && errors.some(e => e?.name === 'AbortError')) {
1255
+ throw new Error(`Overpass API request timed out on all endpoints after ${clientTimeoutMs / 1000}s. The query or area may be too large.`);
1256
+ }
1257
+ const errMsg = (nonAbort || errors[0])?.message || "Unknown Overpass error";
1258
+ throw new Error(`Overpass query failed on all endpoints: ${errMsg}`);
1259
+ }
1260
+ finally {
1261
+ settled = true;
1262
+ for (const t of timers)
1263
+ clearTimeout(t);
1264
+ for (const c of controllers) {
1265
+ try {
1266
+ c.abort();
1267
+ }
1268
+ catch { /* ignore */ }
1269
+ }
1270
+ }
1271
+ const now = Date.now();
1272
+ if (OVERPASS_CACHE.size >= OVERPASS_CACHE_MAX) {
1273
+ const oldestKey = OVERPASS_CACHE.keys().next().value;
1274
+ if (oldestKey)
1275
+ OVERPASS_CACHE.delete(oldestKey);
1276
+ }
1277
+ OVERPASS_CACHE.set(overpassQuery, { timestamp: now, data, endpoint: usedEndpoint });
1278
+ const osmTimestamp = data.osm3s?.timestamp_osm_base || new Date(now).toISOString();
1279
+ return {
1280
+ data,
1281
+ provenance: {
1282
+ provider: `overpass (${usedEndpoint})`,
1283
+ attribution: OSM_ATTRIBUTION,
1284
+ data_timestamp: osmTimestamp,
1285
+ cached: false,
1286
+ cache_age_ms: 0
1287
+ },
1288
+ remarkWarning: typeof data.remark === 'string' ? data.remark : undefined
1289
+ };
1290
+ }
690
1291
  async function getTaginfoSuggestions(keyword) {
691
- const url = `https://taginfo.openstreetmap.org/api/4/search/by_keyword?query=${encodeURIComponent(keyword)}&rp=30`;
692
- const controller = new AbortController();
693
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
1292
+ const cacheKey = keyword.toLowerCase().trim();
1293
+ const cached = TAGINFO_CACHE.get(cacheKey);
1294
+ if (cached && (Date.now() - cached.timestamp < TAGINFO_CACHE_TTL_MS)) {
1295
+ return {
1296
+ text: cached.text,
1297
+ tags: cached.tags,
1298
+ cached: true,
1299
+ cache_age_ms: Date.now() - cached.timestamp,
1300
+ timestamp: new Date(cached.timestamp).toISOString()
1301
+ };
1302
+ }
694
1303
  const MAINSTREAM_KEYS = new Set([
695
1304
  "amenity", "shop", "leisure", "tourism", "natural", "highway",
696
1305
  "office", "healthcare", "craft", "sport", "landuse", "building",
697
- "historic", "emergency", "railway", "aeroway", "public_transport"
1306
+ "historic", "emergency", "railway", "aeroway", "public_transport",
1307
+ "man_made", "Waterway", "waterway", "barrier", "power", "place"
698
1308
  ]);
1309
+ const controller = new AbortController();
1310
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
699
1311
  try {
700
- const response = await fetch(url, {
701
- headers: { 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)` },
702
- signal: controller.signal
703
- });
704
- if (!response.ok) {
705
- throw new Error(`Taginfo status: ${response.status}`);
1312
+ let items = [];
1313
+ if (MAINSTREAM_KEYS.has(cacheKey)) {
1314
+ // User asked for a top-level OSM key (e.g. "amenity", "shop", "tourism") -> fetch its most popular values
1315
+ const keyUrl = `https://taginfo.openstreetmap.org/api/4/keys/values?key=${encodeURIComponent(cacheKey)}&sortname=count_all&sortorder=desc&rp=15`;
1316
+ const res = await fetch(keyUrl, { headers: { 'User-Agent': APP_USER_AGENT }, signal: controller.signal });
1317
+ if (res.ok) {
1318
+ const json = await res.json();
1319
+ items = (json.data || []).map((d) => ({ key: cacheKey, value: d.value, count_all: d.count }));
1320
+ }
706
1321
  }
707
- const data = await response.json();
708
- const results = data.data || [];
709
- const filtered = results
710
- .filter((item) => item.key &&
711
- MAINSTREAM_KEYS.has(item.key) &&
712
- (item.count_all ?? 0) >= 1000)
1322
+ else {
1323
+ // Query both by_value (sorted by real-world count_all) and by_keyword (matches natural-language wiki terms)
1324
+ const valUrl = `https://taginfo.openstreetmap.org/api/4/search/by_value?query=${encodeURIComponent(keyword)}&sortname=count_all&sortorder=desc&rp=25`;
1325
+ const kwUrl = `https://taginfo.openstreetmap.org/api/4/search/by_keyword?query=${encodeURIComponent(keyword)}&rp=25`;
1326
+ const [valRes, kwRes] = await Promise.allSettled([
1327
+ fetch(valUrl, { headers: { 'User-Agent': APP_USER_AGENT }, signal: controller.signal }),
1328
+ fetch(kwUrl, { headers: { 'User-Agent': APP_USER_AGENT }, signal: controller.signal })
1329
+ ]);
1330
+ if (valRes.status === "fulfilled" && valRes.value.ok) {
1331
+ const json = await valRes.value.json();
1332
+ items.push(...(json.data || []));
1333
+ }
1334
+ if (kwRes.status === "fulfilled" && kwRes.value.ok) {
1335
+ const json = await kwRes.value.json();
1336
+ items.push(...(json.data || []));
1337
+ }
1338
+ }
1339
+ const seenSelectors = new Set();
1340
+ const filtered = items
1341
+ .filter((item) => {
1342
+ if (!item.key || !MAINSTREAM_KEYS.has(item.key))
1343
+ return false;
1344
+ if (item.count_all !== undefined && item.count_all < 1000)
1345
+ return false;
1346
+ const valStr = item.value ? `="${item.value}"` : '';
1347
+ const selector = `nwr["${item.key}"${valStr}]`;
1348
+ if (seenSelectors.has(selector))
1349
+ return false;
1350
+ seenSelectors.add(selector);
1351
+ return true;
1352
+ })
713
1353
  .sort((a, b) => (b.count_all ?? 0) - (a.count_all ?? 0))
714
1354
  .slice(0, 10);
1355
+ const now = Date.now();
715
1356
  if (filtered.length === 0) {
716
- return `No mainstream OSM tags found for '${keyword}' with usage count > 1,000.\nTry searching standard keys like amenity, shop, leisure, or tourism.`;
1357
+ const text = `No mainstream OSM tags found for '${keyword}' with usage count > 1,000.\nTry searching standard keys like amenity, shop, leisure, or tourism.`;
1358
+ return {
1359
+ text,
1360
+ tags: [],
1361
+ cached: false,
1362
+ cache_age_ms: 0,
1363
+ timestamp: new Date(now).toISOString()
1364
+ };
717
1365
  }
1366
+ const tags = [];
718
1367
  const lines = filtered.map((item) => {
719
1368
  const valStr = item.value ? `="${item.value}"` : '';
720
- return `- nwr["${item.key}"${valStr}] (${(item.count_all || 0).toLocaleString()} objects)`;
1369
+ const selector = `nwr["${item.key}"${valStr}]`;
1370
+ tags.push(selector);
1371
+ const countInfo = item.count_all !== undefined ? ` (${item.count_all.toLocaleString()} objects)` : '';
1372
+ return `- ${selector}${countInfo}`;
721
1373
  });
722
- return `Taginfo OSM Tag Recommendations for '${keyword}':\n\n${lines.join('\n')}`;
1374
+ const text = `Taginfo OSM Tag Recommendations for '${keyword}':\n\n${lines.join('\n')}`;
1375
+ TAGINFO_CACHE.set(cacheKey, { timestamp: now, text, tags });
1376
+ return {
1377
+ text,
1378
+ tags,
1379
+ cached: false,
1380
+ cache_age_ms: 0,
1381
+ timestamp: new Date(now).toISOString()
1382
+ };
723
1383
  }
724
1384
  catch (err) {
725
- return `Taginfo lookup failed for '${keyword}': ${err.message}`;
1385
+ return {
1386
+ text: `Taginfo lookup failed for '${keyword}': ${err.message}`,
1387
+ tags: [],
1388
+ cached: false,
1389
+ cache_age_ms: 0,
1390
+ timestamp: new Date().toISOString(),
1391
+ warning: `Taginfo lookup failed: ${err.message}`
1392
+ };
726
1393
  }
727
1394
  finally {
728
1395
  clearTimeout(timeout);
@@ -745,22 +1412,55 @@ function buildShareUrlCircleWithMarkers(center, radiusMeters, elements) {
745
1412
  // ---------------------------------------------------------------------------
746
1413
  // Tool Handlers
747
1414
  // ---------------------------------------------------------------------------
1415
+ const BBOX_OBJ_SCHEMA = {
1416
+ type: "object",
1417
+ properties: {
1418
+ lat1: { type: "number" },
1419
+ lng1: { type: "number" },
1420
+ lat2: { type: "number" },
1421
+ lng2: { type: "number" }
1422
+ },
1423
+ required: ["lat1", "lng1", "lat2", "lng2"]
1424
+ };
1425
+ const PROVENANCE_SCHEMA = {
1426
+ type: "object",
1427
+ properties: {
1428
+ provider: { type: "string" },
1429
+ attribution: { type: "string" },
1430
+ data_timestamp: { type: "string" },
1431
+ cached: { type: "boolean" },
1432
+ cache_age_ms: { type: "number" },
1433
+ usage_policy: { type: "string" }
1434
+ },
1435
+ required: ["provider", "data_timestamp", "cached", "cache_age_ms"]
1436
+ };
748
1437
  server.setRequestHandler(ListToolsRequestSchema, async () => {
749
1438
  return {
750
1439
  tools: [
751
1440
  {
752
1441
  name: "get_bounds",
753
- description: "Get converted coordinates for a bounding box or text location search. Supports parsing WKT, GeoJSON, ogrinfo extent, and raw coordinate strings. If MAPBOX_ACCESS_TOKEN is not set, you MUST provide explicit coordinates via 'bbox'.",
1442
+ description: "Get converted coordinates for a bounding box or natural-language location search (powered by Nominatim with configurable Photon & optional Mapbox fallbacks). Supports parsing WKT, GeoJSON, ogrinfo extent, Web Mercator tiles ('z/x/y'), single points ('lat,lng'), and raw coordinate strings, plus optional metric buffering.",
1443
+ annotations: {
1444
+ title: "Get Bounding Box & Projections",
1445
+ readOnlyHint: true,
1446
+ destructiveHint: false,
1447
+ idempotentHint: true,
1448
+ openWorldHint: true
1449
+ },
754
1450
  inputSchema: {
755
1451
  type: "object",
756
1452
  properties: {
757
1453
  location: {
758
1454
  type: "string",
759
- description: "A text location to search for (e.g. 'New York City'). Requires MAPBOX_ACCESS_TOKEN env var. Either 'location' or 'bbox' MUST be provided."
1455
+ description: "A text location to search for (e.g. 'New York City', 'Central Park'). Uses OpenStreetMap Nominatim (or configurable NOMINATIM_API_URL / PHOTON_API_URL / optional MAPBOX_ACCESS_TOKEN). Either 'location' or 'bbox' MUST be provided."
760
1456
  },
761
1457
  bbox: {
762
1458
  type: "string",
763
- description: "The geometry to parse. Can be a raw bounding box string ('lat1,lng1,lat2,lng2'), a WKT polygon/linestring/point, a GeoJSON payload, or an `ogrinfo` extent block. The Minimum Bounding Rectangle (MBR) encapsulating the geometry will be extracted. Either 'location' or 'bbox' MUST be provided."
1459
+ description: "The geometry to parse. Can be a raw bounding box string ('lat1,lng1,lat2,lng2'), a single point ('lat,lng'), a Web Mercator tile ('z/x/y'), a WKT polygon/linestring/point, a GeoJSON payload, or an `ogrinfo` extent block. Either 'location' or 'bbox' MUST be provided."
1460
+ },
1461
+ buffer_meters: {
1462
+ type: "number",
1463
+ description: "Optional distance in meters to expand (positive) or shrink (negative) the bounding box in all directions. Ideal for creating a bounding box around a single point."
764
1464
  },
765
1465
  epsg: {
766
1466
  type: "string",
@@ -790,10 +1490,60 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
790
1490
  { required: ["bbox"] }
791
1491
  ]
792
1492
  },
1493
+ outputSchema: {
1494
+ type: "object",
1495
+ properties: {
1496
+ matched_place: { type: "string" },
1497
+ alternatives: { type: "array", items: { type: "object" } },
1498
+ original_wgs84: BBOX_OBJ_SCHEMA,
1499
+ projected: {
1500
+ type: "object",
1501
+ properties: {
1502
+ xmin: { type: "number" },
1503
+ ymin: { type: "number" },
1504
+ xmax: { type: "number" },
1505
+ ymax: { type: "number" }
1506
+ },
1507
+ required: ["xmin", "ymin", "xmax", "ymax"]
1508
+ },
1509
+ center: {
1510
+ type: "object",
1511
+ properties: { lat: { type: "number" }, lng: { type: "number" } },
1512
+ required: ["lat", "lng"]
1513
+ },
1514
+ tile_indices: {
1515
+ type: "object",
1516
+ properties: { x: { type: "number" }, y: { type: "number" }, z: { type: "number" } },
1517
+ required: ["x", "y", "z"]
1518
+ },
1519
+ epsg: { type: "string" },
1520
+ format: { type: "string" },
1521
+ coord_order: { type: "string" },
1522
+ precision: { type: "number" },
1523
+ area_km2: { type: "number" },
1524
+ dimensions: {
1525
+ type: "object",
1526
+ properties: { width_km: { type: "number" }, height_km: { type: "number" } },
1527
+ required: ["width_km", "height_km"]
1528
+ },
1529
+ share_url: { type: "string" },
1530
+ provenance: PROVENANCE_SCHEMA,
1531
+ complete: { type: "boolean" },
1532
+ warnings: { type: "array", items: { type: "string" } }
1533
+ },
1534
+ required: ["original_wgs84", "projected", "center", "tile_indices", "epsg", "format", "coord_order", "precision", "area_km2", "dimensions", "share_url", "provenance", "complete", "warnings"]
1535
+ }
793
1536
  },
794
1537
  {
795
1538
  name: "get_h3_indices",
796
- description: "Get Uber H3 cell indices for a bounding box area at a target resolution. Input supports WKT, GeoJSON, ogrinfo extent, and raw coordinate strings.",
1539
+ description: "Get Uber H3 cell indices for a polygon (including interior holes), location boundary, or bounding box at a target resolution. When a WKT/GeoJSON polygon or named location boundary is provided, fills the exact polygon geometry (subtracting interior holes) rather than just its bounding rectangle.",
1540
+ annotations: {
1541
+ title: "Generate H3 Hexagonal Grid Indices",
1542
+ readOnlyHint: true,
1543
+ destructiveHint: false,
1544
+ idempotentHint: true,
1545
+ openWorldHint: true
1546
+ },
797
1547
  inputSchema: {
798
1548
  type: "object",
799
1549
  properties: {
@@ -803,7 +1553,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
803
1553
  },
804
1554
  bbox: {
805
1555
  type: "string",
806
- description: "The geometry to parse. Can be a raw bounding box string ('lat1,lng1,lat2,lng2'), WKT, GeoJSON, or ogrinfo extent. Either 'location' or 'bbox' MUST be provided.",
1556
+ description: "The geometry to parse. Can be a raw bounding box string ('lat1,lng1,lat2,lng2'), WKT polygon (with optional holes), GeoJSON polygon/multipolygon, tile 'z/x/y', or ogrinfo extent. Either 'location' or 'bbox' MUST be provided.",
807
1557
  },
808
1558
  resolution: {
809
1559
  type: "number",
@@ -820,6 +1570,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
820
1570
  return_geometry: {
821
1571
  type: "boolean",
822
1572
  description: "If true, return GeoJSON FeatureCollection with hex boundary polygons in addition to cell IDs. Defaults to false."
1573
+ },
1574
+ use_polygon_boundary: {
1575
+ type: "boolean",
1576
+ description: "If true (default), fills the exact WKT/GeoJSON/OSM polygon boundary (including interior holes) instead of its rectangular bounding box when available."
823
1577
  }
824
1578
  },
825
1579
  required: ["resolution"],
@@ -828,24 +1582,68 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
828
1582
  { required: ["bbox"] }
829
1583
  ]
830
1584
  },
1585
+ outputSchema: {
1586
+ type: "object",
1587
+ properties: {
1588
+ resolution: { type: "number" },
1589
+ compact: { type: "boolean" },
1590
+ summary_only: { type: "boolean" },
1591
+ used_polygon_boundary: { type: "boolean" },
1592
+ cell_count: { type: "number" },
1593
+ cells: { type: "array", items: { type: "string" } },
1594
+ sample_cells: { type: "array", items: { type: "string" } },
1595
+ geojson: { type: "object" },
1596
+ original_bbox: BBOX_OBJ_SCHEMA,
1597
+ share_url: { type: "string" },
1598
+ provenance: PROVENANCE_SCHEMA,
1599
+ complete: { type: "boolean" },
1600
+ warnings: { type: "array", items: { type: "string" } }
1601
+ },
1602
+ required: ["resolution", "compact", "summary_only", "used_polygon_boundary", "cell_count", "original_bbox", "share_url", "provenance", "complete", "warnings"]
1603
+ }
831
1604
  },
832
1605
  {
833
1606
  name: "generate_share_url",
834
- description: "Generates a URL that links to the visual Bounding Box tool to display these coordinates on a map. Supports WKT, GeoJSON, ogrinfo extent, and raw coordinate strings as input.",
1607
+ description: "Generates a URL that links to the visual Bounding Box tool to display these coordinates on a map. Supports WKT, GeoJSON, ogrinfo extent, tile 'z/x/y', and raw coordinate strings as input.",
1608
+ annotations: {
1609
+ title: "Generate Map Verification URL",
1610
+ readOnlyHint: true,
1611
+ destructiveHint: false,
1612
+ idempotentHint: true,
1613
+ openWorldHint: false
1614
+ },
835
1615
  inputSchema: {
836
1616
  type: "object",
837
1617
  properties: {
838
1618
  bbox: {
839
1619
  type: "string",
840
- description: "The geometry to parse. Can be a raw bounding box string ('lat1,lng1,lat2,lng2'), WKT, GeoJSON, or ogrinfo extent.",
1620
+ description: "The geometry to parse. Can be a raw bounding box string ('lat1,lng1,lat2,lng2'), WKT, GeoJSON, tile 'z/x/y', or ogrinfo extent.",
841
1621
  }
842
1622
  },
843
1623
  required: ["bbox"],
844
1624
  },
1625
+ outputSchema: {
1626
+ type: "object",
1627
+ properties: {
1628
+ bbox: BBOX_OBJ_SCHEMA,
1629
+ share_url: { type: "string" },
1630
+ provenance: PROVENANCE_SCHEMA,
1631
+ complete: { type: "boolean" },
1632
+ warnings: { type: "array", items: { type: "string" } }
1633
+ },
1634
+ required: ["bbox", "share_url", "provenance", "complete", "warnings"]
1635
+ }
845
1636
  },
846
1637
  {
847
1638
  name: "reverse_geocode",
848
1639
  description: "Reverse geocode latitude and longitude coordinates to discover context, place name, address components, and OSM details ('what is here?').",
1640
+ annotations: {
1641
+ title: "Reverse Geocode Coordinates",
1642
+ readOnlyHint: true,
1643
+ destructiveHint: false,
1644
+ idempotentHint: true,
1645
+ openWorldHint: true
1646
+ },
849
1647
  inputSchema: {
850
1648
  type: "object",
851
1649
  properties: {
@@ -863,11 +1661,40 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
863
1661
  }
864
1662
  },
865
1663
  required: ["lat", "lng"]
1664
+ },
1665
+ outputSchema: {
1666
+ type: "object",
1667
+ properties: {
1668
+ matched_place: { type: "string" },
1669
+ center: {
1670
+ type: "object",
1671
+ properties: { lat: { type: "number" }, lng: { type: "number" } },
1672
+ required: ["lat", "lng"]
1673
+ },
1674
+ bbox: BBOX_OBJ_SCHEMA,
1675
+ address: { type: "object" },
1676
+ class: { type: "string" },
1677
+ type: { type: "string" },
1678
+ osm_type: { type: "string" },
1679
+ osm_id: { type: "number" },
1680
+ share_url: { type: "string" },
1681
+ provenance: PROVENANCE_SCHEMA,
1682
+ complete: { type: "boolean" },
1683
+ warnings: { type: "array", items: { type: "string" } }
1684
+ },
1685
+ required: ["matched_place", "center", "bbox", "address", "share_url", "provenance", "complete", "warnings"]
866
1686
  }
867
1687
  },
868
1688
  {
869
1689
  name: "search_overpass",
870
- description: "Execute an Overpass QL query to find POIs, roads, or other OSM features within an admin boundary area, bounding box, or radius. The server wraps your query in spatial filters and returns structured JSON with distance calculations and a map link.\n\nSEARCH MODES:\n- Location / Admin Area: when 'location' resolves to an OSM boundary polygon (e.g. 'Manhattan', 'Seattle'), searches inside exact boundary polygon.\n- Bounding box (default): searches a rectangular area.\n- Circle: searches within an exact radius when 'radius_meters' is provided.\n\nCOMMON TAG EXAMPLES:\n- Restaurants: `nwr[\"amenity\"=\"restaurant\"]`\n- Pizza: `nwr[\"amenity\"=\"fast_food\"][\"cuisine\"=\"pizza\"]`\n- Supermarkets: `nwr[\"shop\"=\"supermarket\"]`\n- Parks: `nwr[\"leisure\"=\"park\"]`\n- Hospitals: `nwr[\"amenity\"=\"hospital\"]`\n- EV Charging: `nwr[\"amenity\"=\"charging_station\"]`\n- Gyms: `nwr[\"leisure\"=\"fitness_centre\"]`\n- Banks: `nwr[\"amenity\"=\"bank\"]`",
1690
+ description: "Execute an Overpass QL query to find POIs, roads, or other OSM features within an admin boundary area, bounding box, or radius. The server wraps your query in spatial filters, detects Overpass runtime errors, and returns structured JSON with provenance, completeness metadata, distance calculations, and a map link.\n\nSEARCH MODES:\n- Location / Admin Area: when 'location' resolves to an OSM boundary polygon (e.g. 'Manhattan', 'Seattle'), searches inside exact boundary polygon.\n- Bounding box (default): searches a rectangular area.\n- Circle: searches within an exact radius when 'radius_meters' is provided.\n- Fast Count: set 'count_only: true' to get instant element counts without downloading full element payloads.\n\nCOMMON TAG EXAMPLES:\n- Restaurants: `nwr[\"amenity\"=\"restaurant\"]`\n- Pizza: `nwr[\"amenity\"=\"fast_food\"][\"cuisine\"=\"pizza\"]`\n- Supermarkets: `nwr[\"shop\"=\"supermarket\"]`\n- Parks: `nwr[\"leisure\"=\"park\"]`\n- Hospitals: `nwr[\"amenity\"=\"hospital\"]`\n- EV Charging: `nwr[\"amenity\"=\"charging_station\"]`\n- Gyms: `nwr[\"leisure\"=\"fitness_centre\"]`\n- Banks: `nwr[\"amenity\"=\"bank\"]`",
1691
+ annotations: {
1692
+ title: "Search OpenStreetMap via Overpass QL",
1693
+ readOnlyHint: true,
1694
+ destructiveHint: false,
1695
+ idempotentHint: true,
1696
+ openWorldHint: true
1697
+ },
871
1698
  inputSchema: {
872
1699
  type: "object",
873
1700
  properties: {
@@ -910,6 +1737,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
910
1737
  return_geometry: {
911
1738
  type: "boolean",
912
1739
  description: "If true, returns full geometry (polygon/polyline) for ways and relations instead of center point. Defaults to false."
1740
+ },
1741
+ count_only: {
1742
+ type: "boolean",
1743
+ description: "If true, executes an ultra-fast Overpass 'out count;' query and returns total counts (nodes, ways, relations, total) without downloading individual features. Defaults to false."
913
1744
  }
914
1745
  },
915
1746
  required: ["query"],
@@ -918,10 +1749,53 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
918
1749
  { required: ["bbox"] }
919
1750
  ]
920
1751
  },
1752
+ outputSchema: {
1753
+ type: "object",
1754
+ properties: {
1755
+ search_mode: { type: "string" },
1756
+ count_only: { type: "boolean" },
1757
+ matched_place: { type: "string" },
1758
+ alternatives: { type: "array", items: { type: "object" } },
1759
+ osm_area_id: { type: "number" },
1760
+ counts: {
1761
+ type: "object",
1762
+ properties: {
1763
+ nodes: { type: "number" },
1764
+ ways: { type: "number" },
1765
+ relations: { type: "number" },
1766
+ total: { type: "number" }
1767
+ }
1768
+ },
1769
+ distance_from: { type: "object" },
1770
+ generator: { type: "string" },
1771
+ osm3s: { type: "object" },
1772
+ elements: { type: "array", items: { type: "object" } },
1773
+ total_count: { type: "number" },
1774
+ returned_count: { type: "number" },
1775
+ offset: { type: "number" },
1776
+ limit: { type: "number" },
1777
+ truncated: { type: "boolean" },
1778
+ has_more: { type: "boolean" },
1779
+ next_offset: { type: "number" },
1780
+ pagination_hint: { type: "string" },
1781
+ share_url: { type: "string" },
1782
+ provenance: PROVENANCE_SCHEMA,
1783
+ complete: { type: "boolean" },
1784
+ warnings: { type: "array", items: { type: "string" } }
1785
+ },
1786
+ required: ["search_mode", "count_only", "share_url", "provenance", "complete", "warnings"]
1787
+ }
921
1788
  },
922
1789
  {
923
1790
  name: "list_osm_tags",
924
1791
  description: "Discovery tool to look up the correct OpenStreetMap tags for any category. Uses built-in category index and live Taginfo API fallback to prevent tag hallucination.",
1792
+ annotations: {
1793
+ title: "Lookup OpenStreetMap Tags",
1794
+ readOnlyHint: true,
1795
+ destructiveHint: false,
1796
+ idempotentHint: true,
1797
+ openWorldHint: true
1798
+ },
925
1799
  inputSchema: {
926
1800
  type: "object",
927
1801
  properties: {
@@ -931,11 +1805,31 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
931
1805
  }
932
1806
  },
933
1807
  required: ["category"]
1808
+ },
1809
+ outputSchema: {
1810
+ type: "object",
1811
+ properties: {
1812
+ category: { type: "string" },
1813
+ source: { type: "string" },
1814
+ tags: { type: "array", items: { type: "string" } },
1815
+ recommendations_text: { type: "string" },
1816
+ provenance: PROVENANCE_SCHEMA,
1817
+ complete: { type: "boolean" },
1818
+ warnings: { type: "array", items: { type: "string" } }
1819
+ },
1820
+ required: ["category", "source", "tags", "recommendations_text", "provenance", "complete", "warnings"]
934
1821
  }
935
1822
  },
936
1823
  {
937
1824
  name: "aggregate_overpass_h3",
938
- description: "Executes an Overpass query and bins the results into H3 hexagons at a specified resolution to analyze spatial density.",
1825
+ description: "Executes an Overpass query and bins the results into H3 hexagons at a specified resolution to analyze spatial density. Plots the top density hotspots on the shareable verification map.",
1826
+ annotations: {
1827
+ title: "Aggregate OSM Features into H3 Hexagons",
1828
+ readOnlyHint: true,
1829
+ destructiveHint: false,
1830
+ idempotentHint: true,
1831
+ openWorldHint: true
1832
+ },
939
1833
  inputSchema: {
940
1834
  type: "object",
941
1835
  properties: {
@@ -955,6 +1849,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
955
1849
  type: "number",
956
1850
  description: "The H3 resolution level (0-15) for binning. Default is 8."
957
1851
  },
1852
+ radius_meters: {
1853
+ type: "number",
1854
+ description: "Optional search radius in metres around the location/bbox center."
1855
+ },
958
1856
  timeout_seconds: {
959
1857
  type: "number",
960
1858
  description: "Overpass server query timeout in seconds. Default is 25."
@@ -969,9 +1867,257 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
969
1867
  { required: ["location"] },
970
1868
  { required: ["bbox"] }
971
1869
  ]
972
- }
973
- }
974
- ],
1870
+ },
1871
+ outputSchema: {
1872
+ type: "object",
1873
+ properties: {
1874
+ resolution: { type: "number" },
1875
+ matched_place: { type: "string" },
1876
+ alternatives: { type: "array", items: { type: "object" } },
1877
+ osm_area_id: { type: "number" },
1878
+ total_elements: { type: "number" },
1879
+ active_hex_count: { type: "number" },
1880
+ hex_counts: { type: "object" },
1881
+ hex_geojson: { type: "object" },
1882
+ original_bbox: BBOX_OBJ_SCHEMA,
1883
+ share_url: { type: "string" },
1884
+ provenance: PROVENANCE_SCHEMA,
1885
+ complete: { type: "boolean" },
1886
+ warnings: { type: "array", items: { type: "string" } }
1887
+ },
1888
+ required: ["resolution", "total_elements", "active_hex_count", "hex_counts", "hex_geojson", "original_bbox", "share_url", "provenance", "complete", "warnings"]
1889
+ }
1890
+ },
1891
+ {
1892
+ name: "calculate_route",
1893
+ description: "Calculate real-world travel distance, duration, turn-by-turn summary, GeoJSON route geometry, and route bounding box between two locations or coordinate pairs using OpenStreetMap routing (FOSSGIS / OSRM, or configurable OSRM_API_URL). Draws the actual route path on the verification map and enforces rate limits (max 1 req/s).",
1894
+ annotations: {
1895
+ title: "Calculate Route Distance, Duration & Geometry",
1896
+ readOnlyHint: true,
1897
+ destructiveHint: false,
1898
+ idempotentHint: true,
1899
+ openWorldHint: true
1900
+ },
1901
+ inputSchema: {
1902
+ type: "object",
1903
+ properties: {
1904
+ origin: {
1905
+ type: "string",
1906
+ description: "Starting point as 'lat,lng' coordinates (e.g. '40.7128,-74.0060') or a natural-language place name (e.g. 'Times Square, NYC')."
1907
+ },
1908
+ destination: {
1909
+ type: "string",
1910
+ description: "Ending point as 'lat,lng' coordinates (e.g. '40.6413,-73.7781') or a natural-language place name (e.g. 'JFK Airport')."
1911
+ },
1912
+ profile: {
1913
+ type: "string",
1914
+ description: "Travel mode profile: 'driving' (car), 'walking' (foot), or 'cycling' (bicycle). Defaults to 'driving'.",
1915
+ enum: ["driving", "walking", "cycling"]
1916
+ },
1917
+ return_steps: {
1918
+ type: "boolean",
1919
+ description: "If true, includes step-by-step road names and maneuver instructions. Defaults to false."
1920
+ }
1921
+ },
1922
+ required: ["origin", "destination"]
1923
+ },
1924
+ outputSchema: {
1925
+ type: "object",
1926
+ properties: {
1927
+ profile: { type: "string" },
1928
+ origin: {
1929
+ type: "object",
1930
+ properties: { lat: { type: "number" }, lng: { type: "number" }, label: { type: "string" } },
1931
+ required: ["lat", "lng", "label"]
1932
+ },
1933
+ destination: {
1934
+ type: "object",
1935
+ properties: { lat: { type: "number" }, lng: { type: "number" }, label: { type: "string" } },
1936
+ required: ["lat", "lng", "label"]
1937
+ },
1938
+ distance_meters: { type: "number" },
1939
+ distance_km: { type: "number" },
1940
+ duration_seconds: { type: "number" },
1941
+ duration_minutes: { type: "number" },
1942
+ straight_line_meters: { type: "number" },
1943
+ detour_factor: { type: "number" },
1944
+ initial_bearing: {
1945
+ type: "object",
1946
+ properties: { degrees: { type: "number" }, cardinal: { type: "string" } },
1947
+ required: ["degrees", "cardinal"]
1948
+ },
1949
+ route_bbox: BBOX_OBJ_SCHEMA,
1950
+ route_geometry: {
1951
+ type: "object",
1952
+ properties: {
1953
+ type: { type: "string" },
1954
+ coordinates: { type: "array", items: { type: "array", items: { type: "number" } } }
1955
+ },
1956
+ required: ["type", "coordinates"]
1957
+ },
1958
+ steps: { type: "array", items: { type: "object" } },
1959
+ share_url: { type: "string" },
1960
+ provenance: PROVENANCE_SCHEMA,
1961
+ complete: { type: "boolean" },
1962
+ warnings: { type: "array", items: { type: "string" } }
1963
+ },
1964
+ required: ["profile", "origin", "destination", "distance_meters", "distance_km", "duration_seconds", "duration_minutes", "straight_line_meters", "detour_factor", "initial_bearing", "route_bbox", "route_geometry", "share_url", "provenance", "complete", "warnings"]
1965
+ }
1966
+ },
1967
+ {
1968
+ name: "get_elevation",
1969
+ description: "Query terrain elevation in meters above sea level using the Open-Meteo 90m Copernicus DEM API (free for non-commercial use; commercial use requires a paid key or self-hosted ELEVATION_API_URL). Supports querying specific point coordinates OR sampling an N×N point grid across a bounding box to compute sample statistics (note: finite grid sampling over a 90m DEM reports sample statistics, not guaranteed area-wide true minimum, maximum, or slope).",
1970
+ annotations: {
1971
+ title: "Get Terrain Elevation & Sample Statistics",
1972
+ readOnlyHint: true,
1973
+ destructiveHint: false,
1974
+ idempotentHint: true,
1975
+ openWorldHint: true
1976
+ },
1977
+ inputSchema: {
1978
+ type: "object",
1979
+ properties: {
1980
+ points: {
1981
+ type: "array",
1982
+ description: "List of {lat, lng, label?} points to query elevation for (up to 100 points).",
1983
+ items: {
1984
+ type: "object",
1985
+ properties: {
1986
+ lat: { type: "number" },
1987
+ lng: { type: "number" },
1988
+ label: { type: "string" }
1989
+ },
1990
+ required: ["lat", "lng"]
1991
+ }
1992
+ },
1993
+ location: {
1994
+ type: "string",
1995
+ description: "A text location to sample elevation across (e.g. 'Mount Rainier', 'Denver')."
1996
+ },
1997
+ bbox: {
1998
+ type: "string",
1999
+ description: "A bounding box or point string to sample terrain elevation across."
2000
+ },
2001
+ grid_size: {
2002
+ type: "number",
2003
+ description: "Grid dimension N (N x N samples, from 2 to 10) when sampling a bounding box. Defaults to 5 (25 sample points)."
2004
+ }
2005
+ }
2006
+ },
2007
+ outputSchema: {
2008
+ type: "object",
2009
+ properties: {
2010
+ mode: { type: "string" },
2011
+ matched_place: { type: "string" },
2012
+ dem_resolution_meters: { type: "number" },
2013
+ sample_count: { type: "number" },
2014
+ sample_stats: {
2015
+ type: "object",
2016
+ properties: {
2017
+ min_elevation_meters: { type: "number" },
2018
+ max_elevation_meters: { type: "number" },
2019
+ mean_elevation_meters: { type: "number" },
2020
+ sample_relief_meters: { type: "number" },
2021
+ lowest_sample: { type: "object" },
2022
+ highest_sample: { type: "object" },
2023
+ is_finite_sample: { type: "boolean" }
2024
+ },
2025
+ required: ["min_elevation_meters", "max_elevation_meters", "mean_elevation_meters", "sample_relief_meters", "lowest_sample", "highest_sample", "is_finite_sample"]
2026
+ },
2027
+ samples: { type: "array", items: { type: "object" } },
2028
+ bbox: BBOX_OBJ_SCHEMA,
2029
+ share_url: { type: "string" },
2030
+ provenance: PROVENANCE_SCHEMA,
2031
+ complete: { type: "boolean" },
2032
+ warnings: { type: "array", items: { type: "string" } }
2033
+ },
2034
+ required: ["mode", "dem_resolution_meters", "sample_count", "sample_stats", "samples", "bbox", "share_url", "provenance", "complete", "warnings"]
2035
+ }
2036
+ },
2037
+ {
2038
+ name: "spatial_calc",
2039
+ description: "Perform deterministic offline geometric calculations on bounding boxes, points, and polygons (with holes, zero network calls): 'relation' (intersects, contains, overlap area %, union & intersection bbox between two geometries), 'point_in_geometry' (check if a point is inside a bbox or WKT/GeoJSON polygon respecting interior holes), 'distance_bearing' (exact Haversine distance, initial compass bearing, and midpoint between two points), or 'split_grid' (subdivide a bbox into an N x M grid of sub-bboxes for chunked queries).",
2040
+ annotations: {
2041
+ title: "Deterministic Spatial Geometry Calculator",
2042
+ readOnlyHint: true,
2043
+ destructiveHint: false,
2044
+ idempotentHint: true,
2045
+ openWorldHint: false
2046
+ },
2047
+ inputSchema: {
2048
+ type: "object",
2049
+ properties: {
2050
+ operation: {
2051
+ type: "string",
2052
+ description: "The geometric operation to perform.",
2053
+ enum: ["relation", "point_in_geometry", "distance_bearing", "split_grid"]
2054
+ },
2055
+ bbox_a: {
2056
+ type: "string",
2057
+ description: "First geometry / bounding box (for 'relation', 'point_in_geometry', 'split_grid') or origin point 'lat,lng' (for 'distance_bearing')."
2058
+ },
2059
+ bbox_b: {
2060
+ type: "string",
2061
+ description: "Second geometry / bounding box (for 'relation') or destination point 'lat,lng' (for 'distance_bearing')."
2062
+ },
2063
+ point: {
2064
+ type: "string",
2065
+ description: "Point coordinates 'lat,lng' to test in 'point_in_geometry'."
2066
+ },
2067
+ rows: {
2068
+ type: "number",
2069
+ description: "Number of latitude rows for 'split_grid' (1-20, default 2)."
2070
+ },
2071
+ cols: {
2072
+ type: "number",
2073
+ description: "Number of longitude columns for 'split_grid' (1-20, default 2)."
2074
+ }
2075
+ },
2076
+ required: ["operation", "bbox_a"]
2077
+ },
2078
+ outputSchema: {
2079
+ type: "object",
2080
+ properties: {
2081
+ operation: { type: "string" },
2082
+ intersects: { type: "boolean" },
2083
+ a_contains_b: { type: "boolean" },
2084
+ b_contains_a: { type: "boolean" },
2085
+ area_a_km2: { type: "number" },
2086
+ area_b_km2: { type: "number" },
2087
+ intersection_area_km2: { type: "number" },
2088
+ union_area_km2: { type: "number" },
2089
+ overlap_percent_of_a: { type: "number" },
2090
+ overlap_percent_of_b: { type: "number" },
2091
+ centroid_distance_meters: { type: "number" },
2092
+ intersection_bbox: { type: ["object", "null"] },
2093
+ union_bbox: BBOX_OBJ_SCHEMA,
2094
+ point: { type: "object" },
2095
+ inside: { type: "boolean" },
2096
+ inside_bbox: { type: "boolean" },
2097
+ inside_polygon: { type: "boolean" },
2098
+ target_bbox: BBOX_OBJ_SCHEMA,
2099
+ from: { type: "object" },
2100
+ to: { type: "object" },
2101
+ distance_meters: { type: "number" },
2102
+ distance_km: { type: "number" },
2103
+ bearing_degrees: { type: "number" },
2104
+ cardinal_direction: { type: "string" },
2105
+ midpoint: { type: "object" },
2106
+ bbox: BBOX_OBJ_SCHEMA,
2107
+ rows: { type: "number" },
2108
+ cols: { type: "number" },
2109
+ total_cells: { type: "number" },
2110
+ original_bbox: BBOX_OBJ_SCHEMA,
2111
+ cells: { type: "array", items: { type: "object" } },
2112
+ share_url: { type: "string" },
2113
+ provenance: PROVENANCE_SCHEMA,
2114
+ complete: { type: "boolean" },
2115
+ warnings: { type: "array", items: { type: "string" } }
2116
+ },
2117
+ required: ["operation", "share_url", "provenance", "complete", "warnings"]
2118
+ }
2119
+ }
2120
+ ],
975
2121
  };
976
2122
  });
977
2123
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -1003,6 +2149,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1003
2149
  else {
1004
2150
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
1005
2151
  }
2152
+ const bufferMeters = args.buffer_meters;
2153
+ if (typeof bufferMeters === 'number' && bufferMeters !== 0) {
2154
+ bboxObj = expandBBoxMeters(bboxObj, bufferMeters);
2155
+ }
1006
2156
  let epsg = args.epsg || "4326";
1007
2157
  let format = args.format || "csv";
1008
2158
  let coordOrder = args.coord_order || "lng,lat";
@@ -1030,36 +2180,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1030
2180
  const altInfo = geocodeRes && geocodeRes.alternatives.length > 0
1031
2181
  ? `Alternates: ${geocodeRes.alternatives.map(a => `"${a.matched_place}"`).join(', ')}\n\n`
1032
2182
  : '\n';
2183
+ const provenance = geocodeRes?.provenance || {
2184
+ provider: "offline-proj4",
2185
+ data_timestamp: new Date().toISOString(),
2186
+ cached: false,
2187
+ cache_age_ms: 0
2188
+ };
2189
+ const structuredData = {
2190
+ ...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
2191
+ original_wgs84: bboxObj,
2192
+ projected: projCoords,
2193
+ center: {
2194
+ lat: centerLat,
2195
+ lng: centerLng
2196
+ },
2197
+ tile_indices: tileIndices,
2198
+ epsg,
2199
+ format,
2200
+ coord_order: coordOrder,
2201
+ precision,
2202
+ area_km2: areaKm2,
2203
+ dimensions: {
2204
+ width_km: dims.widthKm,
2205
+ height_km: dims.heightKm
2206
+ },
2207
+ share_url: shareUrl,
2208
+ provenance,
2209
+ complete: true,
2210
+ warnings
2211
+ };
1033
2212
  return {
1034
2213
  content: [
1035
2214
  {
1036
2215
  type: "text",
1037
2216
  text: `${geocodeInfo}${altInfo}Original WGS84 Bounds (${format}):\n${WGS84Formatted}\n\nProjected to EPSG:${epsg} (${format}):\n${projectedFormatted}\n\nArea: ${areaKm2.toFixed(3)} km² (${dims.widthKm.toFixed(3)}km x ${dims.heightKm.toFixed(3)}km)\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
1038
- },
1039
- {
1040
- type: "text",
1041
- text: JSON.stringify({
1042
- ...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
1043
- original_wgs84: bboxObj,
1044
- projected: projCoords,
1045
- center: {
1046
- lat: centerLat,
1047
- lng: centerLng
1048
- },
1049
- tile_indices: tileIndices,
1050
- epsg,
1051
- format,
1052
- coord_order: coordOrder,
1053
- precision,
1054
- area_km2: areaKm2,
1055
- dimensions: {
1056
- width_km: dims.widthKm,
1057
- height_km: dims.heightKm
1058
- },
1059
- share_url: shareUrl
1060
- }, null, 2)
1061
2217
  }
1062
- ]
2218
+ ],
2219
+ structuredContent: structuredData
1063
2220
  };
1064
2221
  }
1065
2222
  else if (toolName === "get_h3_indices") {
@@ -1068,16 +2225,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1068
2225
  const compact = args.compact || false;
1069
2226
  const summaryOnly = args.summary_only || false;
1070
2227
  const returnGeometry = args.return_geometry || false;
2228
+ const usePolygonBoundary = args.use_polygon_boundary !== false;
1071
2229
  const warnings = [];
1072
2230
  if (resolution === undefined) {
1073
2231
  return { content: [{ type: "text", text: "Error: 'resolution' is required." }], isError: true };
1074
2232
  }
1075
2233
  let bboxObj;
1076
2234
  let geocodeRes;
2235
+ let polygonsWithHoles = null;
1077
2236
  if (args.location) {
1078
2237
  try {
1079
- geocodeRes = await geocodeLocation(args.location);
2238
+ geocodeRes = await geocodeLocation(args.location, usePolygonBoundary);
1080
2239
  bboxObj = geocodeRes.bbox;
2240
+ if (usePolygonBoundary && geocodeRes.polygons_with_holes) {
2241
+ polygonsWithHoles = geocodeRes.polygons_with_holes;
2242
+ }
1081
2243
  }
1082
2244
  catch (err) {
1083
2245
  if (!args.bbox) {
@@ -1085,49 +2247,69 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1085
2247
  return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
1086
2248
  }
1087
2249
  bboxObj = parseBBox(args.bbox, warnings);
2250
+ if (usePolygonBoundary) {
2251
+ polygonsWithHoles = extractPolygonsWithHolesLatLng(args.bbox);
2252
+ }
1088
2253
  }
1089
2254
  }
1090
2255
  else if (args.bbox) {
1091
2256
  bboxObj = parseBBox(args.bbox, warnings);
2257
+ if (usePolygonBoundary) {
2258
+ polygonsWithHoles = extractPolygonsWithHolesLatLng(args.bbox);
2259
+ }
1092
2260
  }
1093
2261
  else {
1094
2262
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
1095
2263
  }
1096
- const cells = getH3Cells(bboxObj, resolution, compact);
1097
- const shareUrl = buildShareUrl(bboxObj);
1098
- log('info', `get_h3_indices completed`, { resolution, compact, summary_only: summaryOnly, cell_count: cells.length });
2264
+ const cells = getH3Cells(bboxObj, resolution, compact, polygonsWithHoles);
2265
+ const usedPolygon = !!(polygonsWithHoles && polygonsWithHoles.length > 0);
2266
+ const shareUrl = usedPolygon && polygonsWithHoles[0]?.[0]?.length >= 3
2267
+ ? buildShareUrlPolygon(polygonsWithHoles[0][0])
2268
+ : buildShareUrl(bboxObj);
2269
+ const provenance = geocodeRes?.provenance || {
2270
+ provider: "offline-h3-js",
2271
+ data_timestamp: new Date().toISOString(),
2272
+ cached: false,
2273
+ cache_age_ms: 0
2274
+ };
2275
+ log('info', `get_h3_indices completed`, { resolution, compact, summary_only: summaryOnly, cell_count: cells.length, used_polygon: usedPolygon });
1099
2276
  if (summaryOnly) {
1100
2277
  const sampleCells = cells.slice(0, 10);
1101
2278
  const responseData = {
1102
2279
  resolution,
1103
2280
  compact,
1104
2281
  summary_only: true,
2282
+ used_polygon_boundary: usedPolygon,
1105
2283
  cell_count: cells.length,
1106
2284
  sample_cells: sampleCells,
1107
2285
  original_bbox: bboxObj,
1108
- share_url: shareUrl
2286
+ share_url: shareUrl,
2287
+ provenance,
2288
+ complete: false,
2289
+ warnings
1109
2290
  };
1110
2291
  return {
1111
2292
  content: [
1112
2293
  {
1113
2294
  type: "text",
1114
- text: `H3 summary: ${cells.length.toLocaleString()} cells generated at resolution ${resolution} (compacted: ${compact}).\nSample cells: ${sampleCells.join(', ')}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
1115
- },
1116
- {
1117
- type: "text",
1118
- text: JSON.stringify(responseData, null, 2)
2295
+ text: `H3 summary: ${cells.length.toLocaleString()} cells generated at resolution ${resolution} (compacted: ${compact}, polygon_boundary: ${usedPolygon}).\nSample cells: ${sampleCells.join(', ')}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
1119
2296
  }
1120
- ]
2297
+ ],
2298
+ structuredContent: responseData
1121
2299
  };
1122
2300
  }
1123
2301
  const responseData = {
1124
2302
  resolution,
1125
2303
  compact,
1126
2304
  summary_only: false,
2305
+ used_polygon_boundary: usedPolygon,
1127
2306
  cell_count: cells.length,
1128
2307
  cells,
1129
2308
  original_bbox: bboxObj,
1130
- share_url: shareUrl
2309
+ share_url: shareUrl,
2310
+ provenance,
2311
+ complete: true,
2312
+ warnings
1131
2313
  };
1132
2314
  if (returnGeometry) {
1133
2315
  responseData.geojson = h3CellsToGeoJSON(cells);
@@ -1136,13 +2318,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1136
2318
  content: [
1137
2319
  {
1138
2320
  type: "text",
1139
- text: `H3 cells generated: ${cells.length.toLocaleString()}\nResolution: ${resolution} | Compacted: ${compact}\n\nCells:\n${JSON.stringify(cells)}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
1140
- },
1141
- {
1142
- type: "text",
1143
- text: JSON.stringify(responseData, null, 2)
2321
+ text: `H3 cells generated: ${cells.length.toLocaleString()}\nResolution: ${resolution} | Compacted: ${compact} | Polygon boundary: ${usedPolygon}\n\nCells:\n${JSON.stringify(cells)}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
1144
2322
  }
1145
- ]
2323
+ ],
2324
+ structuredContent: responseData
1146
2325
  };
1147
2326
  }
1148
2327
  else if (toolName === "generate_share_url") {
@@ -1155,20 +2334,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1155
2334
  const bboxObj = parseBBox(bbox, warnings);
1156
2335
  const shareUrl = buildShareUrl(bboxObj);
1157
2336
  log('info', `generate_share_url completed`, { share_url: shareUrl });
2337
+ const structuredData = {
2338
+ bbox: bboxObj,
2339
+ share_url: shareUrl,
2340
+ provenance: {
2341
+ provider: "offline-parser",
2342
+ data_timestamp: new Date().toISOString(),
2343
+ cached: false,
2344
+ cache_age_ms: 0
2345
+ },
2346
+ complete: true,
2347
+ warnings
2348
+ };
1158
2349
  return {
1159
2350
  content: [
1160
2351
  {
1161
2352
  type: "text",
1162
2353
  text: `View on map: ${shareUrl}${formatWarnings(warnings)}`
1163
- },
1164
- {
1165
- type: "text",
1166
- text: JSON.stringify({
1167
- bbox: bboxObj,
1168
- share_url: shareUrl
1169
- }, null, 2)
1170
2354
  }
1171
- ]
2355
+ ],
2356
+ structuredContent: structuredData
1172
2357
  };
1173
2358
  }
1174
2359
  else if (toolName === "reverse_geocode") {
@@ -1186,12 +2371,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1186
2371
  {
1187
2372
  type: "text",
1188
2373
  text: `Matched Location: "${result.matched_place}"\nCoordinates: (${result.center.lat.toFixed(5)}, ${result.center.lng.toFixed(5)})\nOSM Details: [${result.osm_type || 'point'}] ${result.osm_id || 'N/A'}\n\nView on map: ${result.share_url}`
1189
- },
1190
- {
1191
- type: "text",
1192
- text: JSON.stringify(result, null, 2)
1193
2374
  }
1194
- ]
2375
+ ],
2376
+ structuredContent: result
1195
2377
  };
1196
2378
  }
1197
2379
  else if (toolName === "search_overpass") {
@@ -1203,6 +2385,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1203
2385
  const useAreaBoundary = args.use_area_boundary !== false;
1204
2386
  const radiusMeters = args.radius_meters;
1205
2387
  const returnGeometry = args.return_geometry || false;
2388
+ const countOnly = args.count_only || false;
1206
2389
  const useCircle = typeof radiusMeters === 'number' && radiusMeters > 0;
1207
2390
  if (!queryRaw) {
1208
2391
  return { content: [{ type: "text", text: "Error: 'query' is required (e.g. `node[\"amenity\"=\"restaurant\"]`)." }], isError: true };
@@ -1220,6 +2403,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1220
2403
  let bboxObj;
1221
2404
  let geocodeRes;
1222
2405
  let usedAreaId;
2406
+ const outDirective = countOnly ? "out count;" : (returnGeometry ? "out geom;" : "out center;");
1223
2407
  if (useCircle) {
1224
2408
  if (args.location) {
1225
2409
  try {
@@ -1245,7 +2429,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1245
2429
  else {
1246
2430
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
1247
2431
  }
1248
- const outDirective = returnGeometry ? "out geom;" : "out center;";
1249
2432
  const circleFilter = `(around:${radiusMeters},${refPoint.lat},${refPoint.lng})`;
1250
2433
  const formattedCore = applySpatialFilter(queryRaw, circleFilter);
1251
2434
  overpassQuery = `[out:json][timeout:${timeoutSec}];\n(${formattedCore});\n${outDirective}`;
@@ -1280,7 +2463,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1280
2463
  else {
1281
2464
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
1282
2465
  }
1283
- const outDirective = returnGeometry ? "out geom;" : "out center;";
1284
2466
  if (usedAreaId) {
1285
2467
  const formattedCore = applySpatialFilter(queryRaw, "(area.searchArea)");
1286
2468
  overpassQuery = `[out:json][timeout:${timeoutSec}];\narea(${usedAreaId})->.searchArea;\n(${formattedCore});\n${outDirective}`;
@@ -1292,53 +2474,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1292
2474
  searchModeLabel = `bounding box`;
1293
2475
  }
1294
2476
  }
1295
- log('info', `search_overpass starting`, { query: queryRaw, mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox'), radius_meters: radiusMeters, timeoutSec });
1296
- const endpoints = [
1297
- "https://overpass-api.de/api/interpreter",
1298
- "https://overpass.kumi.systems/api/interpreter"
1299
- ];
1300
- if (process.env.OVERPASS_API_URL) {
1301
- endpoints.unshift(process.env.OVERPASS_API_URL);
1302
- }
1303
- let data = null;
1304
- let lastError = null;
1305
- const params = new URLSearchParams();
1306
- params.append('data', overpassQuery);
1307
- const clientTimeoutMs = (timeoutSec + 5) * 1000;
1308
- for (const endpoint of endpoints) {
1309
- const controller = new AbortController();
1310
- const timeout = setTimeout(() => controller.abort(), clientTimeoutMs);
1311
- try {
1312
- log('info', `Executing Overpass query at ${endpoint}`);
1313
- const response = await fetch(endpoint, {
1314
- method: "POST",
1315
- body: params.toString(),
1316
- headers: {
1317
- 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
1318
- 'Accept': 'application/json',
1319
- 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`
1320
- },
1321
- signal: controller.signal
1322
- });
1323
- if (!response.ok) {
1324
- throw new Error(`Overpass API returned status: ${response.status} ${response.statusText}`);
1325
- }
1326
- data = await response.json();
1327
- break;
1328
- }
1329
- catch (err) {
1330
- lastError = err;
1331
- log('warn', `Overpass API failed at ${endpoint}`, { error: err.message });
1332
- }
1333
- finally {
1334
- clearTimeout(timeout);
1335
- }
2477
+ log('info', `search_overpass starting`, { query: queryRaw, mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox'), radius_meters: radiusMeters, count_only: countOnly, timeoutSec });
2478
+ const { data, provenance, remarkWarning } = await executeOverpassQuery(overpassQuery, timeoutSec);
2479
+ if (remarkWarning) {
2480
+ warnings.push(`Overpass remark: ${remarkWarning}`);
1336
2481
  }
1337
- if (!data) {
1338
- if (lastError?.name === 'AbortError') {
1339
- return { content: [{ type: "text", text: `Error: Overpass API request timed out on all endpoints after ${clientTimeoutMs / 1000}s. The query or area may be too large.` }], isError: true };
1340
- }
1341
- throw new Error(`Overpass query failed on all endpoints: ${lastError?.message}`);
2482
+ if (countOnly) {
2483
+ const countEl = (data.elements || []).find((e) => e.type === "count") || data.elements?.[0] || {};
2484
+ const tags = countEl.tags || {};
2485
+ const counts = {
2486
+ nodes: parseInt(tags.nodes || "0", 10),
2487
+ ways: parseInt(tags.ways || "0", 10),
2488
+ relations: parseInt(tags.relations || "0", 10),
2489
+ total: parseInt(tags.total || "0", 10)
2490
+ };
2491
+ const shareUrl = useCircle
2492
+ ? buildShareUrlCircleWithMarkers(refPoint, radiusMeters, [])
2493
+ : buildShareUrl(bboxObj || geocodeRes.bbox);
2494
+ const countData = {
2495
+ search_mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox'),
2496
+ count_only: true,
2497
+ ...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
2498
+ ...(usedAreaId ? { osm_area_id: usedAreaId } : {}),
2499
+ counts,
2500
+ share_url: shareUrl,
2501
+ provenance,
2502
+ complete: !remarkWarning,
2503
+ warnings
2504
+ };
2505
+ return {
2506
+ content: [
2507
+ {
2508
+ type: "text",
2509
+ text: `Overpass fast count executed within a ${searchModeLabel}.\nTotal matching elements: ${counts.total.toLocaleString()} (nodes: ${counts.nodes.toLocaleString()}, ways: ${counts.ways.toLocaleString()}, relations: ${counts.relations.toLocaleString()})\n\nView area on map: ${shareUrl}${formatWarnings(warnings)}`
2510
+ }
2511
+ ],
2512
+ structuredContent: countData
2513
+ };
1342
2514
  }
1343
2515
  const seenIds = new Set();
1344
2516
  let parsedElements = (data.elements || []).reduce((acc, e) => {
@@ -1388,15 +2560,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1388
2560
  const nextOffset = hasMore ? offset + limit : undefined;
1389
2561
  const shareUrl = useCircle
1390
2562
  ? buildShareUrlCircleWithMarkers(refPoint, radiusMeters, pagedElements)
1391
- : buildShareUrlWithMarkers(bboxObj || geocodeRes.bbox, pagedElements);
2563
+ : (usedAreaId && !args.bbox && pagedElements.length > 0
2564
+ ? buildShareUrlMarkersOnly(pagedElements)
2565
+ : buildShareUrlWithMarkers(bboxObj || geocodeRes.bbox, pagedElements));
1392
2566
  log('info', `search_overpass completed`, { total: totalCount, returned: pagedElements.length, offset, limit, truncated, mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox') });
1393
2567
  const responseData = {
1394
2568
  search_mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox'),
2569
+ count_only: false,
1395
2570
  ...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
1396
2571
  ...(usedAreaId ? { osm_area_id: usedAreaId } : {}),
1397
2572
  distance_from: refPoint,
1398
- generator: data.generator,
1399
- osm3s: data.osm3s,
2573
+ ...(data.generator ? { generator: data.generator } : {}),
2574
+ ...(data.osm3s ? { osm3s: data.osm3s } : {}),
1400
2575
  elements: pagedElements,
1401
2576
  total_count: totalCount,
1402
2577
  returned_count: pagedElements.length,
@@ -1408,29 +2583,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1408
2583
  pagination_hint: hasMore
1409
2584
  ? `Use offset=${nextOffset} to fetch the next page. Note: Each page re-queries Overpass; for large result sets, consider narrowing your query filters or bounding area instead of deep paging.`
1410
2585
  : "End of result set.",
1411
- share_url: shareUrl
2586
+ share_url: shareUrl,
2587
+ provenance,
2588
+ complete: !truncated && !remarkWarning,
2589
+ warnings
1412
2590
  };
1413
2591
  const topResults = pagedElements.slice(0, 5)
1414
2592
  .map((e) => `- [${e.type}] ${e.name} (${e.coordinates.lat.toFixed(5)}, ${e.coordinates.lon.toFixed(5)}) — ${e.distance_meters}m away`)
1415
2593
  .join('\n');
1416
- const moreNote = pagedElements.length > 5 ? `\n … and ${pagedElements.length - 5} more on this page (see JSON)` : '';
2594
+ const moreNote = pagedElements.length > 5 ? `\n … and ${pagedElements.length - 5} more on this page` : '';
1417
2595
  return {
1418
2596
  content: [
1419
2597
  {
1420
2598
  type: "text",
1421
2599
  text: `Overpass query executed within a ${searchModeLabel}.\nFound ${totalCount} element${totalCount !== 1 ? 's' : ''}${totalCount > limit ? ` (showing items ${offset + 1}-${offset + pagedElements.length} of ${totalCount})` : ''}.\nDistance measured from: ${refPoint.description}\n\nTop results:\n${topResults}${moreNote}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
1422
- },
1423
- {
1424
- type: "text",
1425
- text: JSON.stringify(responseData, null, 2)
1426
2600
  }
1427
- ]
2601
+ ],
2602
+ structuredContent: responseData
1428
2603
  };
1429
2604
  }
1430
2605
  else if (toolName === "list_osm_tags") {
1431
2606
  const args = request.params.arguments || {};
1432
2607
  const category = (args.category || "").toLowerCase().trim();
2608
+ const warnings = [];
1433
2609
  let recommendations = "";
2610
+ let source = "built-in";
2611
+ let tags = [];
2612
+ let provenance = {
2613
+ provider: "built-in-osm-tag-index",
2614
+ data_timestamp: new Date().toISOString(),
2615
+ cached: true,
2616
+ cache_age_ms: 0
2617
+ };
1434
2618
  switch (true) {
1435
2619
  case category.includes('food') || category.includes('restaurant') || category.includes('cafe') || category.includes('pizza') || category.includes('dining'):
1436
2620
  recommendations = `Restaurants: nwr["amenity"="restaurant"]\nFast Food: nwr["amenity"="fast_food"]\nCafes: nwr["amenity"="cafe"]\nBars: nwr["amenity"="bar"]\nPubs: nwr["amenity"="pub"]\nIce Cream: nwr["amenity"="ice_cream"]\nCoffee Shops: nwr["shop"="coffee"]\nBakeries: nwr["shop"="bakery"]`;
@@ -1483,16 +2667,42 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1483
2667
  case category.includes('childcare') || category.includes('daycare') || category.includes('child'):
1484
2668
  recommendations = `Childcare/Daycare: nwr["amenity"="childcare"]\nPlaygrounds: nwr["leisure"="playground"]`;
1485
2669
  break;
1486
- default:
2670
+ default: {
1487
2671
  log('info', `Category '${category}' not in index, querying Taginfo API`);
1488
- recommendations = await getTaginfoSuggestions(category);
2672
+ source = "taginfo";
2673
+ const taginfoRes = await getTaginfoSuggestions(category);
2674
+ recommendations = taginfoRes.text;
2675
+ tags = taginfoRes.tags;
2676
+ if (taginfoRes.warning)
2677
+ warnings.push(taginfoRes.warning);
2678
+ provenance = {
2679
+ provider: "taginfo (https://taginfo.openstreetmap.org)",
2680
+ attribution: OSM_ATTRIBUTION,
2681
+ data_timestamp: taginfoRes.timestamp,
2682
+ cached: taginfoRes.cached,
2683
+ cache_age_ms: taginfoRes.cache_age_ms
2684
+ };
1489
2685
  break;
2686
+ }
2687
+ }
2688
+ if (source === "built-in") {
2689
+ const matches = recommendations.match(/(?:nwr|node|way|relation)\[[^\n;]+/g);
2690
+ tags = matches ? matches.map(m => m.trim()) : [];
1490
2691
  }
1491
2692
  return {
1492
2693
  content: [{
1493
2694
  type: "text",
1494
2695
  text: `OSM Tag Recommendations for '${category}':\n\n${recommendations}\n\nTip: You can use these tag selectors directly in the 'query' argument of search_overpass.`
1495
- }]
2696
+ }],
2697
+ structuredContent: {
2698
+ category,
2699
+ source,
2700
+ tags,
2701
+ recommendations_text: recommendations,
2702
+ provenance,
2703
+ complete: warnings.length === 0,
2704
+ warnings
2705
+ }
1496
2706
  };
1497
2707
  }
1498
2708
  else if (toolName === "aggregate_overpass_h3") {
@@ -1501,10 +2711,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1501
2711
  const resolution = args.resolution ?? 8;
1502
2712
  const timeoutSec = args.timeout_seconds || 25;
1503
2713
  const useAreaBoundary = args.use_area_boundary !== false;
2714
+ const radiusMeters = args.radius_meters;
2715
+ const useCircle = typeof radiusMeters === 'number' && radiusMeters > 0;
1504
2716
  const warnings = [];
1505
2717
  if (!queryRaw) {
1506
2718
  return { content: [{ type: "text", text: "Error: 'query' is required." }], isError: true };
1507
2719
  }
2720
+ if (queryRaw.length > 2000) {
2721
+ return { content: [{ type: "text", text: "Error: Query is too long (maximum 2000 characters)." }], isError: true };
2722
+ }
2723
+ if (queryRaw.includes("[out:") || queryRaw.includes("[timeout:") || queryRaw.includes("[bbox:")) {
2724
+ return { content: [{ type: "text", text: "Error: Query must not contain [out:], [timeout:], or [bbox:] directives." }], isError: true };
2725
+ }
1508
2726
  if (resolution < 0 || resolution > 15 || !Number.isInteger(resolution)) {
1509
2727
  return { content: [{ type: "text", text: `Error: Invalid H3 resolution: ${resolution}. Must be 0-15.` }], isError: true };
1510
2728
  }
@@ -1532,9 +2750,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1532
2750
  else {
1533
2751
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
1534
2752
  }
1535
- log('info', `aggregate_overpass_h3 starting`, { query: queryRaw, resolution, timeoutSec, mode: usedAreaId ? 'area' : 'bbox' });
2753
+ log('info', `aggregate_overpass_h3 starting`, { query: queryRaw, resolution, timeoutSec, mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox') });
1536
2754
  let overpassQuery;
1537
- if (usedAreaId) {
2755
+ const centerPt = geocodeRes?.center || { lat: (bboxObj.lat1 + bboxObj.lat2) / 2, lng: (bboxObj.lng1 + bboxObj.lng2) / 2 };
2756
+ if (useCircle) {
2757
+ const circleFilter = `(around:${radiusMeters},${centerPt.lat},${centerPt.lng})`;
2758
+ const formattedCore = applySpatialFilter(queryRaw, circleFilter);
2759
+ overpassQuery = `[out:json][timeout:${timeoutSec}];\n(${formattedCore});\nout center;`;
2760
+ }
2761
+ else if (usedAreaId) {
1538
2762
  const formattedCore = applySpatialFilter(queryRaw, "(area.searchArea)");
1539
2763
  overpassQuery = `[out:json][timeout:${timeoutSec}];\narea(${usedAreaId})->.searchArea;\n(${formattedCore});\nout center;`;
1540
2764
  }
@@ -1542,46 +2766,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1542
2766
  const formattedCore = applySpatialFilter(queryRaw, "");
1543
2767
  overpassQuery = `[out:json][timeout:${timeoutSec}][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${formattedCore});\nout center;`;
1544
2768
  }
1545
- let data = null;
1546
- let lastError = null;
1547
- const params = new URLSearchParams();
1548
- params.append('data', overpassQuery);
1549
- const endpoints = [
1550
- "https://overpass-api.de/api/interpreter",
1551
- "https://overpass.kumi.systems/api/interpreter"
1552
- ];
1553
- if (process.env.OVERPASS_API_URL) {
1554
- endpoints.unshift(process.env.OVERPASS_API_URL);
2769
+ let execResult;
2770
+ try {
2771
+ execResult = await executeOverpassQuery(overpassQuery, timeoutSec);
1555
2772
  }
1556
- const clientTimeoutMs = (timeoutSec + 5) * 1000;
1557
- for (const endpoint of endpoints) {
1558
- const controller = new AbortController();
1559
- const timeout = setTimeout(() => controller.abort(), clientTimeoutMs);
1560
- try {
1561
- const response = await fetch(endpoint, {
1562
- method: "POST",
1563
- body: params.toString(),
1564
- headers: {
1565
- 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
1566
- 'Accept': 'application/json',
1567
- 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`
1568
- },
1569
- signal: controller.signal
1570
- });
1571
- if (!response.ok)
1572
- throw new Error(`Status: ${response.status}`);
1573
- data = await response.json();
1574
- break;
1575
- }
1576
- catch (err) {
1577
- lastError = err;
1578
- }
1579
- finally {
1580
- clearTimeout(timeout);
1581
- }
2773
+ catch (err) {
2774
+ return { content: [{ type: "text", text: `Error: Overpass query failed: ${err.message}` }], isError: true };
1582
2775
  }
1583
- if (!data) {
1584
- return { content: [{ type: "text", text: `Error: Overpass query failed: ${lastError?.message}` }], isError: true };
2776
+ const { data, provenance, remarkWarning } = execResult;
2777
+ if (remarkWarning) {
2778
+ warnings.push(`Overpass remark: ${remarkWarning}`);
1585
2779
  }
1586
2780
  let rawElements = data.elements || [];
1587
2781
  const hexCounts = {};
@@ -1605,7 +2799,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1605
2799
  });
1606
2800
  }
1607
2801
  });
1608
- const shareUrl = buildShareUrl(bboxObj);
1609
2802
  const totalGrouped = Object.values(hexCounts).reduce((a, b) => a + b, 0);
1610
2803
  const activeHexes = Object.keys(hexCounts);
1611
2804
  const geojson = h3CellsToGeoJSON(activeHexes);
@@ -1614,6 +2807,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1614
2807
  f.properties.count = hexCounts[cellId];
1615
2808
  f.properties.elements = hexElements[cellId];
1616
2809
  });
2810
+ const topHexes = Object.entries(hexCounts)
2811
+ .sort((a, b) => b[1] - a[1])
2812
+ .slice(0, 10);
2813
+ // Plot the top hex hotspot centroids as pins on the verification map!
2814
+ const hotspotMarkers = topHexes.map(([hex, count], idx) => {
2815
+ const [lat, lon] = h3.cellToLatLng(hex);
2816
+ return {
2817
+ coordinates: { lat, lon },
2818
+ name: `#${idx + 1} (${count} items)`
2819
+ };
2820
+ });
2821
+ const shareUrl = useCircle
2822
+ ? buildShareUrlCircleWithMarkers(centerPt, radiusMeters, hotspotMarkers)
2823
+ : (hotspotMarkers.length > 0
2824
+ ? (usedAreaId && !args.bbox
2825
+ ? buildShareUrlMarkersOnly(hotspotMarkers)
2826
+ : buildShareUrlWithMarkers(bboxObj, hotspotMarkers))
2827
+ : buildShareUrl(bboxObj));
1617
2828
  log('info', `aggregate_overpass_h3 completed`, { resolution, elements: totalGrouped, activeHexes: activeHexes.length });
1618
2829
  const responseData = {
1619
2830
  resolution,
@@ -1624,23 +2835,607 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1624
2835
  hex_counts: hexCounts,
1625
2836
  hex_geojson: geojson,
1626
2837
  original_bbox: bboxObj,
1627
- share_url: shareUrl
2838
+ share_url: shareUrl,
2839
+ provenance,
2840
+ complete: !remarkWarning,
2841
+ warnings
1628
2842
  };
1629
- const topHexes = Object.entries(hexCounts)
1630
- .sort((a, b) => b[1] - a[1])
1631
- .slice(0, 10);
1632
2843
  return {
1633
2844
  content: [
1634
2845
  {
1635
2846
  type: "text",
1636
2847
  text: `Aggregated ${totalGrouped} element${totalGrouped !== 1 ? 's' : ''} into ${activeHexes.length} H3 hexagon${activeHexes.length !== 1 ? 's' : ''} at resolution ${resolution}.\n\nTop hexagons by density:\n${topHexes.map(([hex, count]) => `- ${hex}: ${count} element${count !== 1 ? 's' : ''}`).join('\n')}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
2848
+ }
2849
+ ],
2850
+ structuredContent: responseData
2851
+ };
2852
+ }
2853
+ else if (toolName === "calculate_route") {
2854
+ const args = request.params.arguments || {};
2855
+ const originInput = (args.origin || "").trim();
2856
+ const destInput = (args.destination || "").trim();
2857
+ const profile = args.profile || "driving";
2858
+ const returnSteps = args.return_steps || false;
2859
+ const warnings = [];
2860
+ if (!originInput || !destInput) {
2861
+ return { content: [{ type: "text", text: "Error: Both 'origin' and 'destination' are required." }], isError: true };
2862
+ }
2863
+ async function resolveEndpoint(input) {
2864
+ const coordMatch = input.match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/);
2865
+ if (coordMatch) {
2866
+ const p0 = parseFloat(coordMatch[1]);
2867
+ const p1 = parseFloat(coordMatch[2]);
2868
+ if (validateBBox(p0, p1, p0, p1)) {
2869
+ return { lat: p0, lng: p1, label: `(${p0.toFixed(5)}, ${p1.toFixed(5)})` };
2870
+ }
2871
+ else if (validateBBox(p1, p0, p1, p0)) {
2872
+ warnings.push(`Endpoint '${input}' interpreted as lng,lat -> converted to lat=${p1}, lng=${p0}.`);
2873
+ return { lat: p1, lng: p0, label: `(${p1.toFixed(5)}, ${p0.toFixed(5)})` };
2874
+ }
2875
+ }
2876
+ const geo = await geocodeLocation(input);
2877
+ return { lat: geo.center.lat, lng: geo.center.lng, label: geo.matched_place };
2878
+ }
2879
+ const start = await resolveEndpoint(originInput);
2880
+ const end = await resolveEndpoint(destInput);
2881
+ const straightLineMeters = calculateHaversineDistanceMeters(start.lat, start.lng, end.lat, end.lng);
2882
+ const bearing = calculateBearingDegrees(start.lat, start.lng, end.lat, end.lng);
2883
+ // OSRM public servers (FOSSGIS Germany regional profiles + OSRM main demo)
2884
+ const osrmProfileMap = {
2885
+ driving: { fossgis: "routed-car/route/v1/driving", main: "route/v1/driving" },
2886
+ walking: { fossgis: "routed-foot/route/v1/driving", main: "route/v1/foot" },
2887
+ cycling: { fossgis: "routed-bike/route/v1/driving", main: "route/v1/bike" }
2888
+ };
2889
+ const prof = osrmProfileMap[profile] || osrmProfileMap.driving;
2890
+ const coordsPair = `${start.lng.toFixed(6)},${start.lat.toFixed(6)};${end.lng.toFixed(6)},${end.lat.toFixed(6)}`;
2891
+ const queryParams = `?overview=full&geometries=geojson&steps=${returnSteps ? 'true' : 'false'}`;
2892
+ const routeCacheKey = `${profile}:${coordsPair}:${returnSteps}`;
2893
+ let routeData = null;
2894
+ let usedProvider = "";
2895
+ let cachedRoute = false;
2896
+ let cacheAgeMs = 0;
2897
+ let dataTimestamp = new Date().toISOString();
2898
+ const cachedEntry = ROUTE_CACHE.get(routeCacheKey);
2899
+ if (cachedEntry && (Date.now() - cachedEntry.timestamp < ROUTE_CACHE_TTL_MS)) {
2900
+ routeData = cachedEntry.data;
2901
+ usedProvider = cachedEntry.provider;
2902
+ cachedRoute = true;
2903
+ cacheAgeMs = Date.now() - cachedEntry.timestamp;
2904
+ dataTimestamp = new Date(cachedEntry.timestamp).toISOString();
2905
+ }
2906
+ else {
2907
+ const routingUrls = [
2908
+ {
2909
+ url: `https://routing.openstreetmap.de/${prof.fossgis}/${coordsPair}${queryParams}`,
2910
+ provider: "fossgis-osrm (https://routing.openstreetmap.de)"
1637
2911
  },
2912
+ {
2913
+ url: `https://router.project-osrm.org/${prof.main}/${coordsPair}${queryParams}`,
2914
+ provider: "project-osrm-demo (https://router.project-osrm.org)"
2915
+ }
2916
+ ];
2917
+ if (process.env.OSRM_API_URL) {
2918
+ const customBase = process.env.OSRM_API_URL.replace(/\/+$/, "");
2919
+ routingUrls.unshift({
2920
+ url: `${customBase}/${prof.main}/${coordsPair}${queryParams}`,
2921
+ provider: `custom-osrm (${customBase})`
2922
+ });
2923
+ }
2924
+ let lastRouteErr = null;
2925
+ for (const candidate of routingUrls) {
2926
+ await routingRateLimit();
2927
+ const controller = new AbortController();
2928
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
2929
+ try {
2930
+ const res = await fetch(candidate.url, {
2931
+ headers: {
2932
+ 'User-Agent': APP_USER_AGENT,
2933
+ 'Accept': 'application/json'
2934
+ },
2935
+ signal: controller.signal
2936
+ });
2937
+ if (!res.ok)
2938
+ throw new Error(`Status ${res.status}`);
2939
+ const json = await res.json();
2940
+ if (json.code === "Ok" && Array.isArray(json.routes) && json.routes.length > 0) {
2941
+ routeData = json.routes[0];
2942
+ usedProvider = candidate.provider;
2943
+ const now = Date.now();
2944
+ dataTimestamp = new Date(now).toISOString();
2945
+ ROUTE_CACHE.set(routeCacheKey, { timestamp: now, data: routeData, provider: usedProvider });
2946
+ break;
2947
+ }
2948
+ throw new Error(json.message || `Routing returned code ${json.code}`);
2949
+ }
2950
+ catch (err) {
2951
+ lastRouteErr = err;
2952
+ log('warn', `OSRM routing failed at ${candidate.url}`, { error: err.message });
2953
+ }
2954
+ finally {
2955
+ clearTimeout(timeout);
2956
+ }
2957
+ }
2958
+ if (!routeData) {
2959
+ return {
2960
+ content: [{ type: "text", text: `Error: Route calculation failed on all configured OSRM servers: ${lastRouteErr?.message}` }],
2961
+ isError: true
2962
+ };
2963
+ }
2964
+ }
2965
+ const distanceMeters = Math.round(routeData.distance * 10) / 10;
2966
+ const durationSeconds = Math.round(routeData.duration);
2967
+ const durationMinutes = Math.round((durationSeconds / 60) * 10) / 10;
2968
+ const detourFactor = straightLineMeters > 0 ? Math.round((distanceMeters / straightLineMeters) * 100) / 100 : 1;
2969
+ // Extract actual route LineString geometry and compute route bounding box
2970
+ const geomCoords = (routeData.geometry?.coordinates && routeData.geometry.coordinates.length >= 2)
2971
+ ? routeData.geometry.coordinates
2972
+ : [[start.lng, start.lat], [end.lng, end.lat]];
2973
+ const routeGeometry = {
2974
+ type: "LineString",
2975
+ coordinates: geomCoords
2976
+ };
2977
+ const lats = geomCoords.map(c => c[1]);
2978
+ const lngs = geomCoords.map(c => c[0]);
2979
+ const routeBBox = ensureValidBBox(safeMin(lats), safeMin(lngs), safeMax(lats), safeMax(lngs));
2980
+ const midIdx = Math.floor(geomCoords.length / 2);
2981
+ const midPt = geomCoords[midIdx] || [(start.lng + end.lng) / 2, (start.lat + end.lat) / 2];
2982
+ const markers = [
2983
+ { coordinates: { lat: start.lat, lon: start.lng }, name: `Origin: ${start.label}` },
2984
+ { coordinates: { lat: midPt[1], lon: midPt[0] }, name: `Route (${(distanceMeters / 1000).toFixed(1)}km, ${durationMinutes}min)` },
2985
+ { coordinates: { lat: end.lat, lon: end.lng }, name: `Dest: ${end.label}` }
2986
+ ];
2987
+ const shareUrl = buildShareUrlWithRoute(routeBBox, geomCoords, markers);
2988
+ const steps = returnSteps && routeData.legs?.[0]?.steps
2989
+ ? routeData.legs[0].steps.map((s) => ({
2990
+ name: s.name || "unnamed road",
2991
+ distance_meters: Math.round(s.distance),
2992
+ duration_seconds: Math.round(s.duration),
2993
+ ...(s.maneuver?.type ? { maneuver: `${s.maneuver.type}${s.maneuver.modifier ? ' ' + s.maneuver.modifier : ''}` } : {})
2994
+ }))
2995
+ : undefined;
2996
+ const provenance = {
2997
+ provider: usedProvider,
2998
+ attribution: OSM_ATTRIBUTION,
2999
+ data_timestamp: dataTimestamp,
3000
+ cached: cachedRoute,
3001
+ cache_age_ms: cacheAgeMs,
3002
+ usage_policy: "https://routing.openstreetmap.de/about.html (Max 1 req/s, no heavy/bulk usage; configure OSRM_API_URL for production workloads)"
3003
+ };
3004
+ const resultPayload = {
3005
+ profile,
3006
+ origin: start,
3007
+ destination: end,
3008
+ distance_meters: distanceMeters,
3009
+ distance_km: Math.round((distanceMeters / 1000) * 100) / 100,
3010
+ duration_seconds: durationSeconds,
3011
+ duration_minutes: durationMinutes,
3012
+ straight_line_meters: Math.round(straightLineMeters * 10) / 10,
3013
+ detour_factor: detourFactor,
3014
+ initial_bearing: bearing,
3015
+ route_bbox: routeBBox,
3016
+ route_geometry: routeGeometry,
3017
+ ...(steps ? { steps } : {}),
3018
+ share_url: shareUrl,
3019
+ provenance,
3020
+ complete: true,
3021
+ warnings
3022
+ };
3023
+ return {
3024
+ content: [
1638
3025
  {
1639
3026
  type: "text",
1640
- text: JSON.stringify(responseData, null, 2)
3027
+ text: `Route (${profile}): ${start.label} → ${end.label}\nDistance: ${(distanceMeters / 1000).toFixed(2)} km (${distanceMeters.toLocaleString()} m) | Straight-line: ${(straightLineMeters / 1000).toFixed(2)} km (detour factor: ${detourFactor}x)\nEstimated Duration: ${durationMinutes} min (${durationSeconds} s)\nInitial Bearing: ${bearing.degrees}° (${bearing.cardinal})\nGeometry: LineString (${geomCoords.length} vertices)\n\nView route on map: ${shareUrl}${formatWarnings(warnings)}`
1641
3028
  }
3029
+ ],
3030
+ structuredContent: resultPayload
3031
+ };
3032
+ }
3033
+ else if (toolName === "get_elevation") {
3034
+ const args = request.params.arguments || {};
3035
+ const warnings = [];
3036
+ let queryPoints = [];
3037
+ let bboxObj;
3038
+ let geocodeRes;
3039
+ let mode = "points";
3040
+ if (Array.isArray(args.points) && args.points.length > 0) {
3041
+ if (args.points.length > 100) {
3042
+ return { content: [{ type: "text", text: "Error: Maximum 100 points allowed per call." }], isError: true };
3043
+ }
3044
+ queryPoints = args.points.map((p, i) => {
3045
+ const lat = Number(p.lat);
3046
+ const lng = Number(p.lng);
3047
+ if (!validateBBox(lat, lng, lat, lng)) {
3048
+ throw new Error(`Invalid coordinates at index ${i}: (${lat}, ${lng})`);
3049
+ }
3050
+ return { lat, lng, label: p.label || `Point ${i + 1}` };
3051
+ });
3052
+ const lats = queryPoints.map(p => p.lat);
3053
+ const lngs = queryPoints.map(p => p.lng);
3054
+ bboxObj = ensureValidBBox(safeMin(lats), safeMin(lngs), safeMax(lats), safeMax(lngs));
3055
+ }
3056
+ else {
3057
+ const locStr = typeof args.location === 'string' ? args.location.trim() : '';
3058
+ const coordMatch = locStr.match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/);
3059
+ if (coordMatch) {
3060
+ const p0 = parseFloat(coordMatch[1]);
3061
+ const p1 = parseFloat(coordMatch[2]);
3062
+ if (validateBBox(p0, p1, p0, p1)) {
3063
+ bboxObj = { lat1: p0, lng1: p1, lat2: p0, lng2: p1 };
3064
+ }
3065
+ else if (validateBBox(p1, p0, p1, p0)) {
3066
+ warnings.push(`Coordinate '${locStr}' interpreted as lng,lat -> converted to lat=${p1}, lng=${p0}.`);
3067
+ bboxObj = { lat1: p1, lng1: p0, lat2: p1, lng2: p0 };
3068
+ }
3069
+ else {
3070
+ geocodeRes = await geocodeLocation(locStr);
3071
+ bboxObj = geocodeRes.bbox;
3072
+ }
3073
+ }
3074
+ else if (args.location) {
3075
+ geocodeRes = await geocodeLocation(locStr);
3076
+ bboxObj = geocodeRes.bbox;
3077
+ }
3078
+ else if (args.bbox) {
3079
+ bboxObj = parseBBox(args.bbox, warnings);
3080
+ }
3081
+ else {
3082
+ return { content: [{ type: "text", text: "Error: Provide either 'points', 'location', or 'bbox'." }], isError: true };
3083
+ }
3084
+ const isSinglePoint = Math.abs(bboxObj.lat2 - bboxObj.lat1) < 1e-6 && Math.abs(bboxObj.lng2 - bboxObj.lng1) < 1e-6;
3085
+ if (isSinglePoint) {
3086
+ queryPoints = [{ lat: bboxObj.lat1, lng: bboxObj.lng1, label: geocodeRes?.matched_place || "Point" }];
3087
+ }
3088
+ else {
3089
+ mode = "grid";
3090
+ const gridSize = Math.max(2, Math.min(10, Math.round(args.grid_size ?? 5)));
3091
+ warnings.push(`Grid mode samples a finite ${gridSize}x${gridSize} point grid (${gridSize * gridSize} points) over a 90m resolution DEM (Copernicus DEM GLO-90). Sample statistics do not guarantee true area-wide minimum, maximum, or slope measurements.`);
3092
+ for (let r = 0; r < gridSize; r++) {
3093
+ const lat = bboxObj.lat1 + (bboxObj.lat2 - bboxObj.lat1) * (r / (gridSize - 1));
3094
+ for (let c = 0; c < gridSize; c++) {
3095
+ const lng = bboxObj.lng1 + (bboxObj.lng2 - bboxObj.lng1) * (c / (gridSize - 1));
3096
+ queryPoints.push({ lat, lng, label: `Grid (${r + 1},${c + 1})` });
3097
+ }
3098
+ }
3099
+ }
3100
+ }
3101
+ const latsParam = queryPoints.map(p => p.lat.toFixed(6)).join(",");
3102
+ const lngsParam = queryPoints.map(p => p.lng.toFixed(6)).join(",");
3103
+ const elevationBase = (process.env.ELEVATION_API_URL || "https://api.open-meteo.com/v1/elevation").replace(/\/+$/, "");
3104
+ const url = `${elevationBase}?latitude=${latsParam}&longitude=${lngsParam}`;
3105
+ const cacheKey = `${latsParam}|${lngsParam}`;
3106
+ let elevations = [];
3107
+ let cachedElev = false;
3108
+ let cacheAgeMs = 0;
3109
+ let dataTimestamp = new Date().toISOString();
3110
+ const cachedEntry = ELEVATION_CACHE.get(cacheKey);
3111
+ if (cachedEntry && (Date.now() - cachedEntry.timestamp < ELEVATION_CACHE_TTL_MS)) {
3112
+ elevations = cachedEntry.elevations;
3113
+ cachedElev = true;
3114
+ cacheAgeMs = Date.now() - cachedEntry.timestamp;
3115
+ dataTimestamp = new Date(cachedEntry.timestamp).toISOString();
3116
+ }
3117
+ else {
3118
+ const controller = new AbortController();
3119
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
3120
+ try {
3121
+ const res = await fetch(url, {
3122
+ headers: {
3123
+ 'User-Agent': APP_USER_AGENT,
3124
+ 'Accept': 'application/json'
3125
+ },
3126
+ signal: controller.signal
3127
+ });
3128
+ if (!res.ok) {
3129
+ throw new Error(`Open-Meteo Elevation API returned status ${res.status}`);
3130
+ }
3131
+ const json = await res.json();
3132
+ if (!Array.isArray(json.elevation)) {
3133
+ throw new Error(`Unexpected response from elevation API`);
3134
+ }
3135
+ elevations = json.elevation;
3136
+ const now = Date.now();
3137
+ dataTimestamp = new Date(now).toISOString();
3138
+ ELEVATION_CACHE.set(cacheKey, { timestamp: now, elevations });
3139
+ }
3140
+ finally {
3141
+ clearTimeout(timeout);
3142
+ }
3143
+ }
3144
+ const samples = queryPoints.map((pt, idx) => ({
3145
+ label: pt.label,
3146
+ lat: Math.round(pt.lat * 1e6) / 1e6,
3147
+ lng: Math.round(pt.lng * 1e6) / 1e6,
3148
+ elevation_meters: elevations[idx] ?? null
3149
+ })).filter((s) => typeof s.elevation_meters === 'number');
3150
+ if (samples.length === 0) {
3151
+ return { content: [{ type: "text", text: "Error: No elevation data returned for the requested coordinates." }], isError: true };
3152
+ }
3153
+ let minSample = samples[0];
3154
+ let maxSample = samples[0];
3155
+ let sum = 0;
3156
+ for (const s of samples) {
3157
+ if (s.elevation_meters < minSample.elevation_meters)
3158
+ minSample = s;
3159
+ if (s.elevation_meters > maxSample.elevation_meters)
3160
+ maxSample = s;
3161
+ sum += s.elevation_meters;
3162
+ }
3163
+ const meanElevation = Math.round((sum / samples.length) * 10) / 10;
3164
+ const reliefMeters = Math.round((maxSample.elevation_meters - minSample.elevation_meters) * 10) / 10;
3165
+ const markers = mode === "grid"
3166
+ ? [
3167
+ { coordinates: { lat: maxSample.lat, lon: maxSample.lng }, name: `Sample High: ${maxSample.elevation_meters}m` },
3168
+ { coordinates: { lat: minSample.lat, lon: minSample.lng }, name: `Sample Low: ${minSample.elevation_meters}m` }
1642
3169
  ]
3170
+ : samples.map(s => ({ coordinates: { lat: s.lat, lon: s.lng }, name: `${s.label}: ${s.elevation_meters}m` }));
3171
+ const displayBBox = bboxObj.lat1 === bboxObj.lat2 && bboxObj.lng1 === bboxObj.lng2
3172
+ ? expandBBoxMeters(bboxObj, 500)
3173
+ : bboxObj;
3174
+ // Only draw a bounding box rectangle on the map in grid mode; for point lookups, show pins only
3175
+ const shareUrl = mode === "grid"
3176
+ ? buildShareUrlWithMarkers(displayBBox, markers)
3177
+ : buildShareUrlMarkersOnly(markers);
3178
+ const provenance = {
3179
+ provider: `open-meteo-copernicus-dem-90m (${elevationBase})`,
3180
+ attribution: "Copernicus DEM GLO-90 via Open-Meteo (https://open-meteo.com/)",
3181
+ data_timestamp: dataTimestamp,
3182
+ cached: cachedElev,
3183
+ cache_age_ms: cacheAgeMs,
3184
+ usage_policy: "https://open-meteo.com/en/terms (Hosted API is free for non-commercial use only; commercial use requires a paid API subscription or self-hosted ELEVATION_API_URL)"
1643
3185
  };
3186
+ const sampleStats = {
3187
+ min_elevation_meters: minSample.elevation_meters,
3188
+ max_elevation_meters: maxSample.elevation_meters,
3189
+ mean_elevation_meters: meanElevation,
3190
+ sample_relief_meters: reliefMeters,
3191
+ lowest_sample: minSample,
3192
+ highest_sample: maxSample,
3193
+ is_finite_sample: mode === "grid"
3194
+ };
3195
+ const responseData = {
3196
+ mode,
3197
+ ...(geocodeRes ? { matched_place: geocodeRes.matched_place } : {}),
3198
+ dem_resolution_meters: 90,
3199
+ sample_count: samples.length,
3200
+ sample_stats: sampleStats,
3201
+ samples,
3202
+ bbox: displayBBox,
3203
+ share_url: shareUrl,
3204
+ provenance,
3205
+ complete: true,
3206
+ warnings
3207
+ };
3208
+ const summaryText = mode === "grid"
3209
+ ? `Terrain Elevation Sample Grid (${samples.length} samples at 90m DEM resolution${geocodeRes ? ` for "${geocodeRes.matched_place}"` : ''}):\n- Sample Min: ${minSample.elevation_meters} m at (${minSample.lat}, ${minSample.lng})\n- Sample Max: ${maxSample.elevation_meters} m at (${maxSample.lat}, ${maxSample.lng})\n- Sample Mean: ${meanElevation} m | Sample Relief: ${reliefMeters} m`
3210
+ : `Point Elevations (${samples.length}, 90m Copernicus DEM):\n` + samples.map(s => `- ${s.label} (${s.lat}, ${s.lng}): ${s.elevation_meters} m`).join('\n');
3211
+ return {
3212
+ content: [
3213
+ {
3214
+ type: "text",
3215
+ text: `${summaryText}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
3216
+ }
3217
+ ],
3218
+ structuredContent: responseData
3219
+ };
3220
+ }
3221
+ else if (toolName === "spatial_calc") {
3222
+ const args = request.params.arguments || {};
3223
+ const operation = args.operation;
3224
+ const bboxAStr = args.bbox_a;
3225
+ const warnings = [];
3226
+ if (!operation || !bboxAStr) {
3227
+ return { content: [{ type: "text", text: "Error: 'operation' and 'bbox_a' are required." }], isError: true };
3228
+ }
3229
+ const provenance = {
3230
+ provider: "offline-geometry-engine",
3231
+ data_timestamp: new Date().toISOString(),
3232
+ cached: false,
3233
+ cache_age_ms: 0
3234
+ };
3235
+ if (operation === "relation") {
3236
+ const bboxBStr = args.bbox_b;
3237
+ if (!bboxBStr) {
3238
+ return { content: [{ type: "text", text: "Error: 'bbox_b' is required for operation='relation'." }], isError: true };
3239
+ }
3240
+ const a = parseBBox(bboxAStr, warnings);
3241
+ const b = parseBBox(bboxBStr, warnings);
3242
+ const intersects = !(b.lng1 > a.lng2 || b.lng2 < a.lng1 || b.lat1 > a.lat2 || b.lat2 < a.lat1);
3243
+ const aContainsB = a.lat1 <= b.lat1 && a.lat2 >= b.lat2 && a.lng1 <= b.lng1 && a.lng2 >= b.lng2;
3244
+ const bContainsA = b.lat1 <= a.lat1 && b.lat2 >= a.lat2 && b.lng1 <= a.lng1 && b.lng2 >= a.lng2;
3245
+ const unionBBox = {
3246
+ lat1: Math.min(a.lat1, b.lat1),
3247
+ lng1: Math.min(a.lng1, b.lng1),
3248
+ lat2: Math.max(a.lat2, b.lat2),
3249
+ lng2: Math.max(a.lng2, b.lng2)
3250
+ };
3251
+ let intersectionBBox = null;
3252
+ let intersectionAreaKm2 = 0;
3253
+ if (intersects) {
3254
+ intersectionBBox = {
3255
+ lat1: Math.max(a.lat1, b.lat1),
3256
+ lng1: Math.max(a.lng1, b.lng1),
3257
+ lat2: Math.min(a.lat2, b.lat2),
3258
+ lng2: Math.min(a.lng2, b.lng2)
3259
+ };
3260
+ intersectionAreaKm2 = calculateBboxArea(intersectionBBox);
3261
+ }
3262
+ const areaAKm2 = calculateBboxArea(a);
3263
+ const areaBKm2 = calculateBboxArea(b);
3264
+ const unionAreaKm2 = calculateBboxArea(unionBBox);
3265
+ const overlapPctOfA = areaAKm2 > 0 ? Math.round((intersectionAreaKm2 / areaAKm2) * 10000) / 100 : 0;
3266
+ const overlapPctOfB = areaBKm2 > 0 ? Math.round((intersectionAreaKm2 / areaBKm2) * 10000) / 100 : 0;
3267
+ const centroidDistanceMeters = calculateHaversineDistanceMeters((a.lat1 + a.lat2) / 2, (a.lng1 + a.lng2) / 2, (b.lat1 + b.lat2) / 2, (b.lng1 + b.lng2) / 2);
3268
+ const shareUrl = buildShareUrl(intersectionBBox || unionBBox);
3269
+ const payload = {
3270
+ operation: "relation",
3271
+ intersects,
3272
+ a_contains_b: aContainsB,
3273
+ b_contains_a: bContainsA,
3274
+ area_a_km2: Math.round(areaAKm2 * 1000) / 1000,
3275
+ area_b_km2: Math.round(areaBKm2 * 1000) / 1000,
3276
+ intersection_area_km2: Math.round(intersectionAreaKm2 * 1000) / 1000,
3277
+ union_area_km2: Math.round(unionAreaKm2 * 1000) / 1000,
3278
+ overlap_percent_of_a: overlapPctOfA,
3279
+ overlap_percent_of_b: overlapPctOfB,
3280
+ centroid_distance_meters: Math.round(centroidDistanceMeters * 10) / 10,
3281
+ intersection_bbox: intersectionBBox,
3282
+ union_bbox: unionBBox,
3283
+ share_url: shareUrl,
3284
+ provenance,
3285
+ complete: true,
3286
+ warnings
3287
+ };
3288
+ return {
3289
+ content: [
3290
+ {
3291
+ type: "text",
3292
+ text: `Spatial Relation:\n- Intersects: ${intersects} | A contains B: ${aContainsB} | B contains A: ${bContainsA}\n- Intersection Area: ${payload.intersection_area_km2} km² (${overlapPctOfA}% of A, ${overlapPctOfB}% of B)\n- Centroid Distance: ${payload.centroid_distance_meters} m\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
3293
+ }
3294
+ ],
3295
+ structuredContent: payload
3296
+ };
3297
+ }
3298
+ else if (operation === "point_in_geometry") {
3299
+ const ptStr = args.point;
3300
+ if (!ptStr) {
3301
+ return { content: [{ type: "text", text: "Error: 'point' ('lat,lng') is required for operation='point_in_geometry'." }], isError: true };
3302
+ }
3303
+ const ptBbox = parseBBox(ptStr, warnings);
3304
+ const ptLat = (ptBbox.lat1 + ptBbox.lat2) / 2;
3305
+ const ptLng = (ptBbox.lng1 + ptBbox.lng2) / 2;
3306
+ const targetBBox = parseBBox(bboxAStr, warnings);
3307
+ const insideBBox = ptLat >= targetBBox.lat1 && ptLat <= targetBBox.lat2 && ptLng >= targetBBox.lng1 && ptLng <= targetBBox.lng2;
3308
+ const polygonsWithHoles = extractPolygonsWithHolesLatLng(bboxAStr);
3309
+ let insidePolygon;
3310
+ if (polygonsWithHoles && polygonsWithHoles.length > 0) {
3311
+ insidePolygon = polygonsWithHoles.some(polyRings => isPointInPolygonWithHoles(ptLat, ptLng, polyRings));
3312
+ }
3313
+ const isInside = insidePolygon !== undefined ? insidePolygon : insideBBox;
3314
+ const ptMarker = [{ coordinates: { lat: ptLat, lon: ptLng }, name: isInside ? "Inside" : "Outside" }];
3315
+ const shareUrl = polygonsWithHoles && polygonsWithHoles[0]?.[0]?.length >= 3
3316
+ ? buildShareUrlPolygon(polygonsWithHoles[0][0], ptMarker)
3317
+ : buildShareUrlWithMarkers(targetBBox, ptMarker);
3318
+ const payload = {
3319
+ operation: "point_in_geometry",
3320
+ point: { lat: ptLat, lng: ptLng },
3321
+ inside: isInside,
3322
+ inside_bbox: insideBBox,
3323
+ ...(insidePolygon !== undefined ? { inside_polygon: insidePolygon } : {}),
3324
+ target_bbox: targetBBox,
3325
+ share_url: shareUrl,
3326
+ provenance,
3327
+ complete: true,
3328
+ warnings
3329
+ };
3330
+ return {
3331
+ content: [
3332
+ {
3333
+ type: "text",
3334
+ text: `Point (${ptLat}, ${ptLng}) is ${isInside ? "INSIDE" : "OUTSIDE"} the target geometry (inside_bbox: ${insideBBox}${insidePolygon !== undefined ? `, inside_polygon: ${insidePolygon}` : ''}).\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
3335
+ }
3336
+ ],
3337
+ structuredContent: payload
3338
+ };
3339
+ }
3340
+ else if (operation === "distance_bearing") {
3341
+ const bboxBStr = args.bbox_b || args.point;
3342
+ if (!bboxBStr) {
3343
+ return { content: [{ type: "text", text: "Error: 'bbox_b' (or 'point') is required as the destination for operation='distance_bearing'." }], isError: true };
3344
+ }
3345
+ const a = parseBBox(bboxAStr, warnings);
3346
+ const b = parseBBox(bboxBStr, warnings);
3347
+ const lat1 = (a.lat1 + a.lat2) / 2, lng1 = (a.lng1 + a.lng2) / 2;
3348
+ const lat2 = (b.lat1 + b.lat2) / 2, lng2 = (b.lng1 + b.lng2) / 2;
3349
+ const distMeters = calculateHaversineDistanceMeters(lat1, lng1, lat2, lng2);
3350
+ const bearing = calculateBearingDegrees(lat1, lng1, lat2, lng2);
3351
+ const midpoint = {
3352
+ lat: Math.round(((lat1 + lat2) / 2) * 1e6) / 1e6,
3353
+ lng: Math.round(((lng1 + lng2) / 2) * 1e6) / 1e6
3354
+ };
3355
+ const combinedBBox = ensureValidBBox(Math.min(lat1, lat2), Math.min(lng1, lng2), Math.max(lat1, lat2), Math.max(lng1, lng2));
3356
+ const lineTrace = `PB:${lat1.toFixed(5)},${lng1.toFixed(5)},${lat2.toFixed(5)},${lng2.toFixed(5)},${lat1.toFixed(5)},${lng1.toFixed(5)}`;
3357
+ const shareUrl = buildShareUrlMarkersOnly([
3358
+ { coordinates: { lat: lat1, lon: lng1 }, name: "Point A" },
3359
+ { coordinates: { lat: midpoint.lat, lon: midpoint.lng }, name: `Midpoint (${(distMeters / 1000).toFixed(2)}km)` },
3360
+ { coordinates: { lat: lat2, lon: lng2 }, name: "Point B" }
3361
+ ], [lineTrace]);
3362
+ const payload = {
3363
+ operation: "distance_bearing",
3364
+ from: { lat: lat1, lng: lng1 },
3365
+ to: { lat: lat2, lng: lng2 },
3366
+ distance_meters: Math.round(distMeters * 10) / 10,
3367
+ distance_km: Math.round((distMeters / 1000) * 1000) / 1000,
3368
+ bearing_degrees: bearing.degrees,
3369
+ cardinal_direction: bearing.cardinal,
3370
+ midpoint,
3371
+ bbox: combinedBBox,
3372
+ share_url: shareUrl,
3373
+ provenance,
3374
+ complete: true,
3375
+ warnings
3376
+ };
3377
+ return {
3378
+ content: [
3379
+ {
3380
+ type: "text",
3381
+ text: `Distance & Bearing: (${lat1}, ${lng1}) → (${lat2}, ${lng2})\n- Distance: ${payload.distance_km} km (${payload.distance_meters} m)\n- Initial Bearing: ${bearing.degrees}° (${bearing.cardinal})\n- Midpoint: (${midpoint.lat}, ${midpoint.lng})\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
3382
+ }
3383
+ ],
3384
+ structuredContent: payload
3385
+ };
3386
+ }
3387
+ else if (operation === "split_grid") {
3388
+ const rows = Math.max(1, Math.min(20, Math.round(args.rows ?? 2)));
3389
+ const cols = Math.max(1, Math.min(20, Math.round(args.cols ?? 2)));
3390
+ const bbox = parseBBox(bboxAStr, warnings);
3391
+ const dLat = (bbox.lat2 - bbox.lat1) / rows;
3392
+ const dLng = (bbox.lng2 - bbox.lng1) / cols;
3393
+ const cells = [];
3394
+ for (let r = 0; r < rows; r++) {
3395
+ for (let c = 0; c < cols; c++) {
3396
+ const cellBBox = {
3397
+ lat1: Math.round((bbox.lat1 + r * dLat) * 1e6) / 1e6,
3398
+ lng1: Math.round((bbox.lng1 + c * dLng) * 1e6) / 1e6,
3399
+ lat2: Math.round((bbox.lat1 + (r + 1) * dLat) * 1e6) / 1e6,
3400
+ lng2: Math.round((bbox.lng1 + (c + 1) * dLng) * 1e6) / 1e6
3401
+ };
3402
+ cells.push({
3403
+ row: r + 1,
3404
+ col: c + 1,
3405
+ bbox: cellBBox,
3406
+ csv: `${cellBBox.lat1},${cellBBox.lng1},${cellBBox.lat2},${cellBBox.lng2}`
3407
+ });
3408
+ }
3409
+ }
3410
+ const shareUrl = buildShareUrl(bbox);
3411
+ const payload = {
3412
+ operation: "split_grid",
3413
+ rows,
3414
+ cols,
3415
+ total_cells: cells.length,
3416
+ original_bbox: bbox,
3417
+ cells,
3418
+ share_url: shareUrl,
3419
+ provenance,
3420
+ complete: true,
3421
+ warnings
3422
+ };
3423
+ return {
3424
+ content: [
3425
+ {
3426
+ type: "text",
3427
+ text: `Split bounding box into ${rows}x${cols} grid (${cells.length} sub-boxes).\n\nView parent bbox on map: ${shareUrl}${formatWarnings(warnings)}`
3428
+ }
3429
+ ],
3430
+ structuredContent: payload
3431
+ };
3432
+ }
3433
+ else {
3434
+ return {
3435
+ content: [{ type: "text", text: `Error: Unknown spatial_calc operation '${operation}'. Supported: relation, point_in_geometry, distance_bearing, split_grid.` }],
3436
+ isError: true
3437
+ };
3438
+ }
1644
3439
  }
1645
3440
  else {
1646
3441
  log('warn', `Unknown tool requested: ${toolName}`);