bbox-mcp-server 1.3.0 → 1.4.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
  }
@@ -110,7 +124,7 @@ function validateBBox(lat1, lng1, lat2, lng2) {
110
124
  return false;
111
125
  return true;
112
126
  }
113
- function parseBBox(input) {
127
+ function parseBBox(input, warnings) {
114
128
  if (!input || typeof input !== 'string') {
115
129
  throw new Error(`Invalid bbox input.`);
116
130
  }
@@ -174,8 +188,16 @@ function parseBBox(input) {
174
188
  // Support space-separated (GDAL) or comma-separated
175
189
  const parts = cleanInput.split(/[\s,]+/).map(s => parseFloat(s.trim())).filter(n => !isNaN(n));
176
190
  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
191
+ // Default interpretation: lat1, lng1, lat2, lng2
192
+ // Detect ambiguity: if both (lat,lng) and (lng,lat) orderings are valid,
193
+ // the caller may have a transposed box without knowing it.
194
+ const asLatLng = validateBBox(parts[0], parts[1], parts[2], parts[3]);
195
+ const asLngLat = validateBBox(parts[1], parts[0], parts[3], parts[2]);
196
+ if (asLatLng && asLngLat && (parts[0] !== parts[1] || parts[2] !== parts[3])) {
197
+ warnings?.push(`Ambiguous coordinate order: interpreted as lat,lng,lat,lng → (${parts[0]}, ${parts[1]}, ${parts[2]}, ${parts[3]}). ` +
198
+ `If your source uses lng,lat order (GeoJSON, GDAL, most GIS tools), the bounding box may be transposed. ` +
199
+ `Use GeoJSON or WKT input to avoid ambiguity.`);
200
+ }
179
201
  return ensureValidBBox(parts[0], parts[1], parts[2], parts[3]);
180
202
  }
181
203
  throw new Error(`Failed to parse bounding box. Input must be one of: WKT, GeoJSON, ogrinfo extent, or "lat1,lng1,lat2,lng2" string. Got: ${input.substring(0, 50)}...`);
@@ -192,6 +214,9 @@ function ensureValidBBox(lat1, lng1, lat2, lng2) {
192
214
  lng2: Math.max(lng1, lng2)
193
215
  };
194
216
  }
217
+ function formatWarnings(warnings) {
218
+ return warnings.length > 0 ? '\n\n⚠️ ' + warnings.join('\n⚠️ ') : '';
219
+ }
195
220
  function projectBounds(bbox, epsg) {
196
221
  if (epsg === "4326") {
197
222
  return { xmin: bbox.lng1, ymin: bbox.lat1, xmax: bbox.lng2, ymax: bbox.lat2 };
@@ -200,13 +225,20 @@ function projectBounds(bbox, epsg) {
200
225
  if (!proj4.defs(epsgKey)) {
201
226
  throw new Error(`Unknown EPSG code: ${epsg}. Make sure it exists in proj4defs.`);
202
227
  }
203
- const p1 = proj4(epsgKey, [bbox.lng1, bbox.lat1]);
204
- const p2 = proj4(epsgKey, [bbox.lng2, bbox.lat2]);
228
+ // Project all four corners to handle axis flips and oblique/rotated CRS
229
+ const corners = [
230
+ proj4(epsgKey, [bbox.lng1, bbox.lat1]),
231
+ proj4(epsgKey, [bbox.lng2, bbox.lat1]),
232
+ proj4(epsgKey, [bbox.lng1, bbox.lat2]),
233
+ proj4(epsgKey, [bbox.lng2, bbox.lat2]),
234
+ ];
235
+ const xs = corners.map(c => c[0]);
236
+ const ys = corners.map(c => c[1]);
205
237
  return {
206
- xmin: p1[0],
207
- ymin: p1[1],
208
- xmax: p2[0],
209
- ymax: p2[1],
238
+ xmin: Math.min(...xs),
239
+ ymin: Math.min(...ys),
240
+ xmax: Math.max(...xs),
241
+ ymax: Math.max(...ys),
210
242
  };
211
243
  }
212
244
  /**
@@ -224,6 +256,9 @@ async function ensureProjection(epsg) {
224
256
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
225
257
  try {
226
258
  const response = await fetch(`https://epsg.io/${epsg}.proj4`, {
259
+ headers: {
260
+ 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`
261
+ },
227
262
  signal: controller.signal,
228
263
  });
229
264
  if (!response.ok) {
@@ -312,9 +347,11 @@ function calculateBboxArea(bbox) {
312
347
  return widthKm * heightKm;
313
348
  }
314
349
  function latLngToTile(lat, lng, zoom) {
350
+ // Clamp to Web Mercator latitude limits to avoid Math.tan(±90°) → Infinity → NaN
351
+ const clampedLat = Math.max(-85.0511, Math.min(85.0511, lat));
315
352
  const n = Math.pow(2, zoom);
316
353
  const x = Math.floor((lng + 180) / 360 * n);
317
- const latRad = lat * Math.PI / 180;
354
+ const latRad = clampedLat * Math.PI / 180;
318
355
  const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
319
356
  return { z: zoom, x, y };
320
357
  }
@@ -394,90 +431,298 @@ function h3CellsToGeoJSON(cells) {
394
431
  features
395
432
  };
396
433
  }
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.');
434
+ // ---------------------------------------------------------------------------
435
+ // Nominatim Rate Limiter, Caching, and Utilities
436
+ // ---------------------------------------------------------------------------
437
+ let lastNominatimRequestTime = 0;
438
+ const NOMINATIM_CACHE = new Map();
439
+ const NOMINATIM_TTL_MS = 60 * 60 * 1000; // 1 hour TTL
440
+ const NOMINATIM_BASE_URL = (process.env.NOMINATIM_API_URL || "https://nominatim.openstreetmap.org").replace(/\/+$/, "");
441
+ async function fetchNominatim(url) {
442
+ const cached = NOMINATIM_CACHE.get(url);
443
+ if (cached && (Date.now() - cached.timestamp < NOMINATIM_TTL_MS)) {
444
+ return cached.data;
445
+ }
446
+ const now = Date.now();
447
+ const timeSinceLast = now - lastNominatimRequestTime;
448
+ if (timeSinceLast < 1000) {
449
+ await new Promise(resolve => setTimeout(resolve, 1000 - timeSinceLast));
400
450
  }
451
+ lastNominatimRequestTime = Date.now();
401
452
  const controller = new AbortController();
402
453
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
403
454
  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 });
455
+ const response = await fetch(url, {
456
+ headers: {
457
+ 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`,
458
+ 'Accept': 'application/json'
459
+ },
460
+ signal: controller.signal
461
+ });
406
462
  if (!response.ok) {
407
- throw new Error(`Geocoding failed with status: ${response.status} ${response.statusText}`);
463
+ throw new Error(`Nominatim request failed with status: ${response.status} ${response.statusText}`);
408
464
  }
409
465
  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}`);
412
- }
413
- if (!data.features || data.features.length === 0) {
414
- throw new Error(`No results found for location: ${query}`);
466
+ NOMINATIM_CACHE.set(url, { timestamp: Date.now(), data });
467
+ return data;
468
+ }
469
+ catch (err) {
470
+ if (err.name === 'AbortError') {
471
+ throw new Error(`Nominatim request timed out after ${FETCH_TIMEOUT_MS / 1000}s for URL: ${url}`);
415
472
  }
416
- const feature = data.features[0];
417
- if (feature.bbox) {
418
- // mb bbox format: minX, minY, maxX, maxY
473
+ throw err;
474
+ }
475
+ finally {
476
+ clearTimeout(timeout);
477
+ }
478
+ }
479
+ async function geocodeLocation(query) {
480
+ // 1. Try Nominatim first (free, returns exact OSM area boundaries & alternatives)
481
+ try {
482
+ const nominatimUrl = `${NOMINATIM_BASE_URL}/search?q=${encodeURIComponent(query)}&format=json&addressdetails=1&extratags=1&limit=5`;
483
+ const items = await fetchNominatim(nominatimUrl);
484
+ if (Array.isArray(items) && items.length > 0) {
485
+ const parsedItems = items.map((item) => {
486
+ let bbox;
487
+ if (item.boundingbox && item.boundingbox.length === 4) {
488
+ const south = parseFloat(item.boundingbox[0]);
489
+ const north = parseFloat(item.boundingbox[1]);
490
+ const west = parseFloat(item.boundingbox[2]);
491
+ const east = parseFloat(item.boundingbox[3]);
492
+ bbox = ensureValidBBox(south, west, north, east);
493
+ }
494
+ else {
495
+ const lat = parseFloat(item.lat);
496
+ const lon = parseFloat(item.lon);
497
+ bbox = ensureValidBBox(lat - 0.05, lon - 0.05, lat + 0.05, lon + 0.05);
498
+ }
499
+ const center = { lat: parseFloat(item.lat), lng: parseFloat(item.lon) };
500
+ let osm_area_id;
501
+ const osmIdNum = parseInt(item.osm_id, 10);
502
+ if (item.osm_type === "relation" && !isNaN(osmIdNum)) {
503
+ osm_area_id = 3600000000 + osmIdNum;
504
+ }
505
+ else if (item.osm_type === "way" && !isNaN(osmIdNum)) {
506
+ osm_area_id = 2400000000 + osmIdNum;
507
+ }
508
+ return {
509
+ matched_place: item.display_name,
510
+ bbox,
511
+ center,
512
+ osm_area_id,
513
+ class: item.class,
514
+ type: item.type,
515
+ osm_type: item.osm_type,
516
+ osm_id: osmIdNum
517
+ };
518
+ });
519
+ // Boundary relation preference: prefer relation with class=boundary or type=administrative/boundary
520
+ let primaryIndex = parsedItems.findIndex(i => i.osm_type === "relation" &&
521
+ (i.class === "boundary" || i.type === "administrative" || i.type === "boundary"));
522
+ if (primaryIndex === -1)
523
+ primaryIndex = 0;
524
+ const primary = parsedItems[primaryIndex];
525
+ const alternatives = parsedItems.filter((_, idx) => idx !== primaryIndex);
419
526
  return {
420
- lat1: feature.bbox[1],
421
- lng1: feature.bbox[0],
422
- lat2: feature.bbox[3],
423
- lng2: feature.bbox[2]
527
+ ...primary,
528
+ alternatives
424
529
  };
425
530
  }
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;
531
+ }
532
+ catch (err) {
533
+ log('warn', `Nominatim geocoding failed for '${query}', trying Mapbox fallback`, { error: err.message });
534
+ }
535
+ // 2. Fallback to Mapbox if available
536
+ if (MAPBOX_ACCESS_TOKEN) {
537
+ const controller = new AbortController();
538
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
539
+ try {
540
+ const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json?access_token=${MAPBOX_ACCESS_TOKEN}&limit=5`;
541
+ const response = await fetch(url, { signal: controller.signal });
542
+ if (!response.ok) {
543
+ throw new Error(`Geocoding failed with status: ${response.status} ${response.statusText}`);
544
+ }
545
+ const data = await response.json();
546
+ if (!data || !Array.isArray(data.features) || data.features.length === 0) {
547
+ throw new Error(`No results found for location: ${query}`);
548
+ }
549
+ const mapboxItems = data.features.map((feature) => {
550
+ let bbox;
551
+ let center;
552
+ if (feature.bbox) {
553
+ bbox = ensureValidBBox(feature.bbox[1], feature.bbox[0], feature.bbox[3], feature.bbox[2]);
554
+ center = { lat: (bbox.lat1 + bbox.lat2) / 2, lng: (bbox.lng1 + bbox.lng2) / 2 };
555
+ }
556
+ else if (feature.center) {
557
+ center = { lat: feature.center[1], lng: feature.center[0] };
558
+ bbox = ensureValidBBox(center.lat - 0.05, center.lng - 0.05, center.lat + 0.05, center.lng + 0.05);
559
+ }
560
+ else {
561
+ throw new Error(`Unable to determine location bounds for feature`);
562
+ }
563
+ return {
564
+ matched_place: feature.place_name || feature.text || query,
565
+ bbox,
566
+ center,
567
+ class: feature.properties?.category
568
+ };
569
+ });
570
+ const primary = mapboxItems[0];
571
+ const alternatives = mapboxItems.slice(1);
431
572
  return {
432
- lat1: centerLat - offset,
433
- lng1: centerLng - offset,
434
- lat2: centerLat + offset,
435
- lng2: centerLng + offset
573
+ ...primary,
574
+ alternatives
436
575
  };
437
576
  }
438
- else {
439
- throw new Error(`Unable to determine bounding box for location: ${query}`);
577
+ catch (err) {
578
+ if (err.name === 'AbortError') {
579
+ throw new Error(`Geocoding request timed out for query: ${query}`);
580
+ }
581
+ throw err;
440
582
  }
441
- }
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}`);
583
+ finally {
584
+ clearTimeout(timeout);
445
585
  }
446
- throw err;
447
586
  }
448
- finally {
449
- clearTimeout(timeout);
587
+ throw new Error(`No geocoding results found for location: ${query}`);
588
+ }
589
+ async function reverseGeocode(lat, lng, zoom = 18) {
590
+ if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
591
+ throw new Error(`Invalid lat/lng coordinates for reverse geocoding: (${lat}, ${lng})`);
592
+ }
593
+ const clampedZoom = Math.max(0, Math.min(18, Math.round(zoom)));
594
+ const url = `${NOMINATIM_BASE_URL}/reverse?format=json&lat=${lat}&lon=${lng}&zoom=${clampedZoom}&addressdetails=1`;
595
+ const data = await fetchNominatim(url);
596
+ if (!data || !data.display_name) {
597
+ throw new Error(`No reverse geocoding results found for coordinates: (${lat}, ${lng})`);
450
598
  }
599
+ let bbox;
600
+ if (data.boundingbox && data.boundingbox.length === 4) {
601
+ const south = parseFloat(data.boundingbox[0]);
602
+ const north = parseFloat(data.boundingbox[1]);
603
+ const west = parseFloat(data.boundingbox[2]);
604
+ const east = parseFloat(data.boundingbox[3]);
605
+ bbox = ensureValidBBox(south, west, north, east);
606
+ }
607
+ else {
608
+ bbox = ensureValidBBox(lat - 0.005, lng - 0.005, lat + 0.005, lng + 0.005);
609
+ }
610
+ const shareUrl = buildShareUrl(bbox);
611
+ return {
612
+ matched_place: data.display_name,
613
+ center: { lat, lng },
614
+ bbox,
615
+ address: data.address || {},
616
+ class: data.class,
617
+ type: data.type,
618
+ osm_type: data.osm_type,
619
+ osm_id: data.osm_id ? parseInt(data.osm_id, 10) : undefined,
620
+ share_url: shareUrl
621
+ };
451
622
  }
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.');
623
+ function splitOverpassStatements(query) {
624
+ const statements = [];
625
+ let current = '';
626
+ let inDouble = false;
627
+ let inSingle = false;
628
+ for (let i = 0; i < query.length; i++) {
629
+ const char = query[i];
630
+ if (char === '"' && !inSingle) {
631
+ inDouble = !inDouble;
632
+ current += char;
633
+ }
634
+ else if (char === "'" && !inDouble) {
635
+ inSingle = !inSingle;
636
+ current += char;
637
+ }
638
+ else if (char === ';' && !inDouble && !inSingle) {
639
+ if (current.trim()) {
640
+ statements.push(current.trim());
641
+ }
642
+ current = '';
643
+ }
644
+ else {
645
+ current += char;
646
+ }
455
647
  }
648
+ if (current.trim()) {
649
+ statements.push(current.trim());
650
+ }
651
+ return statements;
652
+ }
653
+ /**
654
+ * Attaches a spatial filter (e.g. `(area.searchArea)` or `(around:2000,lat,lng)`)
655
+ * to every selector statement in a multi-statement Overpass QL query string.
656
+ * Respects quoted semicolons in tag values and skips non-selector/output statements.
657
+ */
658
+ function applySpatialFilter(queryRaw, spatialFilter) {
659
+ if (!spatialFilter)
660
+ return queryRaw.trim().endsWith(';') ? queryRaw.trim() : `${queryRaw.trim()};`;
661
+ const statements = splitOverpassStatements(queryRaw);
662
+ if (statements.length === 0)
663
+ return queryRaw;
664
+ const processed = statements.map(stmt => {
665
+ // Skip non-selector statements like 'out center', directives, set operations, comments
666
+ if (/^(out|[()\[]|->)/i.test(stmt) || /\bout\s+(body|skel|ids|meta|geom|center)/i.test(stmt)) {
667
+ return stmt;
668
+ }
669
+ if (stmt.includes(spatialFilter.trim())) {
670
+ return stmt;
671
+ }
672
+ // If statement defines a named set (e.g., `... -> .searchSet`), insert spatial filter before `->`
673
+ if (/->\s*\.[a-zA-Z0-9_]+/.test(stmt)) {
674
+ return stmt.replace(/(->\s*\.[a-zA-Z0-9_]+)/, `${spatialFilter} $1`);
675
+ }
676
+ return `${stmt}${spatialFilter}`;
677
+ });
678
+ return processed.join('; ') + ';';
679
+ }
680
+ function calculateHaversineDistanceMeters(lat1, lng1, lat2, lng2) {
681
+ const R = 6371000; // Earth radius in meters
682
+ const dLat = (lat2 - lat1) * Math.PI / 180;
683
+ const dLng = (lng2 - lng1) * Math.PI / 180;
684
+ const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
685
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
686
+ Math.sin(dLng / 2) * Math.sin(dLng / 2);
687
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
688
+ return R * c;
689
+ }
690
+ async function getTaginfoSuggestions(keyword) {
691
+ const url = `https://taginfo.openstreetmap.org/api/4/search/by_keyword?query=${encodeURIComponent(keyword)}&rp=30`;
456
692
  const controller = new AbortController();
457
693
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
694
+ const MAINSTREAM_KEYS = new Set([
695
+ "amenity", "shop", "leisure", "tourism", "natural", "highway",
696
+ "office", "healthcare", "craft", "sport", "landuse", "building",
697
+ "historic", "emergency", "railway", "aeroway", "public_transport"
698
+ ]);
458
699
  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 };
700
+ const response = await fetch(url, {
701
+ headers: { 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)` },
702
+ signal: controller.signal
703
+ });
704
+ if (!response.ok) {
705
+ throw new Error(`Taginfo status: ${response.status}`);
472
706
  }
473
- else {
474
- throw new Error(`Unable to determine center point for location: ${query}`);
707
+ const data = await response.json();
708
+ const results = data.data || [];
709
+ const filtered = results
710
+ .filter((item) => item.key &&
711
+ MAINSTREAM_KEYS.has(item.key) &&
712
+ (item.count_all ?? 0) >= 1000)
713
+ .sort((a, b) => (b.count_all ?? 0) - (a.count_all ?? 0))
714
+ .slice(0, 10);
715
+ if (filtered.length === 0) {
716
+ return `No mainstream OSM tags found for '${keyword}' with usage count > 1,000.\nTry searching standard keys like amenity, shop, leisure, or tourism.`;
475
717
  }
718
+ const lines = filtered.map((item) => {
719
+ const valStr = item.value ? `="${item.value}"` : '';
720
+ return `- nwr["${item.key}"${valStr}] (${(item.count_all || 0).toLocaleString()} objects)`;
721
+ });
722
+ return `Taginfo OSM Tag Recommendations for '${keyword}':\n\n${lines.join('\n')}`;
476
723
  }
477
724
  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;
725
+ return `Taginfo lookup failed for '${keyword}': ${err.message}`;
481
726
  }
482
727
  finally {
483
728
  clearTimeout(timeout);
@@ -554,7 +799,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
554
799
  properties: {
555
800
  location: {
556
801
  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."
802
+ description: "A text location to search for (e.g. 'San Francisco'). Either 'location' or 'bbox' MUST be provided."
558
803
  },
559
804
  bbox: {
560
805
  type: "string",
@@ -568,6 +813,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
568
813
  type: "boolean",
569
814
  description: "Whether to return a compacted list of cells (mixing resolutions) to minimize response size. Defaults to false."
570
815
  },
816
+ summary_only: {
817
+ type: "boolean",
818
+ 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."
819
+ },
571
820
  return_geometry: {
572
821
  type: "boolean",
573
822
  description: "If true, return GeoJSON FeatureCollection with hex boundary polygons in addition to cell IDs. Defaults to false."
@@ -594,15 +843,37 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
594
843
  required: ["bbox"],
595
844
  },
596
845
  },
846
+ {
847
+ name: "reverse_geocode",
848
+ description: "Reverse geocode latitude and longitude coordinates to discover context, place name, address components, and OSM details ('what is here?').",
849
+ inputSchema: {
850
+ type: "object",
851
+ properties: {
852
+ lat: {
853
+ type: "number",
854
+ description: "Latitude coordinate (-90 to 90)."
855
+ },
856
+ lng: {
857
+ type: "number",
858
+ description: "Longitude coordinate (-180 to 180)."
859
+ },
860
+ zoom: {
861
+ type: "number",
862
+ description: "Detail zoom level from 0 (country) to 18 (building/address). Defaults to 18."
863
+ }
864
+ },
865
+ required: ["lat", "lng"]
866
+ }
867
+ },
597
868
  {
598
869
  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\"]`",
870
+ description: "Execute an Overpass QL query to find POIs, roads, or other OSM features within an admin boundary area, bounding box, or radius. The server wraps your query in spatial filters and returns structured JSON with distance calculations and a map link.\n\nSEARCH MODES:\n- Location / Admin Area: when 'location' resolves to an OSM boundary polygon (e.g. 'Manhattan', 'Seattle'), searches inside exact boundary polygon.\n- Bounding box (default): searches a rectangular area.\n- Circle: searches within an exact radius when 'radius_meters' is provided.\n\nCOMMON TAG EXAMPLES:\n- Restaurants: `nwr[\"amenity\"=\"restaurant\"]`\n- Pizza: `nwr[\"amenity\"=\"fast_food\"][\"cuisine\"=\"pizza\"]`\n- Supermarkets: `nwr[\"shop\"=\"supermarket\"]`\n- Parks: `nwr[\"leisure\"=\"park\"]`\n- Hospitals: `nwr[\"amenity\"=\"hospital\"]`\n- EV Charging: `nwr[\"amenity\"=\"charging_station\"]`\n- Gyms: `nwr[\"leisure\"=\"fitness_centre\"]`\n- Banks: `nwr[\"amenity\"=\"bank\"]`",
600
871
  inputSchema: {
601
872
  type: "object",
602
873
  properties: {
603
874
  location: {
604
875
  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."
876
+ description: "A text location to search for (e.g. 'Manhattan', 'San Francisco'). Either 'location' or 'bbox' MUST be provided."
606
877
  },
607
878
  bbox: {
608
879
  type: "string",
@@ -610,19 +881,35 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
610
881
  },
611
882
  query: {
612
883
  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."
884
+ 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
885
  },
615
886
  radius_meters: {
616
887
  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'."
888
+ description: "Search radius in metres. Enables circle mode — use ONLY when explicit distance is specified ('2km' → 2000)."
618
889
  },
619
890
  limit: {
620
891
  type: "number",
621
- description: "Maximum number of elements to return. Default is 100. Increase if you need more results."
892
+ description: "Maximum number of elements to return per page. Default is 100."
893
+ },
894
+ offset: {
895
+ type: "number",
896
+ description: "Pagination offset for retrieving subsequent pages of results. Default is 0."
897
+ },
898
+ timeout_seconds: {
899
+ type: "number",
900
+ description: "Overpass server query timeout in seconds. Default is 25."
901
+ },
902
+ use_area_boundary: {
903
+ type: "boolean",
904
+ description: "If true (default), uses exact OSM administrative boundary polygon for named locations when available, avoiding bounding box collapse."
905
+ },
906
+ sort_by_distance: {
907
+ type: "boolean",
908
+ description: "If true, sorts returned elements by distance from origin/center. Enabled by default for circle mode and point locations."
622
909
  },
623
910
  return_geometry: {
624
911
  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."
912
+ description: "If true, returns full geometry (polygon/polyline) for ways and relations instead of center point. Defaults to false."
626
913
  }
627
914
  },
628
915
  required: ["query"],
@@ -634,13 +921,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
634
921
  },
635
922
  {
636
923
  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.",
924
+ 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.",
638
925
  inputSchema: {
639
926
  type: "object",
640
927
  properties: {
641
928
  category: {
642
929
  type: "string",
643
- description: "Broad category to look up (e.g. 'food', 'health', 'transport', 'leisure', 'retail')."
930
+ description: "Category or keyword to look up (e.g. 'food', 'ev charging', 'gym', 'coworking', 'banks', 'dog park')."
644
931
  }
645
932
  },
646
933
  required: ["category"]
@@ -667,6 +954,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
667
954
  resolution: {
668
955
  type: "number",
669
956
  description: "The H3 resolution level (0-15) for binning. Default is 8."
957
+ },
958
+ timeout_seconds: {
959
+ type: "number",
960
+ description: "Overpass server query timeout in seconds. Default is 25."
961
+ },
962
+ use_area_boundary: {
963
+ type: "boolean",
964
+ description: "If true (default), uses exact OSM boundary polygon for named locations when available."
670
965
  }
671
966
  },
672
967
  required: ["query", "resolution"],
@@ -685,23 +980,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
685
980
  try {
686
981
  if (toolName === "get_bounds") {
687
982
  const args = request.params.arguments || {};
983
+ const warnings = [];
688
984
  let bboxObj;
985
+ let geocodeRes;
689
986
  if (args.location) {
690
987
  try {
691
- bboxObj = await geocodeLocation(args.location);
988
+ geocodeRes = await geocodeLocation(args.location);
989
+ bboxObj = geocodeRes.bbox;
692
990
  }
693
991
  catch (err) {
694
992
  if (!args.bbox) {
695
993
  log('error', `Geocoding failed for location: ${args.location}`, { error: err.message });
696
994
  return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
697
995
  }
698
- // User provided bbox fallback
699
996
  log('warn', `Geocoding failed, falling back to bbox argument`, { error: err.message });
700
- bboxObj = parseBBox(args.bbox);
997
+ bboxObj = parseBBox(args.bbox, warnings);
701
998
  }
702
999
  }
703
1000
  else if (args.bbox) {
704
- bboxObj = parseBBox(args.bbox);
1001
+ bboxObj = parseBBox(args.bbox, warnings);
705
1002
  }
706
1003
  else {
707
1004
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
@@ -709,14 +1006,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
709
1006
  let epsg = args.epsg || "4326";
710
1007
  let format = args.format || "csv";
711
1008
  let coordOrder = args.coord_order || "lng,lat";
712
- let zoom = args.zoom || 15;
713
- let precision = args.precision || 6;
1009
+ let zoom = args.zoom ?? 15;
1010
+ let precision = args.precision ?? 6;
714
1011
  epsg = epsg.replace(/[^0-9]/g, "");
715
- // Dynamically fetch projection if not bundled
716
1012
  await ensureProjection(epsg);
717
1013
  const projCoords = projectBounds(bboxObj, epsg);
718
1014
  const WGS84Formatted = getFormattedBox(bboxObj, format, precision, coordOrder);
719
- // Build projected bbox for formatting (uses projected values)
720
1015
  const projBbox = {
721
1016
  lat1: projCoords.ymin,
722
1017
  lng1: projCoords.xmin,
@@ -727,20 +1022,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
727
1022
  const shareUrl = buildShareUrl(bboxObj);
728
1023
  const dims = calculateBboxDimensions(bboxObj);
729
1024
  const areaKm2 = calculateBboxArea(bboxObj);
730
- // Calculate center and tile indices based on WGS84 original bounds
731
1025
  const centerLat = (bboxObj.lat1 + bboxObj.lat2) / 2;
732
1026
  const centerLng = (bboxObj.lng1 + bboxObj.lng2) / 2;
733
1027
  const tileIndices = latLngToTile(centerLat, centerLng, zoom);
734
1028
  log('info', `get_bounds completed`, { epsg, format, area_km2: areaKm2 });
1029
+ const geocodeInfo = geocodeRes ? `Matched Place: "${geocodeRes.matched_place}"\n` : '';
1030
+ const altInfo = geocodeRes && geocodeRes.alternatives.length > 0
1031
+ ? `Alternates: ${geocodeRes.alternatives.map(a => `"${a.matched_place}"`).join(', ')}\n\n`
1032
+ : '\n';
735
1033
  return {
736
1034
  content: [
737
1035
  {
738
1036
  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}`
1037
+ 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)}`
740
1038
  },
741
1039
  {
742
1040
  type: "text",
743
1041
  text: JSON.stringify({
1042
+ ...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
744
1043
  original_wgs84: bboxObj,
745
1044
  projected: projCoords,
746
1045
  center: {
@@ -767,35 +1066,64 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
767
1066
  const args = request.params.arguments || {};
768
1067
  const resolution = args.resolution;
769
1068
  const compact = args.compact || false;
1069
+ const summaryOnly = args.summary_only || false;
770
1070
  const returnGeometry = args.return_geometry || false;
1071
+ const warnings = [];
771
1072
  if (resolution === undefined) {
772
1073
  return { content: [{ type: "text", text: "Error: 'resolution' is required." }], isError: true };
773
1074
  }
774
1075
  let bboxObj;
1076
+ let geocodeRes;
775
1077
  if (args.location) {
776
1078
  try {
777
- bboxObj = await geocodeLocation(args.location);
1079
+ geocodeRes = await geocodeLocation(args.location);
1080
+ bboxObj = geocodeRes.bbox;
778
1081
  }
779
1082
  catch (err) {
780
1083
  if (!args.bbox) {
781
1084
  log('error', `Geocoding failed for H3 location: ${args.location}`, { error: err.message });
782
1085
  return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
783
1086
  }
784
- bboxObj = parseBBox(args.bbox);
1087
+ bboxObj = parseBBox(args.bbox, warnings);
785
1088
  }
786
1089
  }
787
1090
  else if (args.bbox) {
788
- bboxObj = parseBBox(args.bbox);
1091
+ bboxObj = parseBBox(args.bbox, warnings);
789
1092
  }
790
1093
  else {
791
1094
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
792
1095
  }
793
1096
  const cells = getH3Cells(bboxObj, resolution, compact);
794
1097
  const shareUrl = buildShareUrl(bboxObj);
795
- log('info', `get_h3_indices completed`, { resolution, compact, cell_count: cells.length });
1098
+ log('info', `get_h3_indices completed`, { resolution, compact, summary_only: summaryOnly, cell_count: cells.length });
1099
+ if (summaryOnly) {
1100
+ const sampleCells = cells.slice(0, 10);
1101
+ const responseData = {
1102
+ resolution,
1103
+ compact,
1104
+ summary_only: true,
1105
+ cell_count: cells.length,
1106
+ sample_cells: sampleCells,
1107
+ original_bbox: bboxObj,
1108
+ share_url: shareUrl
1109
+ };
1110
+ return {
1111
+ content: [
1112
+ {
1113
+ type: "text",
1114
+ text: `H3 summary: ${cells.length.toLocaleString()} cells generated at resolution ${resolution} (compacted: ${compact}).\nSample cells: ${sampleCells.join(', ')}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
1115
+ },
1116
+ {
1117
+ type: "text",
1118
+ text: JSON.stringify(responseData, null, 2)
1119
+ }
1120
+ ]
1121
+ };
1122
+ }
796
1123
  const responseData = {
797
1124
  resolution,
798
1125
  compact,
1126
+ summary_only: false,
799
1127
  cell_count: cells.length,
800
1128
  cells,
801
1129
  original_bbox: bboxObj,
@@ -808,7 +1136,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
808
1136
  content: [
809
1137
  {
810
1138
  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}`
1139
+ text: `H3 cells generated: ${cells.length.toLocaleString()}\nResolution: ${resolution} | Compacted: ${compact}\n\nCells:\n${JSON.stringify(cells)}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
812
1140
  },
813
1141
  {
814
1142
  type: "text",
@@ -820,17 +1148,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
820
1148
  else if (toolName === "generate_share_url") {
821
1149
  const args = request.params.arguments || {};
822
1150
  const bbox = args.bbox;
1151
+ const warnings = [];
823
1152
  if (!bbox) {
824
1153
  return { content: [{ type: "text", text: "Error: 'bbox' is required." }], isError: true };
825
1154
  }
826
- const bboxObj = parseBBox(bbox);
1155
+ const bboxObj = parseBBox(bbox, warnings);
827
1156
  const shareUrl = buildShareUrl(bboxObj);
828
1157
  log('info', `generate_share_url completed`, { share_url: shareUrl });
829
1158
  return {
830
1159
  content: [
831
1160
  {
832
1161
  type: "text",
833
- text: `View on map: ${shareUrl}`
1162
+ text: `View on map: ${shareUrl}${formatWarnings(warnings)}`
834
1163
  },
835
1164
  {
836
1165
  type: "text",
@@ -842,10 +1171,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
842
1171
  ]
843
1172
  };
844
1173
  }
1174
+ else if (toolName === "reverse_geocode") {
1175
+ const args = request.params.arguments || {};
1176
+ const lat = args.lat;
1177
+ const lng = args.lng;
1178
+ const zoom = args.zoom ?? 18;
1179
+ if (lat === undefined || lng === undefined) {
1180
+ return { content: [{ type: "text", text: "Error: Both 'lat' and 'lng' arguments are required." }], isError: true };
1181
+ }
1182
+ const result = await reverseGeocode(lat, lng, zoom);
1183
+ log('info', `reverse_geocode completed`, { lat, lng, matched: result.matched_place });
1184
+ return {
1185
+ content: [
1186
+ {
1187
+ type: "text",
1188
+ text: `Matched Location: "${result.matched_place}"\nCoordinates: (${result.center.lat.toFixed(5)}, ${result.center.lng.toFixed(5)})\nOSM Details: [${result.osm_type || 'point'}] ${result.osm_id || 'N/A'}\n\nView on map: ${result.share_url}`
1189
+ },
1190
+ {
1191
+ type: "text",
1192
+ text: JSON.stringify(result, null, 2)
1193
+ }
1194
+ ]
1195
+ };
1196
+ }
845
1197
  else if (toolName === "search_overpass") {
846
1198
  const args = request.params.arguments || {};
847
1199
  const queryRaw = args.query;
848
1200
  const limit = args.limit || 100;
1201
+ const offset = args.offset || 0;
1202
+ const timeoutSec = args.timeout_seconds || 25;
1203
+ const useAreaBoundary = args.use_area_boundary !== false;
849
1204
  const radiusMeters = args.radius_meters;
850
1205
  const returnGeometry = args.return_geometry || false;
851
1206
  const useCircle = typeof radiusMeters === 'number' && radiusMeters > 0;
@@ -858,20 +1213,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
858
1213
  if (queryRaw.includes("[out:") || queryRaw.includes("[timeout:") || queryRaw.includes("[bbox:")) {
859
1214
  return { content: [{ type: "text", text: "Error: Query must not contain [out:], [timeout:], or [bbox:] directives. The server handles these automatically." }], isError: true };
860
1215
  }
1216
+ const warnings = [];
861
1217
  let overpassQuery;
862
1218
  let searchModeLabel;
863
- let centerPoint;
1219
+ let refPoint;
864
1220
  let bboxObj;
1221
+ let geocodeRes;
1222
+ let usedAreaId;
865
1223
  if (useCircle) {
866
1224
  if (args.location) {
867
1225
  try {
868
- centerPoint = await geocodeLocationPoint(args.location);
1226
+ geocodeRes = await geocodeLocation(args.location);
1227
+ refPoint = { lat: geocodeRes.center.lat, lng: geocodeRes.center.lng, description: `geocoded center of "${geocodeRes.matched_place}"` };
869
1228
  }
870
1229
  catch (err) {
871
1230
  if (args.bbox) {
872
1231
  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 };
1232
+ const fb = parseBBox(args.bbox, warnings);
1233
+ refPoint = { lat: (fb.lat1 + fb.lat2) / 2, lng: (fb.lng1 + fb.lng2) / 2, description: "centroid of provided bounding box" };
875
1234
  }
876
1235
  else {
877
1236
  log('error', `Geocoding failed for radius search: ${args.location}`, { error: err.message });
@@ -880,25 +1239,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
880
1239
  }
881
1240
  }
882
1241
  else if (args.bbox) {
883
- const fb = parseBBox(args.bbox);
884
- centerPoint = { lat: (fb.lat1 + fb.lat2) / 2, lng: (fb.lng1 + fb.lng2) / 2 };
1242
+ const fb = parseBBox(args.bbox, warnings);
1243
+ refPoint = { lat: (fb.lat1 + fb.lat2) / 2, lng: (fb.lng1 + fb.lng2) / 2, description: "centroid of provided bounding box" };
885
1244
  }
886
1245
  else {
887
1246
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
888
1247
  }
889
1248
  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)})`;
1249
+ const circleFilter = `(around:${radiusMeters},${refPoint.lat},${refPoint.lng})`;
1250
+ const formattedCore = applySpatialFilter(queryRaw, circleFilter);
1251
+ overpassQuery = `[out:json][timeout:${timeoutSec}];\n(${formattedCore});\n${outDirective}`;
1252
+ searchModeLabel = `circle — ${radiusMeters}m radius around (${refPoint.lat.toFixed(5)}, ${refPoint.lng.toFixed(5)})`;
892
1253
  }
893
1254
  else {
894
1255
  if (args.location) {
895
1256
  try {
896
- bboxObj = await geocodeLocation(args.location);
1257
+ geocodeRes = await geocodeLocation(args.location);
1258
+ bboxObj = geocodeRes.bbox;
1259
+ refPoint = { lat: geocodeRes.center.lat, lng: geocodeRes.center.lng, description: `geocoded centroid of "${geocodeRes.matched_place}"` };
1260
+ if (useAreaBoundary && geocodeRes.osm_area_id) {
1261
+ usedAreaId = geocodeRes.osm_area_id;
1262
+ }
897
1263
  }
898
1264
  catch (err) {
899
1265
  if (args.bbox) {
900
1266
  log('warn', `Geocoding failed, falling back to bbox argument`, { error: err.message });
901
- bboxObj = parseBBox(args.bbox);
1267
+ bboxObj = parseBBox(args.bbox, warnings);
1268
+ refPoint = { lat: (bboxObj.lat1 + bboxObj.lat2) / 2, lng: (bboxObj.lng1 + bboxObj.lng2) / 2, description: "centroid of provided bounding box" };
902
1269
  }
903
1270
  else {
904
1271
  log('error', `Geocoding failed for overpass location: ${args.location}`, { error: err.message });
@@ -907,16 +1274,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
907
1274
  }
908
1275
  }
909
1276
  else if (args.bbox) {
910
- bboxObj = parseBBox(args.bbox);
1277
+ bboxObj = parseBBox(args.bbox, warnings);
1278
+ refPoint = { lat: (bboxObj.lat1 + bboxObj.lat2) / 2, lng: (bboxObj.lng1 + bboxObj.lng2) / 2, description: "centroid of provided bounding box" };
911
1279
  }
912
1280
  else {
913
1281
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
914
1282
  }
915
1283
  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`;
1284
+ if (usedAreaId) {
1285
+ const formattedCore = applySpatialFilter(queryRaw, "(area.searchArea)");
1286
+ overpassQuery = `[out:json][timeout:${timeoutSec}];\narea(${usedAreaId})->.searchArea;\n(${formattedCore});\n${outDirective}`;
1287
+ searchModeLabel = `exact OSM boundary area (id: ${usedAreaId}) for "${geocodeRes?.matched_place}"`;
1288
+ }
1289
+ else {
1290
+ const formattedCore = applySpatialFilter(queryRaw, "");
1291
+ overpassQuery = `[out:json][timeout:${timeoutSec}][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${formattedCore});\n${outDirective}`;
1292
+ searchModeLabel = `bounding box`;
1293
+ }
918
1294
  }
919
- log('info', `search_overpass starting`, { query: queryRaw, mode: useCircle ? 'circle' : 'bbox', radius_meters: radiusMeters });
1295
+ log('info', `search_overpass starting`, { query: queryRaw, mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox'), radius_meters: radiusMeters, timeoutSec });
920
1296
  const endpoints = [
921
1297
  "https://overpass-api.de/api/interpreter",
922
1298
  "https://overpass.kumi.systems/api/interpreter"
@@ -928,9 +1304,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
928
1304
  let lastError = null;
929
1305
  const params = new URLSearchParams();
930
1306
  params.append('data', overpassQuery);
1307
+ const clientTimeoutMs = (timeoutSec + 5) * 1000;
931
1308
  for (const endpoint of endpoints) {
932
1309
  const controller = new AbortController();
933
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS * 2);
1310
+ const timeout = setTimeout(() => controller.abort(), clientTimeoutMs);
934
1311
  try {
935
1312
  log('info', `Executing Overpass query at ${endpoint}`);
936
1313
  const response = await fetch(endpoint, {
@@ -939,7 +1316,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
939
1316
  headers: {
940
1317
  'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
941
1318
  'Accept': 'application/json',
942
- 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION}`
1319
+ 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`
943
1320
  },
944
1321
  signal: controller.signal
945
1322
  });
@@ -959,23 +1336,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
959
1336
  }
960
1337
  if (!data) {
961
1338
  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 };
1339
+ return { content: [{ type: "text", text: `Error: Overpass API request timed out on all endpoints after ${clientTimeoutMs / 1000}s. The query or area may be too large.` }], isError: true };
963
1340
  }
964
1341
  throw new Error(`Overpass query failed on all endpoints: ${lastError?.message}`);
965
1342
  }
966
1343
  const seenIds = new Set();
967
1344
  let parsedElements = (data.elements || []).reduce((acc, e) => {
968
- if (seenIds.has(e.id))
1345
+ const dedupKey = `${e.type}/${e.id}`;
1346
+ if (seenIds.has(dedupKey))
969
1347
  return acc;
970
- seenIds.add(e.id);
1348
+ seenIds.add(dedupKey);
971
1349
  const lat = e.lat ?? e.center?.lat ?? e.geometry?.[0]?.lat;
972
1350
  const lon = e.lon ?? e.center?.lon ?? e.geometry?.[0]?.lon;
973
1351
  if (lat !== undefined && lon !== undefined) {
1352
+ const distM = calculateHaversineDistanceMeters(refPoint.lat, refPoint.lng, lat, lon);
974
1353
  const el = {
975
1354
  id: e.id,
976
1355
  type: e.type,
977
1356
  name: e.tags?.name || "Unnamed",
978
1357
  coordinates: { lat, lon },
1358
+ distance_meters: Math.round(distM * 10) / 10,
979
1359
  tags: e.tags || {}
980
1360
  };
981
1361
  if (returnGeometry && e.geometry) {
@@ -985,38 +1365,60 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
985
1365
  }
986
1366
  return acc;
987
1367
  }, []);
988
- const elementCount = parsedElements.length;
989
- let truncated = false;
990
- if (parsedElements.length > limit) {
991
- parsedElements = parsedElements.slice(0, limit);
992
- truncated = true;
1368
+ // Deterministic sorting: sort by (distance_meters, type, id) ascending
1369
+ const sortByDistance = args.sort_by_distance !== false;
1370
+ parsedElements.sort((a, b) => {
1371
+ if (sortByDistance) {
1372
+ const diff = (a.distance_meters ?? 0) - (b.distance_meters ?? 0);
1373
+ if (Math.abs(diff) > 0.001)
1374
+ return diff;
1375
+ }
1376
+ const typeDiff = (a.type || "").localeCompare(b.type || "");
1377
+ if (typeDiff !== 0)
1378
+ return typeDiff;
1379
+ return (a.id ?? 0) - (b.id ?? 0);
1380
+ });
1381
+ if (args.location && useAreaBoundary && !usedAreaId && geocodeRes) {
1382
+ warnings.push(`Exact admin boundary polygon unavailable for '${geocodeRes.matched_place}', fell back to bounding box query.`);
993
1383
  }
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' });
1384
+ const totalCount = parsedElements.length;
1385
+ const pagedElements = parsedElements.slice(offset, offset + limit);
1386
+ const truncated = totalCount > offset + limit;
1387
+ const hasMore = totalCount > offset + limit;
1388
+ const nextOffset = hasMore ? offset + limit : undefined;
1389
+ const shareUrl = useCircle
1390
+ ? buildShareUrlCircleWithMarkers(refPoint, radiusMeters, pagedElements)
1391
+ : buildShareUrlWithMarkers(bboxObj || geocodeRes.bbox, pagedElements);
1392
+ log('info', `search_overpass completed`, { total: totalCount, returned: pagedElements.length, offset, limit, truncated, mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox') });
998
1393
  const responseData = {
999
- search_mode: useCircle ? 'circle' : 'bbox',
1000
- ...(useCircle && centerPoint
1001
- ? { center: centerPoint, radius_meters: radiusMeters }
1002
- : { original_bbox: bboxObj }),
1394
+ search_mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox'),
1395
+ ...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
1396
+ ...(usedAreaId ? { osm_area_id: usedAreaId } : {}),
1397
+ distance_from: refPoint,
1003
1398
  generator: data.generator,
1004
1399
  osm3s: data.osm3s,
1005
- elements: parsedElements,
1006
- total_count: elementCount,
1007
- returned_count: parsedElements.length,
1400
+ elements: pagedElements,
1401
+ total_count: totalCount,
1402
+ returned_count: pagedElements.length,
1403
+ offset,
1404
+ limit,
1008
1405
  truncated,
1406
+ has_more: hasMore,
1407
+ ...(nextOffset !== undefined ? { next_offset: nextOffset } : {}),
1408
+ pagination_hint: hasMore
1409
+ ? `Use offset=${nextOffset} to fetch the next page. Note: Each page re-queries Overpass; for large result sets, consider narrowing your query filters or bounding area instead of deep paging.`
1410
+ : "End of result set.",
1009
1411
  share_url: shareUrl
1010
1412
  };
1011
- const topResults = parsedElements.slice(0, 5)
1012
- .map((e) => `- [${e.type}] ${e.name} (${e.coordinates.lat.toFixed(5)}, ${e.coordinates.lon.toFixed(5)})`)
1413
+ const topResults = pagedElements.slice(0, 5)
1414
+ .map((e) => `- [${e.type}] ${e.name} (${e.coordinates.lat.toFixed(5)}, ${e.coordinates.lon.toFixed(5)}) — ${e.distance_meters}m away`)
1013
1415
  .join('\n');
1014
- const moreNote = parsedElements.length > 5 ? `\n … and ${parsedElements.length - 5} more (see JSON)` : '';
1416
+ const moreNote = pagedElements.length > 5 ? `\n … and ${pagedElements.length - 5} more on this page (see JSON)` : '';
1015
1417
  return {
1016
1418
  content: [
1017
1419
  {
1018
1420
  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}`
1421
+ 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)}`
1020
1422
  },
1021
1423
  {
1022
1424
  type: "text",
@@ -1027,72 +1429,119 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1027
1429
  }
1028
1430
  else if (toolName === "list_osm_tags") {
1029
1431
  const args = request.params.arguments || {};
1030
- const category = (args.category || "").toLowerCase();
1432
+ const category = (args.category || "").toLowerCase().trim();
1031
1433
  let recommendations = "";
1032
1434
  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"]`;
1435
+ case category.includes('food') || category.includes('restaurant') || category.includes('cafe') || category.includes('pizza') || category.includes('dining'):
1436
+ 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
1437
  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"]`;
1438
+ case category.includes('health') || category.includes('medical') || category.includes('doctor') || category.includes('hospital') || category.includes('pharmacy'):
1439
+ 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
1440
  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"]`;
1441
+ 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'):
1442
+ 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
1443
  break;
1042
- case category.includes('leisure') || category.includes('park') || category.includes('entertainment'):
1444
+ case category.includes('leisure') || category.includes('park') || category.includes('playground') || category.includes('theatre') || category.includes('cinema'):
1043
1445
  recommendations = `Parks: nwr["leisure"="park"]\nPlaygrounds: nwr["leisure"="playground"]\nTheatres: nwr["amenity"="theatre"]\nCinemas: nwr["amenity"="cinema"]\nSports Centres: nwr["leisure"="sports_centre"]`;
1044
1446
  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"]`;
1447
+ case category.includes('retail') || category.includes('shop') || category.includes('store') || category.includes('supermarket') || category.includes('mall'):
1448
+ 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
1449
  break;
1048
- case category.includes('education') || category.includes('school'):
1450
+ case category.includes('education') || category.includes('school') || category.includes('university') || category.includes('college') || category.includes('library'):
1049
1451
  recommendations = `Schools: nwr["amenity"="school"]\nUniversities: nwr["amenity"="university"]\nColleges: nwr["amenity"="college"]\nKindergartens: nwr["amenity"="kindergarten"]\nLibraries: nwr["amenity"="library"]`;
1050
1452
  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"]`;
1453
+ case category.includes('tourism') || category.includes('hotel') || category.includes('motel') || category.includes('hostel') || category.includes('museum') || category.includes('attraction'):
1454
+ 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"]`;
1455
+ break;
1456
+ case category.includes('finance') || category.includes('bank') || category.includes('atm') || category.includes('post') || category.includes('police') || category.includes('fire') || category.includes('embassy'):
1457
+ 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"]`;
1458
+ break;
1459
+ 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'):
1460
+ 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"]`;
1461
+ break;
1462
+ 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'):
1463
+ 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"]`;
1464
+ break;
1465
+ case category.includes('religion') || category.includes('worship') || category.includes('church') || category.includes('mosque') || category.includes('synagogue') || category.includes('temple') || category.includes('cemetery'):
1466
+ 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"]`;
1467
+ break;
1468
+ case category.includes('ev') || category.includes('automotive') || category.includes('car') || category.includes('charging') || category.includes('mechanic') || category.includes('rental'):
1469
+ 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"]`;
1470
+ break;
1471
+ case category.includes('nightlife') || category.includes('nightclub') || category.includes('casino') || category.includes('arcade') || category.includes('bowling') || category.includes('escape'):
1472
+ 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"]`;
1473
+ break;
1474
+ 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'):
1475
+ 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"]`;
1476
+ break;
1477
+ case category.includes('work') || category.includes('office') || category.includes('coworking') || category.includes('conference'):
1478
+ recommendations = `Coworking Spaces: nwr["office"="coworking"]; nwr["amenity"="coworking_space"];\nGeneral Offices: nwr["office"]\nConference Centres: nwr["amenity"="conference_centre"]`;
1479
+ break;
1480
+ case category.includes('pet') || category.includes('dog') || category.includes('vet') || category.includes('zoo') || category.includes('animal'):
1481
+ recommendations = `Dog Parks: nwr["leisure"="dog_park"]\nVeterinarians: nwr["amenity"="veterinary"]\nPet Shops: nwr["shop"="pet"]\nZoos: nwr["tourism"="zoo"]`;
1482
+ break;
1483
+ case category.includes('childcare') || category.includes('daycare') || category.includes('child'):
1484
+ recommendations = `Childcare/Daycare: nwr["amenity"="childcare"]\nPlaygrounds: nwr["leisure"="playground"]`;
1053
1485
  break;
1054
1486
  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.`;
1487
+ log('info', `Category '${category}' not in index, querying Taginfo API`);
1488
+ recommendations = await getTaginfoSuggestions(category);
1056
1489
  break;
1057
1490
  }
1058
1491
  return {
1059
1492
  content: [{
1060
1493
  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.`
1494
+ 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.`
1062
1495
  }]
1063
1496
  };
1064
1497
  }
1065
1498
  else if (toolName === "aggregate_overpass_h3") {
1066
1499
  const args = request.params.arguments || {};
1067
1500
  const queryRaw = args.query;
1068
- const resolution = args.resolution || 8;
1501
+ const resolution = args.resolution ?? 8;
1502
+ const timeoutSec = args.timeout_seconds || 25;
1503
+ const useAreaBoundary = args.use_area_boundary !== false;
1504
+ const warnings = [];
1069
1505
  if (!queryRaw) {
1070
1506
  return { content: [{ type: "text", text: "Error: 'query' is required." }], isError: true };
1071
1507
  }
1508
+ if (resolution < 0 || resolution > 15 || !Number.isInteger(resolution)) {
1509
+ return { content: [{ type: "text", text: `Error: Invalid H3 resolution: ${resolution}. Must be 0-15.` }], isError: true };
1510
+ }
1072
1511
  let bboxObj;
1512
+ let geocodeRes;
1513
+ let usedAreaId;
1073
1514
  if (args.location) {
1074
1515
  try {
1075
- bboxObj = await geocodeLocation(args.location);
1516
+ geocodeRes = await geocodeLocation(args.location);
1517
+ bboxObj = geocodeRes.bbox;
1518
+ if (useAreaBoundary && geocodeRes.osm_area_id) {
1519
+ usedAreaId = geocodeRes.osm_area_id;
1520
+ }
1076
1521
  }
1077
1522
  catch (err) {
1078
1523
  if (!args.bbox) {
1079
1524
  return { content: [{ type: "text", text: `Error: Geocoding failed: ${err.message}` }], isError: true };
1080
1525
  }
1081
- bboxObj = parseBBox(args.bbox);
1526
+ bboxObj = parseBBox(args.bbox, warnings);
1082
1527
  }
1083
1528
  }
1084
1529
  else if (args.bbox) {
1085
- bboxObj = parseBBox(args.bbox);
1530
+ bboxObj = parseBBox(args.bbox, warnings);
1086
1531
  }
1087
1532
  else {
1088
1533
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
1089
1534
  }
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 };
1535
+ log('info', `aggregate_overpass_h3 starting`, { query: queryRaw, resolution, timeoutSec, mode: usedAreaId ? 'area' : 'bbox' });
1536
+ let overpassQuery;
1537
+ if (usedAreaId) {
1538
+ const formattedCore = applySpatialFilter(queryRaw, "(area.searchArea)");
1539
+ overpassQuery = `[out:json][timeout:${timeoutSec}];\narea(${usedAreaId})->.searchArea;\n(${formattedCore});\nout center;`;
1540
+ }
1541
+ else {
1542
+ const formattedCore = applySpatialFilter(queryRaw, "");
1543
+ overpassQuery = `[out:json][timeout:${timeoutSec}][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${formattedCore});\nout center;`;
1094
1544
  }
1095
- const overpassQuery = `[out:json][timeout:25][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${queryRaw};);\nout center;`;
1096
1545
  let data = null;
1097
1546
  let lastError = null;
1098
1547
  const params = new URLSearchParams();
@@ -1104,9 +1553,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1104
1553
  if (process.env.OVERPASS_API_URL) {
1105
1554
  endpoints.unshift(process.env.OVERPASS_API_URL);
1106
1555
  }
1556
+ const clientTimeoutMs = (timeoutSec + 5) * 1000;
1107
1557
  for (const endpoint of endpoints) {
1108
1558
  const controller = new AbortController();
1109
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS * 2);
1559
+ const timeout = setTimeout(() => controller.abort(), clientTimeoutMs);
1110
1560
  try {
1111
1561
  const response = await fetch(endpoint, {
1112
1562
  method: "POST",
@@ -1114,7 +1564,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1114
1564
  headers: {
1115
1565
  'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
1116
1566
  'Accept': 'application/json',
1117
- 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION}`
1567
+ 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`
1118
1568
  },
1119
1569
  signal: controller.signal
1120
1570
  });
@@ -1134,14 +1584,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1134
1584
  return { content: [{ type: "text", text: `Error: Overpass query failed: ${lastError?.message}` }], isError: true };
1135
1585
  }
1136
1586
  let rawElements = data.elements || [];
1137
- // Bin into H3 Hexagons
1138
1587
  const hexCounts = {};
1139
1588
  const hexElements = {};
1140
1589
  const seenIds = new Set();
1141
1590
  rawElements.forEach((e) => {
1142
- if (seenIds.has(e.id))
1591
+ const dedupKey = `${e.type}/${e.id}`;
1592
+ if (seenIds.has(dedupKey))
1143
1593
  return;
1144
- seenIds.add(e.id);
1594
+ seenIds.add(dedupKey);
1145
1595
  const lat = e.lat ?? e.center?.lat;
1146
1596
  const lon = e.lon ?? e.center?.lon;
1147
1597
  if (lat !== undefined && lon !== undefined) {
@@ -1149,7 +1599,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1149
1599
  hexCounts[h3Index] = (hexCounts[h3Index] || 0) + 1;
1150
1600
  if (!hexElements[h3Index])
1151
1601
  hexElements[h3Index] = [];
1152
- // Keep element data minimal to avoid massive payloads
1153
1602
  hexElements[h3Index].push({
1154
1603
  id: e.id,
1155
1604
  name: e.tags?.name || "Unnamed"
@@ -1158,10 +1607,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1158
1607
  });
1159
1608
  const shareUrl = buildShareUrl(bboxObj);
1160
1609
  const totalGrouped = Object.values(hexCounts).reduce((a, b) => a + b, 0);
1161
- // Generate GeoJSON of the active hexes
1162
1610
  const activeHexes = Object.keys(hexCounts);
1163
1611
  const geojson = h3CellsToGeoJSON(activeHexes);
1164
- // Inject the counts into the GeoJSON properties
1165
1612
  geojson.features.forEach((f) => {
1166
1613
  const cellId = f.properties.h3_index;
1167
1614
  f.properties.count = hexCounts[cellId];
@@ -1170,6 +1617,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1170
1617
  log('info', `aggregate_overpass_h3 completed`, { resolution, elements: totalGrouped, activeHexes: activeHexes.length });
1171
1618
  const responseData = {
1172
1619
  resolution,
1620
+ ...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
1621
+ ...(usedAreaId ? { osm_area_id: usedAreaId } : {}),
1173
1622
  total_elements: totalGrouped,
1174
1623
  active_hex_count: activeHexes.length,
1175
1624
  hex_counts: hexCounts,
@@ -1177,7 +1626,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1177
1626
  original_bbox: bboxObj,
1178
1627
  share_url: shareUrl
1179
1628
  };
1180
- // Sort map for display
1181
1629
  const topHexes = Object.entries(hexCounts)
1182
1630
  .sort((a, b) => b[1] - a[1])
1183
1631
  .slice(0, 10);
@@ -1185,7 +1633,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1185
1633
  content: [
1186
1634
  {
1187
1635
  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}`
1636
+ 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)}`
1189
1637
  },
1190
1638
  {
1191
1639
  type: "text",