bbox-mcp-server 1.3.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
@@ -4,11 +4,21 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import * as h3 from "h3-js";
6
6
  import proj4 from "proj4";
7
+ import pkg from "./package.json" with { type: "json" };
7
8
  import proj4defs from "./proj4defs.json" with { type: "json" };
8
9
  import { wktToGeoJSON as parseWKT } from "@terraformer/wkt";
9
10
  const SERVER_NAME = "bbox-mcp-server";
10
- const SERVER_VERSION = "1.2.6";
11
- const MAX_H3_CELLS = process.env.MAX_H3_CELLS ? parseInt(process.env.MAX_H3_CELLS, 10) : 50000;
11
+ const SERVER_VERSION = pkg.version;
12
+ const MAX_H3_CELLS = (() => {
13
+ if (!process.env.MAX_H3_CELLS)
14
+ return 50000;
15
+ const v = parseInt(process.env.MAX_H3_CELLS, 10);
16
+ if (!Number.isFinite(v) || v <= 0) {
17
+ console.error(JSON.stringify({ timestamp: new Date().toISOString(), level: 'warn', server: 'bbox-mcp-server', message: `Invalid MAX_H3_CELLS="${process.env.MAX_H3_CELLS}", using default 50000` }));
18
+ return 50000;
19
+ }
20
+ return v;
21
+ })();
12
22
  const MAPBOX_ACCESS_TOKEN = process.env.MAPBOX_ACCESS_TOKEN;
13
23
  const MAX_INPUT_LENGTH = 1_000_000; // 1 MB input size guard
14
24
  const FETCH_TIMEOUT_MS = 10_000; // 10 second timeout for external API calls
@@ -56,6 +66,9 @@ initProj4();
56
66
  */
57
67
  function extractCoords(obj) {
58
68
  const result = [];
69
+ // Only follow GeoJSON-structural keys to avoid walking into properties
70
+ // which may contain arbitrary numeric arrays.
71
+ const GEO_KEYS = new Set(['coordinates', 'geometry', 'geometries', 'features']);
59
72
  const stack = [obj];
60
73
  while (stack.length > 0) {
61
74
  const current = stack.pop();
@@ -70,9 +83,10 @@ function extractCoords(obj) {
70
83
  }
71
84
  }
72
85
  else if (current && typeof current === 'object') {
73
- const values = Object.values(current);
74
- for (let i = values.length - 1; i >= 0; i--) {
75
- stack.push(values[i]);
86
+ for (const key of Object.keys(current)) {
87
+ if (GEO_KEYS.has(key)) {
88
+ stack.push(current[key]);
89
+ }
76
90
  }
77
91
  }
78
92
  }
@@ -101,6 +115,14 @@ function safeMax(arr) {
101
115
  // ---------------------------------------------------------------------------
102
116
  // Pure Functions Ported from bbox.js
103
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
+ }
104
126
  function validateBBox(lat1, lng1, lat2, lng2) {
105
127
  if (isNaN(lat1) || isNaN(lng1) || isNaN(lat2) || isNaN(lng2))
106
128
  return false;
@@ -110,7 +132,15 @@ function validateBBox(lat1, lng1, lat2, lng2) {
110
132
  return false;
111
133
  return true;
112
134
  }
113
- function parseBBox(input) {
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
+ }
143
+ function parseBBox(input, warnings) {
114
144
  if (!input || typeof input !== 'string') {
115
145
  throw new Error(`Invalid bbox input.`);
116
146
  }
@@ -118,67 +148,138 @@ function parseBBox(input) {
118
148
  if (input.length > MAX_INPUT_LENGTH) {
119
149
  throw new Error(`Input too large (${(input.length / 1024).toFixed(0)} KB). Maximum allowed is ${(MAX_INPUT_LENGTH / 1024).toFixed(0)} KB.`);
120
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
+ }
121
163
  // --- 1. ogrinfo Extent Sniffer ---
122
164
  // Matches: "Extent: (-74.006000, 40.712800) - (-73.985500, 40.758000)"
123
165
  const ogrMatch = input.match(/Extent\:\s\((.*)\)/);
124
166
  if (ogrMatch && ogrMatch[1]) {
125
- try {
126
- const pairs = ogrMatch[1].split(") - (");
127
- if (pairs.length === 2) {
128
- const minCoords = pairs[0].replace('(', '').split(',').map(s => parseFloat(s.trim()));
129
- const maxCoords = pairs[1].replace(')', '').split(',').map(s => parseFloat(s.trim()));
130
- // Assuming format: (minX, minY) - (maxX, maxY) where X=lng, Y=lat
131
- 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}`);
132
173
  }
133
- }
134
- catch (e) {
135
- 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]);
136
179
  }
137
180
  }
138
181
  // --- 2. GeoJSON Sniffer ---
139
- try {
140
- const json = JSON.parse(input);
141
- if (json.bbox && json.bbox.length === 4) {
142
- // standard GeoJSON bbox: [minX, minY, maxX, maxY]
143
- 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);
144
202
  }
145
- // Multi-Geometry Coordinate Extraction (using shared, stack-safe utility)
146
203
  const allCoords = extractCoords(json);
147
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
+ }
148
208
  const lngs = allCoords.map(c => c[0]);
149
209
  const lats = allCoords.map(c => c[1]);
150
210
  return ensureValidBBox(safeMin(lats), safeMin(lngs), safeMax(lats), safeMax(lngs));
151
211
  }
152
- }
153
- catch (e) {
154
- log('info', 'parseBBox: GeoJSON parse failed, trying next parser', { error: e?.message });
212
+ throw new Error(`GeoJSON input contains no valid coordinates or bbox.`);
155
213
  }
156
214
  // --- 3. WKT Sniffer ---
157
- 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;
158
217
  try {
159
- const geojson = parseWKT(input);
160
- const allCoords = extractCoords(geojson.coordinates || geojson);
161
- if (allCoords.length > 0) {
162
- const lngs = allCoords.map(c => c[0]);
163
- const lats = allCoords.map(c => c[1]);
164
- return ensureValidBBox(safeMin(lats), safeMin(lngs), safeMax(lats), safeMax(lngs));
165
- }
218
+ geojson = parseWKT(input);
166
219
  }
167
220
  catch (e) {
168
- 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);
169
254
  }
255
+ throw new Error(`Invalid point coordinates: (${p0}, ${p1}). Latitude must be [-90, 90] and longitude [-180, 180].`);
170
256
  }
171
- // --- 4. Standard Coordinate Array Sniffer ---
172
- // Strip brackets/parentheses and split by commas or spaces
173
- const cleanInput = input.replace(/[\[\]\(\)]/g, '').trim();
174
- // Support space-separated (GDAL) or comma-separated
175
- const parts = cleanInput.split(/[\s,]+/).map(s => parseFloat(s.trim())).filter(n => !isNaN(n));
176
257
  if (parts.length === 4) {
177
- // Assume format: lat1, lng1, lat2, lng2 OR minX, minY, maxX, maxY
178
- // BBox finder defaults to minLat, minLng, maxLat, maxLng
179
- return ensureValidBBox(parts[0], parts[1], parts[2], parts[3]);
258
+ const asLatLng = validateBBox(parts[0], parts[1], parts[2], parts[3]);
259
+ const asLngLat = validateBBox(parts[1], parts[0], parts[3], parts[2]);
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]);
279
+ }
280
+ throw new Error(`Invalid coordinates or out of bounds: lat[-90, 90], lng[-180, 180]. Provided: ${parts.join(', ')}`);
180
281
  }
181
- 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)}`);
182
283
  }
183
284
  function ensureValidBBox(lat1, lng1, lat2, lng2) {
184
285
  if (!validateBBox(lat1, lng1, lat2, lng2)) {
@@ -192,6 +293,9 @@ function ensureValidBBox(lat1, lng1, lat2, lng2) {
192
293
  lng2: Math.max(lng1, lng2)
193
294
  };
194
295
  }
296
+ function formatWarnings(warnings) {
297
+ return warnings.length > 0 ? '\n\n⚠️ ' + warnings.join('\n⚠️ ') : '';
298
+ }
195
299
  function projectBounds(bbox, epsg) {
196
300
  if (epsg === "4326") {
197
301
  return { xmin: bbox.lng1, ymin: bbox.lat1, xmax: bbox.lng2, ymax: bbox.lat2 };
@@ -200,13 +304,20 @@ function projectBounds(bbox, epsg) {
200
304
  if (!proj4.defs(epsgKey)) {
201
305
  throw new Error(`Unknown EPSG code: ${epsg}. Make sure it exists in proj4defs.`);
202
306
  }
203
- const p1 = proj4(epsgKey, [bbox.lng1, bbox.lat1]);
204
- const p2 = proj4(epsgKey, [bbox.lng2, bbox.lat2]);
307
+ // Project all four corners to handle axis flips and oblique/rotated CRS
308
+ const corners = [
309
+ proj4(epsgKey, [bbox.lng1, bbox.lat1]),
310
+ proj4(epsgKey, [bbox.lng2, bbox.lat1]),
311
+ proj4(epsgKey, [bbox.lng1, bbox.lat2]),
312
+ proj4(epsgKey, [bbox.lng2, bbox.lat2]),
313
+ ];
314
+ const xs = corners.map(c => c[0]);
315
+ const ys = corners.map(c => c[1]);
205
316
  return {
206
- xmin: p1[0],
207
- ymin: p1[1],
208
- xmax: p2[0],
209
- ymax: p2[1],
317
+ xmin: Math.min(...xs),
318
+ ymin: Math.min(...ys),
319
+ xmax: Math.max(...xs),
320
+ ymax: Math.max(...ys),
210
321
  };
211
322
  }
212
323
  /**
@@ -224,6 +335,9 @@ async function ensureProjection(epsg) {
224
335
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
225
336
  try {
226
337
  const response = await fetch(`https://epsg.io/${epsg}.proj4`, {
338
+ headers: {
339
+ 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`
340
+ },
227
341
  signal: controller.signal,
228
342
  });
229
343
  if (!response.ok) {
@@ -312,20 +426,86 @@ function calculateBboxArea(bbox) {
312
426
  return widthKm * heightKm;
313
427
  }
314
428
  function latLngToTile(lat, lng, zoom) {
429
+ // Clamp to Web Mercator latitude limits to avoid Math.tan(±90°) → Infinity → NaN
430
+ const clampedLat = Math.max(-85.0511, Math.min(85.0511, lat));
315
431
  const n = Math.pow(2, zoom);
316
432
  const x = Math.floor((lng + 180) / 360 * n);
317
- const latRad = lat * Math.PI / 180;
433
+ const latRad = clampedLat * Math.PI / 180;
318
434
  const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
319
435
  return { z: zoom, x, y };
320
436
  }
321
- function buildShareUrl(bbox) {
322
- const hash = `${bbox.lat1.toFixed(6)},${bbox.lng1.toFixed(6)},${bbox.lat2.toFixed(6)},${bbox.lng2.toFixed(6)}`;
323
- 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);
324
446
  }
325
- function buildShareUrlWithMarkers(bbox, elements) {
326
- 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) {
327
507
  const MAX_MARKERS = 30;
328
- const markerParts = elements
508
+ return elements
329
509
  .filter(e => e.coordinates?.lat !== undefined && e.coordinates?.lon !== undefined)
330
510
  .slice(0, MAX_MARKERS)
331
511
  .map(e => {
@@ -334,10 +514,75 @@ function buildShareUrlWithMarkers(bbox, elements) {
334
514
  name = name.slice(0, 27) + '...';
335
515
  return `${e.coordinates.lat.toFixed(6)},${e.coordinates.lon.toFixed(6)},${encodeURIComponent(name)}`;
336
516
  });
337
- 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];
338
535
  return `https://vibhorsingh.com/boundingbox/#${hashParts.join('|')}`;
339
536
  }
340
- 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) {
341
586
  if (resolution < 0 || resolution > 15 || !Number.isInteger(resolution)) {
342
587
  throw new Error(`Invalid H3 resolution: ${resolution}. Must be an integer between 0 and 15.`);
343
588
  }
@@ -351,15 +596,30 @@ function getH3Cells(bbox, resolution, compact) {
351
596
  if (estimatedCount > MAX_H3_CELLS * 2) {
352
597
  throw new Error(`Area too large for resolution ${resolution}. Estimated ${Math.floor(estimatedCount).toLocaleString()} cells. Maximum allowed is ${MAX_H3_CELLS.toLocaleString()}.`);
353
598
  }
354
- // H3 v4 polygon format: [ [lat, lng]... ]
355
- const polygon = [
356
- [bbox.lat1, bbox.lng1],
357
- [bbox.lat2, bbox.lng1],
358
- [bbox.lat2, bbox.lng2],
359
- [bbox.lat1, bbox.lng2],
360
- [bbox.lat1, bbox.lng1]
361
- ];
362
- 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
+ }
363
623
  if (cells.length > MAX_H3_CELLS) {
364
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.`);
365
625
  }
@@ -394,90 +654,742 @@ function h3CellsToGeoJSON(cells) {
394
654
  features
395
655
  };
396
656
  }
397
- async function geocodeLocation(query) {
398
- if (!MAPBOX_ACCESS_TOKEN) {
399
- throw new Error('Mapbox API token not configured. Set MAPBOX_ACCESS_TOKEN environment variable to use the location search feature. Alternatively, provide exact coordinate bounds via the `bbox` argument.');
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);
682
+ const NOMINATIM_CACHE = new Map();
683
+ const NOMINATIM_TTL_MS = 60 * 60 * 1000; // 1 hour TTL
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(/\/+$/, "");
686
+ async function fetchNominatim(url) {
687
+ const cached = NOMINATIM_CACHE.get(url);
688
+ if (cached && (Date.now() - cached.timestamp < NOMINATIM_TTL_MS)) {
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
+ };
695
+ }
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
+ };
400
706
  }
401
707
  const controller = new AbortController();
402
708
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
403
709
  try {
404
- const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json?access_token=${MAPBOX_ACCESS_TOKEN}&limit=1`;
405
- const response = await fetch(url, { signal: controller.signal });
710
+ const response = await fetch(url, {
711
+ headers: {
712
+ 'User-Agent': APP_USER_AGENT,
713
+ 'Accept': 'application/json'
714
+ },
715
+ signal: controller.signal
716
+ });
406
717
  if (!response.ok) {
407
- throw new Error(`Geocoding failed with status: ${response.status} ${response.statusText}`);
718
+ throw new Error(`Nominatim request failed with status: ${response.status} ${response.statusText}`);
408
719
  }
409
720
  const data = await response.json();
410
- if (!data || typeof data !== "object" || !Array.isArray(data.features)) {
411
- throw new Error(`Unexpected geocoding response format from Mapbox API for query: ${query}`);
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
+ };
729
+ }
730
+ catch (err) {
731
+ if (err.name === 'AbortError') {
732
+ throw new Error(`Nominatim request timed out after ${FETCH_TIMEOUT_MS / 1000}s for URL: ${url}`);
733
+ }
734
+ throw err;
735
+ }
736
+ finally {
737
+ clearTimeout(timeout);
738
+ }
739
+ }
740
+ const PHOTON_CACHE = new Map();
741
+ async function geocodeLocation(query, includePolygon = false, warnings) {
742
+ // 1. Try Nominatim first (free, returns exact OSM area boundaries & alternatives)
743
+ try {
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);
748
+ if (Array.isArray(items) && items.length > 0) {
749
+ const parsedItems = items.map((item) => {
750
+ let bbox;
751
+ if (item.boundingbox && item.boundingbox.length === 4) {
752
+ const south = parseFloat(item.boundingbox[0]);
753
+ const north = parseFloat(item.boundingbox[1]);
754
+ const west = parseFloat(item.boundingbox[2]);
755
+ const east = parseFloat(item.boundingbox[3]);
756
+ bbox = ensureValidBBox(south, west, north, east);
757
+ }
758
+ else {
759
+ const lat = parseFloat(item.lat);
760
+ const lon = parseFloat(item.lon);
761
+ bbox = ensureValidBBox(lat - 0.05, lon - 0.05, lat + 0.05, lon + 0.05);
762
+ }
763
+ const center = { lat: parseFloat(item.lat), lng: parseFloat(item.lon) };
764
+ let osm_area_id;
765
+ const osmIdNum = parseInt(item.osm_id, 10);
766
+ if (item.osm_type === "relation" && !isNaN(osmIdNum)) {
767
+ osm_area_id = 3600000000 + osmIdNum;
768
+ }
769
+ else if (item.osm_type === "way" && !isNaN(osmIdNum)) {
770
+ osm_area_id = 2400000000 + osmIdNum;
771
+ }
772
+ const polygons_with_holes = item.geojson ? (extractPolygonsWithHolesLatLng(item.geojson) || undefined) : undefined;
773
+ return {
774
+ matched_place: item.display_name,
775
+ bbox,
776
+ center,
777
+ osm_area_id,
778
+ class: item.class,
779
+ type: item.type,
780
+ osm_type: item.osm_type,
781
+ osm_id: osmIdNum,
782
+ ...(polygons_with_holes ? { polygons_with_holes } : {})
783
+ };
784
+ });
785
+ let primaryIndex = parsedItems.findIndex(i => i.osm_type === "relation" &&
786
+ (i.class === "boundary" || i.type === "administrative" || i.type === "boundary"));
787
+ if (primaryIndex === -1)
788
+ primaryIndex = 0;
789
+ const primary = parsedItems[primaryIndex];
790
+ const alternatives = parsedItems.filter((_, idx) => idx !== primaryIndex);
791
+ return {
792
+ ...primary,
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();
412
821
  }
413
- if (!data.features || data.features.length === 0) {
414
- throw new Error(`No results found for location: ${query}`);
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
+ }
415
844
  }
416
- const feature = data.features[0];
417
- if (feature.bbox) {
418
- // mb bbox format: minX, minY, maxX, maxY
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
+ });
419
880
  return {
420
- lat1: feature.bbox[1],
421
- lng1: feature.bbox[0],
422
- lat2: feature.bbox[3],
423
- lng2: feature.bbox[2]
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
+ }
424
891
  };
425
892
  }
426
- else if (feature.center) {
427
- // create a small bbox around the center point (10km roughly)
428
- const centerLng = feature.center[0];
429
- const centerLat = feature.center[1];
430
- const offset = 0.05;
893
+ }
894
+ catch (err) {
895
+ log('warn', `Photon fallback geocoding failed for '${query}'`, { error: err.message });
896
+ }
897
+ // 3. Optional fallback to Mapbox if configured
898
+ if (MAPBOX_ACCESS_TOKEN) {
899
+ const controller = new AbortController();
900
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
901
+ try {
902
+ const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json?access_token=${MAPBOX_ACCESS_TOKEN}&limit=5`;
903
+ const response = await fetch(url, { signal: controller.signal });
904
+ if (!response.ok) {
905
+ throw new Error(`Geocoding failed with status: ${response.status} ${response.statusText}`);
906
+ }
907
+ const data = await response.json();
908
+ if (!data || !Array.isArray(data.features) || data.features.length === 0) {
909
+ throw new Error(`No results found for location: ${query}`);
910
+ }
911
+ const mapboxItems = data.features.map((feature) => {
912
+ let bbox;
913
+ let center;
914
+ if (feature.bbox) {
915
+ bbox = ensureValidBBox(feature.bbox[1], feature.bbox[0], feature.bbox[3], feature.bbox[2]);
916
+ center = { lat: (bbox.lat1 + bbox.lat2) / 2, lng: (bbox.lng1 + bbox.lng2) / 2 };
917
+ }
918
+ else if (feature.center) {
919
+ center = { lat: feature.center[1], lng: feature.center[0] };
920
+ bbox = ensureValidBBox(center.lat - 0.05, center.lng - 0.05, center.lat + 0.05, center.lng + 0.05);
921
+ }
922
+ else {
923
+ throw new Error(`Unable to determine location bounds for feature`);
924
+ }
925
+ return {
926
+ matched_place: feature.place_name || feature.text || query,
927
+ bbox,
928
+ center,
929
+ class: feature.properties?.category
930
+ };
931
+ });
932
+ const primary = mapboxItems[0];
933
+ const alternatives = mapboxItems.slice(1);
431
934
  return {
432
- lat1: centerLat - offset,
433
- lng1: centerLng - offset,
434
- lat2: centerLat + offset,
435
- lng2: centerLng + offset
935
+ ...primary,
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
+ }
436
944
  };
437
945
  }
946
+ catch (err) {
947
+ if (err.name === 'AbortError') {
948
+ throw new Error(`Geocoding request timed out for query: ${query}`);
949
+ }
950
+ throw err;
951
+ }
952
+ finally {
953
+ clearTimeout(timeout);
954
+ }
955
+ }
956
+ throw new Error(`No geocoding results found for location: ${query}`);
957
+ }
958
+ async function reverseGeocode(lat, lng, zoom = 18) {
959
+ if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
960
+ throw new Error(`Invalid lat/lng coordinates for reverse geocoding: (${lat}, ${lng})`);
961
+ }
962
+ const clampedZoom = Math.max(0, Math.min(18, Math.round(zoom)));
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);
966
+ if (!data || !data.display_name) {
967
+ throw new Error(`No reverse geocoding results found for coordinates: (${lat}, ${lng})`);
968
+ }
969
+ let bbox;
970
+ if (data.boundingbox && data.boundingbox.length === 4) {
971
+ const south = parseFloat(data.boundingbox[0]);
972
+ const north = parseFloat(data.boundingbox[1]);
973
+ const west = parseFloat(data.boundingbox[2]);
974
+ const east = parseFloat(data.boundingbox[3]);
975
+ bbox = ensureValidBBox(south, west, north, east);
976
+ }
977
+ else {
978
+ bbox = ensureValidBBox(lat - 0.005, lng - 0.005, lat + 0.005, lng + 0.005);
979
+ }
980
+ const shareUrl = buildShareUrlMarkersOnly([{ coordinates: { lat, lon: lng }, name: data.display_name }]);
981
+ return {
982
+ matched_place: data.display_name,
983
+ center: { lat, lng },
984
+ bbox,
985
+ address: data.address || {},
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: []
1001
+ };
1002
+ }
1003
+ function splitOverpassStatements(query) {
1004
+ const statements = [];
1005
+ let current = '';
1006
+ let inDouble = false;
1007
+ let inSingle = false;
1008
+ for (let i = 0; i < query.length; i++) {
1009
+ const char = query[i];
1010
+ if (char === '"' && !inSingle) {
1011
+ inDouble = !inDouble;
1012
+ current += char;
1013
+ }
1014
+ else if (char === "'" && !inDouble) {
1015
+ inSingle = !inSingle;
1016
+ current += char;
1017
+ }
1018
+ else if (char === ';' && !inDouble && !inSingle) {
1019
+ if (current.trim()) {
1020
+ statements.push(current.trim());
1021
+ }
1022
+ current = '';
1023
+ }
438
1024
  else {
439
- throw new Error(`Unable to determine bounding box for location: ${query}`);
1025
+ current += char;
440
1026
  }
441
1027
  }
442
- catch (err) {
443
- if (err.name === 'AbortError') {
444
- throw new Error(`Geocoding request timed out after ${FETCH_TIMEOUT_MS / 1000}s for query: ${query}`);
1028
+ if (current.trim()) {
1029
+ statements.push(current.trim());
1030
+ }
1031
+ return statements;
1032
+ }
1033
+ /**
1034
+ * Attaches a spatial filter (e.g. `(area.searchArea)` or `(around:2000,lat,lng)`)
1035
+ * to every selector statement in a multi-statement Overpass QL query string.
1036
+ * Respects quoted semicolons in tag values and skips non-selector/output statements.
1037
+ */
1038
+ function applySpatialFilter(queryRaw, spatialFilter) {
1039
+ if (!spatialFilter)
1040
+ return queryRaw.trim().endsWith(';') ? queryRaw.trim() : `${queryRaw.trim()};`;
1041
+ const statements = splitOverpassStatements(queryRaw);
1042
+ if (statements.length === 0)
1043
+ return queryRaw;
1044
+ const processed = statements.map(stmt => {
1045
+ // Skip non-selector statements like 'out center', directives, set operations, comments
1046
+ if (/^(out|[()\[]|->)/i.test(stmt) || /\bout\s+(body|skel|ids|meta|geom|center)/i.test(stmt)) {
1047
+ return stmt;
445
1048
  }
446
- throw err;
1049
+ if (stmt.includes(spatialFilter.trim())) {
1050
+ return stmt;
1051
+ }
1052
+ // If statement defines a named set (e.g., `... -> .searchSet`), insert spatial filter before `->`
1053
+ if (/->\s*\.[a-zA-Z0-9_]+/.test(stmt)) {
1054
+ return stmt.replace(/(->\s*\.[a-zA-Z0-9_]+)/, `${spatialFilter} $1`);
1055
+ }
1056
+ return `${stmt}${spatialFilter}`;
1057
+ });
1058
+ return processed.join('; ') + ';';
1059
+ }
1060
+ function calculateHaversineDistanceMeters(lat1, lng1, lat2, lng2) {
1061
+ const R = 6371000; // Earth radius in meters
1062
+ const dLat = (lat2 - lat1) * Math.PI / 180;
1063
+ const dLng = (lng2 - lng1) * Math.PI / 180;
1064
+ const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
1065
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
1066
+ Math.sin(dLng / 2) * Math.sin(dLng / 2);
1067
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
1068
+ return R * c;
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}`);
447
1259
  }
448
1260
  finally {
449
- clearTimeout(timeout);
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);
450
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
+ };
451
1290
  }
452
- async function geocodeLocationPoint(query) {
453
- if (!MAPBOX_ACCESS_TOKEN) {
454
- throw new Error('Mapbox API token not configured. Set MAPBOX_ACCESS_TOKEN to use location search. Provide explicit coordinates via bbox instead.');
1291
+ async function getTaginfoSuggestions(keyword) {
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
+ };
455
1302
  }
1303
+ const MAINSTREAM_KEYS = new Set([
1304
+ "amenity", "shop", "leisure", "tourism", "natural", "highway",
1305
+ "office", "healthcare", "craft", "sport", "landuse", "building",
1306
+ "historic", "emergency", "railway", "aeroway", "public_transport",
1307
+ "man_made", "Waterway", "waterway", "barrier", "power", "place"
1308
+ ]);
456
1309
  const controller = new AbortController();
457
1310
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
458
1311
  try {
459
- const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json?access_token=${MAPBOX_ACCESS_TOKEN}&limit=1`;
460
- const response = await fetch(url, { signal: controller.signal });
461
- if (!response.ok)
462
- throw new Error(`Geocoding failed: ${response.status} ${response.statusText}`);
463
- const data = await response.json();
464
- if (!data?.features?.length)
465
- throw new Error(`No results found for location: ${query}`);
466
- const feature = data.features[0];
467
- if (feature.center) {
468
- return { lat: feature.center[1], lng: feature.center[0] };
469
- }
470
- else if (feature.bbox) {
471
- return { lat: (feature.bbox[1] + feature.bbox[3]) / 2, lng: (feature.bbox[0] + feature.bbox[2]) / 2 };
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
+ }
472
1321
  }
473
1322
  else {
474
- throw new Error(`Unable to determine center point for location: ${query}`);
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
+ })
1353
+ .sort((a, b) => (b.count_all ?? 0) - (a.count_all ?? 0))
1354
+ .slice(0, 10);
1355
+ const now = Date.now();
1356
+ if (filtered.length === 0) {
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
+ };
475
1365
  }
1366
+ const tags = [];
1367
+ const lines = filtered.map((item) => {
1368
+ const valStr = item.value ? `="${item.value}"` : '';
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}`;
1373
+ });
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
+ };
476
1383
  }
477
1384
  catch (err) {
478
- if (err.name === 'AbortError')
479
- throw new Error(`Geocoding timed out after ${FETCH_TIMEOUT_MS / 1000}s for: ${query}`);
480
- throw err;
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
+ };
481
1393
  }
482
1394
  finally {
483
1395
  clearTimeout(timeout);
@@ -500,22 +1412,55 @@ function buildShareUrlCircleWithMarkers(center, radiusMeters, elements) {
500
1412
  // ---------------------------------------------------------------------------
501
1413
  // Tool Handlers
502
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
+ };
503
1437
  server.setRequestHandler(ListToolsRequestSchema, async () => {
504
1438
  return {
505
1439
  tools: [
506
1440
  {
507
1441
  name: "get_bounds",
508
- 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
+ },
509
1450
  inputSchema: {
510
1451
  type: "object",
511
1452
  properties: {
512
1453
  location: {
513
1454
  type: "string",
514
- 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."
515
1456
  },
516
1457
  bbox: {
517
1458
  type: "string",
518
- 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."
519
1464
  },
520
1465
  epsg: {
521
1466
  type: "string",
@@ -545,20 +1490,70 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
545
1490
  { required: ["bbox"] }
546
1491
  ]
547
1492
  },
548
- },
549
- {
550
- name: "get_h3_indices",
551
- 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.",
552
- inputSchema: {
1493
+ outputSchema: {
553
1494
  type: "object",
554
1495
  properties: {
555
- location: {
556
- type: "string",
557
- description: "A text location to search for (e.g. 'San Francisco'). Requires MAPBOX_ACCESS_TOKEN env var. Either 'location' or 'bbox' MUST be provided."
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
+ }
1536
+ },
1537
+ {
1538
+ name: "get_h3_indices",
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
+ },
1547
+ inputSchema: {
1548
+ type: "object",
1549
+ properties: {
1550
+ location: {
1551
+ type: "string",
1552
+ description: "A text location to search for (e.g. 'San Francisco'). Either 'location' or 'bbox' MUST be provided."
558
1553
  },
559
1554
  bbox: {
560
1555
  type: "string",
561
- 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.",
562
1557
  },
563
1558
  resolution: {
564
1559
  type: "number",
@@ -568,9 +1563,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
568
1563
  type: "boolean",
569
1564
  description: "Whether to return a compacted list of cells (mixing resolutions) to minimize response size. Defaults to false."
570
1565
  },
1566
+ summary_only: {
1567
+ type: "boolean",
1568
+ description: "If true, returns only cell count, sample cells, and map share URL without dumping thousands of cell IDs into context. Ideal for high resolutions. Defaults to false."
1569
+ },
571
1570
  return_geometry: {
572
1571
  type: "boolean",
573
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."
574
1577
  }
575
1578
  },
576
1579
  required: ["resolution"],
@@ -579,30 +1582,125 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
579
1582
  { required: ["bbox"] }
580
1583
  ]
581
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
+ }
582
1604
  },
583
1605
  {
584
1606
  name: "generate_share_url",
585
- 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
+ },
586
1615
  inputSchema: {
587
1616
  type: "object",
588
1617
  properties: {
589
1618
  bbox: {
590
1619
  type: "string",
591
- 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.",
592
1621
  }
593
1622
  },
594
1623
  required: ["bbox"],
595
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
+ }
1636
+ },
1637
+ {
1638
+ name: "reverse_geocode",
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
+ },
1647
+ inputSchema: {
1648
+ type: "object",
1649
+ properties: {
1650
+ lat: {
1651
+ type: "number",
1652
+ description: "Latitude coordinate (-90 to 90)."
1653
+ },
1654
+ lng: {
1655
+ type: "number",
1656
+ description: "Longitude coordinate (-180 to 180)."
1657
+ },
1658
+ zoom: {
1659
+ type: "number",
1660
+ description: "Detail zoom level from 0 (country) to 18 (building/address). Defaults to 18."
1661
+ }
1662
+ },
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"]
1686
+ }
596
1687
  },
597
1688
  {
598
1689
  name: "search_overpass",
599
- description: "Execute an Overpass QL query to find POIs, roads, or other OSM features within a bounding box or radius. The server wraps your query in the appropriate spatial filter and returns structured JSON with a map link.\n\nSEARCH MODES:\n- Bounding box (default): searches a rectangular area. Use for region/area queries ('hospitals in Manhattan', 'parks in downtown Seattle').\n- Circle: searches within an exact radius. Use ONLY when the user specifies an explicit distance ('within 2km of JFK', '500 metres from the Eiffel Tower', '1 mile from Times Square'). Omit radius_meters for vague terms like 'near' or 'close to'.\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- Schools: `nwr[\"amenity\"=\"school\"]`\n- Parking: `nwr[\"amenity\"=\"parking\"]`\n- Highways/Roads: `way[\"highway\"]`",
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
+ },
600
1698
  inputSchema: {
601
1699
  type: "object",
602
1700
  properties: {
603
1701
  location: {
604
1702
  type: "string",
605
- description: "A text location to search for (e.g. 'San Francisco', 'JFK Airport'). Requires MAPBOX_ACCESS_TOKEN env var. Either 'location' or 'bbox' MUST be provided."
1703
+ description: "A text location to search for (e.g. 'Manhattan', 'San Francisco'). Either 'location' or 'bbox' MUST be provided."
606
1704
  },
607
1705
  bbox: {
608
1706
  type: "string",
@@ -610,19 +1708,39 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
610
1708
  },
611
1709
  query: {
612
1710
  type: "string",
613
- description: "The Overpass QL core query. Example: `node[\"amenity\"=\"cafe\"]` or `nwr[\"leisure\"=\"park\"]`. DO NOT include spatial filters, bounding box, or output directives — the server handles those automatically."
1711
+ description: "The Overpass QL core query. Example: `node[\"amenity\"=\"cafe\"]` or `nwr[\"leisure\"=\"park\"]`. Multi-statement queries supported (e.g. `nwr[\"office\"=\"coworking\"]; nwr[\"amenity\"=\"coworking_space\"]`). DO NOT include spatial filters or output directives."
614
1712
  },
615
1713
  radius_meters: {
616
1714
  type: "number",
617
- description: "Search radius in metres. Enables circle mode — use ONLY when the user specifies an explicit distance (e.g. '2km' → 2000, '500 metres' → 500, '1 mile' → 1609). Omit entirely for area/region queries or vague terms like 'near'."
1715
+ description: "Search radius in metres. Enables circle mode — use ONLY when explicit distance is specified ('2km' → 2000)."
618
1716
  },
619
1717
  limit: {
620
1718
  type: "number",
621
- description: "Maximum number of elements to return. Default is 100. Increase if you need more results."
1719
+ description: "Maximum number of elements to return per page. Default is 100."
1720
+ },
1721
+ offset: {
1722
+ type: "number",
1723
+ description: "Pagination offset for retrieving subsequent pages of results. Default is 0."
1724
+ },
1725
+ timeout_seconds: {
1726
+ type: "number",
1727
+ description: "Overpass server query timeout in seconds. Default is 25."
1728
+ },
1729
+ use_area_boundary: {
1730
+ type: "boolean",
1731
+ description: "If true (default), uses exact OSM administrative boundary polygon for named locations when available, avoiding bounding box collapse."
1732
+ },
1733
+ sort_by_distance: {
1734
+ type: "boolean",
1735
+ description: "If true, sorts returned elements by distance from origin/center. Enabled by default for circle mode and point locations."
622
1736
  },
623
1737
  return_geometry: {
624
1738
  type: "boolean",
625
- description: "If true, returns the full geometry (polygon/polyline) for ways and relations instead of just the center point. Necessary for area or length calculations. Defaults to false."
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."
626
1744
  }
627
1745
  },
628
1746
  required: ["query"],
@@ -631,24 +1749,87 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
631
1749
  { required: ["bbox"] }
632
1750
  ]
633
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
+ }
634
1788
  },
635
1789
  {
636
1790
  name: "list_osm_tags",
637
- description: "Discovery tool to look up the correct OpenStreetMap tags for a given category. Helps prevent hallucinating incorrect tags before writing an Overpass query.",
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
+ },
638
1799
  inputSchema: {
639
1800
  type: "object",
640
1801
  properties: {
641
1802
  category: {
642
1803
  type: "string",
643
- description: "Broad category to look up (e.g. 'food', 'health', 'transport', 'leisure', 'retail')."
1804
+ description: "Category or keyword to look up (e.g. 'food', 'ev charging', 'gym', 'coworking', 'banks', 'dog park')."
644
1805
  }
645
1806
  },
646
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"]
647
1821
  }
648
1822
  },
649
1823
  {
650
1824
  name: "aggregate_overpass_h3",
651
- 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
+ },
652
1833
  inputSchema: {
653
1834
  type: "object",
654
1835
  properties: {
@@ -667,6 +1848,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
667
1848
  resolution: {
668
1849
  type: "number",
669
1850
  description: "The H3 resolution level (0-15) for binning. Default is 8."
1851
+ },
1852
+ radius_meters: {
1853
+ type: "number",
1854
+ description: "Optional search radius in metres around the location/bbox center."
1855
+ },
1856
+ timeout_seconds: {
1857
+ type: "number",
1858
+ description: "Overpass server query timeout in seconds. Default is 25."
1859
+ },
1860
+ use_area_boundary: {
1861
+ type: "boolean",
1862
+ description: "If true (default), uses exact OSM boundary polygon for named locations when available."
670
1863
  }
671
1864
  },
672
1865
  required: ["query", "resolution"],
@@ -674,6 +1867,254 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
674
1867
  { required: ["location"] },
675
1868
  { required: ["bbox"] }
676
1869
  ]
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"]
677
2118
  }
678
2119
  }
679
2120
  ],
@@ -685,38 +2126,42 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
685
2126
  try {
686
2127
  if (toolName === "get_bounds") {
687
2128
  const args = request.params.arguments || {};
2129
+ const warnings = [];
688
2130
  let bboxObj;
2131
+ let geocodeRes;
689
2132
  if (args.location) {
690
2133
  try {
691
- bboxObj = await geocodeLocation(args.location);
2134
+ geocodeRes = await geocodeLocation(args.location);
2135
+ bboxObj = geocodeRes.bbox;
692
2136
  }
693
2137
  catch (err) {
694
2138
  if (!args.bbox) {
695
2139
  log('error', `Geocoding failed for location: ${args.location}`, { error: err.message });
696
2140
  return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
697
2141
  }
698
- // User provided bbox fallback
699
2142
  log('warn', `Geocoding failed, falling back to bbox argument`, { error: err.message });
700
- bboxObj = parseBBox(args.bbox);
2143
+ bboxObj = parseBBox(args.bbox, warnings);
701
2144
  }
702
2145
  }
703
2146
  else if (args.bbox) {
704
- bboxObj = parseBBox(args.bbox);
2147
+ bboxObj = parseBBox(args.bbox, warnings);
705
2148
  }
706
2149
  else {
707
2150
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
708
2151
  }
2152
+ const bufferMeters = args.buffer_meters;
2153
+ if (typeof bufferMeters === 'number' && bufferMeters !== 0) {
2154
+ bboxObj = expandBBoxMeters(bboxObj, bufferMeters);
2155
+ }
709
2156
  let epsg = args.epsg || "4326";
710
2157
  let format = args.format || "csv";
711
2158
  let coordOrder = args.coord_order || "lng,lat";
712
- let zoom = args.zoom || 15;
713
- let precision = args.precision || 6;
2159
+ let zoom = args.zoom ?? 15;
2160
+ let precision = args.precision ?? 6;
714
2161
  epsg = epsg.replace(/[^0-9]/g, "");
715
- // Dynamically fetch projection if not bundled
716
2162
  await ensureProjection(epsg);
717
2163
  const projCoords = projectBounds(bboxObj, epsg);
718
2164
  const WGS84Formatted = getFormattedBox(bboxObj, format, precision, coordOrder);
719
- // Build projected bbox for formatting (uses projected values)
720
2165
  const projBbox = {
721
2166
  lat1: projCoords.ymin,
722
2167
  lng1: projCoords.xmin,
@@ -727,79 +2172,144 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
727
2172
  const shareUrl = buildShareUrl(bboxObj);
728
2173
  const dims = calculateBboxDimensions(bboxObj);
729
2174
  const areaKm2 = calculateBboxArea(bboxObj);
730
- // Calculate center and tile indices based on WGS84 original bounds
731
2175
  const centerLat = (bboxObj.lat1 + bboxObj.lat2) / 2;
732
2176
  const centerLng = (bboxObj.lng1 + bboxObj.lng2) / 2;
733
2177
  const tileIndices = latLngToTile(centerLat, centerLng, zoom);
734
2178
  log('info', `get_bounds completed`, { epsg, format, area_km2: areaKm2 });
2179
+ const geocodeInfo = geocodeRes ? `Matched Place: "${geocodeRes.matched_place}"\n` : '';
2180
+ const altInfo = geocodeRes && geocodeRes.alternatives.length > 0
2181
+ ? `Alternates: ${geocodeRes.alternatives.map(a => `"${a.matched_place}"`).join(', ')}\n\n`
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
+ };
735
2212
  return {
736
2213
  content: [
737
2214
  {
738
2215
  type: "text",
739
- text: `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}`
740
- },
741
- {
742
- type: "text",
743
- text: JSON.stringify({
744
- original_wgs84: bboxObj,
745
- projected: projCoords,
746
- center: {
747
- lat: centerLat,
748
- lng: centerLng
749
- },
750
- tile_indices: tileIndices,
751
- epsg,
752
- format,
753
- coord_order: coordOrder,
754
- precision,
755
- area_km2: areaKm2,
756
- dimensions: {
757
- width_km: dims.widthKm,
758
- height_km: dims.heightKm
759
- },
760
- share_url: shareUrl
761
- }, null, 2)
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)}`
762
2217
  }
763
- ]
2218
+ ],
2219
+ structuredContent: structuredData
764
2220
  };
765
2221
  }
766
2222
  else if (toolName === "get_h3_indices") {
767
2223
  const args = request.params.arguments || {};
768
2224
  const resolution = args.resolution;
769
2225
  const compact = args.compact || false;
2226
+ const summaryOnly = args.summary_only || false;
770
2227
  const returnGeometry = args.return_geometry || false;
2228
+ const usePolygonBoundary = args.use_polygon_boundary !== false;
2229
+ const warnings = [];
771
2230
  if (resolution === undefined) {
772
2231
  return { content: [{ type: "text", text: "Error: 'resolution' is required." }], isError: true };
773
2232
  }
774
2233
  let bboxObj;
2234
+ let geocodeRes;
2235
+ let polygonsWithHoles = null;
775
2236
  if (args.location) {
776
2237
  try {
777
- bboxObj = await geocodeLocation(args.location);
2238
+ geocodeRes = await geocodeLocation(args.location, usePolygonBoundary);
2239
+ bboxObj = geocodeRes.bbox;
2240
+ if (usePolygonBoundary && geocodeRes.polygons_with_holes) {
2241
+ polygonsWithHoles = geocodeRes.polygons_with_holes;
2242
+ }
778
2243
  }
779
2244
  catch (err) {
780
2245
  if (!args.bbox) {
781
2246
  log('error', `Geocoding failed for H3 location: ${args.location}`, { error: err.message });
782
2247
  return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
783
2248
  }
784
- bboxObj = parseBBox(args.bbox);
2249
+ bboxObj = parseBBox(args.bbox, warnings);
2250
+ if (usePolygonBoundary) {
2251
+ polygonsWithHoles = extractPolygonsWithHolesLatLng(args.bbox);
2252
+ }
785
2253
  }
786
2254
  }
787
2255
  else if (args.bbox) {
788
- bboxObj = parseBBox(args.bbox);
2256
+ bboxObj = parseBBox(args.bbox, warnings);
2257
+ if (usePolygonBoundary) {
2258
+ polygonsWithHoles = extractPolygonsWithHolesLatLng(args.bbox);
2259
+ }
789
2260
  }
790
2261
  else {
791
2262
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
792
2263
  }
793
- const cells = getH3Cells(bboxObj, resolution, compact);
794
- const shareUrl = buildShareUrl(bboxObj);
795
- log('info', `get_h3_indices completed`, { resolution, compact, 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 });
2276
+ if (summaryOnly) {
2277
+ const sampleCells = cells.slice(0, 10);
2278
+ const responseData = {
2279
+ resolution,
2280
+ compact,
2281
+ summary_only: true,
2282
+ used_polygon_boundary: usedPolygon,
2283
+ cell_count: cells.length,
2284
+ sample_cells: sampleCells,
2285
+ original_bbox: bboxObj,
2286
+ share_url: shareUrl,
2287
+ provenance,
2288
+ complete: false,
2289
+ warnings
2290
+ };
2291
+ return {
2292
+ content: [
2293
+ {
2294
+ type: "text",
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)}`
2296
+ }
2297
+ ],
2298
+ structuredContent: responseData
2299
+ };
2300
+ }
796
2301
  const responseData = {
797
2302
  resolution,
798
2303
  compact,
2304
+ summary_only: false,
2305
+ used_polygon_boundary: usedPolygon,
799
2306
  cell_count: cells.length,
800
2307
  cells,
801
2308
  original_bbox: bboxObj,
802
- share_url: shareUrl
2309
+ share_url: shareUrl,
2310
+ provenance,
2311
+ complete: true,
2312
+ warnings
803
2313
  };
804
2314
  if (returnGeometry) {
805
2315
  responseData.geojson = h3CellsToGeoJSON(cells);
@@ -808,46 +2318,74 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
808
2318
  content: [
809
2319
  {
810
2320
  type: "text",
811
- text: `H3 cells generated: ${cells.length.toLocaleString()}\nResolution: ${resolution} | Compacted: ${compact}\n\nCells:\n${JSON.stringify(cells)}\n\nView on map: ${shareUrl}`
812
- },
813
- {
814
- type: "text",
815
- 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)}`
816
2322
  }
817
- ]
2323
+ ],
2324
+ structuredContent: responseData
818
2325
  };
819
2326
  }
820
2327
  else if (toolName === "generate_share_url") {
821
2328
  const args = request.params.arguments || {};
822
2329
  const bbox = args.bbox;
2330
+ const warnings = [];
823
2331
  if (!bbox) {
824
2332
  return { content: [{ type: "text", text: "Error: 'bbox' is required." }], isError: true };
825
2333
  }
826
- const bboxObj = parseBBox(bbox);
2334
+ const bboxObj = parseBBox(bbox, warnings);
827
2335
  const shareUrl = buildShareUrl(bboxObj);
828
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
+ };
829
2349
  return {
830
2350
  content: [
831
2351
  {
832
2352
  type: "text",
833
- text: `View on map: ${shareUrl}`
834
- },
2353
+ text: `View on map: ${shareUrl}${formatWarnings(warnings)}`
2354
+ }
2355
+ ],
2356
+ structuredContent: structuredData
2357
+ };
2358
+ }
2359
+ else if (toolName === "reverse_geocode") {
2360
+ const args = request.params.arguments || {};
2361
+ const lat = args.lat;
2362
+ const lng = args.lng;
2363
+ const zoom = args.zoom ?? 18;
2364
+ if (lat === undefined || lng === undefined) {
2365
+ return { content: [{ type: "text", text: "Error: Both 'lat' and 'lng' arguments are required." }], isError: true };
2366
+ }
2367
+ const result = await reverseGeocode(lat, lng, zoom);
2368
+ log('info', `reverse_geocode completed`, { lat, lng, matched: result.matched_place });
2369
+ return {
2370
+ content: [
835
2371
  {
836
2372
  type: "text",
837
- text: JSON.stringify({
838
- bbox: bboxObj,
839
- share_url: shareUrl
840
- }, null, 2)
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}`
841
2374
  }
842
- ]
2375
+ ],
2376
+ structuredContent: result
843
2377
  };
844
2378
  }
845
2379
  else if (toolName === "search_overpass") {
846
2380
  const args = request.params.arguments || {};
847
2381
  const queryRaw = args.query;
848
2382
  const limit = args.limit || 100;
2383
+ const offset = args.offset || 0;
2384
+ const timeoutSec = args.timeout_seconds || 25;
2385
+ const useAreaBoundary = args.use_area_boundary !== false;
849
2386
  const radiusMeters = args.radius_meters;
850
2387
  const returnGeometry = args.return_geometry || false;
2388
+ const countOnly = args.count_only || false;
851
2389
  const useCircle = typeof radiusMeters === 'number' && radiusMeters > 0;
852
2390
  if (!queryRaw) {
853
2391
  return { content: [{ type: "text", text: "Error: 'query' is required (e.g. `node[\"amenity\"=\"restaurant\"]`)." }], isError: true };
@@ -858,20 +2396,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
858
2396
  if (queryRaw.includes("[out:") || queryRaw.includes("[timeout:") || queryRaw.includes("[bbox:")) {
859
2397
  return { content: [{ type: "text", text: "Error: Query must not contain [out:], [timeout:], or [bbox:] directives. The server handles these automatically." }], isError: true };
860
2398
  }
2399
+ const warnings = [];
861
2400
  let overpassQuery;
862
2401
  let searchModeLabel;
863
- let centerPoint;
2402
+ let refPoint;
864
2403
  let bboxObj;
2404
+ let geocodeRes;
2405
+ let usedAreaId;
2406
+ const outDirective = countOnly ? "out count;" : (returnGeometry ? "out geom;" : "out center;");
865
2407
  if (useCircle) {
866
2408
  if (args.location) {
867
2409
  try {
868
- centerPoint = await geocodeLocationPoint(args.location);
2410
+ geocodeRes = await geocodeLocation(args.location);
2411
+ refPoint = { lat: geocodeRes.center.lat, lng: geocodeRes.center.lng, description: `geocoded center of "${geocodeRes.matched_place}"` };
869
2412
  }
870
2413
  catch (err) {
871
2414
  if (args.bbox) {
872
2415
  log('warn', `Geocoding failed for radius search, using bbox center`, { error: err.message });
873
- const fb = parseBBox(args.bbox);
874
- centerPoint = { lat: (fb.lat1 + fb.lat2) / 2, lng: (fb.lng1 + fb.lng2) / 2 };
2416
+ const fb = parseBBox(args.bbox, warnings);
2417
+ refPoint = { lat: (fb.lat1 + fb.lat2) / 2, lng: (fb.lng1 + fb.lng2) / 2, description: "centroid of provided bounding box" };
875
2418
  }
876
2419
  else {
877
2420
  log('error', `Geocoding failed for radius search: ${args.location}`, { error: err.message });
@@ -880,25 +2423,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
880
2423
  }
881
2424
  }
882
2425
  else if (args.bbox) {
883
- const fb = parseBBox(args.bbox);
884
- centerPoint = { lat: (fb.lat1 + fb.lat2) / 2, lng: (fb.lng1 + fb.lng2) / 2 };
2426
+ const fb = parseBBox(args.bbox, warnings);
2427
+ refPoint = { lat: (fb.lat1 + fb.lat2) / 2, lng: (fb.lng1 + fb.lng2) / 2, description: "centroid of provided bounding box" };
885
2428
  }
886
2429
  else {
887
2430
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
888
2431
  }
889
- const outDirective = returnGeometry ? "out geom;" : "out center;";
890
- overpassQuery = `[out:json][timeout:25];\n(${queryRaw}(around:${radiusMeters},${centerPoint.lat},${centerPoint.lng}););\n${outDirective}`;
891
- searchModeLabel = `circle — ${radiusMeters}m radius around (${centerPoint.lat.toFixed(5)}, ${centerPoint.lng.toFixed(5)})`;
2432
+ const circleFilter = `(around:${radiusMeters},${refPoint.lat},${refPoint.lng})`;
2433
+ const formattedCore = applySpatialFilter(queryRaw, circleFilter);
2434
+ overpassQuery = `[out:json][timeout:${timeoutSec}];\n(${formattedCore});\n${outDirective}`;
2435
+ searchModeLabel = `circle — ${radiusMeters}m radius around (${refPoint.lat.toFixed(5)}, ${refPoint.lng.toFixed(5)})`;
892
2436
  }
893
2437
  else {
894
2438
  if (args.location) {
895
2439
  try {
896
- bboxObj = await geocodeLocation(args.location);
2440
+ geocodeRes = await geocodeLocation(args.location);
2441
+ bboxObj = geocodeRes.bbox;
2442
+ refPoint = { lat: geocodeRes.center.lat, lng: geocodeRes.center.lng, description: `geocoded centroid of "${geocodeRes.matched_place}"` };
2443
+ if (useAreaBoundary && geocodeRes.osm_area_id) {
2444
+ usedAreaId = geocodeRes.osm_area_id;
2445
+ }
897
2446
  }
898
2447
  catch (err) {
899
2448
  if (args.bbox) {
900
2449
  log('warn', `Geocoding failed, falling back to bbox argument`, { error: err.message });
901
- bboxObj = parseBBox(args.bbox);
2450
+ bboxObj = parseBBox(args.bbox, warnings);
2451
+ refPoint = { lat: (bboxObj.lat1 + bboxObj.lat2) / 2, lng: (bboxObj.lng1 + bboxObj.lng2) / 2, description: "centroid of provided bounding box" };
902
2452
  }
903
2453
  else {
904
2454
  log('error', `Geocoding failed for overpass location: ${args.location}`, { error: err.message });
@@ -907,75 +2457,77 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
907
2457
  }
908
2458
  }
909
2459
  else if (args.bbox) {
910
- bboxObj = parseBBox(args.bbox);
2460
+ bboxObj = parseBBox(args.bbox, warnings);
2461
+ refPoint = { lat: (bboxObj.lat1 + bboxObj.lat2) / 2, lng: (bboxObj.lng1 + bboxObj.lng2) / 2, description: "centroid of provided bounding box" };
911
2462
  }
912
2463
  else {
913
2464
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
914
2465
  }
915
- const outDirective = returnGeometry ? "out geom;" : "out center;";
916
- overpassQuery = `[out:json][timeout:25][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${queryRaw};);\n${outDirective}`;
917
- searchModeLabel = `bounding box`;
918
- }
919
- log('info', `search_overpass starting`, { query: queryRaw, mode: useCircle ? 'circle' : 'bbox', radius_meters: radiusMeters });
920
- const endpoints = [
921
- "https://overpass-api.de/api/interpreter",
922
- "https://overpass.kumi.systems/api/interpreter"
923
- ];
924
- if (process.env.OVERPASS_API_URL) {
925
- endpoints.unshift(process.env.OVERPASS_API_URL);
926
- }
927
- let data = null;
928
- let lastError = null;
929
- const params = new URLSearchParams();
930
- params.append('data', overpassQuery);
931
- for (const endpoint of endpoints) {
932
- const controller = new AbortController();
933
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS * 2);
934
- try {
935
- log('info', `Executing Overpass query at ${endpoint}`);
936
- const response = await fetch(endpoint, {
937
- method: "POST",
938
- body: params.toString(),
939
- headers: {
940
- 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
941
- 'Accept': 'application/json',
942
- 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION}`
943
- },
944
- signal: controller.signal
945
- });
946
- if (!response.ok) {
947
- throw new Error(`Overpass API returned status: ${response.status} ${response.statusText}`);
948
- }
949
- data = await response.json();
950
- break;
951
- }
952
- catch (err) {
953
- lastError = err;
954
- log('warn', `Overpass API failed at ${endpoint}`, { error: err.message });
2466
+ if (usedAreaId) {
2467
+ const formattedCore = applySpatialFilter(queryRaw, "(area.searchArea)");
2468
+ overpassQuery = `[out:json][timeout:${timeoutSec}];\narea(${usedAreaId})->.searchArea;\n(${formattedCore});\n${outDirective}`;
2469
+ searchModeLabel = `exact OSM boundary area (id: ${usedAreaId}) for "${geocodeRes?.matched_place}"`;
955
2470
  }
956
- finally {
957
- clearTimeout(timeout);
2471
+ else {
2472
+ const formattedCore = applySpatialFilter(queryRaw, "");
2473
+ overpassQuery = `[out:json][timeout:${timeoutSec}][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${formattedCore});\n${outDirective}`;
2474
+ searchModeLabel = `bounding box`;
958
2475
  }
959
2476
  }
960
- if (!data) {
961
- if (lastError?.name === 'AbortError') {
962
- return { content: [{ type: "text", text: `Error: Overpass API request timed out on all endpoints after ${(FETCH_TIMEOUT_MS * 4) / 1000}s. The query or radius may be too large.` }], isError: true };
963
- }
964
- throw new Error(`Overpass query failed on all endpoints: ${lastError?.message}`);
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}`);
2481
+ }
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
+ };
965
2514
  }
966
2515
  const seenIds = new Set();
967
2516
  let parsedElements = (data.elements || []).reduce((acc, e) => {
968
- if (seenIds.has(e.id))
2517
+ const dedupKey = `${e.type}/${e.id}`;
2518
+ if (seenIds.has(dedupKey))
969
2519
  return acc;
970
- seenIds.add(e.id);
2520
+ seenIds.add(dedupKey);
971
2521
  const lat = e.lat ?? e.center?.lat ?? e.geometry?.[0]?.lat;
972
2522
  const lon = e.lon ?? e.center?.lon ?? e.geometry?.[0]?.lon;
973
2523
  if (lat !== undefined && lon !== undefined) {
2524
+ const distM = calculateHaversineDistanceMeters(refPoint.lat, refPoint.lng, lat, lon);
974
2525
  const el = {
975
2526
  id: e.id,
976
2527
  type: e.type,
977
2528
  name: e.tags?.name || "Unnamed",
978
2529
  coordinates: { lat, lon },
2530
+ distance_meters: Math.round(distM * 10) / 10,
979
2531
  tags: e.tags || {}
980
2532
  };
981
2533
  if (returnGeometry && e.geometry) {
@@ -985,163 +2537,255 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
985
2537
  }
986
2538
  return acc;
987
2539
  }, []);
988
- const elementCount = parsedElements.length;
989
- let truncated = false;
990
- if (parsedElements.length > limit) {
991
- parsedElements = parsedElements.slice(0, limit);
992
- truncated = true;
993
- }
994
- const shareUrl = useCircle && centerPoint
995
- ? buildShareUrlCircleWithMarkers(centerPoint, radiusMeters, parsedElements)
996
- : buildShareUrlWithMarkers(bboxObj, parsedElements);
997
- log('info', `search_overpass completed`, { elements: elementCount, returned: parsedElements.length, truncated, mode: useCircle ? 'circle' : 'bbox' });
2540
+ // Deterministic sorting: sort by (distance_meters, type, id) ascending
2541
+ const sortByDistance = args.sort_by_distance !== false;
2542
+ parsedElements.sort((a, b) => {
2543
+ if (sortByDistance) {
2544
+ const diff = (a.distance_meters ?? 0) - (b.distance_meters ?? 0);
2545
+ if (Math.abs(diff) > 0.001)
2546
+ return diff;
2547
+ }
2548
+ const typeDiff = (a.type || "").localeCompare(b.type || "");
2549
+ if (typeDiff !== 0)
2550
+ return typeDiff;
2551
+ return (a.id ?? 0) - (b.id ?? 0);
2552
+ });
2553
+ if (args.location && useAreaBoundary && !usedAreaId && geocodeRes) {
2554
+ warnings.push(`Exact admin boundary polygon unavailable for '${geocodeRes.matched_place}', fell back to bounding box query.`);
2555
+ }
2556
+ const totalCount = parsedElements.length;
2557
+ const pagedElements = parsedElements.slice(offset, offset + limit);
2558
+ const truncated = totalCount > offset + limit;
2559
+ const hasMore = totalCount > offset + limit;
2560
+ const nextOffset = hasMore ? offset + limit : undefined;
2561
+ const shareUrl = useCircle
2562
+ ? buildShareUrlCircleWithMarkers(refPoint, radiusMeters, pagedElements)
2563
+ : (usedAreaId && !args.bbox && pagedElements.length > 0
2564
+ ? buildShareUrlMarkersOnly(pagedElements)
2565
+ : buildShareUrlWithMarkers(bboxObj || geocodeRes.bbox, pagedElements));
2566
+ log('info', `search_overpass completed`, { total: totalCount, returned: pagedElements.length, offset, limit, truncated, mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox') });
998
2567
  const responseData = {
999
- search_mode: useCircle ? 'circle' : 'bbox',
1000
- ...(useCircle && centerPoint
1001
- ? { center: centerPoint, radius_meters: radiusMeters }
1002
- : { original_bbox: bboxObj }),
1003
- generator: data.generator,
1004
- osm3s: data.osm3s,
1005
- elements: parsedElements,
1006
- total_count: elementCount,
1007
- returned_count: parsedElements.length,
2568
+ search_mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox'),
2569
+ count_only: false,
2570
+ ...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
2571
+ ...(usedAreaId ? { osm_area_id: usedAreaId } : {}),
2572
+ distance_from: refPoint,
2573
+ ...(data.generator ? { generator: data.generator } : {}),
2574
+ ...(data.osm3s ? { osm3s: data.osm3s } : {}),
2575
+ elements: pagedElements,
2576
+ total_count: totalCount,
2577
+ returned_count: pagedElements.length,
2578
+ offset,
2579
+ limit,
1008
2580
  truncated,
1009
- share_url: shareUrl
2581
+ has_more: hasMore,
2582
+ ...(nextOffset !== undefined ? { next_offset: nextOffset } : {}),
2583
+ pagination_hint: hasMore
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.`
2585
+ : "End of result set.",
2586
+ share_url: shareUrl,
2587
+ provenance,
2588
+ complete: !truncated && !remarkWarning,
2589
+ warnings
1010
2590
  };
1011
- const topResults = parsedElements.slice(0, 5)
1012
- .map((e) => `- [${e.type}] ${e.name} (${e.coordinates.lat.toFixed(5)}, ${e.coordinates.lon.toFixed(5)})`)
2591
+ const topResults = pagedElements.slice(0, 5)
2592
+ .map((e) => `- [${e.type}] ${e.name} (${e.coordinates.lat.toFixed(5)}, ${e.coordinates.lon.toFixed(5)}) — ${e.distance_meters}m away`)
1013
2593
  .join('\n');
1014
- const moreNote = parsedElements.length > 5 ? `\n … and ${parsedElements.length - 5} more (see JSON)` : '';
2594
+ const moreNote = pagedElements.length > 5 ? `\n … and ${pagedElements.length - 5} more on this page` : '';
1015
2595
  return {
1016
2596
  content: [
1017
2597
  {
1018
2598
  type: "text",
1019
- text: `Overpass query executed within a ${searchModeLabel}.\nFound ${elementCount} element${elementCount !== 1 ? 's' : ''}${truncated ? ` (showing first ${limit})` : ''}.\n\nTop results:\n${topResults}${moreNote}\n\nView on map: ${shareUrl}`
1020
- },
1021
- {
1022
- type: "text",
1023
- text: JSON.stringify(responseData, null, 2)
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)}`
1024
2600
  }
1025
- ]
2601
+ ],
2602
+ structuredContent: responseData
1026
2603
  };
1027
2604
  }
1028
2605
  else if (toolName === "list_osm_tags") {
1029
2606
  const args = request.params.arguments || {};
1030
- const category = (args.category || "").toLowerCase();
2607
+ const category = (args.category || "").toLowerCase().trim();
2608
+ const warnings = [];
1031
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
+ };
1032
2618
  switch (true) {
1033
- case category.includes('food') || category.includes('restaurant') || category.includes('cafe'):
1034
- recommendations = `Restaurants: nwr["amenity"="restaurant"]\nFast Food: nwr["amenity"="fast_food"]\nCafes: nwr["amenity"="cafe"]\nBars: nwr["amenity"="bar"]\nPubs: nwr["amenity"="pub"]`;
2619
+ case category.includes('food') || category.includes('restaurant') || category.includes('cafe') || category.includes('pizza') || category.includes('dining'):
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"]`;
1035
2621
  break;
1036
- case category.includes('health') || category.includes('medical'):
1037
- recommendations = `Hospitals: nwr["amenity"="hospital"]\nClinics: nwr["amenity"="clinic"]\nPharmacies: nwr["amenity"="pharmacy"]\nDoctors: nwr["amenity"="doctors"]\nDentists: nwr["amenity"="dentist"]`;
2622
+ case category.includes('health') || category.includes('medical') || category.includes('doctor') || category.includes('hospital') || category.includes('pharmacy'):
2623
+ recommendations = `Hospitals: nwr["amenity"="hospital"]\nClinics: nwr["amenity"="clinic"]\nPharmacies: nwr["amenity"="pharmacy"]\nDoctors: nwr["amenity"="doctors"]\nDentists: nwr["amenity"="dentist"]\nGeneral Healthcare: nwr["healthcare"]`;
1038
2624
  break;
1039
- case category.includes('transport') || category.includes('transit') || category.includes('traffic'):
1040
- recommendations = `Parking: nwr["amenity"="parking"]\nBus Stops: nwr["highway"="bus_stop"]\nSubway Stations: nwr["railway"="station"]["station"="subway"]\nTrain Stations: nwr["railway"="station"]\nBicycle Parking: nwr["amenity"="bicycle_parking"]\nGas Stations: nwr["amenity"="fuel"]`;
2625
+ case category.includes('transport') || category.includes('transit') || category.includes('bus') || category.includes('train') || category.includes('subway') || category.includes('gas') || category.includes('fuel') || category.includes('parking') || category.includes('airport') || category.includes('taxi'):
2626
+ recommendations = `Parking: nwr["amenity"="parking"]\nBus Stops: nwr["highway"="bus_stop"]\nSubway Stations: nwr["railway"="station"]["station"="subway"]\nTrain Stations: nwr["railway"="station"]\nBicycle Parking: nwr["amenity"="bicycle_parking"]\nGas Stations: nwr["amenity"="fuel"]\nFerry Terminals: nwr["amenity"="ferry_terminal"]\nAerodromes: nwr["aeroway"="aerodrome"]\nTaxi Stands: nwr["amenity"="taxi"]`;
1041
2627
  break;
1042
- case category.includes('leisure') || category.includes('park') || category.includes('entertainment'):
2628
+ case category.includes('leisure') || category.includes('park') || category.includes('playground') || category.includes('theatre') || category.includes('cinema'):
1043
2629
  recommendations = `Parks: nwr["leisure"="park"]\nPlaygrounds: nwr["leisure"="playground"]\nTheatres: nwr["amenity"="theatre"]\nCinemas: nwr["amenity"="cinema"]\nSports Centres: nwr["leisure"="sports_centre"]`;
1044
2630
  break;
1045
- case category.includes('retail') || category.includes('shop') || category.includes('store'):
1046
- recommendations = `Supermarkets: nwr["shop"="supermarket"]\nConvenience Stores: nwr["shop"="convenience"]\nMalls: nwr["shop"="mall"]\nClothes Shops: nwr["shop"="clothes"]\nBakeries: nwr["shop"="bakery"]`;
2631
+ case category.includes('retail') || category.includes('shop') || category.includes('store') || category.includes('supermarket') || category.includes('mall'):
2632
+ recommendations = `Supermarkets: nwr["shop"="supermarket"]\nConvenience Stores: nwr["shop"="convenience"]\nMalls: nwr["shop"="mall"]\nClothes Shops: nwr["shop"="clothes"]\nBakeries: nwr["shop"="bakery"]\nAlcohol Shops: nwr["shop"="alcohol"]\nHairdressers: nwr["shop"="hairdresser"]\nLaundries: nwr["shop"="laundry"]`;
1047
2633
  break;
1048
- case category.includes('education') || category.includes('school'):
2634
+ case category.includes('education') || category.includes('school') || category.includes('university') || category.includes('college') || category.includes('library'):
1049
2635
  recommendations = `Schools: nwr["amenity"="school"]\nUniversities: nwr["amenity"="university"]\nColleges: nwr["amenity"="college"]\nKindergartens: nwr["amenity"="kindergarten"]\nLibraries: nwr["amenity"="library"]`;
1050
2636
  break;
1051
- case category.includes('tourism') || category.includes('hotel'):
1052
- recommendations = `Hotels: nwr["tourism"="hotel"]\nMotels: nwr["tourism"="motel"]\nHostels: nwr["tourism"="hostel"]\nAttractions: nwr["tourism"="attraction"]\nMuseums: nwr["tourism"="museum"]\nInformation: nwr["tourism"="information"]`;
2637
+ case category.includes('tourism') || category.includes('hotel') || category.includes('motel') || category.includes('hostel') || category.includes('museum') || category.includes('attraction'):
2638
+ recommendations = `Hotels: nwr["tourism"="hotel"]\nMotels: nwr["tourism"="motel"]\nHostels: nwr["tourism"="hostel"]\nAttractions: nwr["tourism"="attraction"]\nMuseums: nwr["tourism"="museum"]\nGuest Houses: nwr["tourism"="guest_house"]\nApartments: nwr["tourism"="apartment"]\nCampsites: nwr["tourism"="camp_site"]`;
2639
+ break;
2640
+ case category.includes('finance') || category.includes('bank') || category.includes('atm') || category.includes('post') || category.includes('police') || category.includes('fire') || category.includes('embassy'):
2641
+ recommendations = `Banks: nwr["amenity"="bank"]\nATMs: nwr["amenity"="atm"]\nPost Offices: nwr["amenity"="post_office"]\nPolice: nwr["amenity"="police"]\nFire Stations: nwr["amenity"="fire_station"]\nCourthouses: nwr["amenity"="courthouse"]\nEmbassies: nwr["office"="diplomatic"]`;
2642
+ break;
2643
+ case category.includes('fitness') || category.includes('sport') || category.includes('gym') || category.includes('swimming') || category.includes('pool') || category.includes('stadium') || category.includes('golf') || category.includes('climbing'):
2644
+ recommendations = `Gyms: nwr["leisure"="fitness_centre"]\nSwimming Pools: nwr["leisure"="swimming_pool"]\nStadiums: nwr["leisure"="stadium"]\nSports Pitches: nwr["leisure"="pitch"]\nGolf Courses: nwr["leisure"="golf_course"]\nClimbing: nwr["sport"="climbing"]`;
2645
+ break;
2646
+ case category.includes('nature') || category.includes('outdoor') || category.includes('forest') || category.includes('water') || category.includes('beach') || category.includes('peak') || category.includes('viewpoint') || category.includes('hike') || category.includes('trail'):
2647
+ recommendations = `Forests: nwr["landuse"="forest"]\nWater Bodies: nwr["natural"="water"]\nBeaches: nwr["natural"="beach"]\nPeaks: nwr["natural"="peak"]\nViewpoints: nwr["tourism"="viewpoint"]\nNature Reserves: nwr["leisure"="nature_reserve"]\nPicnic Sites: nwr["tourism"="picnic_site"]\nHiking Trails: way["highway"="path"]["sac_scale"]`;
2648
+ break;
2649
+ case category.includes('religion') || category.includes('worship') || category.includes('church') || category.includes('mosque') || category.includes('synagogue') || category.includes('temple') || category.includes('cemetery'):
2650
+ recommendations = `Places of Worship: nwr["amenity"="place_of_worship"]\nChurches: nwr["amenity"="place_of_worship"]["religion"="christian"]\nMosques: nwr["amenity"="place_of_worship"]["religion"="muslim"]\nSynagogues: nwr["amenity"="place_of_worship"]["religion"="jewish"]\nTemples: nwr["amenity"="place_of_worship"]["religion"~"buddhist|hindu"]\nCemeteries: nwr["landuse"="cemetery"]`;
2651
+ break;
2652
+ case category.includes('ev') || category.includes('automotive') || category.includes('car') || category.includes('charging') || category.includes('mechanic') || category.includes('rental'):
2653
+ recommendations = `EV Charging: nwr["amenity"="charging_station"]\nCar Repair: nwr["shop"="car_repair"]\nCar Dealers: nwr["shop"="car"]\nCar Wash: nwr["amenity"="car_wash"]\nCar Rental: nwr["amenity"="car_rental"]\nDriving Schools: nwr["amenity"="driving_school"]`;
2654
+ break;
2655
+ case category.includes('nightlife') || category.includes('nightclub') || category.includes('casino') || category.includes('arcade') || category.includes('bowling') || category.includes('escape'):
2656
+ recommendations = `Nightclubs: nwr["amenity"="nightclub"]\nCasinos: nwr["amenity"="casino"]\nArcades: nwr["leisure"="amusement_arcade"]\nBowling Alleys: nwr["leisure"="bowling_alley"]\nEscape Rooms: nwr["leisure"="escape_game"]`;
2657
+ break;
2658
+ case category.includes('utility') || category.includes('toilet') || category.includes('water') || category.includes('bench') || category.includes('waste') || category.includes('recycling') || category.includes('shower') || category.includes('locker'):
2659
+ recommendations = `Toilets: nwr["amenity"="toilets"]\nDrinking Water: nwr["amenity"="drinking_water"]\nBenches: nwr["amenity"="bench"]\nWaste Baskets: nwr["amenity"="waste_basket"]\nRecycling: nwr["amenity"="recycling"]\nPublic Showers: nwr["amenity"="shower"]\nLockers: nwr["amenity"="locker"]`;
2660
+ break;
2661
+ case category.includes('work') || category.includes('office') || category.includes('coworking') || category.includes('conference'):
2662
+ recommendations = `Coworking Spaces: nwr["office"="coworking"]; nwr["amenity"="coworking_space"];\nGeneral Offices: nwr["office"]\nConference Centres: nwr["amenity"="conference_centre"]`;
1053
2663
  break;
1054
- default:
1055
- recommendations = `Top categories:\n- Food: nwr["amenity"="restaurant"], nwr["amenity"="cafe"]\n- Health: nwr["amenity"="hospital"]\n- Transport: nwr["highway"="bus_stop"], nwr["amenity"="parking"]\n- Leisure: nwr["leisure"="park"]\n- Retail: nwr["shop"="supermarket"]\n- Education: nwr["amenity"="school"]\n\nPlease try searching for one of those broader categories (e.g. "health") for more detailed tag combinations.`;
2664
+ case category.includes('pet') || category.includes('dog') || category.includes('vet') || category.includes('zoo') || category.includes('animal'):
2665
+ recommendations = `Dog Parks: nwr["leisure"="dog_park"]\nVeterinarians: nwr["amenity"="veterinary"]\nPet Shops: nwr["shop"="pet"]\nZoos: nwr["tourism"="zoo"]`;
1056
2666
  break;
2667
+ case category.includes('childcare') || category.includes('daycare') || category.includes('child'):
2668
+ recommendations = `Childcare/Daycare: nwr["amenity"="childcare"]\nPlaygrounds: nwr["leisure"="playground"]`;
2669
+ break;
2670
+ default: {
2671
+ log('info', `Category '${category}' not in index, querying Taginfo API`);
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
+ };
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()) : [];
1057
2691
  }
1058
2692
  return {
1059
2693
  content: [{
1060
2694
  type: "text",
1061
- text: `OSM Tag Recommendations for '${category}':\n\n${recommendations}\n\nTip: You can usually just use these directly as the 'query' argument in search_overpass.`
1062
- }]
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.`
2696
+ }],
2697
+ structuredContent: {
2698
+ category,
2699
+ source,
2700
+ tags,
2701
+ recommendations_text: recommendations,
2702
+ provenance,
2703
+ complete: warnings.length === 0,
2704
+ warnings
2705
+ }
1063
2706
  };
1064
2707
  }
1065
2708
  else if (toolName === "aggregate_overpass_h3") {
1066
2709
  const args = request.params.arguments || {};
1067
2710
  const queryRaw = args.query;
1068
- const resolution = args.resolution || 8;
2711
+ const resolution = args.resolution ?? 8;
2712
+ const timeoutSec = args.timeout_seconds || 25;
2713
+ const useAreaBoundary = args.use_area_boundary !== false;
2714
+ const radiusMeters = args.radius_meters;
2715
+ const useCircle = typeof radiusMeters === 'number' && radiusMeters > 0;
2716
+ const warnings = [];
1069
2717
  if (!queryRaw) {
1070
2718
  return { content: [{ type: "text", text: "Error: 'query' is required." }], isError: true };
1071
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
+ }
2726
+ if (resolution < 0 || resolution > 15 || !Number.isInteger(resolution)) {
2727
+ return { content: [{ type: "text", text: `Error: Invalid H3 resolution: ${resolution}. Must be 0-15.` }], isError: true };
2728
+ }
1072
2729
  let bboxObj;
2730
+ let geocodeRes;
2731
+ let usedAreaId;
1073
2732
  if (args.location) {
1074
2733
  try {
1075
- bboxObj = await geocodeLocation(args.location);
2734
+ geocodeRes = await geocodeLocation(args.location);
2735
+ bboxObj = geocodeRes.bbox;
2736
+ if (useAreaBoundary && geocodeRes.osm_area_id) {
2737
+ usedAreaId = geocodeRes.osm_area_id;
2738
+ }
1076
2739
  }
1077
2740
  catch (err) {
1078
2741
  if (!args.bbox) {
1079
2742
  return { content: [{ type: "text", text: `Error: Geocoding failed: ${err.message}` }], isError: true };
1080
2743
  }
1081
- bboxObj = parseBBox(args.bbox);
2744
+ bboxObj = parseBBox(args.bbox, warnings);
1082
2745
  }
1083
2746
  }
1084
2747
  else if (args.bbox) {
1085
- bboxObj = parseBBox(args.bbox);
2748
+ bboxObj = parseBBox(args.bbox, warnings);
1086
2749
  }
1087
2750
  else {
1088
2751
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
1089
2752
  }
1090
- log('info', `aggregate_overpass_h3 starting`, { query: queryRaw, resolution });
1091
- // Ensure H3 resolution is valid early
1092
- if (resolution < 0 || resolution > 15 || !Number.isInteger(resolution)) {
1093
- return { content: [{ type: "text", text: `Error: Invalid H3 resolution: ${resolution}. Must be 0-15.` }], isError: true };
2753
+ log('info', `aggregate_overpass_h3 starting`, { query: queryRaw, resolution, timeoutSec, mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox') });
2754
+ let overpassQuery;
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;`;
1094
2760
  }
1095
- const overpassQuery = `[out:json][timeout:25][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${queryRaw};);\nout center;`;
1096
- let data = null;
1097
- let lastError = null;
1098
- const params = new URLSearchParams();
1099
- params.append('data', overpassQuery);
1100
- const endpoints = [
1101
- "https://overpass-api.de/api/interpreter",
1102
- "https://overpass.kumi.systems/api/interpreter"
1103
- ];
1104
- if (process.env.OVERPASS_API_URL) {
1105
- endpoints.unshift(process.env.OVERPASS_API_URL);
2761
+ else if (usedAreaId) {
2762
+ const formattedCore = applySpatialFilter(queryRaw, "(area.searchArea)");
2763
+ overpassQuery = `[out:json][timeout:${timeoutSec}];\narea(${usedAreaId})->.searchArea;\n(${formattedCore});\nout center;`;
1106
2764
  }
1107
- for (const endpoint of endpoints) {
1108
- const controller = new AbortController();
1109
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS * 2);
1110
- try {
1111
- const response = await fetch(endpoint, {
1112
- method: "POST",
1113
- body: params.toString(),
1114
- headers: {
1115
- 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
1116
- 'Accept': 'application/json',
1117
- 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION}`
1118
- },
1119
- signal: controller.signal
1120
- });
1121
- if (!response.ok)
1122
- throw new Error(`Status: ${response.status}`);
1123
- data = await response.json();
1124
- break;
1125
- }
1126
- catch (err) {
1127
- lastError = err;
1128
- }
1129
- finally {
1130
- clearTimeout(timeout);
1131
- }
2765
+ else {
2766
+ const formattedCore = applySpatialFilter(queryRaw, "");
2767
+ overpassQuery = `[out:json][timeout:${timeoutSec}][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${formattedCore});\nout center;`;
2768
+ }
2769
+ let execResult;
2770
+ try {
2771
+ execResult = await executeOverpassQuery(overpassQuery, timeoutSec);
1132
2772
  }
1133
- if (!data) {
1134
- return { content: [{ type: "text", text: `Error: Overpass query failed: ${lastError?.message}` }], isError: true };
2773
+ catch (err) {
2774
+ return { content: [{ type: "text", text: `Error: Overpass query failed: ${err.message}` }], isError: true };
2775
+ }
2776
+ const { data, provenance, remarkWarning } = execResult;
2777
+ if (remarkWarning) {
2778
+ warnings.push(`Overpass remark: ${remarkWarning}`);
1135
2779
  }
1136
2780
  let rawElements = data.elements || [];
1137
- // Bin into H3 Hexagons
1138
2781
  const hexCounts = {};
1139
2782
  const hexElements = {};
1140
2783
  const seenIds = new Set();
1141
2784
  rawElements.forEach((e) => {
1142
- if (seenIds.has(e.id))
2785
+ const dedupKey = `${e.type}/${e.id}`;
2786
+ if (seenIds.has(dedupKey))
1143
2787
  return;
1144
- seenIds.add(e.id);
2788
+ seenIds.add(dedupKey);
1145
2789
  const lat = e.lat ?? e.center?.lat;
1146
2790
  const lon = e.lon ?? e.center?.lon;
1147
2791
  if (lat !== undefined && lon !== undefined) {
@@ -1149,50 +2793,649 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1149
2793
  hexCounts[h3Index] = (hexCounts[h3Index] || 0) + 1;
1150
2794
  if (!hexElements[h3Index])
1151
2795
  hexElements[h3Index] = [];
1152
- // Keep element data minimal to avoid massive payloads
1153
2796
  hexElements[h3Index].push({
1154
2797
  id: e.id,
1155
2798
  name: e.tags?.name || "Unnamed"
1156
2799
  });
1157
2800
  }
1158
2801
  });
1159
- const shareUrl = buildShareUrl(bboxObj);
1160
2802
  const totalGrouped = Object.values(hexCounts).reduce((a, b) => a + b, 0);
1161
- // Generate GeoJSON of the active hexes
1162
2803
  const activeHexes = Object.keys(hexCounts);
1163
2804
  const geojson = h3CellsToGeoJSON(activeHexes);
1164
- // Inject the counts into the GeoJSON properties
1165
2805
  geojson.features.forEach((f) => {
1166
2806
  const cellId = f.properties.h3_index;
1167
2807
  f.properties.count = hexCounts[cellId];
1168
2808
  f.properties.elements = hexElements[cellId];
1169
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));
1170
2828
  log('info', `aggregate_overpass_h3 completed`, { resolution, elements: totalGrouped, activeHexes: activeHexes.length });
1171
2829
  const responseData = {
1172
2830
  resolution,
2831
+ ...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
2832
+ ...(usedAreaId ? { osm_area_id: usedAreaId } : {}),
1173
2833
  total_elements: totalGrouped,
1174
2834
  active_hex_count: activeHexes.length,
1175
2835
  hex_counts: hexCounts,
1176
2836
  hex_geojson: geojson,
1177
2837
  original_bbox: bboxObj,
1178
- share_url: shareUrl
2838
+ share_url: shareUrl,
2839
+ provenance,
2840
+ complete: !remarkWarning,
2841
+ warnings
1179
2842
  };
1180
- // Sort map for display
1181
- const topHexes = Object.entries(hexCounts)
1182
- .sort((a, b) => b[1] - a[1])
1183
- .slice(0, 10);
1184
2843
  return {
1185
2844
  content: [
1186
2845
  {
1187
2846
  type: "text",
1188
- 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}`
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)"
1189
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: [
1190
3025
  {
1191
3026
  type: "text",
1192
- 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)}`
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`);
1193
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` }
1194
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)"
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
1195
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
+ }
1196
3439
  }
1197
3440
  else {
1198
3441
  log('warn', `Unknown tool requested: ${toolName}`);